From 9e66e0d2ec827cefefe63de244e90182239be813 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:45:21 +0200 Subject: [PATCH] New comps from footage get a unique name, as Duplicate does Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/comp.rs | 7 +-- crates/engine/src/commands/file_more.rs | 4 ++ crates/engine/src/commands/project_items.rs | 3 +- crates/engine/src/tests_menu_cmds.rs | 51 +++++++++++++++++++++ crates/project/src/lib.rs | 13 ++++++ 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 3fcf9c6d7..e00b4eb24 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -108,12 +108,7 @@ fn new_comp(s: &mut Session, p: &Value) -> Result { } apply_settings(&mut c, p); let id = s.edit("New Composition", None, |proj, st| { - let mut name = name.clone(); - let mut k = 2; - while proj.items.values().any(|i| i.name == name) { - name = format!("{} {k}", name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end()); - k += 1; - } + let name = proj.unique_item_name(&name, |_| false); let id = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into())); st.project_selection = vec![id]; Ok(id) diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index ef26d59b9..1c703e5e0 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -207,6 +207,10 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result { let name = lead.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(lead.name.clone()); let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) }; let cid = s.edit("New Comp from Selection", None, |proj, st| { + // A name another item already has gets the next free number, as Duplicate gives + // ("clip", "clip 2", "clip 3"). The comp's own footage and solid sources don't + // count, so an extensionless one still names its first comp; a source comp does. + let name = proj.unique_item_name(&name, |i| g.contains(&i.id) && !matches!(i.kind, ItemKind::Comp(_))); let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into())); st.project_selection = vec![cid]; Ok(cid) diff --git a/crates/engine/src/commands/project_items.rs b/crates/engine/src/commands/project_items.rs index 197c11a0d..0868349ab 100644 --- a/crates/engine/src/commands/project_items.rs +++ b/crates/engine/src/commands/project_items.rs @@ -219,8 +219,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { if it.is_folder() { return Err(bad(c, "folders can't be duplicated")); } - let base = it.name.trim_end_matches(|ch: char| ch.is_ascii_digit()).trim_end().to_string(); - let name = (2..).map(|i| format!("{base} {i}")).find(|n| proj.find_by_name(n).is_none()).unwrap_or_default(); + let name = proj.numbered_item_name(&it.name, |_| false); let mut kind = it.kind.clone(); if let effectcraft_project::ItemKind::Comp(comp) = &mut kind { let comp = std::sync::Arc::make_mut(comp); diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c1639..3e1611727 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1069,6 +1069,57 @@ fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() { assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err()); } +#[test] +fn comps_made_from_the_same_footage_get_numbered_names_as_duplicate_does() { + let mut s = Session::default(); + let add = |s: &mut Session, name: &str, kind: ItemKind| std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, kind); + let still = || { + ItemKind::Footage(effectcraft_project::Footage { + kind: effectcraft_project::FootageKind::Still, + width: 200, + height: 100, + has_video: true, + ..Default::default() + }) + }; + let clip = add(&mut s, "clip.png", still()); + let other = add(&mut s, "other.png", still()); + let make = |s: &mut Session, sel: Vec, p: Value| -> Vec { + s.state.project_selection = sel; + let r = s.execute("file.newCompFromSelection", p).unwrap(); + r["comps"].as_array().unwrap().iter().map(|c| s.project.item(effectcraft_project::ItemId(c.as_u64().unwrap())).unwrap().name.clone()).collect() + }; + // The same footage again and again: "clip", "clip 2", "clip 3". + assert_eq!(make(&mut s, vec![clip], json!({})), ["clip"]); + assert_eq!(make(&mut s, vec![clip], json!({})), ["clip 2"]); + assert_eq!(make(&mut s, vec![clip], json!({})), ["clip 3"]); + // Several items at once (one comp each): every new comp has a name of its own. + assert_eq!(make(&mut s, vec![clip, other, clip], json!({})), ["clip 4", "other", "clip 5"]); + // One comp for all, named after the item it takes its settings from. + assert_eq!(make(&mut s, vec![clip, other], json!({"single": true})), ["clip 6"]); + assert_eq!(make(&mut s, vec![clip, other], json!({"single": true, "dimensionsFrom": 1})), ["other 2"]); + // Undo removes the latest comp and frees its name for the next one. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.find_by_name("other 2").is_none()); + assert_eq!(make(&mut s, vec![other], json!({})), ["other 2"]); + // A solid or extensionless footage still names its own first comp; the second is numbered. + let red = add(&mut s, "Red Solid 1", ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 0.0, 0.0], width: 100, height: 100, pixel_aspect: 1.0 })); + assert_eq!(make(&mut s, vec![red], json!({})), ["Red Solid 1"]); + assert_eq!(make(&mut s, vec![red], json!({})), ["Red Solid 2"]); + // Duplicate numbers its copies the same way, from the same helper. + let first = s.project.find_by_name("clip").unwrap().id; + s.state.project_selection = vec![first]; + let r = s.execute("project.duplicate", json!({})).unwrap(); + assert_eq!(s.project.item(effectcraft_project::ItemId(r["items"][0].as_u64().unwrap())).unwrap().name, "clip 7"); + // A comp made from a comp doesn't take its source comp's name. + assert_eq!(make(&mut s, vec![first], json!({})), ["clip 8"]); + // Several comps at once are one undo step; undoing frees all their names. + assert_eq!(make(&mut s, vec![clip, other], json!({})), ["clip 9", "other 3"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.find_by_name("clip 9").is_none() && s.project.find_by_name("other 3").is_none()); + assert_eq!(make(&mut s, vec![other, clip], json!({})), ["other 3", "clip 9"]); +} + #[test] fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() { let mut s = comp(); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce7..313b7467b 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1375,6 +1375,19 @@ impl Project { pub fn find_by_name(&self, name: &str) -> Option<&Item> { self.items.values().find(|i| i.name == name) } + /// A numbered name for a copy of `base` that no project item has (Edit ▸ Duplicate): its + /// trailing number is dropped and the lowest free one from 2 is added (`Comp 1` → `Comp 2`, + /// `Name` → `Name 2`, then `Name 3`). Items for which `ignore` holds don't count. + pub fn numbered_item_name(&self, base: &str, ignore: impl Fn(&Item) -> bool) -> String { + let stem = base.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end(); + let taken = |n: &str| self.items.values().any(|i| i.name == n && !ignore(i)); + (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() + } + /// `base` when no project item has that name, else the next free numbered name as + /// [`Project::numbered_item_name`] gives it. Items for which `ignore` holds don't count. + pub fn unique_item_name(&self, base: &str, ignore: impl Fn(&Item) -> bool) -> String { + if self.items.values().any(|i| i.name == base && !ignore(i)) { self.numbered_item_name(base, ignore) } else { base.to_string() } + } pub fn folder_named(&self, name: &str) -> Option { self.items.values().find(|i| i.is_folder() && i.name == name).map(|i| i.id) }