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()); +}