mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
New comps from footage get a unique name, as Duplicate does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9efe75330c
commit
9e66e0d2ec
5 files changed
+70
-8
No files matched your search
@@ -108,12 +108,7 @@ fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -207,6 +207,10 @@ fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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)
|
||||
|
||||
@@ -219,8 +219,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
|
||||
@@ -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<effectcraft_project::ItemId>, p: Value| -> Vec<String> {
|
||||
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();
|
||||
|
||||
@@ -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<ItemId> {
|
||||
self.items.values().find(|i| i.is_folder() && i.name == name).map(|i| i.id)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user