diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 955eb04b7..f4282ea65 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -397,7 +397,7 @@ fn listed_when_opened(doc: &Document, open: &OpenRows, opts: &PanelOptions, quer } let mut rows = vec![]; push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, doc), &mut rows); - rows.iter().any(|r| r.node.id == id && r.guide.is_none()) + rows.iter().any(|r| r.node.id == id && r.guide.is_none() && !r.spine) } /// The active document's uid and its last selected object, the one Locate Object locates. diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index 722391911..bbafbb755 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -63,6 +63,8 @@ pub(super) struct Row { pub(super) dim: bool, /// A ruler guide on the layer `node` (its index in the document's guides): the guide's row. pub(super) guide: Option, + /// The spine of blend `node`: its row, below the blend's key objects (#4820). + pub(super) spine: bool, } /// An object kind under the Filter's By Object (`ui.layersFilter`'s `objects`). @@ -262,10 +264,20 @@ pub(super) fn push_rows( let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (narrowing || open_rows.contains(n.id)); let at = out.len(); - out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None }); + out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None, spine: false }); if open { for i in guides.into_iter().rev() { - out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) }); + out.push(Row { + node: n.clone(), + depth: depth + 1, + clip_path: false, + opens: false, + open: false, + colour, + dim: !shown, + guide: Some(i), + spine: false, + }); } } if open && let Some(children) = n.children() { @@ -273,6 +285,21 @@ pub(super) fn push_rows( push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out); } } + // A blend's spine is a row in it, below its key objects, as Release leaves it (not while + // searching or filtering: it is no object of its own). + if open && !narrowing && matches!(n.kind, NodeKind::Blend { .. }) { + out.push(Row { + node: n.clone(), + depth: depth + 1, + clip_path: false, + opens: false, + open: false, + colour, + dim: !shown, + guide: None, + spine: true, + }); + } // Searching or filtering, a row stays for a match of its own (its name holds the search text // and the filter keeps it) or for a row it keeps inside it. if narrowing && out.len() == at + 1 && !(filter.keeps(n) && (!searching || n.display_name().to_lowercase().contains(query))) { diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index f9e05716d..2c43a363a 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -156,6 +156,47 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { } } +/// The row of the spine of blend `item` (#4820): a path mark and ``, the selection square +/// while the blend is selected. A click selects the blend, whose spine Direct Selection then edits +/// on the canvas; Shift or Cmd adds it to (or takes it from) the selection. The spine of a hidden +/// or locked blend can't be selected. +fn spine_row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { + let (t, h) = (&view.t, view.h); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); + let blend = item.node.id; + let selected = view.sel.contains(&blend); + let editable = (view.doc.editable())(blend); + if editable && resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6)); + } + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); + let mut x = r.left() + 2.0 * COLUMN + 2.0; + colour_bar(ui, &item.node, r, x, item.colour); + x += 6.0 + item.depth as f32 * INDENT + 16.0; + // The path mark: a short diagonal between two anchor dots. + let (a, b) = (egui::pos2(x + 2.0, r.center().y + 5.0), egui::pos2(x + 14.0, r.center().y - 5.0)); + ui.painter().line_segment([a, b], Stroke::new(1.5, t.accent)); + for p in [a, b] { + ui.painter().rect_filled(egui::Rect::from_center_size(p, vec2(3.0, 3.0)), 0.0, t.accent); + } + x += 22.0; + let name = format!("<{}>", tl!("Path")); + let color = if item.dim { t.text_dim } else { t.text }; + ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color); + let col_x = r.right() - 43.5; + ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); + if selected { + let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0)); + ui.painter().rect_filled(q, 0.0, item.colour); + ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside); + } + if editable && resp.clicked() { + let m = ui.input(|i| i.modifiers); + let action = if m.shift || m.command { "select.toggle" } else { "select.set" }; + out.actions.push((action.into(), json!({"ids": [blend.0]}))); + } +} + /// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the /// release puts them on that layer (`guide.setLayer`, one undo step). fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) { @@ -206,6 +247,9 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { if let Some(i) = item.guide { return guide_row(ui, view, item, i, out); } + if item.spine { + return spine_row(ui, view, item, out); + } let n = &*item.node; let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour); let doc = view.doc; diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index b78e6acc5..297afd693 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -1591,3 +1591,38 @@ fn a_canvas_selection_reveals_its_active_layer() { app.session.active_mut().unwrap().active_layer = doc.layer_of(NodeId(rects[0])); assert!(shown(&mut app, &top) && !shown(&mut app, &bottom), "the highlighted row keeps the view"); } + +/// #4820: an open blend lists its spine below its key objects, and a click on the spine's row +/// selects the blend (whose spine Direct Selection edits); a locked blend's spine row selects +/// nothing, and searching leaves the row out. +#[test] +fn a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 300, "height": 200})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap(); + let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + let blend = NodeId(run(&mut app, "object.blend.make", json!({}))["id"].as_u64().unwrap()); + run(&mut app, "select.none", json!({})); + expand(&mut app, &json!({})).unwrap(); + let st = app.session.active().unwrap(); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let kinds: Vec<(u64, bool)> = rows.iter().map(|r| (r.node.id.0, r.spine)).collect(); + assert_eq!(kinds[1..], [(blend.0, false), (b, false), (a, false), (blend.0, true)], "the spine row comes last in the blend"); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "rect", &Filter::default()); + assert!(!rows.iter().any(|r| r.spine), "no spine row while searching"); + // Rows top down: the layer, the blend, both keys (with target circles), then the spine. + let ctx = egui::Context::default(); + let (circles, _) = frame(&mut app, &ctx, vec![], false); + assert_eq!(circles.len(), 4); + let spine_row = egui::pos2(150.0, circles[3].y + (circles[3].y - circles[2].y)); + let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.to_vec(); + click_with(&mut app, &ctx, spine_row, egui::Modifiers::NONE); + assert_eq!(selected(&app), [blend], "its row selects the blend"); + click_with(&mut app, &ctx, spine_row, egui::Modifiers::SHIFT); + assert!(selected(&app).is_empty(), "Shift-click takes it out again"); + run(&mut app, "layer.setProps", json!({"ids": [blend.0], "locked": true})); + click_with(&mut app, &ctx, spine_row, egui::Modifiers::NONE); + assert!(selected(&app).is_empty(), "a locked blend's spine can't be selected"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9c0145989..b1a507056 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Pen: Cmd/Ctrl drags with the lent selection tool, not hidden box handles, #4980) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Layers panel: a blend's spine row, #4820) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -90,6 +90,7 @@ tools, never checked side by side) isn't captured row by row. | Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) | | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | +| Layers panel lists a blend's spine (#4820) | done | an open blend shows a `` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | | Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one; Shift held at the press on an anchor moves it, handles and all (Shift kept down: at 45° steps) instead of pulling out handles | | Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Fidelity goes down to 0.01 pt in the dialog, the tool and `path.freehand` (the Smooth tool keeps 0.05); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 pt, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 pt, tenths up to 1 pt and whole points above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path | @@ -387,6 +388,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Layers panel: an open blend lists its spine as a `` row below its key objects, and a click selects the blend so Direct Selection edits the spine (#4820); scores and estimates unchanged | | 2026-11-01 | minor | Pen with Cmd/Ctrl held: the lent Selection tool no longer scales the path being drawn by bounding-box handles the canvas hides while its end anchor is selected (a flat path did nothing under a resize pointer); a Cmd drag moves the path instead, and with Direct Selection moves anchors and segments, one undo step each (#4980); scores and estimates unchanged | | 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains | | 2026-11-01 | minor | Export for Screens has Include Artboard Fill beside Include Bleed (on by default, remembered; `includeArtboardFill`): the files draw each artboard's background color behind its art, as exports and print now do, and the Print dialog's preview shows it (#4937); scores and estimates unchanged |