From 8ada6016301082044aaa4b29e19508e27bf27d72 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:17:53 -0500 Subject: [PATCH 1/4] Duplicate gives the copy the next automatic name and keeps a chosen name, as PowerPoint does --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 18 ++++++++++-- crates/engine/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69..b6549bd4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) | | 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value | | 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) | +| 2026-10-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" | | 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms | | 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All | | 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b098503c..3ce31937 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -508,6 +508,19 @@ fn paste_text(s: &mut Session, p: &Value) -> Result { paste_plain(s, &t) } +/// Duplicate gives a copy the next automatic name ("Rectangle 4" after "Rectangle 1"), as +/// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing +/// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}" +/// counts as automatic. +fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet) { + let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) }; + let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' ')); + if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) { + sh.name = super::insert::first_free_name(&base, taken); + taken.insert(sh.name.clone()); + } +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let off = s.prefs.duplicate_offset; @@ -532,6 +545,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { }) .unwrap_or((off, off)); reid("edit.duplicate", doc, &mut copies)?; + let mut taken = super::current_names(doc, sel); + walk_mut(&mut copies, &mut |c| rename_automatic(c, &mut taken)); for (c, b) in copies.iter_mut().zip(boxes) { let mut x = b; x.x += dx; @@ -540,9 +555,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { if sel.target == crate::Target::Slides { c.ph = None; } - if !c.name.is_empty() { - c.name = format!("{} copy", c.name.trim_end_matches(" copy")); - } } let new_ids: Vec = copies.iter().map(|c| c.id).collect(); let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4dfc21f4..f182870f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -237,6 +237,61 @@ fn pasted_shapes_get_the_next_automatic_name() { assert_eq!(names(&s).last().unwrap(), "Hero"); } +/// PowerPoint 16.0 (Windows): on "Rectangle 1, Oval 2, TextBox 3, Hero" Duplicate gives +/// "Rectangle 5" and then "Rectangle 6", "TextBox 7"; the copy of "Hero" (and of its copy) is +/// "Hero". Never "Hero copy". +#[test] +fn duplicate_gives_the_next_automatic_name_and_keeps_a_chosen_one() { + let mut s = session(); + let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::>(); + let add = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + s.doc().unwrap().selection.shapes[0].0 + }; + let rect = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [10, 10, 50, 50]})); + let tb = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [100, 10, 50, 50]})); + s.execute("shape.rename", &json!({"id": tb, "name": "Hero"})).unwrap(); + let n = names(&s).len(); + let auto = names(&s)[n - 2].clone(); + let (base, _) = auto.rsplit_once(' ').unwrap(); + + s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap(); + let all = names(&s); + assert_eq!(all[n], format!("{base} {}", n + 1), "{all:?}"); + assert_eq!(all[n + 1], format!("{base} {}", n + 2), "{all:?}"); + + s.execute("edit.duplicate", &json!({"ids": [tb]})).unwrap(); + let copy = s.doc().unwrap().selection.shapes[0].0; + s.execute("edit.duplicate", &json!({"ids": [copy]})).unwrap(); + let all = names(&s); + assert_eq!(&all[n + 2..], ["Hero", "Hero"], "{all:?}"); +} + +#[test] +fn duplicating_a_group_renames_its_automatic_children() { + let mut s = session(); + let mut ids = vec![]; + for x in [10, 100] { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap(); + ids.push(s.doc().unwrap().selection.shapes[0].0); + } + s.execute("arrange.group", &json!({"ids": ids})).unwrap(); + let group = s.doc().unwrap().selection.shapes[0].0; + s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap(); + let doc = s.doc().unwrap(); + let shapes = &doc.current_slide().unwrap().shapes; + let (orig, copy) = (&shapes[shapes.len() - 2], &shapes[shapes.len() - 1]); + assert!(orig.is_group() && copy.is_group()); + assert_ne!(orig.name, copy.name); + assert!(copy.name.starts_with("Group "), "{}", copy.name); + let all: Vec<_> = orig.children().iter().chain(copy.children()).map(|c| c.name.as_str()).collect(); + let mut unique = all.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + #[test] fn pasting_after_a_deleted_shape_does_not_repeat_a_name() { let mut s = session(); From fa0a3e9dee444ac375756cfd4c231fbfe7c27c58 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:40:21 -0500 Subject: [PATCH 2/4] Duplicate: count the names inside groups when numbering copies --- crates/engine/src/cmd/edit.rs | 6 +++++- crates/engine/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3ce31937..8a220c17 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -545,7 +545,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { }) .unwrap_or((off, off)); reid("edit.duplicate", doc, &mut copies)?; - let mut taken = super::current_names(doc, sel); + // Every name on the slide, group members included, so a copy never repeats one. + let mut taken = std::collections::HashSet::new(); + deckcraft_model::walk(&list, &mut |sh, _| { + taken.insert(sh.name.clone()); + }); walk_mut(&mut copies, &mut |c| rename_automatic(c, &mut taken)); for (c, b) in copies.iter_mut().zip(boxes) { let mut x = b; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f182870f..133e5a46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -268,6 +268,30 @@ fn duplicate_gives_the_next_automatic_name_and_keeps_a_chosen_one() { assert_eq!(&all[n + 2..], ["Hero", "Hero"], "{all:?}"); } +/// The next automatic name looks at the names inside groups too. +#[test] +fn duplicating_a_group_does_not_repeat_the_names_of_its_members() { + let mut s = session(); + let mut ids = vec![]; + for x in [10, 70, 130, 190] { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap(); + ids.push(s.doc().unwrap().selection.shapes[0].0); + } + s.execute("arrange.group", &json!({"ids": [ids[2], ids[3]]})).unwrap(); + let group = s.doc().unwrap().selection.shapes[0].0; + // Fewer top-level shapes than names in use: the next number is one a member already has. + s.execute("edit.delete", &json!({"ids": [ids[0], ids[1]]})).unwrap(); + s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap(); + let doc = s.doc().unwrap(); + let mut all = vec![]; + deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone())); + let mut unique = all.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + +/// Group members are renamed like any other copied shape. #[test] fn duplicating_a_group_renames_its_automatic_children() { let mut s = session(); From ddd7d210ef71874ba7c0ba96c0036eef98ad531d Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:02:22 -0500 Subject: [PATCH 3/4] Duplicate: number the copy from the shape count, not the count of distinct names --- crates/engine/src/cmd/edit.rs | 13 +++++++++---- crates/engine/src/cmd/insert.rs | 9 +++++---- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8a220c17..ff69575f 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -330,7 +330,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) { - sh.name = super::insert::first_free_name(base, &taken); + sh.name = super::insert::first_free_name(base, &taken, taken.len()); } taken.insert(sh.name.clone()); if let Some(x) = sh.xfrm.as_mut() { @@ -512,11 +512,11 @@ fn paste_text(s: &mut Session, p: &Value) -> Result { /// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing /// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}" /// counts as automatic. -fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet) { +fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet, count: usize) { let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) }; let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' ')); if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) { - sh.name = super::insert::first_free_name(&base, taken); + sh.name = super::insert::first_free_name(&base, taken, count); taken.insert(sh.name.clone()); } } @@ -547,10 +547,15 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { reid("edit.duplicate", doc, &mut copies)?; // Every name on the slide, group members included, so a copy never repeats one. let mut taken = std::collections::HashSet::new(); + let mut count = 0; deckcraft_model::walk(&list, &mut |sh, _| { taken.insert(sh.name.clone()); + count += 1; + }); + walk_mut(&mut copies, &mut |c| { + rename_automatic(c, &mut taken, count); + count += 1; }); - walk_mut(&mut copies, &mut |c| rename_automatic(c, &mut taken)); for (c, b) in copies.iter_mut().zip(boxes) { let mut x = b; x.x += dx; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index f2a2b9bd..95af342c 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -172,9 +172,10 @@ pub(crate) fn auto_name_base(shape: &Shape) -> String { } /// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses, -/// so a gap left by a deleted shape never makes a second shape with the same name. -pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet) -> String { - (taken.len() + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default() +/// so a gap left by a deleted shape never makes a second shape with the same name. `count` is the +/// number of shapes, which exceeds the number of names when two shapes share one. +pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet, count: usize) -> String { + (count + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default() } pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result { @@ -183,7 +184,7 @@ pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bo if shape.name.is_empty() { let base = auto_name_base(&shape); let taken = super::current_names(doc, sel); - shape.name = first_free_name(&base, &taken); + shape.name = first_free_name(&base, &taken, taken.len()); } let id = shape.id; let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 133e5a46..b2298100 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -268,6 +268,26 @@ fn duplicate_gives_the_next_automatic_name_and_keeps_a_chosen_one() { assert_eq!(&all[n + 2..], ["Hero", "Hero"], "{all:?}"); } +/// Two shapes sharing a chosen name are still two shapes: the next number follows the count. +#[test] +fn duplicate_numbers_from_the_shape_count_when_names_repeat() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap(); + let rect = s.doc().unwrap().selection.shapes[0].0; + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 10, 50, 50]})).unwrap(); + let hero = s.doc().unwrap().selection.shapes[0].0; + s.execute("shape.rename", &json!({"id": hero, "name": "Hero"})).unwrap(); + s.execute("edit.duplicate", &json!({"ids": [hero]})).unwrap(); + let before = s.doc().unwrap().current_slide().unwrap().shapes.len(); + s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap(); + let doc = s.doc().unwrap(); + let shapes = &doc.current_slide().unwrap().shapes; + let source = shapes.iter().find(|x| x.id.0 == rect).unwrap(); + let base = source.name.rsplit_once(' ').unwrap().0; + let names: Vec<_> = shapes.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(shapes[before].name, format!("{base} {}", before + 1), "{names:?}"); +} + /// The next automatic name looks at the names inside groups too. #[test] fn duplicating_a_group_does_not_repeat_the_names_of_its_members() { From 352fec7725408e80bdee168aa02ac4d7e9af9f5a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 15:53:18 -0400 Subject: [PATCH 4/4] Insert and paste count group members when picking the next automatic name Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd.rs | 14 ++++++++++++-- crates/engine/src/cmd/edit.rs | 9 ++++++--- crates/engine/src/cmd/insert.rs | 4 ++-- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 59513c6b..6f20c414 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -515,8 +515,18 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c } } -pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet { - current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default() +/// Every name on the slide, group members included, and the number of shapes (which exceeds the +/// number of names when two shapes share one). +pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet, usize) { + let mut names = std::collections::HashSet::new(); + let mut count = 0; + if let Some(v) = current_shapes(doc, sel) { + deckcraft_model::walk(v, &mut |sh, _| { + names.insert(sh.name.clone()); + count += 1; + }); + } + (names, count) } /// The command palette runs a command with `{}`. True when the params doc names a value that diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ff69575f..f084c622 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -321,7 +321,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { let mut shapes = clip.shapes.clone(); reid("edit.paste", doc, &mut shapes)?; crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media)); - let mut taken = super::current_names(doc, sel); + let (mut taken, mut count) = super::current_names(doc, sel); for sh in &mut shapes { // Only a name already on the slide is changed (two shapes with one name are what the // issue is about); otherwise it stays, as Morph pairs shapes across slides by name. @@ -330,9 +330,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) { - sh.name = super::insert::first_free_name(base, &taken, taken.len()); + sh.name = super::insert::first_free_name(base, &taken, count); } - taken.insert(sh.name.clone()); + deckcraft_model::walk(std::slice::from_ref(sh), &mut |m, _| { + taken.insert(m.name.clone()); + count += 1; + }); if let Some(x) = sh.xfrm.as_mut() { x.x += off; x.y += off; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 92f44711..5d9c9344 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -198,8 +198,8 @@ pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bo shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); if shape.name.is_empty() { let base = auto_name_base(&shape); - let taken = super::current_names(doc, sel); - shape.name = first_free_name(&base, &taken, taken.len()); + let (taken, count) = super::current_names(doc, sel); + shape.name = first_free_name(&base, &taken, count); } let id = shape.id; let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b2298100..da4dfab4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -311,6 +311,33 @@ fn duplicating_a_group_does_not_repeat_the_names_of_its_members() { assert_eq!(unique.len(), all.len(), "{all:?}"); } +/// A shape inserted or pasted after a group was duplicated takes none of its members' names. +#[test] +fn inserting_after_duplicating_a_group_does_not_repeat_a_member_name() { + let mut s = session(); + let mut ids = vec![]; + for x in [10, 70] { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap(); + ids.push(s.doc().unwrap().selection.shapes[0].0); + } + s.execute("arrange.group", &json!({"ids": ids})).unwrap(); + let group = s.doc().unwrap().selection.shapes[0].0; + s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap(); + // Fewer top-level shapes than shapes: counting only the top level reaches a member's number. + for x in [130, 190, 250] { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap(); + } + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + let mut all = vec![]; + deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone())); + let mut unique = all.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + /// Group members are renamed like any other copied shape. #[test] fn duplicating_a_group_renames_its_automatic_children() {