Skip to content

Commit 1cdac2a

Browse files
committed
Add Ctrl+D duplicate, drag-to-move files, multi-select drag ghost, folder drop highlights
1 parent b02b97b commit 1cdac2a

6 files changed

Lines changed: 377 additions & 31 deletions

File tree

crates/editor/renzora_asset_browser/src/grid.rs

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ pub fn grid_ui_interactive(
153153
state.pending_delete = state.selected_assets.iter().cloned().collect();
154154
}
155155

156+
// Ctrl+D to duplicate
157+
if ctx.input(|i| (i.modifiers.ctrl || i.modifiers.command) && i.key_pressed(egui::Key::D)) && !state.selected_assets.is_empty() {
158+
ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::D));
159+
ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::D));
160+
state.duplicate_selected();
161+
}
162+
156163
// Escape to cancel rename or close context menu
157164
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
158165
state.renaming_asset = None;
@@ -174,6 +181,7 @@ pub fn grid_ui_interactive(
174181
let mut right_clicked = false;
175182
let mut pending_rename_rect: Option<egui::Rect> = None;
176183
let mut pending_rename_font: f32 = 11.0;
184+
let mut drop_target_folder: Option<PathBuf> = None;
177185

178186
// The visible grid pane rect (used for hit-testing pointer vs grid area)
179187
let grid_pane_rect = ui.max_rect();
@@ -216,11 +224,34 @@ pub fn grid_ui_interactive(
216224
state.selection_anchor = Some(entry.path.clone());
217225
}
218226
}
219-
// Drag detection — only for files (not folders)
220-
if !entry.is_dir && tile.response.drag_started() {
227+
// Drag detection — files and folders
228+
if tile.response.drag_started() {
221229
drag_started_index = Some(index);
222230
}
223231

232+
// Drop target: folder tiles under the pointer during an active drag
233+
// Can't use response.hovered() because egui gives hover to the drag source
234+
if entry.is_dir && !state.drag_moving.is_empty() && !state.drag_moving.contains(&entry.path) {
235+
let pointer_over = ctx.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos()))
236+
.map(|p| tile.rect.contains(p))
237+
.unwrap_or(false);
238+
if pointer_over {
239+
drop_target_folder = Some(entry.path.clone());
240+
let accent = theme.semantic.accent.to_color32();
241+
ui.painter().rect_filled(
242+
tile.rect,
243+
8.0,
244+
Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 40),
245+
);
246+
ui.painter().rect_stroke(
247+
tile.rect,
248+
8.0,
249+
Stroke::new(2.0, accent),
250+
StrokeKind::Inside,
251+
);
252+
}
253+
}
254+
224255
let (icon, color) = if entry.is_dir {
225256
(regular::FOLDER, folder_icon_color(&entry.name))
226257
} else {
@@ -456,19 +487,46 @@ pub fn grid_ui_interactive(
456487
}
457488

458489
// Build drag payload if a file drag started
459-
let drag_payload = drag_started_index.map(|idx| {
490+
let mut drag_payload = None;
491+
if let Some(idx) = drag_started_index {
460492
let entry = &entries[idx];
461-
let (icon, color) = file_icon(&entry.path);
462-
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
463-
AssetDragPayload {
464-
path: entry.path.clone(),
465-
name: entry.name.clone(),
466-
icon: icon.to_string(),
467-
color,
468-
origin,
469-
is_detached: false,
493+
494+
// Start internal drag-move: include all selected items, or just the dragged one
495+
if state.selected_assets.contains(&entry.path) && state.selected_assets.len() > 1 {
496+
state.drag_moving = state.selected_assets.iter().cloned().collect();
497+
} else {
498+
state.drag_moving = vec![entry.path.clone()];
470499
}
471-
});
500+
501+
// Only produce viewport drag payload for files (not folders)
502+
if !entry.is_dir {
503+
let (icon, color) = file_icon(&entry.path);
504+
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
505+
drag_payload = Some(AssetDragPayload {
506+
path: entry.path.clone(),
507+
name: entry.name.clone(),
508+
icon: icon.to_string(),
509+
color,
510+
origin,
511+
is_detached: false,
512+
drag_count: state.drag_moving.len(),
513+
});
514+
}
515+
}
516+
517+
// Update drop target for ghost label
518+
if !state.drag_moving.is_empty() {
519+
state.move_drop_target = drop_target_folder.clone();
520+
}
521+
522+
// Handle drop on a folder tile
523+
if !state.drag_moving.is_empty() && ctx.input(|i| i.pointer.any_released()) {
524+
if let Some(target) = drop_target_folder {
525+
state.pending_move = Some((state.drag_moving.clone(), target));
526+
}
527+
state.drag_moving.clear();
528+
state.move_drop_target = None;
529+
}
472530

473531
GridResult {
474532
drag_payload,

crates/editor/renzora_asset_browser/src/lib.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,37 @@ impl EditorPanel for AssetBrowserPanel {
412412
}
413413
}
414414

415+
// --- Process pending move (drag-to-folder) ---
416+
if let Some((sources, target)) = state.pending_move.take() {
417+
let mut moved = 0usize;
418+
for source in &sources {
419+
if let Some(file_name) = source.file_name() {
420+
let dest = target.join(file_name);
421+
if source == &dest || source == &target {
422+
continue;
423+
}
424+
// Don't move a folder into itself
425+
if dest.starts_with(source) {
426+
continue;
427+
}
428+
match std::fs::rename(source, &dest) {
429+
Ok(_) => moved += 1,
430+
Err(e) => {
431+
state.last_error = Some(format!("Move failed: {}", e));
432+
state.error_timeout = 3.0;
433+
}
434+
}
435+
}
436+
}
437+
state.selected_assets.clear();
438+
state.selected_path = None;
439+
// Navigate to the target folder so the user can see the moved files
440+
if moved > 0 {
441+
state.navigate_to(target.clone());
442+
state.expanded_folders.insert(target);
443+
}
444+
}
445+
415446
// --- Process pending delete ---
416447
if !state.pending_delete.is_empty() {
417448
let to_delete: Vec<_> = state.pending_delete.drain(..).collect();
@@ -430,6 +461,62 @@ impl EditorPanel for AssetBrowserPanel {
430461
state.selected_path = state.selected_assets.iter().next().cloned();
431462
}
432463

464+
// --- Internal drag ghost (for folder drags or when no viewport payload) ---
465+
if !state.drag_moving.is_empty() {
466+
if let Some(pos) = ui.ctx().pointer_latest_pos() {
467+
let count = state.drag_moving.len();
468+
let items_text = if count > 1 {
469+
format!("{} items", count)
470+
} else {
471+
state.drag_moving[0].file_name()
472+
.and_then(|n| n.to_str())
473+
.unwrap_or("item")
474+
.to_string()
475+
};
476+
477+
let label = if let Some(ref target) = state.move_drop_target {
478+
let folder_name = target.file_name()
479+
.and_then(|n| n.to_str())
480+
.unwrap_or("folder");
481+
format!("{} Move {} to {}", regular::ARROW_RIGHT, items_text, folder_name)
482+
} else {
483+
let icon = if count > 1 {
484+
regular::FOLDERS
485+
} else if state.drag_moving[0].is_dir() {
486+
regular::FOLDER
487+
} else {
488+
regular::FILE
489+
};
490+
format!("{} {}", icon, items_text)
491+
};
492+
493+
let accent = theme.semantic.accent.to_color32();
494+
let border_color = if state.move_drop_target.is_some() { accent } else {
495+
theme.widgets.border.to_color32()
496+
};
497+
498+
let ghost_pos = pos + egui::vec2(14.0, -10.0);
499+
egui::Area::new(egui::Id::new("asset_move_ghost"))
500+
.fixed_pos(ghost_pos)
501+
.order(egui::Order::Tooltip)
502+
.interactable(false)
503+
.show(ui.ctx(), |ui| {
504+
egui::Frame::NONE
505+
.fill(theme.surfaces.panel.to_color32())
506+
.stroke(Stroke::new(1.5, border_color))
507+
.inner_margin(egui::Margin::symmetric(8, 5))
508+
.corner_radius(egui::CornerRadius::same(6))
509+
.show(ui, |ui| {
510+
ui.label(
511+
egui::RichText::new(label)
512+
.font(FontId::proportional(11.0))
513+
.color(theme.text.primary.to_color32()),
514+
);
515+
});
516+
});
517+
}
518+
}
519+
433520
// --- Error display ---
434521
if let Some(ref error) = state.last_error {
435522
let error_rect = egui::Rect::from_min_size(
@@ -694,6 +781,11 @@ fn render_context_menu(
694781
}
695782
}
696783

784+
if menu_item(ui, regular::COPY, "Duplicate", "Ctrl+D", text_primary) {
785+
state.duplicate_selected();
786+
state.context_menu_pos = None;
787+
}
788+
697789
let delete_label = if state.selected_assets.len() > 1 {
698790
format!("Delete ({})", state.selected_assets.len())
699791
} else {

crates/editor/renzora_asset_browser/src/list.rs

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ pub fn list_ui_interactive(ui: &mut egui::Ui, state: &mut AssetBrowserState, the
7777
state.pending_delete = state.selected_assets.iter().cloned().collect();
7878
}
7979

80+
// Ctrl+D to duplicate
81+
if ctx.input(|i| (i.modifiers.ctrl || i.modifiers.command) && i.key_pressed(egui::Key::D)) && !state.selected_assets.is_empty() {
82+
ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::D));
83+
ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::D));
84+
state.duplicate_selected();
85+
}
86+
8087
// Escape to cancel rename or close context menu
8188
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
8289
state.renaming_asset = None;
@@ -90,6 +97,7 @@ pub fn list_ui_interactive(ui: &mut egui::Ui, state: &mut AssetBrowserState, the
9097
let mut double_clicked_index: Option<usize> = None;
9198
let mut drag_started_index: Option<usize> = None;
9299
let mut right_clicked = false;
100+
let mut drop_target_folder: Option<PathBuf> = None;
93101

94102
egui::ScrollArea::vertical()
95103
.id_salt("asset_list")
@@ -211,9 +219,31 @@ pub fn list_ui_interactive(ui: &mut egui::Ui, state: &mut AssetBrowserState, the
211219
state.selection_anchor = Some(entry.path.clone());
212220
}
213221
}
214-
if !entry.is_dir && resp.drag_started() {
222+
if resp.drag_started() {
215223
drag_started_index = Some(index);
216224
}
225+
226+
// Drop target: folder rows under the pointer during an active drag
227+
if entry.is_dir && !state.drag_moving.is_empty() && !state.drag_moving.contains(&entry.path) {
228+
let pointer_over = ctx.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos()))
229+
.map(|p| row_rect.contains(p))
230+
.unwrap_or(false);
231+
if pointer_over {
232+
drop_target_folder = Some(entry.path.clone());
233+
let accent = theme.semantic.accent.to_color32();
234+
ui.painter().rect_filled(
235+
row_rect,
236+
2.0,
237+
egui::Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 40),
238+
);
239+
ui.painter().rect_stroke(
240+
row_rect,
241+
2.0,
242+
egui::Stroke::new(1.5, accent),
243+
egui::StrokeKind::Inside,
244+
);
245+
}
246+
}
217247
}
218248
});
219249

@@ -239,19 +269,42 @@ pub fn list_ui_interactive(ui: &mut egui::Ui, state: &mut AssetBrowserState, the
239269
state.handle_click(path, ctrl_held, shift_held);
240270
}
241271

242-
let drag_payload = drag_started_index.map(|idx| {
272+
let mut drag_payload = None;
273+
if let Some(idx) = drag_started_index {
243274
let entry = &entries[idx];
244-
let (icon, color) = file_icon(&entry.path);
245-
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
246-
AssetDragPayload {
247-
path: entry.path.clone(),
248-
name: entry.name.clone(),
249-
icon: icon.to_string(),
250-
color,
251-
origin,
252-
is_detached: false,
275+
if state.selected_assets.contains(&entry.path) && state.selected_assets.len() > 1 {
276+
state.drag_moving = state.selected_assets.iter().cloned().collect();
277+
} else {
278+
state.drag_moving = vec![entry.path.clone()];
253279
}
254-
});
280+
if !entry.is_dir {
281+
let (icon, color) = file_icon(&entry.path);
282+
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
283+
drag_payload = Some(AssetDragPayload {
284+
path: entry.path.clone(),
285+
name: entry.name.clone(),
286+
icon: icon.to_string(),
287+
color,
288+
origin,
289+
is_detached: false,
290+
drag_count: state.drag_moving.len(),
291+
});
292+
}
293+
}
294+
295+
// Update drop target for ghost label
296+
if !state.drag_moving.is_empty() {
297+
state.move_drop_target = drop_target_folder.clone();
298+
}
299+
300+
// Handle drop on a folder row
301+
if !state.drag_moving.is_empty() && ctx.input(|i| i.pointer.any_released()) {
302+
if let Some(target) = drop_target_folder {
303+
state.pending_move = Some((state.drag_moving.clone(), target));
304+
}
305+
state.drag_moving.clear();
306+
state.move_drop_target = None;
307+
}
255308

256309
GridResult { drag_payload, double_clicked_file, thumbnail_requests: Vec::new() }
257310
}

0 commit comments

Comments
 (0)