Merge pull request #92 from storytold/m3-delete-confirm

M3.16: deleting Project items in use asks first (After Effects parity)
This commit is contained in:
BFlat 2026-10-06 11:36:27 +07:00 committed by GitHub
commit 80c246d843
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 176 additions and 8 deletions

View File

@ -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<ItemId> {
@ -140,17 +140,40 @@ fn set_comment(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// The items deleting `ids` removes: them and, for folders, everything inside.
fn doomed_items(project: &Project, ids: &[ItemId]) -> Vec<ItemId> {
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<Value> {
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<Value> {
let c = "project.delete";
let ids = items_p(s, p, c)?;
let mut doomed: Vec<ItemId> = 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<CommandSpec> {
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),
]
}

View File

@ -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.).

View File

@ -196,6 +196,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::unsaved::guard(app, id, &params) {
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, &params) {
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())

View File

@ -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<bool> = 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;
}
}
}

View File

@ -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),
}
}

View File

@ -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;

View File

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