From 046e4439440fca9d4385e16cea9526857b1020ac Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:52:57 +0300 Subject: [PATCH 1/4] Fix #5054: a double-click on a repeat isolates it to edit its source A double-click on a Radial, Grid or Mirror Repeat with the Selection tool enters isolation mode on it. There its source art is selected and edited as usual (a double-click on type in it switches to the Type tool on that text) and every copy follows, the repeat keeping its settings. --- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/repeat_edit.rs | 40 ++++++++++++++++++++++++++ crates/tools/src/select.rs | 8 +++++- docs/target-app-parity.md | 5 ++++ docs/ui-parity.md | 1 + 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/tests/repeat_edit.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 26cf67627..4345f640c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -135,6 +135,7 @@ mod recolor; mod recovery; mod reflecthit; mod registration; +mod repeat_edit; mod save; mod saveoptions; mod scalestrokes; diff --git a/crates/engine/src/tests/repeat_edit.rs b/crates/engine/src/tests/repeat_edit.rs new file mode 100644 index 000000000..ca57a569f --- /dev/null +++ b/crates/engine/src/tests/repeat_edit.rs @@ -0,0 +1,40 @@ +//! Editing a repeat's source art (#5054): a double-click on a repeat with the Selection tool +//! enters isolation mode on it; there a double-click on its type edits the text, and the repeat +//! keeps its settings and draws the new text in every copy. + +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use crate::*; + +fn click(s: &mut Session, kind: PointerKind, x: f64, y: f64) -> Vec { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo { smart_guides: false, ..ViewInfo::default() }).unwrap() +} + +#[test] +fn a_double_click_on_a_repeat_isolates_it_to_edit_its_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let text = NodeId(s.execute("text.create", &json!({"x": 50, "y": 60, "text": "Alice", "size": 24})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [text.0]})).unwrap(); + let repeat = NodeId(s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap()); + let spec = |s: &Session| match &s.doc().unwrap().doc.node(repeat).unwrap().kind { + NodeKind::Repeat(r) => serde_json::to_value(r).unwrap(), + k => panic!("not a repeat: {k:?}"), + }; + let before = spec(&s); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let b = s.doc().unwrap().doc.node(text).unwrap().geometric_bounds().unwrap(); + let (x, y) = (b.center().x, b.center().y); + click(&mut s, PointerKind::DoubleClick, x, y); + assert_eq!(s.doc().unwrap().isolation, Some(repeat), "the double-click isolates the repeat"); + // Inside it, a double-click on the type asks for the Type tool, which edits that text. + let asked = click(&mut s, PointerKind::DoubleClick, x, y); + assert!(asked.iter().any(|r| matches!(r, UiRequest::SwitchTool(t) if t == "type")), "{asked:?}"); + s.execute("text.setText", &json!({"id": text.0, "text": "Bob"})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(text).unwrap().kind else { panic!("not type") }; + assert_eq!(t.plain_text(), "Bob"); + assert_eq!(s.doc().unwrap().doc.position(text).and_then(|(p, ..)| p), Some(repeat), "still the repeat's source"); + assert_eq!(spec(&s)["rows"], before["rows"], "the repeat keeps its settings"); +} diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 46f2401f2..17d5fef17 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1035,7 +1035,8 @@ pub(crate) fn is_threadable(n: &vectorcraft_doc::Node) -> bool { /// Does a double-click or the Control bar's Isolate Selected Object button isolate an object of /// this kind? A group, a compound path or a single path, image, mesh or placed file does. Blends, -/// envelopes, repeats and symbol instances do not; they have editing modes of their own. +/// envelopes, repeats and symbol instances do not; they have editing modes of their own (a +/// double-click on a repeat isolates it to edit its source art). pub fn isolates(kind: &vectorcraft_doc::NodeKind) -> bool { use vectorcraft_doc::NodeKind as K; matches!(kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_)) @@ -1198,6 +1199,11 @@ impl SelectionTool { if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })) { return if cx.doc.symbol_edit.is_some() { vec![] } else { vec![Action::Exec("symbol.edit".into(), json!({ "id": top.0 }))] }; } + // A repeat: its source art is edited in isolation mode, where the repeat follows each + // edit (#5054), as the reference app's Repeat does. + if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Repeat(_))) && cx.isolation != Some(top) { + return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; + } // An object that [`isolates`], not the one already isolated. if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| isolates(&n.kind)) && cx.isolation != Some(top) { return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dd083fdb1..d69100339 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1513,10 +1513,15 @@ Edit › Keyboard Shortcuts lists every item of the Effect menu under its menu p While a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the kind of the corners being edited (Round, Inverted Round, Chamfer, in turn, from the pressed corner's), shown live with the radius and kept, with it, as one undo step when the drag ends; a press with an arrow and no drag changes just the kind (#2898). Alt-clicking a widget still cycles it as before. Test: `tests::livecorners::arrows_while_dragging_a_corner_widget_change_the_corner_kind`. +## Repeat: edit the source art in isolation mode (2026-11-01) + +A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool enters isolation mode on it ([isolate objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/isolate-objects.html): a double-click with the Selection tool isolates an object; [repeat patterns overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/repeat-patterns-overview.html): every instance follows edits to the original object); there the source art is selected and edited as usual (a double-click on type in it switches to the Type tool on that text) and every copy follows, with the repeat's settings kept (#5054). Test: `tests::repeat_edit::a_double_click_on_a_repeat_isolates_it_to_edit_its_text`. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Repeat: a double-click isolates it to edit its source art, text included (#5054); evidence added; scores unchanged. | | 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. | | 2026-11-01 | minor | Shortcuts for Effect menu items (#3296); evidence added; scores unchanged. | | 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 88e87ddc3..e915b8a65 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -400,6 +400,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | A double-click on a Repeat with the Selection tool enters isolation mode on it, where its source art (type included: a double-click there edits the text) is edited and every copy follows (#5054); scores and estimates unchanged | | 2026-11-01 | minor | Live Corners: while a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the corners' kind (round, inverted round, chamfer) with the radius, as one undo step (#2898); scores and estimates unchanged | | 2026-11-01 | minor | Keyboard Shortcuts: every Effect menu item (Distort & Transform › Transform…, Stylize › Drop Shadow…, …) is listed under its menu path and takes a shortcut of its own, which the menu shows and which opens that effect's dialog (or applies an effect without options) (#3296); scores and estimates unchanged | | 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged | From 55a28d245562b3ca9c03ddf71ad62ea82515cb77 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:51:01 +0300 Subject: [PATCH 2/4] Repeat edit test: compare the repeat's actual settings RepeatSpec keeps rows, columns and spacing under kind; the test now compares that, and checks it was read. --- crates/engine/src/tests/repeat_edit.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/repeat_edit.rs b/crates/engine/src/tests/repeat_edit.rs index ca57a569f..f8318ad88 100644 --- a/crates/engine/src/tests/repeat_edit.rs +++ b/crates/engine/src/tests/repeat_edit.rs @@ -36,5 +36,6 @@ fn a_double_click_on_a_repeat_isolates_it_to_edit_its_text() { let NodeKind::Text(t) = &s.doc().unwrap().doc.node(text).unwrap().kind else { panic!("not type") }; assert_eq!(t.plain_text(), "Bob"); assert_eq!(s.doc().unwrap().doc.position(text).and_then(|(p, ..)| p), Some(repeat), "still the repeat's source"); - assert_eq!(spec(&s)["rows"], before["rows"], "the repeat keeps its settings"); + assert_eq!(spec(&s)["kind"], before["kind"], "the repeat keeps its settings (rows, columns, spacing)"); + assert!(before["kind"].is_object(), "the settings are read: {before}"); } From 85138b26522255e096bcd9249001c2f8e9877ac8 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:31:47 +0300 Subject: [PATCH 3/4] Repeat isolation: draw the isolated repeat undimmed; test exit, undo and groups A live object reached the renderer without passing the isolated-object check that groups go through, so an isolated repeat was dimmed with the art around it. Tests cover leaving isolation (double-click outside, exitIsolation), undo/redo of an edit inside, and a repeat of a group. --- crates/engine/src/tests/repeat_edit.rs | 99 ++++++++++++++++++++++++++ crates/render/src/renderer/nodes.rs | 9 ++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests/repeat_edit.rs b/crates/engine/src/tests/repeat_edit.rs index f8318ad88..347c05888 100644 --- a/crates/engine/src/tests/repeat_edit.rs +++ b/crates/engine/src/tests/repeat_edit.rs @@ -39,3 +39,102 @@ fn a_double_click_on_a_repeat_isolates_it_to_edit_its_text() { assert_eq!(spec(&s)["kind"], before["kind"], "the repeat keeps its settings (rows, columns, spacing)"); assert!(before["kind"].is_object(), "the settings are read: {before}"); } + +/// A text repeat isolated by a double-click on it: returns (session, text, repeat, point on the text). +fn isolated_text_repeat() -> (Session, NodeId, NodeId, (f64, f64)) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let text = NodeId(s.execute("text.create", &json!({"x": 50, "y": 60, "text": "Alice", "size": 24})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [text.0]})).unwrap(); + let repeat = NodeId(s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let b = s.doc().unwrap().doc.node(text).unwrap().geometric_bounds().unwrap(); + let at = (b.center().x, b.center().y); + click(&mut s, PointerKind::DoubleClick, at.0, at.1); + assert_eq!(s.doc().unwrap().isolation, Some(repeat)); + (s, text, repeat, at) +} + +fn plain_text(s: &Session, id: NodeId) -> String { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not type") }; + t.plain_text() +} + +/// #5054 review: a double-click outside the repeat leaves isolation mode, and so does +/// `object.exitIsolation` (what Escape and Exit Isolation Mode run); the repeat stays a repeat. +#[test] +fn isolation_on_a_repeat_ends_by_double_click_outside_or_exit() { + let (mut s, _, repeat, _) = isolated_text_repeat(); + click(&mut s, PointerKind::DoubleClick, 580.0, 380.0); + assert_eq!(s.doc().unwrap().isolation, None, "a double-click on empty canvas leaves isolation mode"); + let (mut s, _, repeat2, at) = isolated_text_repeat(); + assert_eq!(repeat, repeat2); + s.execute("object.exitIsolation", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, None, "Escape's command leaves isolation mode"); + assert!(matches!(s.doc().unwrap().doc.node(repeat).unwrap().kind, NodeKind::Repeat(_)), "still a repeat"); + // And a double-click on it isolates it again. + click(&mut s, PointerKind::DoubleClick, at.0, at.1); + assert_eq!(s.doc().unwrap().isolation, Some(repeat)); +} + +/// #5054 review: an edit made in a repeat's isolation mode undoes in one step and the repeat +/// keeps drawing its source; isolating it adds no undo step of its own. +#[test] +fn an_edit_inside_an_isolated_repeat_undoes() { + let (mut s, text, repeat, _) = isolated_text_repeat(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("text.setText", &json!({"id": text.0, "text": "Bob"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(plain_text(&s, text), "Alice"); + assert_eq!(s.doc().unwrap().doc.position(text).and_then(|(p, ..)| p), Some(repeat), "still the repeat's source"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(plain_text(&s, text), "Bob"); +} + +/// #5054 review: a repeat of a group isolates on a double-click, and a double-click on the group +/// inside it goes one level further, into the group, where its art can be selected alone. +#[test] +fn a_repeat_of_a_group_isolates_then_enters_the_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [group.0]})).unwrap(); + let repeat = NodeId(s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.select_tool("selection", ViewInfo::default()).unwrap(); + click(&mut s, PointerKind::DoubleClick, 55.0, 55.0); + assert_eq!(s.doc().unwrap().isolation, Some(repeat), "the first double-click isolates the repeat"); + click(&mut s, PointerKind::DoubleClick, 55.0, 55.0); + assert_eq!(s.doc().unwrap().isolation, Some(group), "the second enters the group inside it"); + click(&mut s, PointerKind::Down, 55.0, 55.0); + click(&mut s, PointerKind::Up, 55.0, 55.0); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a)], "inside the group a click selects one rectangle"); +} + +/// #5054: in a repeat's isolation mode the repeat itself draws as usual (its source and every +/// copy); only the art around it is dimmed, as for an isolated group. +#[test] +fn an_isolated_repeat_is_not_dimmed() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [r], "color": "#000000"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let repeat = s.execute("object.repeat.grid", &json!({})).unwrap()["id"].as_u64().unwrap(); + let other = s.execute("shape.rectangle", &json!({"x": 150, "y": 150, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [other], "color": "#000000"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let render = |iso: Option| { + let opts = vectorcraft_render::RenderOptions { isolated: iso.map(NodeId), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts) + }; + let alpha = |img: &vectorcraft_render::Rendered, x: usize, y: usize| img.pixels[(y * 200 + x) * 4 + 3]; + let plain = render(None); + let iso = render(Some(repeat)); + assert_eq!(alpha(&plain, 20, 20), 255, "the source"); + assert_eq!(alpha(&iso, 20, 20), 255, "the isolated repeat's source is not dimmed"); + assert!(alpha(&iso, 165, 165) < 200, "the art around it is: {}", alpha(&iso, 165, 165)); +} diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index b40e7154d..c604e290b 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -273,7 +273,14 @@ impl Renderer { return; } if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) { - return self.draw_live(ctx, f, a); + // The isolated object itself (a repeat isolated to edit its source, #5054) draws its + // art as usual, as an isolated group does in `draw_layer_node`. + let settle = f.opts.isolated == Some(a.id) && f.doc.mask_edit.is_none(); + let outer = self.isolation_settled; + self.isolation_settled = outer || settle; + self.draw_live(ctx, f, a); + self.isolation_settled = outer; + return; } // Its live-effect geometry depends on the node alone, and for plug-in effects on the // plug-ins installed (`check_plugins`), so it can be kept across frames. From 1072f9b9bfb1c98df979ff2f9a3f32a854b00a55 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 2 Nov 2026 13:50:05 +0700 Subject: [PATCH 4/4] Repeat: a double-click isolates it only with Double Click To Isolate on --- crates/tools/src/select.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 17d5fef17..e6fa43602 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1036,7 +1036,7 @@ pub(crate) fn is_threadable(n: &vectorcraft_doc::Node) -> bool { /// Does a double-click or the Control bar's Isolate Selected Object button isolate an object of /// this kind? A group, a compound path or a single path, image, mesh or placed file does. Blends, /// envelopes, repeats and symbol instances do not; they have editing modes of their own (a -/// double-click on a repeat isolates it to edit its source art). +/// repeat's is isolation of the repeat itself, to edit its source art). pub fn isolates(kind: &vectorcraft_doc::NodeKind) -> bool { use vectorcraft_doc::NodeKind as K; matches!(kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_)) @@ -1201,7 +1201,10 @@ impl SelectionTool { } // A repeat: its source art is edited in isolation mode, where the repeat follows each // edit (#5054), as the reference app's Repeat does. - if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Repeat(_))) && cx.isolation != Some(top) { + if cx.double_click_isolate + && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Repeat(_))) + && cx.isolation != Some(top) + { return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; } // An object that [`isolates`], not the one already isolated.