Merge pull request #7265 from dotCooCoo/feat/live-paint-cursor-swatch-preview

Live Paint Bucket: Cursor Swatch Preview and arrow keys pick swatches
This commit is contained in:
BFlat
2026-11-14 09:33:39 +07:00
committed by GitHub
15 changed files with 515 additions and 53 deletions
+201 -3
View File
@@ -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::<Vec<_>>(), [&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<String> = 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<Paint> = 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();
@@ -3284,9 +3481,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())));
+6
View File
@@ -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 {
File diff suppressed because one or more lines are too long
+43 -19
View File
@@ -143,6 +143,38 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option<usize>, 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<usize>, 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<usize>, matched_only: bool) -> Vec<Overlay> {
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<Overlay> {
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<Action> {
/// 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<Action> {
// 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 {
+77 -8
View File
@@ -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<usize>; 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<Action> {
self.fill_drag = Some((params.clone(), vec![vec![p]]));
vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)]
@@ -377,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);
@@ -387,24 +415,63 @@ impl Tool for LivePaintBucketTool {
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
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<Action> {
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<Action> {
if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32)
/// 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<Overlay> {
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 +483,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 +494,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" => {
@@ -612,7 +681,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<String, Value>) -> Result<(), String> {
let switches: &[&str] = match tool {
"livePaintBucket" => &["paintFills", "paintStrokes", "highlight"],
"livePaintBucket" => &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"],
"livePaintSelection" => &["selectFills", "selectStrokes"],
_ => return Ok(()),
};
+50 -2
View File
@@ -1087,6 +1087,42 @@ 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));
}
/// 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();
@@ -1113,7 +1149,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 +1272,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 +1287,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 +1306,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 +1343,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 +1562,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 +1928,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 +1972,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 +2000,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] }));
+5
View File
@@ -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.
+1 -1
View File
@@ -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"]),
+8
View File
@@ -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
@@ -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<Value, String> {
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();
File diff suppressed because one or more lines are too long
+28 -8
View File
@@ -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,8 +893,13 @@ 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);
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)
&& 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()
+68
View File
@@ -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));
@@ -2245,6 +2254,65 @@ 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");
}
/// 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.
+12 -3
View File
@@ -3521,7 +3521,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. `tool.setOption` gives an error that names the option and sets none of
@@ -3534,6 +3535,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.
@@ -9902,8 +9911,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,
+1
View File
@@ -1445,6 +1445,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)).