Project: Shift+Delete deletes the selected items without asking (#484)

With the Project panel focused, Delete Project Items Without Confirmation
runs project.delete on the selection: items compositions use go without
the prompt, with their layers, in one undo step. In the other panels it
does nothing, and like every shortcut it doesn't fire while typing or
with a dialog open.

On Windows the windowing layer sends Shift+Delete as Cut (the old Cut
key), which was mapped to Ctrl+X: in the Project panel, which has no
clipboard, a Cut with only Shift held is Shift+Delete now. Elsewhere it
still cuts.

Tests (ui_project_delete): Shift+Delete as a key and as Windows' Cut
deletes a comp, a solid in use, footage and a folder without the prompt
and one undo restores them; a multi-selection with a folder holding a
solid in use goes together; an empty selection deletes nothing; the next
Delete still asks after Shift+Delete or an answered prompt; it does
nothing in the Composition panel, while renaming or with a dialog open;
Shift+Backspace, Alt/Ctrl+Delete and Ctrl/Alt+Shift+Delete don't delete.
The deleting tests fail without this change.
This commit is contained in:
tylerjenningsw committed 2026-10-09 16:27:48 -04:00
1 parent 01ba8a85e9
commit a65d70f10e
2 files changed
+163 -3

No files matched your search

+14
View File
@@ -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, &params) {
@@ -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,
+149 -3
View File
@@ -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");
}
}