diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index aa4ff7201..63edc65be 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -265,13 +265,11 @@ impl Comp { self.layers.iter().find(|l| matches!(l.source, LayerSource::Camera) && l.switches.video && l.is_active_at(t)) } - /// Unique layer name with a numeric suffix (`Shape Layer 2`). + /// `base`, or the next free numbered name when a layer already has it (`Shape Layer 2`, + /// `v-b1.mp4 2`; see `numbered_name`). pub fn unique_layer_name(&self, base: &str) -> String { - if !self.layers.iter().any(|l| l.name == base) { - return base.to_string(); - } - let stem = base.trim_end_matches(|c: char| c.is_ascii_digit()).trim_end(); - (2..).map(|i| format!("{stem} {i}")).find(|n| !self.layers.iter().any(|l| &l.name == n)).unwrap_or_default() + let taken = |n: &str| self.layers.iter().any(|l| l.name == n); + if taken(base) { crate::item::numbered_name(base, taken) } else { base.to_string() } } } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 1ef3d1f0a..047f1ef3f 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -4,6 +4,19 @@ use super::layer::LayerSource; use super::settings::{APP_VERSION, ProjectError, ProjectSettings, SCHEMA_VERSION, SavedView}; use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Tick, file_check, limits}; +/// `base` numbered with the lowest counter from 2 for which `taken` is false: a copy of an item or +/// another layer of the same name. A number set off by a space is a counter and is replaced +/// (`Red Solid 1` → `Red Solid 2`, `shot 2` → `shot 3`); digits that end a word belong to the +/// name and stay (`v-b1.mp4` → `v-b1.mp4 2`, `clip2.mov` → `clip2.mov 2`). +pub(crate) fn numbered_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + let head = base.trim_end_matches(|c: char| c.is_ascii_digit()); + let stem = match head.strip_suffix(' ') { + Some(stem) if head.len() < base.len() && !stem.trim_end().is_empty() => stem.trim_end(), + _ => base, + }; + (2u64..).map(|k| format!("{stem} {k}")).find(|n| !taken(n)).unwrap_or_default() +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ItemId(pub u64); @@ -162,13 +175,11 @@ 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. + /// A numbered name for a copy of `base` that no project item has (Edit ▸ Duplicate), as + /// `numbered_name` gives it (`Comp 1` → `Comp 2`, `Name` → `Name 2`, then `Name 3`, + /// `v-b1.mp4` → `v-b1.mp4 2`). 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() + numbered_name(base, |n| self.items.values().any(|i| i.name == n && !ignore(i))) } /// `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. diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index a47792b89..ed0d05588 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -113,6 +113,33 @@ fn resolution_factors_and_scales() { assert_eq!(Resolution::Third.scale(0.1, 1.0), 1.0 / 3.0); } +/// Another layer of the same source, and a duplicated item, get a numbered name. Only a number set +/// off by a space is a counter; digits that end a word stay (`v-b1.mp4` gave `v-b1.mp 2`). +#[test] +fn numbered_names_keep_digits_that_end_a_word() { + // (the name in use, the next layer's and the item copy's name) + let rows = [ + ("v-b1.mp4", "v-b1.mp4 2"), + ("clip2.mov", "clip2.mov 2"), + ("shot 2", "shot 3"), + ("a.b.c", "a.b.c 2"), + ("Background", "Background 2"), + ("Red Solid 1", "Red Solid 2"), + ]; + for (name, next) in rows { + let mut p = Project::default(); + let mut comp = Comp::new(64, 36, FrameRate::FPS_29_97, Tick::from_seconds_f64(1.0)); + let solid = Solid { color: [0.5; 3], width: 64, height: 36, pixel_aspect: 1.0 }; + let item = p.add_item(name, effectcraft_color::Label::Red, None, ItemKind::Solid(solid)); + let first = build::layer(&mut p, &comp, name, LayerSource::Solid { item }, (64, 36), None); + assert_eq!(first.name, name, "the first layer of {name}"); + comp.layers.push(first); + let second = build::layer(&mut p, &comp, name, LayerSource::Solid { item }, (64, 36), None); + assert_eq!(second.name, next, "the second layer of {name}"); + assert_eq!(p.numbered_item_name(name, |_| false), next, "a copy of the item {name}"); + } +} + #[test] fn unique_names_and_cycles() { let (p, cid) = project_with_layer();