From dd3438cb7f192e1b741ad82f537d53091f75ad86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:09:16 -0800 Subject: [PATCH 1/3] 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 f74fe812928289908eeeb7431a93357ddb7bade2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:19:33 -0800 Subject: [PATCH 2/3] Live Paint: the swatch preview follows the release point; nudges read the key press's modifiers - After a fill drag, the Live Paint Bucket stores the release point, so the cursor swatch preview shows there instead of where the pointer last moved before the press. - Arrow nudges and Alt+arrow type spacing read Shift and Alt from the key press, as the tool keys do, so a modifier released within the same frame still makes a 10 pt nudge, a copy, or a spacing step. - tool.setOption accepts cursorSwatchPreview for the Live Paint Bucket and refuses a value that is not true or false. --- crates/engine/src/tests/build.rs | 7 +++-- crates/tools/src/builder/live_paint.rs | 4 ++- crates/tools/src/builder/tests.rs | 21 ++++++++++++++ crates/ui-egui/src/shortcuts.rs | 39 +++++++++++++++----------- crates/ui-egui/src/shortcuts/tests.rs | 37 ++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index d958ae7cfa..f3681c5991 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3294,9 +3294,10 @@ fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() { let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)]; let widths = [json!("wide"), json!(null), json!(true)]; let switches = [json!("true"), json!(null), json!(1)]; - for (tool, keys) in - [("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])] - { + for (tool, keys) in [ + ("livePaintBucket", &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"][..]), + ("livePaintSelection", &["selectFills", "selectStrokes"][..]), + ] { let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect(); bad.extend(colors.iter().map(|v| ("color", v.clone()))); bad.extend(widths.iter().map(|v| ("width", v.clone()))); diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index f629174e6a..a907a319bb 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -403,6 +403,8 @@ impl Tool for LivePaintBucketTool { } PointerKind::Drag => self.drag_fill(p), PointerKind::Up => { + // The swatch preview shows again at the release point. + self.at = Some((p, ev.mods.shift, ev.mods.alt)); let mut acts = self.drag_fill(p); if self.fill_drag.take().is_some() { acts.push(Action::Commit); @@ -673,7 +675,7 @@ pub(super) fn lp_rgb_color(v: &Value) -> bool { /// value it cannot read. pub fn check_live_paint_options(tool: &str, values: &serde_json::Map) -> Result<(), String> { let switches: &[&str] = match tool { - "livePaintBucket" => &["paintFills", "paintStrokes", "highlight"], + "livePaintBucket" => &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"], "livePaintSelection" => &["selectFills", "selectStrokes"], _ => return Ok(()), }; diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 14976f03c9..07a703be2d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1087,6 +1087,27 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() { assert_eq!(id, "livePaint.strokeEdge"); } +/// After a fill drag, the cursor swatch preview shows at the release point, not where the pointer +/// last moved before the press. +#[test] +fn bucket_swatch_preview_follows_the_release_point() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + let chip_x = |t: &LivePaintBucketTool| { + t.overlays(&cx).iter().find_map(|o| if let Overlay::Swatch { p, selected: true, .. } = o { Some(p.x) } else { None }) + }; + t.pointer(&cx, &ev(PointerKind::Move, 25.0, 25.0)); + assert_eq!(chip_x(&t), Some(25.0)); + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 25.0)); + assert_eq!(chip_x(&t), None, "no chips during the drag"); + t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0)); + assert_eq!(chip_x(&t), Some(75.0)); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1dd2ef1d96..ea32f6c078 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -625,8 +625,10 @@ fn dialog_or_palette_open(app: &VectorcraftApp) -> bool { /// Route Alt+arrows to spacing controls when text objects are selected. fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { - let m = ctx.input(|i| i.modifiers); - if !m.alt { + let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)]; + // The modifiers held when each arrow went down, as `nudge_and_clear` reads them. + let pressed = keys.map(|(key, tool_key)| (key, tool_key, ctx.input(|i| press_modifiers(i, key)))); + if !pressed.iter().any(|(.., m)| m.alt) { return false; } let orientation = app.session.active().and_then(|st| { @@ -644,9 +646,8 @@ fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let Some(vertical) = orientation else { return false; }; - let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)]; - for (key, tool_key) in keys { - if ctx.input_mut(|i| i.consume_key(m, key)) { + for (key, tool_key, m) in pressed { + if m.alt && ctx.input_mut(|i| i.consume_key(m, key)) { if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false, vertical) { let by = if m.command { by * 5.0 } else { by }; let _ = app.run("type.step", json!({"attribute": attribute, "by": by})); @@ -685,13 +686,27 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo settings_on_a_mac(app, ctx, mac(ctx)); } +/// The modifiers held when `key` last went down in this frame, which can differ from the frame's +/// at its end; the frame's when the key did not go down. +fn press_modifiers(i: &egui::InputState, key: Key) -> Modifiers { + i.events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers) +} + /// Apply arrow-key movement and clear selected art when no text editor owns input, unless a Layers /// or Appearance row has Delete/Backspace (`rows_keys`). fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context, rows_keys: bool) { // Arrow nudges (Shift = ×10, Alt = copy). let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)]; for (k, dx, dy) in arrows { - let m = ctx.input(|i| i.modifiers); + // The modifiers held when the key went down, as `claimed_tool_keys` reads them. + let m = ctx.input(|i| press_modifiers(i, k)); if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) { let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt})); } @@ -878,17 +893,7 @@ 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), ] { - // 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) - }); + let pm = ctx.input(|i| press_modifiers(i, k)); if !rows_take(k) && ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key_with(tk, crate::canvas::mods(pm, false), view) diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index ae1f966696..92a1ab9768 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2220,6 +2220,15 @@ fn alt_arrows_step_selected_type_object() { assert_eq!(r["runs"][0]["style"]["tracking"], json!(20.0)); assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]); + // Alt released within the frame of the press: the press's Alt still steps the spacing. + frame( + &mut app, + vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT), egui::Event::ModifiersChanged(Modifiers::NONE)], + ); + let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); + assert_eq!(r["runs"][0]["style"]["tracking"], json!(40.0)); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)], "the text is not copied"); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]); let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap(); assert_eq!(r["runs"][0]["style"]["leading"], json!(26.0)); @@ -2276,6 +2285,34 @@ fn live_paint_bucket_takes_only_plain_arrows_for_swatches() { assert_ne!(app.session.paint.fill, fill, "a plain Right picks the next swatch"); } +/// An arrow nudge takes Shift and Alt from the key press, also when they are released within the +/// same frame: Shift+Right moves 10 points, and Alt+Right copies. +#[test] +fn arrow_nudge_takes_the_modifiers_of_the_key_press() { + 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(); + let rect = vectorcraft_doc::NodeId(rect); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [rect.0]})).unwrap(); + let released = |modifiers| { + vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key: Key::ArrowRight, physical_key: Some(Key::ArrowRight), pressed: true, repeat: false, modifiers }, + egui::Event::ModifiersChanged(Modifiers::NONE), + ] + }; + let left = |app: &VectorcraftApp, id| app.session.active().unwrap().doc.bounds_of(&[id], false).unwrap().x0; + + frame(&mut app, released(Modifiers::SHIFT)); + assert_eq!(left(&app, rect), 10.0, "Shift+Right moves 10 points"); + + frame(&mut app, released(Modifiers::ALT)); + let selected = app.session.active().unwrap().selection.objects.clone(); + assert_ne!(selected.as_slice(), [rect], "Alt+Right copies"); + assert_eq!(left(&app, rect), 10.0, "the original stays"); +} + /// #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. From 270e58a4613ab71a0dbc98f39e9c22084283058d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:24:40 -0800 Subject: [PATCH 3/3] Live Paint: the Bucket's swatch preview and highlight go when the pointer leaves the canvas --- crates/tools/src/builder/live_paint.rs | 6 ++++++ crates/tools/src/builder/tests.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index a907a319bb..2ae2f082ee 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -456,6 +456,12 @@ impl Tool for LivePaintBucketTool { if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] } } + /// The pointer hovers nothing for the tool: its highlight and its swatch preview go. + fn hover_ended(&mut self, _cx: &ToolContext) { + self.hover = LpHover::None; + self.at = None; + } + 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. diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 07a703be2d..01b64ee8c3 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1108,6 +1108,21 @@ fn bucket_swatch_preview_follows_the_release_point() { assert_eq!(chip_x(&t), Some(75.0)); } +/// When the pointer leaves the canvas, the Live Paint Bucket's swatch preview and highlight go. +#[test] +fn bucket_hover_ended_drops_the_swatch_preview_and_highlight() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); + let ov = t.overlays(&cx); + assert!(ov.iter().any(|o| matches!(o, Overlay::Swatch { .. })) && ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}"); + t.hover_ended(&cx); + assert!(t.overlays(&cx).is_empty()); +} + #[test] fn bucket_options_choose_what_a_click_paints() { let (d, gid, ..) = live_group_doc();