diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 89866b26..0ad462a3 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -908,6 +908,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone()))); ui.close(); } + let duplicate_items = targets(app, *id, selected); + // Like Edit > Duplicate: folders disable the whole operation, including mixed selections. + let can_duplicate = !duplicate_items.is_empty() + && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder())); + let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate")); + if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) { + duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut)); + } + let response = ui.add_enabled(can_duplicate, duplicate); + app.auto.add("project.context.duplicate", response.rect, "Duplicate"); + if response.clicked() { + actions.push(("project.duplicate".into(), json!({"items": duplicate_items}))); + ui.close(); + } if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() { actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null}))); ui.close(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 23637c2b..1a800a7c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -48,6 +48,7 @@ mod ui_parameter_spacing; mod ui_presets_folder; mod ui_preview_align; mod ui_project_delete; +mod ui_project_duplicate; mod ui_project_keyboard; mod ui_project_new_folder; mod ui_project_rows; diff --git a/crates/ui-egui/tests/it/ui_project_duplicate.rs b/crates/ui-egui/tests/it/ui_project_duplicate.rs new file mode 100644 index 00000000..2dac885b --- /dev/null +++ b/crates/ui-egui/tests/it/ui_project_duplicate.rs @@ -0,0 +1,173 @@ +//! #2207: Duplicate is reachable from a Project row's context menu, with the same targets, +//! naming, undo and shortcut preset as Edit > Duplicate. + +use effectcraft_engine::Session; +use effectcraft_engine::project::ItemId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::menus::{parse_shortcut, shortcut_text}; +use egui::{Event, Key, Modifiers, PointerButton, Rect, Shape, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +const DUPLICATE: &str = "project.context.duplicate"; + +fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) { + let mut s = Session::default(); + let mut comp = |name: &str| { + ItemId(s.execute("comp.new", json!({"name": name, "width": 64, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap()) + }; + let main = comp("Main"); + let other = comp("Other"); + let folder = ItemId(s.execute("project.newFolder", json!({"name": "Bin"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, main, other, folder) +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, button: PointerButton, modifiers: Modifiers) { + let p = rect(h, id).center(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button, pressed: false, modifiers }); + h.run_steps(3); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); +} + +fn row(h: &mut Harness<'_, EffectcraftApp>, item: ItemId, button: PointerButton, modifiers: Modifiers) { + click(h, &format!("project.item.{}.name", item.0), button, modifiers); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.run_steps(2); +} + +fn copy_name(h: &Harness<'_, EffectcraftApp>, id: ItemId) -> String { + h.state().session.project.item(id).unwrap().name.clone() +} + +fn menu_text(h: &Harness<'_, EffectcraftApp>, text: &str) -> bool { + fn contains(shape: &Shape, bounds: Rect, text: &str) -> bool { + match shape { + Shape::Text(t) => bounds.contains(t.pos) && t.galley.text() == text, + Shape::Vec(shapes) => shapes.iter().any(|s| contains(s, bounds, text)), + _ => false, + } + } + let bounds = rect(h, DUPLICATE); + h.output().shapes.iter().any(|s| contains(&s.shape, bounds, text)) +} + +#[test] +fn unselected_row_duplicates_only_itself_and_undo_restores_the_project() { + let (mut h, main, other, _) = harness(); + row(&mut h, other, PointerButton::Primary, Modifiers::NONE); + let before = h.state().session.project.clone(); + let undo = h.state().session.history.undo.len(); + row(&mut h, main, PointerButton::Secondary, Modifiers::NONE); + assert_eq!(h.state().session.state.project_selection, [main]); + assert!(menu_text(&h, &shortcut_text("Cmd+D")), "the default platform shortcut is shown"); + click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE); + let selected = h.state().session.state.project_selection.clone(); + let [copy] = selected.as_slice() else { panic!("one selected copy") }; + assert!(!before.items.contains_key(copy), "the copy has a fresh ID"); + assert_eq!(copy_name(&h, *copy), "Main 2"); + assert_eq!(copy_name(&h, other), "Other"); + assert_eq!(h.state().session.project.items.len(), before.items.len() + 1); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.project, before); +} + +#[test] +fn selected_rows_duplicate_together_in_selection_order_with_numbered_names() { + let (mut h, main, other, _) = harness(); + // Deliberately reverse alphabetical row order: command targets retain selection order. + row(&mut h, other, PointerButton::Primary, Modifiers::NONE); + row(&mut h, main, PointerButton::Primary, Modifiers::COMMAND); + assert_eq!(h.state().session.state.project_selection, [other, main]); + let before = h.state().session.project.clone(); + let undo = h.state().session.history.undo.len(); + row(&mut h, main, PointerButton::Secondary, Modifiers::NONE); + click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE); + let selected = h.state().session.state.project_selection.clone(); + let [a, b] = selected.as_slice() else { panic!("two selected copies") }; + assert_ne!(a, b); + assert!(selected.iter().all(|id| !before.items.contains_key(id))); + assert_eq!((copy_name(&h, *a), copy_name(&h, *b)), ("Other 2".to_string(), "Main 2".to_string())); + assert_eq!(h.state().session.project.items.len(), before.items.len() + 2); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + // Duplicate the selected copies again: the existing engine numbering continues. + row(&mut h, *b, PointerButton::Secondary, Modifiers::NONE); + click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE); + let names: Vec<_> = h.state().session.state.project_selection.iter().map(|id| copy_name(&h, *id)).collect(); + assert_eq!(names, ["Other 3", "Main 3"]); +} + +#[test] +fn folders_and_mixed_selections_disable_duplicate_without_partial_changes() { + for mixed in [false, true] { + let (mut h, main, _, folder) = harness(); + row(&mut h, main, PointerButton::Primary, Modifiers::NONE); + if mixed { + row(&mut h, folder, PointerButton::Primary, Modifiers::COMMAND); + } + // An unselected folder replaces the selection; a selected non-folder keeps the mixed set. + row(&mut h, if mixed { main } else { folder }, PointerButton::Secondary, Modifiers::NONE); + let before = h.state().session.project.clone(); + let selected = h.state().session.state.project_selection.clone(); + assert!(selected.contains(&folder)); + let undo = h.state().session.history.undo.len(); + let status = h.state().ui.status.clone(); + click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE); + assert_eq!(h.state().session.project, before, "mixed={mixed}: no partial copy"); + assert_eq!(h.state().session.state.project_selection, selected); + assert_eq!(h.state().session.history.undo.len(), undo); + assert_eq!(h.state().ui.status, status, "no failing engine command was dispatched"); + } +} + +#[test] +fn duplicate_shortcut_still_dispatches_and_the_menu_tracks_rebinding_and_unbinding() { + let (mut h, main, _, _) = harness(); + row(&mut h, main, PointerButton::Primary, Modifiers::NONE); + assert_eq!(h.state().ui.focused, PanelKind::Project); + let (d, command) = parse_shortcut("Cmd+D").unwrap(); + key(&mut h, command, d); + let selected = h.state().session.state.project_selection.clone(); + let [copy] = selected.as_slice() else { panic!("one shortcut copy") }; + assert_ne!(*copy, main); + assert_eq!(copy_name(&h, *copy), "Main 2"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": "F6"})).unwrap(); + row(&mut h, main, PointerButton::Secondary, Modifiers::NONE); + assert!(menu_text(&h, "F6")); + assert!(!menu_text(&h, &shortcut_text("Cmd+D"))); + key(&mut h, Key::Escape, Modifiers::NONE); + row(&mut h, main, PointerButton::Primary, Modifiers::NONE); + let before = h.state().session.project.clone(); + key(&mut h, command, d); + assert_eq!(h.state().session.project, before, "the former shortcut stays unbound"); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().session.project.items.len(), before.items.len() + 1); + h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": null})).unwrap(); + row(&mut h, main, PointerButton::Secondary, Modifiers::NONE); + assert!(!menu_text(&h, "F6")); + assert!(!menu_text(&h, &shortcut_text("Cmd+D"))); + click(&mut h, DUPLICATE, PointerButton::Primary, Modifiers::NONE); + assert_eq!(h.state().session.project.items.len(), before.items.len() + 2, "unbinding keeps the menu action usable"); +}