mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
This commit is contained in:
@@ -908,6 +908,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ctx.data_mut(|d| d.insert_temp::<Editing>(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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user