mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -06:00
Merge pull request #1439
Duplicate names the copy like PowerPoint: next automatic name, chosen names kept
This commit is contained in:
@@ -164,6 +164,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) |
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>, 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
|
||||
|
||||
@@ -321,7 +321,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
&& !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());
|
||||
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;
|
||||
@@ -508,6 +511,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>, 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, count);
|
||||
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 +548,17 @@ 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();
|
||||
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;
|
||||
});
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
@@ -540,9 +567,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"))?;
|
||||
|
||||
@@ -187,9 +187,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>) -> 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<String>, 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<ShapeId> {
|
||||
@@ -197,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);
|
||||
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"))?;
|
||||
|
||||
@@ -237,6 +237,132 @@ 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:?}");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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:?}");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user