diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6d4bc18a..2a9b85a8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -300,6 +300,14 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V None => Err("select a Render Queue item first".into()), }; } + // Shift+Delete deletes the Project panel's selection without asking; it does nothing in the + // other panels. + if id == "project.deleteWithoutConfirmation" { + if app.ui.focused != PanelKind::Project { + return Ok(Value::Null); + } + return crate::panels::delete_items::delete_confirmed(app, ctx, json!({})); + } let now = ctx.input(|i| i.time); // Closing a modified project asks to save it first; the command runs once answered. if crate::panels::unsaved::guard(app, id, ¶ms) { @@ -713,6 +721,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } // View ▸ New Viewer. "view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}), + // Run by id (control channel, MCP): the Project panel's selection, whatever has focus. + "project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?, "window.scriptPanel" => { let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32; app.show_panel(PanelKind::ScriptPanel(id)); @@ -1496,6 +1506,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, // Paste and their variants (Ctrl+Alt+C…) run. let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND }; + // On Windows, Shift+Delete is the old Cut key and arrives as Cut too. The Project panel + // has no clipboard: there it is Shift+Delete (Delete Project Items Without Confirmation). + let shift_delete = !i.modifiers.command && i.modifiers.shift && app.ui.focused == PanelKind::Project; i.events .iter() .filter_map(|e| match e { @@ -1510,6 +1523,7 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { } egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => Some((egui::Key::Space, egui::Modifiers::NONE, false)), egui::Event::Copy if clipboard => Some((egui::Key::C, held, true)), + egui::Event::Cut if clipboard && shift_delete => Some((egui::Key::Delete, i.modifiers, true)), egui::Event::Cut if clipboard => Some((egui::Key::X, held, true)), egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held, true)), _ => None, diff --git a/crates/ui-egui/tests/ui_project_delete.rs b/crates/ui-egui/tests/ui_project_delete.rs index 9963727d..052d2ca7 100644 --- a/crates/ui-egui/tests/ui_project_delete.rs +++ b/crates/ui-egui/tests/ui_project_delete.rs @@ -1,4 +1,5 @@ //! Project-panel deletion uses its selection, even when a Timeline layer is still selected. +//! Shift+Delete deletes it without asking (Delete Project Items Without Confirmation). use effectcraft_engine::Session; use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, LayerId}; @@ -51,6 +52,30 @@ fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { h.run_steps(2); } +/// Shift+Delete as each platform delivers it: a key press (macOS, Linux), or on Windows a Cut +/// event with Shift held (the windowing layer turns Shift+Delete into Cut). +#[derive(Clone, Copy, Debug)] +enum ShiftDelete { + Key, + WindowsCut, +} + +fn shift_delete(h: &mut Harness<'_, EffectcraftApp>, how: ShiftDelete) { + match how { + ShiftDelete::Key => key(h, Key::Delete, Modifiers::SHIFT), + ShiftDelete::WindowsCut => { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.step(); + h.input_mut().events.push(Event::Cut); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + } + } +} + +const SHIFT_DELETE: [ShiftDelete; 2] = [ShiftDelete::Key, ShiftDelete::WindowsCut]; + /// Items that compositions use ask first, as in After Effects: answer Delete when asked. /// Returns whether it asked. fn confirm_if_asked(h: &mut Harness<'_, EffectcraftApp>) -> bool { @@ -138,12 +163,22 @@ fn typing_dialogs_and_modified_keys_do_not_delete_project_items() { let item = items[0]; click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE); let project = h.state().session.project.clone(); - for delete_key in [Key::Delete, Key::Backspace] { - key(&mut h, delete_key, Modifiers::SHIFT); - assert_eq!(h.state().session.project, project); + // Only plain Delete/Backspace and Shift+Delete delete Project items. + for (k, m) in [ + (Key::Backspace, Modifiers::SHIFT), + (Key::Delete, Modifiers::ALT), + (Key::Delete, Modifiers::COMMAND), + (Key::Delete, Modifiers::SHIFT | Modifiers::COMMAND), + (Key::Delete, Modifiers::SHIFT | Modifiers::ALT), + ] { + key(&mut h, k, m); + assert_eq!(h.state().session.project, project, "{m:?}+{k:?}"); } h.state_mut().dialog = Some(effectcraft_ui_egui::Dialog::About); key(&mut h, Key::Delete, Modifiers::NONE); + for how in SHIFT_DELETE { + shift_delete(&mut h, how); + } assert_eq!(h.state().session.project, project); h.state_mut().dialog = None; h.run_steps(2); @@ -154,6 +189,10 @@ fn typing_dialogs_and_modified_keys_do_not_delete_project_items() { key(&mut h, delete_key, Modifiers::NONE); assert_eq!(h.state().session.project, project, "typing must not delete the item"); } + for how in SHIFT_DELETE { + shift_delete(&mut h, how); + assert_eq!(h.state().session.project, project, "typing must not delete the item ({how:?})"); + } } /// Deleting an item a composition uses asks first, as in After Effects, and says what goes with @@ -190,3 +229,110 @@ fn deleting_items_in_use_asks_first() { h.state_mut().session.execute("project.delete", json!({"items": [solid.0]})).unwrap(); assert!(h.state().session.project.item(solid).is_none()); } + +/// Shift+Delete deletes the selected item without asking, even one a composition uses, with the +/// layers that use it, in one undo step (Delete Project Items Without Confirmation). +#[test] +fn shift_delete_deletes_without_asking_and_undoes_once() { + for how in SHIFT_DELETE { + for target in 0..4 { + let (mut h, items, keep) = harness(); + let item = items[target]; + click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE); + let project = h.state().session.project.clone(); + let undo = h.state().session.history.undo.len(); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None, "{how:?} item {target}: no prompt"); + assert!(h.state().session.project.item(item).is_none(), "{how:?} should delete project item {target}"); + assert!(h.state().session.active_comp().unwrap().layer(keep).is_some(), "the unrelated Timeline layer stays"); + assert!(h.state().session.state.project_selection.is_empty()); + 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, project, "one undo restores the item and its references"); + } + } +} + +#[test] +fn shift_delete_deletes_a_multi_selection_and_folder_contents_together() { + for how in SHIFT_DELETE { + let (mut h, items, keep) = harness(); + let (solid, folder) = (items[1], items[3]); + // The solid (used by a layer) inside the folder: deleting the folder takes both. + h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": folder.0})).unwrap(); + h.run_steps(2); + click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE); + click(&mut h, &format!("project.item.{}.name", folder.0), Modifiers::COMMAND); + assert_eq!(h.state().session.state.project_selection, vec![items[0], folder]); + let project = h.state().session.project.clone(); + let undo = h.state().session.history.undo.len(); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None, "{how:?}: no prompt"); + for gone in [items[0], solid, folder] { + assert!(h.state().session.project.item(gone).is_none(), "{how:?}: {gone:?} goes"); + } + assert!(h.state().session.project.item(items[2]).is_some(), "the unselected item stays"); + let uses = h.state().session.project.comps().flat_map(|(_, c)| c.layers.iter()).filter(|l| l.source.item() == Some(solid)).count(); + assert_eq!(uses, 0, "the layer using the solid goes with it"); + assert!(h.state().session.active_comp().unwrap().layer(keep).is_some()); + 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, project); + } +} + +/// Shift+Delete with nothing selected deletes nothing, and skipping the prompt once (Shift+Delete, +/// or answering Delete) doesn't skip it for the next Delete. +#[test] +fn shift_delete_and_answered_prompts_dont_skip_the_next_prompt() { + for how in SHIFT_DELETE { + let (mut h, items, keep) = harness(); + let solid = items[1]; + click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE); + h.state_mut().session.execute("project.select", json!({"items": []})).unwrap(); + let project = h.state().session.project.clone(); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None); + assert_eq!(h.state().session.project, project, "{how:?} with nothing selected deletes nothing"); + assert!(h.state().session.active_comp().unwrap().layer(keep).is_some()); + + // Without asking once, then Delete on an item in use still asks. + click(&mut h, &format!("project.item.{}.name", items[2].0), Modifiers::NONE); + shift_delete(&mut h, how); + assert!(h.state().session.project.item(items[2]).is_none()); + click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::DeleteItems), "{how:?}: Delete still asks after Shift+Delete"); + click(&mut h, "dialog.deleteItems.cancel", Modifiers::NONE); + assert!(h.state().session.project.item(solid).is_some()); + } + + // Answering Delete runs that deletion only: the next Delete asks again. + let (mut h, items, _) = harness(); + let solid = items[1]; + h.state_mut().session.execute("layer.newSolid", json!({"name": "Second solid", "color": "#806040"})).unwrap(); + let second = h.state().session.project.items.values().find(|i| i.name == "Second solid").unwrap().id; + h.run_steps(2); + click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE); + key(&mut h, Key::Delete, Modifiers::NONE); + assert!(confirm_if_asked(&mut h)); + assert!(h.state().session.project.item(solid).is_none()); + click(&mut h, &format!("project.item.{}.name", second.0), Modifiers::NONE); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(h.state().dialog, Some(Dialog::DeleteItems), "the answer was for the first deletion only"); +} + +/// Shift+Delete only deletes Project items with the Project panel focused. +#[test] +fn shift_delete_in_other_panels_keeps_project_items() { + for how in SHIFT_DELETE { + let (mut h, items, _) = harness(); + click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE); + click(&mut h, "viewer.comp", Modifiers::NONE); + assert_eq!(h.state().ui.focused, PanelKind::Composition); + assert_eq!(h.state().session.state.project_selection, vec![items[0]]); + shift_delete(&mut h, how); + assert_eq!(h.state().dialog, None); + assert!(items.iter().all(|i| h.state().session.project.item(*i).is_some()), "{how:?} in the Composition panel"); + } +}