367 lines
19 KiB
Rust
367 lines
19 KiB
Rust
//! 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};
|
|
use effectcraft_ui_egui::dock::PanelKind;
|
|
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
|
|
use egui::{Event, Key, Modifiers, pos2};
|
|
use egui_kittest::Harness;
|
|
use serde_json::json;
|
|
|
|
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<ItemId>, LayerId) {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "Delete me", "width": 320, "height": 180, "duration": 4})).unwrap();
|
|
let comp = s.active_comp_id().unwrap();
|
|
s.execute("comp.new", json!({"name": "Working composition", "width": 320, "height": 180, "duration": 4})).unwrap();
|
|
s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap();
|
|
let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id;
|
|
let footage = std::sync::Arc::make_mut(&mut s.project).add_item(
|
|
"Data asset",
|
|
Default::default(),
|
|
None,
|
|
ItemKind::Footage(Footage { kind: FootageKind::Data, path: "original.json".into(), data: Some("{}".into()), ..Default::default() }),
|
|
);
|
|
let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap());
|
|
let keep = LayerId(s.execute("layer.newNull", json!({"name": "Keep this layer"})).unwrap()["layer"].as_u64().unwrap());
|
|
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
|
let folders: Vec<u64> = h.state().session.project.items.values().filter(|i| i.is_folder()).map(|i| i.id.0).collect();
|
|
h.state_mut().ui.project_open_folders.extend(folders);
|
|
h.run_steps(3);
|
|
(h, vec![comp, solid, footage, folder], keep)
|
|
}
|
|
|
|
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str, modifiers: Modifiers) {
|
|
// Let a dialog opened by the previous input finish its first (sizing) frame, so its buttons
|
|
// are where the automation tree says.
|
|
h.run_steps(2);
|
|
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
|
let p = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
|
|
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
|
h.input_mut().events.push(Event::PointerMoved(p));
|
|
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
|
h.step();
|
|
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
|
h.run_steps(2);
|
|
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
|
}
|
|
|
|
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.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, 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),
|
|
// Shift down, Cut and Shift up all in one frame.
|
|
ShiftDelete::WindowsCut => frame(h, &[Event::ModifiersChanged(Modifiers::SHIFT), Event::Cut, Event::ModifiersChanged(Modifiers::NONE)]),
|
|
}
|
|
}
|
|
|
|
/// Queue `events` together and run them as one frame.
|
|
fn frame(h: &mut Harness<'_, EffectcraftApp>, events: &[Event]) {
|
|
h.input_mut().events.extend(events.iter().cloned());
|
|
h.step();
|
|
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 {
|
|
if h.state().dialog != Some(Dialog::DeleteItems) {
|
|
return false;
|
|
}
|
|
click(h, "dialog.deleteItems.delete", Modifiers::NONE);
|
|
true
|
|
}
|
|
|
|
#[test]
|
|
fn delete_and_backspace_remove_project_items_and_undo() {
|
|
for delete_key in [Key::Delete, Key::Backspace] {
|
|
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);
|
|
assert_eq!(h.state().ui.focused, PanelKind::Project);
|
|
assert_eq!(h.state().session.state.project_selection, vec![item]);
|
|
assert_eq!(h.state().session.state.selected_layers, vec![keep]);
|
|
let project = h.state().session.project.clone();
|
|
let undo = h.state().session.history.undo.len();
|
|
key(&mut h, delete_key, Modifiers::NONE);
|
|
// Only the solid is used by a layer: only it asks first.
|
|
assert_eq!(confirm_if_asked(&mut h), target == 1, "item {target}");
|
|
assert!(h.state().session.project.item(item).is_none(), "{delete_key:?} 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.project.items.len(), project.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, project, "one undo restores the item and its references");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn project_multi_selection_deletes_together_and_undoes_once() {
|
|
let (mut h, items, keep) = harness();
|
|
for (i, item) in items.iter().enumerate() {
|
|
click(&mut h, &format!("project.item.{}.name", item.0), if i == 0 { Modifiers::NONE } else { Modifiers::COMMAND });
|
|
}
|
|
assert_eq!(h.state().session.state.project_selection, items);
|
|
let project = h.state().session.project.clone();
|
|
let undo = h.state().session.history.undo.len();
|
|
key(&mut h, Key::Backspace, Modifiers::NONE);
|
|
assert!(confirm_if_asked(&mut h), "the solid is in use");
|
|
assert!(items.iter().all(|i| h.state().session.project.item(*i).is_none()));
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_project_selection_does_not_delete_a_timeline_layer() {
|
|
let (mut h, items, keep) = harness();
|
|
click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE);
|
|
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
|
|
let undo = h.state().session.history.undo.len();
|
|
for delete_key in [Key::Delete, Key::Backspace] {
|
|
key(&mut h, delete_key, Modifiers::NONE);
|
|
assert!(h.state().session.active_comp().unwrap().layer(keep).is_some());
|
|
assert_eq!(h.state().session.history.undo.len(), undo);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn viewer_focus_keeps_layer_deletion_with_a_project_selection() {
|
|
for delete_key in [Key::Delete, Key::Backspace] {
|
|
let (mut h, items, keep) = harness();
|
|
click(&mut h, &format!("project.item.{}.name", items[0].0), Modifiers::NONE);
|
|
click(&mut h, "viewer.comp", Modifiers::NONE);
|
|
h.state_mut().session.execute("layer.select", json!({"layers": [keep.0]})).unwrap();
|
|
assert_eq!(h.state().ui.focused, PanelKind::Composition);
|
|
key(&mut h, delete_key, Modifiers::NONE);
|
|
assert!(h.state().session.active_comp().unwrap().layer(keep).is_none());
|
|
assert!(h.state().session.project.item(items[0]).is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typing_dialogs_and_modified_keys_do_not_delete_project_items() {
|
|
let (mut h, items, _) = harness();
|
|
let item = items[0];
|
|
click(&mut h, &format!("project.item.{}.name", item.0), Modifiers::NONE);
|
|
let project = h.state().session.project.clone();
|
|
// 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);
|
|
key(&mut h, Key::Enter, Modifiers::NONE);
|
|
h.run_steps(2);
|
|
assert!(h.ctx.egui_wants_keyboard_input(), "the inline rename field has focus");
|
|
for delete_key in [Key::Backspace, Key::Delete] {
|
|
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
|
|
/// it; Cancel and Escape keep everything, Delete removes the item and the layers using it in one
|
|
/// undo step. Agents running the command directly are not asked.
|
|
#[test]
|
|
fn deleting_items_in_use_asks_first() {
|
|
let (mut h, items, _) = harness();
|
|
let solid = items[1];
|
|
let usage = h.state_mut().session.execute("project.usage", json!({"items": [solid.0]})).unwrap();
|
|
assert_eq!(usage, json!({"items": 1, "layers": 1, "comps": 1}));
|
|
let uses = |h: &Harness<'_, EffectcraftApp>| {
|
|
h.state().session.project.comps().flat_map(|(_, c)| c.layers.iter()).filter(|l| l.source.item() == Some(solid)).count()
|
|
};
|
|
click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE);
|
|
for cancel in ["button", "escape"] {
|
|
key(&mut h, Key::Delete, Modifiers::NONE);
|
|
assert_eq!(h.state().dialog, Some(Dialog::DeleteItems));
|
|
if cancel == "button" {
|
|
click(&mut h, "dialog.deleteItems.cancel", Modifiers::NONE);
|
|
} else {
|
|
key(&mut h, Key::Escape, Modifiers::NONE);
|
|
}
|
|
assert_eq!(h.state().dialog, None, "{cancel}");
|
|
assert!(h.state().session.project.item(solid).is_some() && uses(&h) == 1, "{cancel} keeps the item and its layer");
|
|
}
|
|
let undo = h.state().session.history.undo.len();
|
|
key(&mut h, Key::Delete, Modifiers::NONE);
|
|
assert!(confirm_if_asked(&mut h));
|
|
assert!(h.state().session.project.item(solid).is_none() && uses(&h) == 0);
|
|
assert_eq!(h.state().session.history.undo.len(), undo + 1);
|
|
// The command itself (agents, scripts) deletes without asking.
|
|
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// A Cut is read with the modifiers held where it is among the frame's events, not those held
|
|
/// after the frame: Shift released in the same frame is still Shift+Delete, and a Ctrl+X followed
|
|
/// in the same frame by Ctrl up and Shift down is not.
|
|
#[test]
|
|
fn a_cut_is_read_with_the_modifiers_where_it_is_in_the_frame() {
|
|
use Event::{Cut, ModifiersChanged as M};
|
|
let (shift, ctrl, none) = (Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::NONE);
|
|
// (held from the frame before, this frame's events, whether the solid in use goes)
|
|
let cases: [(Modifiers, Vec<Event>, bool); 5] = [
|
|
(shift, vec![Cut, M(none)], true),
|
|
(ctrl, vec![Cut, M(none), M(shift)], false),
|
|
(none, vec![M(shift), Cut, M(none)], true),
|
|
(none, vec![M(ctrl), Cut, M(none), M(shift)], false),
|
|
(shift, vec![M(none), Cut, M(shift)], false),
|
|
];
|
|
for (n, (before, events, deletes)) in cases.into_iter().enumerate() {
|
|
let (mut h, items, _) = harness();
|
|
let solid = items[1];
|
|
click(&mut h, &format!("project.item.{}.name", solid.0), Modifiers::NONE);
|
|
frame(&mut h, &[M(before)]);
|
|
frame(&mut h, &events);
|
|
frame(&mut h, &[M(none)]);
|
|
assert_eq!(h.state().dialog, None, "case {n}: no prompt");
|
|
assert_eq!(h.state().session.project.item(solid).is_none(), deletes, "case {n}: {events:?} after {before:?}");
|
|
}
|
|
}
|