collective

Browse / github.com/storytold/deckcraft / patch

Duplicate names the copy like PowerPoint: next automatic name, chosen names kept

Duplicate names the copy like PowerPoint: next automatic name, chosen names kept ## What Duplicate (Ctrl+D) now names the copy the way PowerPoint does. Before, every copy of a named shape became "X copy" ("Rectangle 1 copy", "Hero copy"). - A shape with an automatic name ("Rectangle 1", "TextBox 3", "Group 15") gets the next automatic name for its kind ("Rectangle 5"), the same one a new shape would get. The shapes inside a duplicated group are renamed the same way. - A name you chose ("Hero") stays as it is on the copy. Paste already behaves like this (#1334); this brings Duplicate in line with it. ## What PowerPoint does (observed) PowerPoint 16.0 for Windows (build

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1439, open)
Base
27009cd941b22cb0788cd5040f9fad91472230c7
Size
+99 −3 in 3 files
Review
pending
Shared
2026-10-28
Id
55b0baeaa7104f9fb0a9228d

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/edit.rs crates/engine/src/tests.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- 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--- 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<Value> {     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<String>) {+    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<Value> {     let ids = targets(s, p)?;     let off = s.prefs.duplicate_offset;@@ -532,6 +545,12 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {             })             .unwrap_or((off, off));         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();+        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;             x.x += dx;@@ -540,9 +559,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {             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<ShapeId> = 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--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -237,6 +237,85 @@ 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::<Vec<_>>();+    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:?}");+}++/// 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();+    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();