From 9624024dc1bb7427a2b91a3a0b9116b631498434 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:35:57 +0700 Subject: [PATCH] M3.16: deleting Project items that compositions use asks first As in After Effects, the Delete key and the Project panel's trash button ask before deleting items that layers in other compositions use, saying how many layers in how many compositions go with them (Delete / Cancel; Escape cancels). Unused items are deleted at once; agents running project.delete directly are not asked. project.usage {items?} reports the count, sharing the deletion's item and usage helpers. Follow-up to #72. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/project_items.rs | 38 +++++++-- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/panels/delete_items.rs | 86 +++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 3 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/tests/ui_project_delete.rs | 50 +++++++++++- 7 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/panels/delete_items.rs diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index a3949896..197c11a0 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -5,7 +5,7 @@ use effectcraft_project::{ItemId, Project}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, str_p}; -use crate::{EngineError, Result, Session, cmd}; +use crate::{EngineError, Result, Session, cmd, query}; /// `item` (id or name) → id. fn item_ref(s: &Session, v: &Value, cmd: &str) -> Result { @@ -140,17 +140,40 @@ fn set_comment(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The items deleting `ids` removes: them and, for folders, everything inside. +fn doomed_items(project: &Project, ids: &[ItemId]) -> Vec { + project.items.keys().copied().filter(|id| ids.iter().any(|d| is_within(project, *id, *d))).collect() +} + +/// What deleting `items` takes with it: (layers using them, in how many comps), counting only +/// the comps that stay. +fn usage_of(project: &Project, items: &[ItemId]) -> (usize, usize) { + let mut layers = 0; + let mut comps = 0; + for (_, comp) in project.comps().filter(|(cid, _)| !items.contains(cid)) { + let n = comp.layers.iter().filter(|l| l.source.item().is_some_and(|i| items.contains(&i))).count(); + layers += n; + comps += usize::from(n > 0); + } + (layers, comps) +} + +/// What deleting the items (default: selected) would delete: the items (with folder contents) +/// and the layers in other compositions that use them. The Project panel asks before deleting +/// items in use, as After Effects does. +fn usage(s: &mut Session, p: &Value) -> Result { + let ids = items_p(s, p, "project.usage")?; + let doomed = doomed_items(&s.project, &ids); + let (layers, comps) = usage_of(&s.project, &doomed); + Ok(json!({"items": doomed.len(), "layers": layers, "comps": comps})) +} + /// Delete project items (folders with their contents). Layers that use a deleted item and Render /// Queue items of deleted comps go too, like After Effects' Edit ▸ Clear in the Project panel. fn delete(s: &mut Session, p: &Value) -> Result { let c = "project.delete"; let ids = items_p(s, p, c)?; - let mut doomed: Vec = vec![]; - for id in s.project.items.keys() { - if ids.iter().any(|d| is_within(&s.project, *id, *d)) { - doomed.push(*id); - } - } + let doomed = doomed_items(&s.project, &ids); let n = doomed.len(); s.edit("Delete Items", None, |proj, st| { for id in &doomed { @@ -242,6 +265,7 @@ pub fn specs() -> Vec { always, delete ), + query!("project.usage", "Project Item Usage", "{items?: [id|name] (default: selected)} → {items, layers, comps} that deleting them removes", usage), cmd!("project.duplicate", "Duplicate Project Items", [], None, "{items?: [id|name] (default: selected)} → {items}", always, duplicate), ] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 63d1db37..eb2aa1b7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -84,6 +84,8 @@ pub enum Dialog { RenderTemplates, /// Save changes before closing the project? (`panels::unsaved`). UnsavedChanges, + /// Delete Project items that compositions use? (`panels::delete_items`). + DeleteItems, } /// Host hooks provided by the native app (file pickers etc.). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c83bb03f..0c2e7dec 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -196,6 +196,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V if crate::panels::unsaved::guard(app, id, ¶ms) { return Ok(json!({"dialog": "unsavedChanges"})); } + // Deleting Project items that compositions use asks first, as in After Effects. + if crate::panels::delete_items::guard(app, id, ¶ms) { + return Ok(json!({"dialog": "deleteItems"})); + } // J / K and Select All Keyframes act on what the Timeline shows (its revealed properties). if matches!(id, "time.nextKey" | "time.previousKey" | "keys.selectAll") && ["visible", "layers", "layer", "prop"].iter().all(|k| params.get(*k).is_none()) diff --git a/crates/ui-egui/src/panels/delete_items.rs b/crates/ui-egui/src/panels/delete_items.rs new file mode 100644 index 00000000..dbda93d7 --- /dev/null +++ b/crates/ui-egui/src/panels/delete_items.rs @@ -0,0 +1,86 @@ +//! Deleting Project items that compositions use asks first, as in After Effects: the Delete key +//! and the Project panel's trash button (`project.delete` through the UI) show how many layers in +//! how many compositions go with the items (`project.usage`). Items nothing uses are deleted at +//! once. Agents running `project.delete` with `engine.execute` are never asked. +//! +//! Automation ids: `dialog.deleteItems.delete`, `dialog.deleteItems.cancel`. + +use egui::{Color32, vec2}; +use serde_json::Value; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +/// The deletion waiting on the prompt: its params and what it takes with it. +#[derive(Clone, Debug, Default)] +pub struct Pending { + pub command: Option<(Value, Value)>, + /// The next `project.delete` runs without asking (the user answered Delete). + pub answered: bool, +} + +fn plural(n: u64, one: &str, many: &str) -> String { + format!("{n} {}", if n == 1 { one } else { many }) +} + +/// Whether `id` needs the prompt first. When it does, the prompt opens holding the command and +/// the caller must not run it (it runs once the user answers Delete). +pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> bool { + if id != "project.delete" || std::mem::take(&mut app.dialog_state.delete_items.answered) { + return false; + } + let Ok(usage) = app.session.execute("project.usage", params.clone()) else { return false }; + if usage["layers"].as_u64().unwrap_or(0) == 0 { + return false; + } + app.dialog_state.delete_items.command = Some((params.clone(), usage)); + app.dialog = Some(Dialog::DeleteItems); + true +} + +pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let Some((params, usage)) = app.dialog_state.delete_items.command.clone() else { + app.dialog = None; + return; + }; + let n = |k: &str| usage[k].as_u64().unwrap_or(0); + let mut answer: Option = None; + super::dialogs::modal(ctx, "Delete Items", vec2(460.0, 160.0), t, |ui| { + let text = format!( + "Delete {}? {} in {} {} {} and will be deleted too.", + plural(n("items"), "item", "items"), + plural(n("layers"), "layer", "layers"), + plural(n("comps"), "composition", "compositions"), + if n("layers") == 1 { "uses" } else { "use" }, + if n("items") == 1 { "it" } else { "them" }, + ); + ui.label(egui::RichText::new(text).color(t.tab_text_active)); + ui.add_space(4.0); + ui.label(egui::RichText::new("You can undo this.").color(t.text_dim).size(11.0)); + ui.add_space(18.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" Delete ").color(Color32::WHITE)).fill(t.danger)); + app.auto.add("dialog.deleteItems.delete", r.rect, "Delete"); + if r.clicked() { + answer = Some(true); + } + let r = ui.button("Cancel"); + app.auto.add("dialog.deleteItems.cancel", r.rect, "Cancel"); + if r.clicked() { + answer = Some(false); + } + }); + }); + if answer.is_none() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + answer = Some(false); + } + let Some(delete) = answer else { return }; + app.dialog = None; + app.dialog_state.delete_items.command = None; + if delete { + app.dialog_state.delete_items.answered = true; + if let Err(e) = crate::menus::invoke(app, ctx, "project.delete", params) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d806827c..5832d2c8 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -57,6 +57,8 @@ pub struct DialogState { pub layer_style: super::layer_styles_dialog::LayerStyleState, /// The unsaved-changes prompt and the command it holds. pub unsaved: super::unsaved::Pending, + /// Deleting Project items in use: the deletion waiting on the prompt. + pub delete_items: super::delete_items::Pending, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -188,6 +190,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t), Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t), Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t), + Dialog::DeleteItems => super::delete_items::show(app, ctx, &t), } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0021c159..c7484859 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -4,6 +4,7 @@ pub mod anim_tools; pub mod camera_tracker_ui; pub mod comp_settings; pub mod content_fill_panel; +pub mod delete_items; pub mod dialogs; pub mod dialogs_3d; pub mod effect_controls; diff --git a/crates/ui-egui/tests/ui_project_delete.rs b/crates/ui-egui/tests/ui_project_delete.rs index a343740d..97f12d86 100644 --- a/crates/ui-egui/tests/ui_project_delete.rs +++ b/crates/ui-egui/tests/ui_project_delete.rs @@ -2,8 +2,8 @@ use effectcraft_engine::Session; use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind, LayerId}; -use effectcraft_ui_egui::EffectcraftApp; 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; @@ -48,6 +48,16 @@ fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { h.run_steps(2); } +/// 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] { @@ -61,6 +71,8 @@ fn delete_and_backspace_remove_project_items_and_undo() { 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()); @@ -82,6 +94,7 @@ fn project_multi_selection_deletes_together_and_undoes_once() { 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); @@ -139,3 +152,38 @@ fn typing_dialogs_and_modified_keys_do_not_delete_project_items() { assert_eq!(h.state().session.project, project, "typing must not delete the item"); } } + +/// 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()); +}