From 82bf9624bfebcb147672a8e9c6354c0110319d36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:44:24 -0800 Subject: [PATCH 01/42] Smooth: the cursor shows Not Allowed when no path is selected to smooth The Smooth tool showed its own cursor with nothing selected, though a drag then changes nothing. It now shows Not Allowed until a path it can act on is selected (an editable path, not a guide, with three or more anchors on one of its subpaths); a selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. - The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool. The Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. - A Smooth release, from the Smooth tool or from the Pencil, Paintbrush or Blob Brush with Alt, sends path.smoothRegion only while such a path is selected. With only type or other objects selected it sends no command. - Use Precise Cursors leaves Not Allowed as it is. - The cursor catalog and docs/target-app-parity.md describe the rule. --- crates/engine/src/tests/prefs.rs | 22 +++- crates/tools/src/cursors.toml | 9 +- crates/tools/src/draw2/gesture.rs | 22 ++-- crates/tools/src/draw2/gesture/tests.rs | 131 ++++++++++++++++++++++-- crates/tools/src/lib.rs | 12 ++- docs/target-app-parity.md | 3 +- 6 files changed, 179 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 37127f686a..121ada4a2a 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -665,7 +665,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { ("blobBrush", Cursor::BlobBrush), ("shaper", Cursor::Shaper), ("pencil", Cursor::PencilNew), - ("smooth", Cursor::Smooth), ("pathEraser", Cursor::PathEraser), ("join", Cursor::Join), ("eraser", Cursor::Eraser), @@ -710,6 +709,27 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { } } +/// With no selected path, the Smooth tool shows Not Allowed, and Use Precise Cursors leaves it so; with a +/// selected path, it shows its own pointer, or a crosshair with the preference on. +#[test] +fn use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer() { + use vectorcraft_tools::{Cursor, Mods}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let at = vectorcraft_geom::Point::new(300.0, 300.0); + s.select_tool("smooth", ViewInfo::default()).unwrap(); + for precise in [false, true] { + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap(); + assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), Cursor::NotAllowed, "precise {precise}"); + } + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (precise, c) in [(false, Cursor::Smooth), (true, Cursor::Crosshair)] { + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap(); + assert_eq!(s.cursor(at, Mods::default(), ViewInfo::default()), c, "precise {precise}"); + } +} + /// With Alt held while drawing, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors /// turns those into a crosshair too. #[test] diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 6f5b32e56a..674ecebf60 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -453,6 +453,7 @@ group = "Navigation" shown_when = [ "The Rotate, Reflect, Scale and Shear tools with nothing selected.", "Puppet Warp off the mesh, where no pin can go.", + "The Smooth tool, and the Pencil or Blob Brush where Alt gives the Smooth tool, when no path is selected that the Smooth tool can act on: a drag changes nothing.", ] hotspot = "The centre." designer = false @@ -690,10 +691,10 @@ look = "Only a small dot at the centre, as the Blob Brush's: the tip outline dra id = "Smooth" group = "Drawing and editing tools" shown_when = [ - "The Smooth tool: a drag along a selected path smooths it.", - "The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.", - "The Paintbrush tool with Alt held before a press while a path is selected that the Smooth tool can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and through a drag that smooths.", - "The Blob Brush tool with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.", + "The Smooth tool while a path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), wherever the pointer is: a drag along the path smooths it. With no such path selected, Not Allowed shows.", + "The Pencil tool with Alt key toggles to Smooth Tool on and such a path selected: with Alt held before a stroke begins to draw, and through a drag that smooths.", + "The Paintbrush tool with Alt held before a press while such a path is selected, and through a drag that smooths.", + "The Blob Brush tool with such a path selected: with Alt held before a press, or before the drag has moved from the press point, and through a drag that smooths.", ] hotspot = "The tip." diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 2de44f97ba..a96cffd307 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -763,7 +763,8 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool { cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)).any(|n| { let mut found = false; n.walk(&mut |child| { - if matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.control_bounds().is_some() && path.subpaths.iter().any(|sp| sp.anchors.len() >= 3)) + if !found + && matches!(&child.kind, NodeKind::Path { guide: false, path, .. } if path.subpaths.iter().any(|sp| sp.anchors.len() >= 3) && path.control_bounds().is_some()) && editable(child.id) { found = true; @@ -773,6 +774,13 @@ fn has_selected_smooth_path(cx: &ToolContext) -> bool { }) } +/// The Smooth tool's cursor: Not Allowed when no selected path can be smoothed, since a drag then +/// changes nothing. A selected path anywhere on the canvas is enough, so the cursor does not depend +/// on where the pointer is. +fn smooth_cursor(cx: &ToolContext) -> Cursor { + if has_selected_smooth_path(cx) { Cursor::Smooth } else { Cursor::NotAllowed } +} + impl GestureTool { pub fn new(id: &str) -> Self { let id: &'static str = match id { @@ -1147,7 +1155,7 @@ impl GestureTool { let tol = cx.tol(self.edit_within); let radius = cx.tol(SMOOTH_RADIUS); if self.smoothing { - if has_sel && pts.len() >= 2 { + if pts.len() >= 2 && has_selected_smooth_path(cx) { return vec![Action::Exec( "path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.smooth_fidelity}), @@ -1199,7 +1207,7 @@ impl GestureTool { }), )], "knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))], - "smooth" if has_sel && pts.len() >= 2 => { + "smooth" if pts.len() >= 2 && has_selected_smooth_path(cx) => { vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))] } "pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))], @@ -1504,13 +1512,15 @@ impl Tool for GestureTool { } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { // An active smoothing drag, or Alt before a press that would switch the Pencil, Paintbrush - // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. + // or Blob Brush to Smooth. A Paintbrush stroke already drawing keeps drawing with Alt. With + // no selected path to smooth, the Pencil and the Blob Brush show Not Allowed, as the Smooth + // tool does. if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) || (!self.active && self.id == "paintbrush" && m.alt && has_selected_smooth_path(cx)) || self.blob_alt_smooths(m) { - return Cursor::Smooth; + return smooth_cursor(cx); } // Before a press that would draw a straight line: over an end of a selected open path, // the line continues that path from the end (`PointerKind::Down`). @@ -1533,7 +1543,7 @@ impl Tool for GestureTool { "blobBrush" => Cursor::BlobBrush, "eraser" => Cursor::Eraser, "knife" => Cursor::Knife, - "smooth" => Cursor::Smooth, + "smooth" => smooth_cursor(cx), "pathEraser" => Cursor::PathEraser, "join" => Cursor::Join, // Before a press that draws a new path the Pencil shows its star; over a selected path diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index 624adc2e98..98e154f870 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -369,7 +369,7 @@ fn the_freehand_tools_want_every_sample() { #[test] fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { - let (d, _) = doc_with_rect(); + let (d, rect) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -396,18 +396,26 @@ fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { } // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. + // With nothing selected to smooth, the Smooth tool it lends shows Not Allowed. let mut t = GestureTool::new("pencil"); t.set_option("altSmooth", &json!(true)); - assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + assert_eq!(t.cursor(&cx, at, alt), Cursor::NotAllowed); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); - // A smoothing drag keeps the Smooth cursor when Alt is let go. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); + // A smoothing drag keeps the lent cursor when Alt is let go: Smooth with a selected path, Not + // Allowed without one. + let mut selected = Selection::default(); + selected.set([rect]); + for (sel, c) in [(&s, Cursor::NotAllowed), (&selected, Cursor::Smooth)] { + let cx = crate::testutil::cx(&d, sel, &p); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), c, "a smoothing drag"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); + } } #[test] @@ -2223,6 +2231,117 @@ fn smooth_needs_selection_and_brackets_resize() { assert_eq!(b.options()["size"], 11.0); } +/// The Smooth tool shows Not Allowed while no selected path can be smoothed: nothing selected, only +/// type, a path of two anchors or a locked path. A selected path anywhere, alone or in a selected +/// group, gives the Smooth cursor wherever the pointer is, and the cursor follows the selection while +/// the tool stays chosen. +#[test] +fn smooth_shows_not_allowed_without_a_selected_path_to_smooth() { + use std::sync::Arc; + let (mut d, rect) = doc_with_rect(); + let layer = d.layers[0].id; + let line_id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(layer), 1, vectorcraft_doc::Node::path(line_id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 350.0, 350.0, 400.0)); + let inner = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::default_art()); + let group = vectorcraft_doc::Node::group(d.alloc_id(), vec![Arc::new(inner)]); + let group_id = group.id; + d.insert(Some(layer), 2, group).unwrap(); + let (text_doc, text) = doc_with_area_type(); + let mut locked = d.clone(); + locked.node_mut(rect).unwrap().locked = true; + let p = paint(); + let sel = |ids: &[NodeId]| { + let mut s = Selection::default(); + s.set(ids.iter().copied()); + s + }; + let t = GestureTool::new("smooth"); + let far = Point::new(480.0, 20.0); + let cases: [(&vectorcraft_doc::Document, Selection, Cursor, &str); 7] = [ + (&d, Selection::default(), Cursor::NotAllowed, "nothing selected"), + (&text_doc, sel(&[text]), Cursor::NotAllowed, "type"), + (&d, sel(&[line_id]), Cursor::NotAllowed, "a two-anchor path"), + (&locked, sel(&[rect]), Cursor::NotAllowed, "a locked path"), + (&d, sel(&[rect]), Cursor::Smooth, "a path away from the pointer"), + (&d, sel(&[line_id, rect]), Cursor::Smooth, "a path beside one it cannot smooth"), + (&d, sel(&[group_id]), Cursor::Smooth, "a group holding a path"), + ]; + for (doc, s, c, what) in &cases { + assert_eq!(t.cursor(&cx(doc, s, &p), far, Mods::default()), *c, "{what}"); + } + // The cursor changes as the selection does, with the same tool. + let (none, one) = (Selection::default(), sel(&[rect])); + for (s, c) in [(&none, Cursor::NotAllowed), (&one, Cursor::Smooth), (&none, Cursor::NotAllowed)] { + assert_eq!(t.cursor(&cx(&d, s, &p), Point::new(150.0, 150.0), Mods::default()), c); + } + // A drag with Not Allowed showing sends nothing. + let mut t = GestureTool::new("smooth"); + let acts = drag(&mut t, &cx(&d, &none, &p), &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts.iter().all(|a| a.is_empty()), "{acts:?}"); +} + +/// The Blob Brush's Alt shows Not Allowed with no selected path to smooth, and the Smooth cursor with +/// one; the Paintbrush's Alt without one keeps the closing cursor, since its release closes the stroke. +#[test] +fn borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth() { + let (d, rect) = doc_with_rect(); + let none = Selection::default(); + let mut one = Selection::default(); + one.set([rect]); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(400.0, 400.0); + let blob = GestureTool::new("blobBrush"); + assert_eq!(blob.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed); + assert_eq!(blob.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth); + assert_eq!(blob.cursor(&cx(&d, &none, &p), at, Mods::default()), Cursor::BlobBrush); + let mut pencil = GestureTool::new("pencil"); + pencil.set_option("altSmooth", &json!(true)); + assert_eq!(pencil.cursor(&cx(&d, &none, &p), at, alt), Cursor::NotAllowed); + assert_eq!(pencil.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth); + let brush = GestureTool::new("paintbrush"); + assert_eq!(brush.cursor(&cx(&d, &none, &p), at, alt), Cursor::PaintbrushClose); + assert_eq!(brush.cursor(&cx(&d, &one, &p), at, alt), Cursor::Smooth); +} + +/// A Smooth release sends no command while the selection holds no path it can smooth, from the +/// Smooth tool and from the Blob Brush and Pencil with Alt; with a path selected it smooths. +#[test] +fn smoothing_release_sends_nothing_without_a_selected_path_to_smooth() { + let (text_doc, text) = doc_with_area_type(); + let (d, rect) = doc_with_rect(); + let mut type_only = Selection::default(); + type_only.set([text]); + let mut one = Selection::default(); + one.set([rect]); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let stroke = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)]; + let smooths = |acts: &[Vec]| acts.iter().flatten().any(|a| matches!(a, Action::Exec(c, _) if c == "path.smoothRegion")); + for (id, mods) in [("smooth", Mods::default()), ("blobBrush", alt), ("pencil", alt)] { + let tool = || { + let mut t = GestureTool::new(id); + if id == "pencil" { + t.set_option("altSmooth", &json!(true)); + } + t + }; + let mut c = cx(&text_doc, &type_only, &p); + c.smart_guides = false; + let acts = drag_held(&mut tool(), &c, &stroke, mods); + assert!(!smooths(&acts), "{id} with only type selected: {acts:?}"); + let mut c = cx(&d, &one, &p); + c.smart_guides = false; + let acts = drag_held(&mut tool(), &c, &stroke, mods); + assert!(smooths(&acts), "{id} with a path selected: {acts:?}"); + } +} + #[test] fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 17e6f038e9..1447847213 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -619,8 +619,16 @@ mod cursor_tests { /// Use Precise Cursors turns it into the crosshair. #[test] fn drawing_tools_show_their_own_cursor() { - let (d, _) = doc_with_rect(); + let (d, rect) = doc_with_rect(); let away = Point::new(400.0, 400.0); + // The Smooth tool shows its own cursor only with a selected path (Not Allowed without one). + let mut selected = Selection::default(); + selected.set([rect]); + let paint = paint(); + let cursor_of = |id: &str| match id { + "smooth" => create(id).cursor(&cx(&d, &selected, &paint), away, Mods::default()), + _ => cursor_at(id, &d, away), + }; for (id, c) in [ ("lasso", Cursor::Lasso), (addons::enclosing_lasso::ID, Cursor::Lasso), @@ -646,7 +654,7 @@ mod cursor_tests { ("symbolStyler", Cursor::SymbolStyler), ("mesh", Cursor::MeshAdd), ] { - assert_eq!(cursor_at(id, &d, away), c, "{id}"); + assert_eq!(cursor_of(id), c, "{id}"); assert_eq!(c.precise(), Cursor::Crosshair, "{id}"); // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The // Live Paint Bucket's bucket is drawn. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..163b56d661 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1041,7 +1041,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; double-click, or Alt-click on any point but an end of an open path, toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). An Alt-click on an end of a selected open path makes that end current without changing it, so the next click goes on from it, as [the help page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-shapes-with-the-curvature-tool.html) describes for continuing a path; an Alt-click that starts a new path places a corner point, as later Alt-clicks do. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). While a Pencil or Paintbrush stroke is drawn back to within Close Paths When Ends Are Within of its start, the cursor shows the closing ring and a release there closes the path, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes for the Pencil. The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). Blob Brush and Eraser Tool Options' Size is Fixed or Pressure with a Variation (#4590, #3961): with Pressure a pen stroke is Size − Variation wide where it presses lightest and Size + Variation where it presses hardest, and a mouse stroke keeps the Size. With the Blob Brush, Shift held during a drag keeps the stroke on the horizontal or vertical line through the press point, whichever is nearer to the pointer (90° steps from the Constrain Angle preference), and the points dragged since Shift went down move to the other line when the pointer crosses to it; the points dragged while Shift is up follow the pointer. - With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool, the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. + With the Blob Brush, Alt held before the press, or pressed before the drag has moved, switches to the Smooth tool, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists: the cursor shows the Smooth tool (Not Allowed with no path selected that it can act on), the drag shows a thin line and, with a path selected that it can smooth, the dashed band it smooths, without the length label, Shift does not constrain it, and the release smooths the selected paths under the drag (`path.smoothRegion`) instead of painting; with nothing selected the release changes no path. A stroke that has moved keeps painting when Alt goes down. Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). A path the Smooth tool changes stays selected whole, and the anchor points and segments selected on it with Direct Selection are deselected. Selected paths the drag does not change keep their selected anchors, and undo restores the earlier selection (`tests/draw2.rs`, `smoothing_a_path_drops_its_direct_selected_anchors`). While the Smooth tool drags with a path selected that it can smooth, a dashed outline shows the band it smooths: everything within its radius of the drag (12 screen pixels, VectorCraft's own value), round at both ends; Alt smoothing with the Pencil, Paintbrush and Blob Brush shows the same band. While Alt is held during a Paintbrush stroke or before a press with no path selected that the Smooth tool can act on, the Paintbrush shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. Before a Paintbrush press with such a path selected, Alt shows the Smooth cursor, and holding Alt at the press smooths that path with the Smooth tool's radius and Smooth Tool Options' Fidelity. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open. With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes. @@ -1118,6 +1118,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. + The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`). Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`). From dd3438cb7f192e1b741ad82f537d53091f75ad86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:09:16 -0800 Subject: [PATCH 02/42] Live Paint Bucket: Cursor Swatch Preview and arrow keys pick swatches Live Paint Bucket Options gets a Cursor Swatch Preview checkbox (on at first, saved with the other options). With the Bucket selected, Left and Right set the active fill, or the stroke when the Stroke proxy is in front, to the color swatch before or after it in the Swatches panel, wrapping around at the ends, with the preview on or off. The selected art keeps its paint. The preview shows the active paint above the pointer between the swatches before and after it; a color that is no swatch shows alone until Right picks the first swatch or Left the last, and a paint that is not a solid color shows no chip. The chips hide during a fill drag and while Alt samples with the eyedropper. The fill and the stroke each remember the swatch picked for them, so swatches of the same color are each reached in turn. The Bucket and the Shape Builder now take the arrows for swatches only without Shift, Alt or Cmd/Ctrl, judged by the modifiers held when the key went down, so modified arrows keep their shortcuts (Tool::claims_key_with, Session::tool_claims_key_with). Docs: docs/mcp.md, docs/target-app-parity.md, tool.options params text. --- crates/engine/src/tests/build.rs | 197 ++++++++++++++++++ crates/engine/src/tooling.rs | 6 + crates/mcp/src/ui_catalog.rs | 2 +- crates/tools/src/builder.rs | 62 ++++-- crates/tools/src/builder/live_paint.rs | 75 ++++++- crates/tools/src/builder/tests.rs | 16 +- crates/tools/src/lib.rs | 5 + crates/tools/src/settings.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 8 + .../ui-egui/src/dialogs/live_paint_bucket.rs | 20 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 19 +- crates/ui-egui/src/shortcuts/tests.rs | 31 +++ docs/mcp.md | 15 +- docs/target-app-parity.md | 1 + 15 files changed, 418 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 6eeace1073..30956a6e4e 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -1748,6 +1748,203 @@ fn shape_builder_arrow_keys_pass_swatches_of_the_same_color() { assert_eq!(s.paint.fill, after, "the swatch after Twin B"); } +/// The colors of the cursor swatch preview's chips, left to right, and which one is ringed. +fn swatch_chips(s: &mut Session, v: ViewInfo) -> Vec<([u8; 4], bool)> { + let mut chips: Vec<(f64, [u8; 4], bool)> = s + .overlays(v) + .into_iter() + .filter_map(|o| match o { + vectorcraft_tools::Overlay::Swatch { p, color, selected } => Some((p.x, color, selected)), + _ => None, + }) + .collect(); + chips.sort_by(|a, b| a.0.total_cmp(&b.0)); + chips.into_iter().map(|(_, c, sel)| (c, sel)).collect() +} + +/// A solid paint's color as the chips draw it. +fn chip_color(p: &Paint) -> [u8; 4] { + match p { + Paint::Solid { color, .. } => color.to_rgba8(1.0), + _ => panic!("not a solid color: {p:?}"), + } +} + +/// Live Paint Bucket: Left and Right step the active paint through the document's color swatches +/// (the selected art keeps its paint), the Cursor Swatch Preview (on at first) shows the active +/// paint ringed between the swatches before and after it, and with it off the arrows still step. +#[test] +fn live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches() { + let mut s = session(); + let (a, _) = two(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + assert_eq!(s.tool_options_of("livePaintBucket")["cursorSwatchPreview"], json!(true)); + assert!(s.tool_claims_key(ToolKey::Right, v) && s.tool_claims_key(ToolKey::Left, v)); + assert!(!s.tool_claims_key(ToolKey::Up, v), "Up and Down keep their own use"); + let list: Vec<(String, Paint)> = + vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| (sw.name.clone(), sw.paint.clone())).collect(); + assert!(list.len() >= 4); + let n = list.len(); + let stroke = s.paint.stroke.clone(); + s.execute("paint.setFill", &json!({"swatch": list[0].0, "ids": []})).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, list[2].1); + s.tool_key(ToolKey::Left, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, list[1].1); + assert_eq!(s.paint.stroke, stroke, "the stroke stays"); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill"); + s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap(); + assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&list[0].1), false), (chip_color(&list[1].1), true), (chip_color(&list[2].1), false)]); + let alt = Mods { alt: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0).with_mods(alt), v).unwrap(); + assert!(swatch_chips(&mut s, v).is_empty(), "Alt samples with the eyedropper and shows no chips"); + s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap(); + // Past the first swatch, Left wraps around to the last. + s.tool_key(ToolKey::Left, Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Left, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, list[n - 1].1); + assert_eq!(swatch_chips(&mut s, v).iter().map(|c| c.0).collect::>(), [&list[n - 2].1, &list[n - 1].1, &list[0].1].map(chip_color)); + // With the Stroke proxy in front, the arrows step the stroke and the chips show it. + s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(); + let fill = s.paint.fill.clone(); + s.execute("paint.setStroke", &json!({"swatch": list[1].0, "ids": []})).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &list[2].1)); + assert!(!s.fill_active, "the Stroke proxy stays in front"); + assert_eq!(swatch_chips(&mut s, v)[1], (chip_color(&list[2].1), true)); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its paint"); + // Cursor Swatch Preview off hides the chips; the arrows still step. + s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"cursorSwatchPreview": false}})).unwrap(); + assert!(swatch_chips(&mut s, v).is_empty()); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.stroke, list[3].1); +} + +/// The Live Paint Bucket and the Shape Builder take Left and Right for swatches only without +/// modifiers: Shift, Alt and Cmd/Ctrl arrows are left to their shortcuts and change no paint. +#[test] +fn swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts() { + let mut s = session(); + two(&mut s); + let v = ViewInfo::default(); + let first = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[0].name.clone(); + s.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap(); + for tool in ["livePaintBucket", "shapeBuilder"] { + s.select_tool(tool, v).unwrap(); + if tool == "shapeBuilder" { + s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches"}})).unwrap(); + } + let fill = s.paint.fill.clone(); + assert!(s.tool_claims_key_with(ToolKey::Right, Mods::default(), v), "{tool}"); + for m in [Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }, Mods { cmd: true, ..Default::default() }] { + for key in [ToolKey::Left, ToolKey::Right] { + assert!(!s.tool_claims_key_with(key, m, v), "{tool} {key:?} {m:?}"); + s.tool_key(key, m, v).unwrap(); + assert_eq!(s.paint.fill, fill, "{tool} {key:?} {m:?}"); + } + } + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_ne!(s.paint.fill, fill, "{tool}: a plain Right steps"); + } +} + +/// Two swatches of the same color: the Bucket's arrows step through both, and the fill and the +/// stroke each step on from the swatch picked for them. +#[test] +fn live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke() { + let mut s = session(); + two(&mut s); + for name in ["Twin A", "Twin B"] { + s.execute("swatch.new", &json!({"name": name, "color": "#123456"})).unwrap(); + } + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + let names: Vec = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.name.clone()).collect(); + let a = names.iter().position(|n| n == "Twin A").unwrap(); + assert_eq!(names[a + 1], "Twin B"); + let after = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[(a + 2) % names.len()].paint.clone(); + // The fill goes to Twin B. + s.execute("paint.setFill", &json!({"swatch": "Twin A", "ids": []})).unwrap(); + let twin = s.paint.fill.clone(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, twin, "Twin B"); + // The stroke starts at Twin A: Right goes to Twin B, then past it. + s.execute("paint.setStroke", &json!({"swatch": "Twin A", "ids": []})).unwrap(); + assert!(!s.fill_active); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.stroke, twin, "Twin B for the stroke"); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.stroke, after, "the swatch after Twin B"); + // Back on the fill, which is still Twin B: Right goes past it. + s.execute("paint.toggleActive", &json!({"fill": true})).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, after, "the fill steps on from Twin B"); +} + +/// A color that is no swatch shows alone and Right picks the first swatch, Left the last; a paint +/// that is not a solid color shows no chip; one swatch steps to itself; with no color swatch the +/// tool leaves the arrows alone; during a fill drag the arrows change nothing. +#[test] +fn live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags() { + let mut s = session(); + live(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap(); + let list: Vec = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.paint.clone()).collect(); + let odd = Paint::solid(Color::rgb8(0x12, 0x34, 0x57)); + assert!(!list.contains(&odd)); + s.paint.fill = odd.clone(); + assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&odd), true)]); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, list[0]); + s.paint.fill = odd.clone(); + s.tool_key(ToolKey::Left, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, list[list.len() - 1]); + s.paint.fill = Paint::None; + assert!(swatch_chips(&mut s, v).is_empty()); + + // During a fill drag the arrows are not taken and change nothing. + s.paint.fill = list[0].clone(); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + assert!(s.tool_busy()); + assert!(!s.tool_claims_key(ToolKey::Right, v)); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, list[0]); + assert!(swatch_chips(&mut s, v).is_empty(), "no chips during a drag"); + s.pointer(&PointerEvent::new(PointerKind::Up, 25.0, 50.0), v).unwrap(); + + // One color swatch: both arrows pick it, and it stands on both sides of itself. + let one = list[0].clone(); + s.edit("t", |d, _| { + d.swatch_groups.clear(); + d.swatches.retain(|sw| sw.paint == one); + d.swatches.truncate(1); + Ok(()) + }) + .unwrap(); + assert_eq!(vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).len(), 1); + s.paint.fill = odd; + s.tool_key(ToolKey::Left, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, one); + s.pointer(&PointerEvent::new(PointerKind::Move, 301.0, 300.0), v).unwrap(); + assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&one), false), (chip_color(&one), true), (chip_color(&one), false)]); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, one); + + // No color swatch: the arrows are left to the canvas and the paint stays. + s.edit("t", |d, _| { + d.swatches.clear(); + Ok(()) + }) + .unwrap(); + assert!(!s.tool_claims_key(ToolKey::Right, v) && !s.tool_claims_key(ToolKey::Left, v)); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, one); +} + #[test] fn shape_builder_tool_alt_deletes_line_pieces() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index ae0c55ac76..c974a119e2 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1164,6 +1164,12 @@ impl Session { self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) } + /// Does the active tool take `key`, pressed with `mods` held, ahead of the shortcuts bound to it + /// (see `Tool::claims_key_with`)? + pub fn tool_claims_key_with(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> bool { + self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key_with(cx, key, mods)) + } + /// Has the active tool started something that Escape ends while no button is pressed (see /// `Tool::ends_on_escape`)? pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool { diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index dcde7c5076..db63f1c96b 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -925,7 +925,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 1c5a895161..8b9157bc25 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -143,6 +143,38 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option, forward: boo } } +/// Is no Shift, Alt, Cmd or Ctrl held? The arrows step swatches only then. +fn plain(mods: Mods) -> bool { + !(mods.shift || mods.alt || mods.cmd || mods.ctrl) +} + +/// The color swatch the Right (`forward`) or Left arrow key picks for the current `paint`, with its +/// index in [`color_swatches`] (see [`step_swatch`]); None when the document has no color swatch. +fn pick_swatch(doc: &Document, paint: &Paint, last: Option, forward: bool) -> Option<(usize, String)> { + let list = color_swatches(doc); + step_swatch(&list, paint, last, forward).and_then(|i| Some((i, list.get(i)?.name.clone()))) +} + +/// The cursor swatch preview for the current `paint` with the pointer at `at`: the swatch before +/// it, `paint` itself (ringed, while it is a solid color) and the swatch after it, side by side above +/// the pointer. With `matched_only`, the swatches beside it show only while `paint` is one of the +/// color swatches; otherwise the last and the first swatch stand beside a color that is none of them. +fn swatch_chips(cx: &ToolContext, at: Point, paint: &Paint, last: Option, matched_only: bool) -> Vec { + let list = color_swatches(cx.doc); + let rgba = |p: &Paint| match p { + Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)), + _ => None, + }; + let beside = !matched_only || swatch_index(&list, paint, last).is_some(); + let chip = |forward| beside.then(|| step_swatch(&list, paint, last, forward)).flatten().and_then(|i| rgba(&list.get(i)?.paint)); + let (before, after) = (chip(false), chip(true)); + let y = at.y - cx.tol(22.0); + [(before, -16.0, false), (rgba(paint), 0.0, true), (after, 16.0, false)] + .into_iter() + .filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected })) + .collect() +} + /// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase /// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in /// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges @@ -425,20 +457,7 @@ impl ShapeBuilderTool { /// The cursor swatch preview: the swatch before the current fill, the current fill (ringed) /// and the swatch after it, side by side above the pointer. fn swatch_chips(&self, cx: &ToolContext) -> Vec { - let Some(at) = self.at else { return vec![] }; - let list = color_swatches(cx.doc); - let fill = &cx.paint.fill; - let rgba = |p: &Paint| match p { - Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)), - _ => None, - }; - let chip = |forward| step_swatch(&list, fill, self.picked, forward).and_then(|i| rgba(&list.get(i)?.paint)); - let (before, after) = (chip(false), chip(true)); - let y = at.y - cx.tol(22.0); - [(before, -16.0, false), (rgba(fill), 0.0, true), (after, 16.0, false)] - .into_iter() - .filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected })) - .collect() + self.at.map(|at| swatch_chips(cx, at, &cx.paint.fill, self.picked, false)).unwrap_or_default() } } @@ -712,9 +731,15 @@ impl Tool for ShapeBuilderTool { (self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right)) || (self.dragging && key == ToolKey::Escape) } - fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + /// The arrows pick swatches only without modifiers; Shift, Alt and Cmd/Ctrl arrows keep their + /// shortcuts. + fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> bool { + self.claims_key(cx, key) && (key == ToolKey::Escape || plain(mods)) + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { // While a merge is being computed, the arrows leave the current fill it takes as it was. - if !self.claims_key(cx, key) || self.editing() { + if !self.claims_key_with(cx, key, mods) || self.editing() { return vec![]; } if key == ToolKey::Escape { @@ -726,13 +751,12 @@ impl Tool for ShapeBuilderTool { (self.marquee, self.hover, self.far) = (None, None, false); return vec![]; } - let list = color_swatches(cx.doc); - let Some((i, s)) = step_swatch(&list, &cx.paint.fill, self.picked, key == ToolKey::Right).and_then(|i| Some((i, list.get(i)?))) else { + let Some((i, name)) = pick_swatch(cx.doc, &cx.paint.fill, self.picked, key == ToolKey::Right) else { return vec![]; }; self.picked = Some(i); // The current fill only (no `ids`): the selected art keeps its own. - vec![Action::Exec("paint.setFill".into(), json!({ "swatch": s.name, "ids": [] }))] + vec![Action::Exec("paint.setFill".into(), json!({ "swatch": name, "ids": [] }))] } fn options(&self) -> Value { diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index e2d6c9f77e..885d82526a 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -3,7 +3,7 @@ use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_e use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent, - PointerKind, Rect, Tool, ToolContext, ToolKey, Value, json, json_ids, path_contains, + PointerKind, Rect, Tool, ToolContext, ToolKey, Value, color_swatches, json, json_ids, path_contains, pick_swatch, plain, swatch_chips, }; // ---------- Live Paint Bucket ---------- @@ -171,6 +171,14 @@ enum LpPaint { /// click paints nothing. With Highlight off, the face or edge under the pointer is not /// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints /// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper. +/// +/// The Left and Right arrow keys without Shift, Alt or Cmd/Ctrl set the active paint (the fill, +/// or the stroke when the Stroke proxy is in front) to the color swatch before or after it in the +/// Swatches panel, wrapping around; from a color that is no swatch, Right picks the first swatch +/// and Left the last. The selected art keeps its paint. With `cursorSwatchPreview` (on), the +/// active paint shows as a chip above the pointer between the swatches before and after it, +/// except during a fill drag or with Alt held; a solid color that is no swatch shows alone, and a +/// paint that is not a solid color shows no chip. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -183,6 +191,11 @@ pub struct LivePaintBucketTool { highlight: bool, color: [u8; 3], width: f64, + /// Cursor Swatch Preview. + preview: bool, + /// The index in [`color_swatches`] of the swatch the arrow keys picked last for the fill and for + /// the stroke. + picked: [Option; 2], } /// Maximum points in one Live Paint fill path chunk. @@ -200,11 +213,24 @@ impl Default for LivePaintBucketTool { highlight: true, color: HIGHLIGHT_RED, width: LP_BUCKET_WIDTH, + preview: true, + picked: [None; 2], } } } impl LivePaintBucketTool { + /// The active paint the arrow keys step and the preview shows: the fill, or the stroke when the + /// Stroke proxy is in front. + fn active_paint<'a>(cx: &'a ToolContext) -> &'a vectorcraft_color::Paint { + if cx.fill_active { &cx.paint.fill } else { &cx.paint.stroke } + } + + /// The slot of [`Self::picked`] for the active paint. + fn picked_slot(cx: &ToolContext) -> usize { + usize::from(!cx.fill_active) + } + fn begin_fill(&mut self, params: Value, p: Point) -> Vec { self.fill_drag = Some((params.clone(), vec![vec![p]])); vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)] @@ -387,24 +413,57 @@ impl Tool for LivePaintBucketTool { } } - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] } + /// Escape during a fill drag cancels it. Left and Right pick the color swatch before or after + /// the active paint, while no drag is on. + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if key == ToolKey::Escape { + return if self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] }; + } + if !self.claims_key_with(cx, key, mods) { + return vec![]; + } + let slot = Self::picked_slot(cx); + let Some((i, name)) = pick_swatch(cx.doc, Self::active_paint(cx), self.picked[slot], key == ToolKey::Right) else { + return vec![]; + }; + self.picked[slot] = Some(i); + // The active paint only (no `ids`): the selected art keeps its own. + let cmd = if cx.fill_active { "paint.setFill" } else { "paint.setStroke" }; + vec![Action::Exec(cmd.into(), json!({ "swatch": name, "ids": [] }))] } fn busy(&self) -> bool { self.fill_drag.is_some() } - fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - key == ToolKey::Escape && self.busy() + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + match key { + ToolKey::Escape => self.busy(), + ToolKey::Left | ToolKey::Right => !self.busy() && !color_swatches(cx.doc).is_empty(), + _ => false, + } + } + + /// The arrows pick swatches only without modifiers; Shift, Alt and Cmd/Ctrl arrows keep their + /// shortcuts. + fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> bool { + self.claims_key(cx, key) && (key == ToolKey::Escape || plain(mods)) } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32) + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32); + // No chips during a fill drag, or while Alt makes the tool sample with the eyedropper. + if self.preview + && !self.busy() + && let Some((at, _, false)) = self.at + { + out.extend(swatch_chips(cx, at, Self::active_paint(cx), self.picked[Self::picked_slot(cx)], true)); + } + out } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { @@ -416,6 +475,7 @@ impl Tool for LivePaintBucketTool { json!({ "paintFills": self.fills, "paintStrokes": self.strokes, + "cursorSwatchPreview": self.preview, "highlight": self.highlight, "color": format!("#{r:02x}{g:02x}{b:02x}"), "width": self.width, @@ -426,6 +486,7 @@ impl Tool for LivePaintBucketTool { match key { "paintFills" => self.fills = v.as_bool().unwrap_or(self.fills), "paintStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes), + "cursorSwatchPreview" => self.preview = v.as_bool().unwrap_or(self.preview), "highlight" => self.highlight = v.as_bool().unwrap_or(self.highlight), "color" => self.color = lp_color(v).unwrap_or(self.color), "width" => { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 298e6a1907..14976f03c9 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1113,7 +1113,12 @@ fn bucket_options_choose_what_a_click_paints() { }; let (face, edge) = (Some(Rect::new(50.0, 0.0, 100.0, 50.0)), Some(Rect::new(50.0, 0.0, 50.0, 50.0))); let mut t = LivePaintBucketTool::default(); - assert_eq!(t.options(), json!({"paintFills": true, "paintStrokes": false, "highlight": true, "color": "#ff2a2a", "width": 4.0})); + assert_eq!( + t.options(), + json!({"paintFills": true, "paintStrokes": false, "cursorSwatchPreview": true, "highlight": true, "color": "#ff2a2a", "width": 4.0}) + ); + // The highlight alone, without the swatch chips. + t.set_option("cursorSwatchPreview", &json!(false)); // Paint Fills alone: 1 pt from the edge, a click fills the face and Shift-click strokes the edge. assert_eq!((click(&mut t, 51.0, Mods::default()), hovered(&mut t, 51.0, Mods::default())), (fill(51.0), face)); assert_eq!((click(&mut t, 51.0, shift), hovered(&mut t, 51.0, shift)), (stroke(51.0), edge)); @@ -1231,6 +1236,7 @@ fn bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths() { s.set([g]); let live = cx(&d, &s, &p); let mut t = LivePaintBucketTool::default(); + t.set_option("cursorSwatchPreview", &json!(false)); t.pointer(&live, &ev(PointerKind::Move, pos.x, pos.y)); t.poll(&live, true); assert!(t.overlays(&live).is_empty()); @@ -1245,6 +1251,7 @@ fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() { let p = paint(); let cx = cx(&d, &s, &p); let mut t = LivePaintBucketTool::default(); + t.set_option("cursorSwatchPreview", &json!(false)); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); t.poll(&cx, true); assert!(!t.overlays(&cx).is_empty(), "Paint Fills highlights the region"); @@ -1263,6 +1270,7 @@ fn bucket_options_set_the_highlight() { let p = paint(); let cx = cx(&d, &s, &p); let mut t = LivePaintBucketTool::default(); + t.set_option("cursorSwatchPreview", &json!(false)); let highlight = |t: &mut LivePaintBucketTool| { t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); match t.overlays(&cx).as_slice() { @@ -1299,6 +1307,7 @@ fn bucket_highlights_an_edge_thinner_than_a_face() { let p = paint(); let cx = cx(&d, &s, &p); let mut t = LivePaintBucketTool::default(); + t.set_option("cursorSwatchPreview", &json!(false)); let width = |t: &mut LivePaintBucketTool, x: f64, shift: bool| { t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(Mods { shift, ..Default::default() })); match t.overlays(&cx).as_slice() { @@ -1517,7 +1526,7 @@ fn bucket_hover_builds_a_large_map_on_another_thread() { poll_until_ready(&mut t, &cx); assert!(matches!(t.hover, LpHover::Region(_)), "{:?}", t.hover); assert_eq!((t.cache.builds, t.cache.background), (1, 1)); - assert_eq!(t.overlays(&cx).len(), 1); + assert_eq!(t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1); } #[test] @@ -1883,6 +1892,7 @@ fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { }; assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); let mut bucket = LivePaintBucketTool::default(); + bucket.set_option("cursorSwatchPreview", &json!(false)); bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 }))); @@ -1926,6 +1936,7 @@ fn live_paint_tools_skip_hidden_edges() { }; assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); let mut bucket = LivePaintBucketTool::default(); + bucket.set_option("cursorSwatchPreview", &json!(false)); bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 }))); @@ -1953,6 +1964,7 @@ fn live_paint_tools_skip_hidden_faces() { }; // Where the faces overlap, both tools take the back group's face under the hidden one. let mut bucket = LivePaintBucketTool::default(); + bucket.set_option("cursorSwatchPreview", &json!(false)); bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); let fill = Action::Preview("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 17e6f038e9..33f2b7bd3c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -182,6 +182,11 @@ pub trait Tool: Send { fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { false } + /// [`Self::claims_key`] for a press with `mods` held. By default the modifiers do not matter; + /// a tool that takes a key only without modifiers leaves the modified presses to the shortcuts. + fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> bool { + self.claims_key(cx, key) + } /// Has the tool started something that Escape ends while no button is pressed (a path being /// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or /// a rotated view. diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index e7b9f9a5c4..a4d1b1a64f 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -46,7 +46,7 @@ const ROWS: &[Row] = &[ // Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`). (&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]), // Live Paint Bucket Options (`builder::LivePaintBucketTool`). - (&["livePaintBucket"], None, &["paintFills", "paintStrokes", "highlight", "color", "width"]), + (&["livePaintBucket"], None, &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight", "color", "width"]), (&["polygon"], None, &["sides"]), (&["star"], None, &["points"]), (&["freeTransform"], None, &["constrain"]), diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d6df378c87..c4d324ad2b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -680,9 +680,17 @@ fn bucket_modifier_only_hover_updates_the_visible_cursor() { frame(app, &ctx, events); app.canvas.hover_cursor.map(|(_, _, cursor)| cursor) }; + // The cursor swatch preview's chips go and come back with Alt too. + let chips = |app: &mut VectorcraftApp| { + let view = app.view_info(); + app.session.overlays(view).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Swatch { .. })).count() + }; assert_eq!(shown(&mut app, vec![Event::PointerMoved(at)]), Some(Cursor::LivePaintBucket)); + assert!(chips(&mut app) > 0); assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT)]), Some(Cursor::Eyedropper)); + assert_eq!(chips(&mut app), 0); assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]), Some(Cursor::LivePaintBucket)); + assert!(chips(&mut app) > 0); } /// The Image Trace panel's eye-button hold ends when the pointer is released, even when the diff --git a/crates/ui-egui/src/dialogs/live_paint_bucket.rs b/crates/ui-egui/src/dialogs/live_paint_bucket.rs index c040cdcc31..f515c3d904 100644 --- a/crates/ui-egui/src/dialogs/live_paint_bucket.rs +++ b/crates/ui-egui/src/dialogs/live_paint_bucket.rs @@ -4,7 +4,8 @@ //! `tool.setOption {tool: "livePaintBucket", values}`. //! //! Fields: `paintFills` and `paintStrokes` (the faces and the edges of Live Paint groups), -//! `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight +//! `cursorSwatchPreview` (whether the active paint and the color swatches beside it show above the +//! pointer), `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight //! color: `#rrggbb`, or a layer color's name such as "Red") and `width` (the width of a face's //! highlight in screen pixels; an edge's highlight is half as wide). @@ -25,7 +26,7 @@ pub const KIND: &str = "livePaintBucketOptions"; const TOOL: &str = "livePaintBucket"; /// The options the dialog edits. -const KEYS: [&str; 5] = ["paintFills", "paintStrokes", "highlight", "color", "width"]; +const KEYS: [&str; 6] = ["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight", "color", "width"]; /// Width of the label column. const LABEL_W: f32 = 60.0; @@ -48,6 +49,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "paintFills", tl!("Paint Fills")); form::check(ui, d, "paintStrokes", tl!("Paint Strokes")); + form::check(ui, d, "cursorSwatchPreview", tl!("Cursor Swatch Preview")); ui.add_space(6.0); form::check(ui, d, "highlight", tl!("Highlight")); let color = resolve(&d.str("color")).unwrap_or(Color::BLACK); @@ -120,17 +122,18 @@ mod tests { crate::toolbar::open_options(&mut app, TOOL).unwrap(); let text = painted(&mut app); - for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Highlight", "Color:", "Width:"] { + for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Cursor Swatch Preview", "Highlight", "Color:", "Width:"] { assert!(text.contains(label), "{label} in {text}"); } let d = app.ui.dialog.as_mut().unwrap(); assert_eq!(d.kind, KIND); assert_eq!( - (d.bool("paintFills"), d.bool("paintStrokes"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)), - (true, false, true, "#ff2a2a".to_string(), 4.0) + (d.bool("paintFills"), d.bool("paintStrokes"), d.bool("cursorSwatchPreview"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)), + (true, false, true, true, "#ff2a2a".to_string(), 4.0) ); d.fields.insert("paintFills".into(), json!(false)); d.fields.insert("paintStrokes".into(), json!(true)); + d.fields.insert("cursorSwatchPreview".into(), json!(false)); d.fields.insert("color".into(), json!("Green")); d.fields.insert("width".into(), json!(8)); super::super::confirm(&mut app).unwrap(); @@ -141,10 +144,13 @@ mod tests { // They last while another tool is chosen, and the dialog opens on them again. app.select_tool("selection"); let o = app.session.tool_options_of(TOOL); - assert_eq!((o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["color"].as_str()), (Some(false), Some(true), Some("#4fff4f"))); + assert_eq!( + (o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["cursorSwatchPreview"].as_bool(), o["color"].as_str()), + (Some(false), Some(true), Some(false), Some("#4fff4f")) + ); app.run("tool.options", json!({"tool": TOOL})).unwrap(); let d = app.ui.dialog.as_mut().unwrap(); - assert_eq!((d.bool("paintStrokes"), d.str("color")), (true, "#4fff4f".to_string())); + assert_eq!((d.bool("paintStrokes"), d.bool("cursorSwatchPreview"), d.str("color")), (true, false, "#4fff4f".to_string())); // Highlight off: the tool outlines nothing under the pointer. d.fields.insert("highlight".into(), json!(false)); super::super::confirm(&mut app).unwrap(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 925d7adf3b..a565bcaaf2 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -926,7 +926,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil and paintbrush, the same at any zoom; pt for smooth and blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0b5794b615..1dd2ef1d96 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -878,8 +878,23 @@ fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewIn // The Smart Pen Tool's Tab while it draws a path (the key otherwise hides the panels). (Key::Tab, ToolKey::Tab), ] { - if !rows_take(k) && ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) { - let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view); + // The modifiers held when the key went down, which can differ from the frame's at its end. + let pm = ctx.input(|i| { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k => Some(*modifiers), + _ => None, + }) + .unwrap_or(m) + }); + if !rows_take(k) + && ctx.input(|i| i.key_pressed(k)) + && app.session.tool_claims_key_with(tk, crate::canvas::mods(pm, false), view) + && ctx.input_mut(|i| i.consume_key(pm, k)) + { + let r = app.session.tool_key(tk, crate::canvas::mods(pm, false), view); // Keyboard actions can change the Artboard tool's active board too. Keep the // navigator aligned with its valid survivor, as pointer dispatch does. if r.is_ok() diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 60b8c5eacd..ae1f966696 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2245,6 +2245,37 @@ fn alt_arrows_step_selected_type_object() { assert_ne!(selected[0], vectorcraft_doc::NodeId(rect), "a non-type object is still copied by Alt+arrow"); } +/// With the Live Paint Bucket, a plain Right picks the next color swatch, while Alt+Right keeps +/// copying and nudging the selection, and a Shift+Right released within the same frame is still a +/// Shift+Right that picks no swatch. +#[test] +fn live_paint_bucket_takes_only_plain_arrows_for_swatches() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [rect]})).unwrap(); + let first = vectorcraft_tools::builder::color_swatches(&app.session.active().unwrap().doc)[0].name.clone(); + app.session.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap(); + app.select_tool("livePaintBucket"); + let fill = app.session.paint.fill.clone(); + + frame(&mut app, press(Key::ArrowRight, Modifiers::ALT)); + assert_eq!(app.session.paint.fill, fill, "Alt+Right picks no swatch"); + let selected = &app.session.active().unwrap().selection.objects; + assert_ne!(selected.as_slice(), [vectorcraft_doc::NodeId(rect)], "Alt+Right copies and nudges the selection"); + + let shift_then_released = vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::ArrowRight, physical_key: Some(Key::ArrowRight), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::ModifiersChanged(Modifiers::NONE), + ]; + frame(&mut app, shift_then_released); + assert_eq!(app.session.paint.fill, fill, "Shift+Right picks no swatch"); + + frame(&mut app, press(Key::ArrowRight, Modifiers::NONE)); + assert_ne!(app.session.paint.fill, fill, "a plain Right picks the next swatch"); +} + /// #793: with a layout without Latin letters the one-key shortcuts go by the key's position: /// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection /// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A. diff --git a/docs/mcp.md b/docs/mcp.md index cb5c40501c..4f843939a8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3437,7 +3437,8 @@ already walks Image Trace. Short agent entry points: at the pointer selects under these rules: the arrow with a filled square for a face, with a short line for an edge, and with an x for nothing. It keeps the look it had at the press until the release. The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens - them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the + them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `cursorSwatchPreview` (on), + `highlight` (on), `color` (the highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the face highlight's width, 1 to 20 screen pixels; 4; an edge's highlight is half as wide). The dialog's OK gives an error for a `color` with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. With `paintFills` alone on, a click runs `livePaint.fill` and Shift-click runs `livePaint.strokeEdge`. @@ -3446,6 +3447,14 @@ already walks Image Trace. Short agent entry points: `livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under the pointer is not outlined. + With the Live Paint Bucket selected and no drag in progress, Left and Right without Shift, Alt or Cmd/Ctrl (which + keep their shortcuts) run `paint.setFill {swatch, ids: []}` (or + `paint.setStroke` when the Stroke proxy is in front) with the color swatch before or after the active paint in the + Swatches panel, wrapping around at the ends; from a color that is no swatch, Right picks the first swatch and Left + the last, and with no color swatch the tool does not take the keys. The selected art keeps its paint. With + `cursorSwatchPreview` on, the active paint shows as a ringed chip above the pointer between the swatches before and + after it, except during a fill drag or with Alt held; a solid color that is no swatch shows alone, and a paint that is not a solid color shows no chip. Left and + Right step the swatches with `cursorSwatchPreview` off too. `livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as `livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces. @@ -9703,8 +9712,8 @@ The Shape Builder tool's options are its Shape Builder Tool Options (dialog `sha `openFilledAsClosed` (on: a filled open path bounds a region as if an invisible edge closed it; off: it cuts regions as an unfilled path does), `pickColorFrom` (`artwork`, the default: a merged shape takes the appearance, opacity and blending mode of the front-most selected object under the drag's first point, else of the one under its last point, -else of the front-most selected object; `swatches`: the current fill, and the Left and Right arrow keys step the -current fill through the document's color swatches), `cursorSwatchPreview` (off; with `swatches`, chips above the +else of the front-most selected object; `swatches`: the current fill, and the Left and Right arrow keys without +Shift, Alt or Cmd/Ctrl step the current fill through the document's color swatches), `cursorSwatchPreview` (off; with `swatches`, chips above the pointer show the current fill and the swatches before and after it), `selection` (`freeform`, the default, or `straightLine`: a drag touches what the line from its first point to the pointer crosses), `highlightFill` (on by default; when it is off, merge mode shades no regions), `gapDetection` (off) with `gapLength` (pt, 0 to 72; 3), `clickStrokeSplitsPath` (In Merge Mode, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..e0b61377f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1401,6 +1401,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`. With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. + Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). From 3429bbc76a6a9ee363756969e8d19aa4a5d335f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:16:15 -0800 Subject: [PATCH 03/42] Save, Export for Screens: warnings name an option the file doesn't read The save commands' warnings now follow an export's .ai rules: a legacy .ai file (aiVersion "7") reads only its own options, and the other .ai and .ait files don't read outlineText, hiddenObjects, transparentImages or preserveEditing. They name any option given beside the path that the file doesn't read, not only compress, version, preview and precision, and any such key in options. The bytes a save writes don't change. The Save Options dialog sends a .ai save only the options the chosen version reads, so a save from the dialog gets none of these warnings. When the document's own file is already a .ai, the .ai options that a save doesn't give and its file doesn't read keep their remembered values, so the dialog still shows them; the save doesn't write them, and a retry in another folder doesn't send them. Export for Screens and Export Assets already accepted a key in a format row or in a format's settings that only another format takes. Their warnings now name it as given (formats[0].quality, settings.png.quality). A key in a format's settings is named only when no row of that format, as the export writes it, reads it. The options of an exportPreset, and for assets.export the settings the document keeps, get no warning. Docs: params text of document.save, document.exportForScreens and assets.export; docs/mcp.md; docs/target-app-parity.md. --- crates/engine/src/cmd/assets.rs | 4 +- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/export.rs | 27 +++-- crates/engine/src/cmd/fileio/save.rs | 79 +++++++++++++-- crates/engine/src/cmd/fileio/screens.rs | 94 ++++++++++++++++-- crates/engine/src/cmd/fileio/tests.rs | 4 +- .../engine/src/cmd/fileio/tests_exportas.rs | 99 +++++++++++++++++++ crates/engine/src/cmd/fileio/tests_screens.rs | 90 +++++++++++++++++ crates/engine/src/cmd/recovery.rs | 1 + crates/mcp/src/tests/fileio.rs | 10 +- crates/ui-egui/src/dialogs/save_options.rs | 10 +- .../ui-egui/src/dialogs/save_options/tests.rs | 27 +++++ crates/ui-egui/src/dialogs/tests_screens.rs | 33 +++++++ docs/mcp.md | 18 +++- docs/target-app-parity.md | 2 +- 16 files changed, 452 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 1ebde5c026..e30e6efcdc 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), @@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result { } q.insert("assets".into(), json!(all)); } - export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e)) + export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e)) } fn settings_set(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 9154ce7ee5..97f957ed0e 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads, leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 5abe3e879c..e7656bb6d0 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,