mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
01ba8a85e9
commit
a65d70f10e
2 files changed
+163
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user