diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 235715a0b6..bdb1ca2e7b 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -1,7 +1,7 @@ use super::cache::RegionCache; use super::lookup::{ - connected_edges, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, visible_edge_near, - visible_face_at, + connected_edges, edges, faces, flood_faces, is_live_paint, leaf_shapes, live_paint_at, live_paint_at_where, lp_stroke_paint, node_outline, + visible_edge_near, visible_face_at, }; use super::map::{HIGHLIGHT_RED, sorted_roots}; use super::{ @@ -711,8 +711,14 @@ pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); /// with Shift held at the press, each of them leaves the selection if it is selected and joins it /// otherwise. A double-click on a face selects it and the faces of its group that are not parted /// from it by a stroked edge; on an edge, it selects the edge and the edges joined to it end to end -/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). With Shift held, each of them -/// leaves the selection if it is selected and joins it otherwise. +/// that have its stroke paint ([`LivePaintSelectionTool::reach_at`]). A triple-click on a face +/// selects every face of its group with that face's fill; on an edge, every edge of its group with +/// that edge's stroke paint ([`LivePaintSelectionTool::same_paint_at`]). With Shift held, each part a +/// double-click reaches leaves the selection if it is selected and joins it otherwise, and after a +/// Shift-triple-click each part with the clicked part's paint is toggled from the selection there +/// was before the three clicks. Clicks of the three made without Shift select as they do alone, so +/// when Shift is first held at a later click, the parts are toggled from the selection there was +/// before that click. /// A click and a marquee change the selection on release, so Escape between the press /// and the release (which removes the marquee) leaves the selection as it was. The tool does not /// highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer @@ -733,6 +739,33 @@ pub struct LivePaintSelectionTool { width: f64, /// The drag in progress, from the press to the release. drag: Option, + /// The part each of the last two clicks toggled with Shift held, the latest last (None for a + /// click that toggled nothing). A click or marquee without Shift clears both. + recent: [Option; 2], + /// The parts the last double-click, the two clicks before it and the Shift-clicks after it + /// toggled, each listed once when toggled an odd number of times. A Shift-triple-click toggles + /// them back as it toggles the parts with the clicked part's paint, so the selection ends as it + /// was before the clicks with those parts toggled. + toggled: Vec, +} + +/// `a` (which lists each id once) with each id of `b` toggled in turn: an id `b` lists an odd number +/// of times is removed when `a` holds it and added at the end otherwise, in `b`'s order. +fn toggle_ids(a: &mut Vec, b: &[NodeId]) { + use std::collections::HashSet; + let mut odd = HashSet::new(); + for id in b { + if !odd.insert(*id) { + odd.remove(id); + } + } + let held: HashSet = a.iter().copied().collect(); + a.retain(|x| !odd.contains(x)); + for id in b { + if !held.contains(id) && odd.remove(id) { + a.push(*id); + } + } } /// A press and drag of the Live Paint Selection tool. @@ -770,6 +803,8 @@ impl Default for LivePaintSelectionTool { color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH, drag: None, + recent: [None; 2], + toggled: vec![], } } } @@ -812,6 +847,25 @@ impl LivePaintSelectionTool { let Some(group) = cx.doc.node(gid) else { return vec![] }; if edge { connected_edges(group, part.id, &lp_stroke_paint(part)) } else { flood_faces(group, part.id) } } + + /// What a triple-click at `p` selects, Alt held or not: on the face a click there picks + /// ([`LivePaintSelectionTool::picks_edge`]), that face and every other face of its group that is + /// not hidden and has its fill; on the edge a click picks, that edge and every other edge of its + /// group that is not hidden and draws with the stroke paint it draws with ([`lp_stroke_paint`]). + /// The part under the pointer comes first. Empty where a click selects no face or edge. + fn same_paint_at(&self, cx: &ToolContext, p: Point, alt: bool) -> Vec { + let Some(edge) = self.picks_edge(alt) else { return vec![] }; + let Some((gid, Some(part))) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, &|id| lp_selectable(cx, id)) else { return vec![] }; + let Some(group) = cx.doc.node(gid) else { return vec![] }; + let others: Vec = if edge { + let like = lp_stroke_paint(part); + edges(group).into_iter().filter(|e| e.visible && e.id != part.id && lp_stroke_paint(e) == like).map(|e| e.id).collect() + } else { + let like = part.appearance.fill_paint(); + faces(group).into_iter().filter(|f| f.visible && f.id != part.id && f.appearance.fill_paint() == like).map(|f| f.id).collect() + }; + std::iter::once(part.id).chain(others).collect() + } } /// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and @@ -918,6 +972,17 @@ impl Tool for LivePaintSelectionTool { let d = LpDrag { cur: p, ..d }; let Some(r) = d.marquee(cx) else { // A click selects what the press picked. + // A click without Shift replaces the selection, so what earlier clicks toggled + // no longer counts. + if d.shift { + self.recent = [self.recent[1], d.hit]; + if let Some(id) = d.hit { + toggle_ids(&mut self.toggled, &[id]); + } + } else { + self.recent = [None; 2]; + self.toggled.clear(); + } return match (d.hit, d.shift) { (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], @@ -925,6 +990,8 @@ impl Tool for LivePaintSelectionTool { (None, false) => vec![Action::Exec("select.none".into(), json!({}))], }; }; + self.recent = [None; 2]; + self.toggled.clear(); let ids = lp_parts_in(cx, r, self.fills, self.strokes); match (ids.is_empty(), d.shift) { (true, true) => vec![], @@ -937,15 +1004,36 @@ impl Tool for LivePaintSelectionTool { // The two clicks before it already selected the part under the pointer (or, with // Shift, toggled it twice); the double-click selects the part with what it reaches. let ids = self.reach_at(cx, p, ev.mods.alt); + self.toggled.clear(); + if ev.mods.shift { + let clicks: Vec = self.recent.iter().flatten().copied().collect(); + toggle_ids(&mut self.toggled, &clicks); + toggle_ids(&mut self.toggled, &ids); + } match (ids.is_empty(), ev.mods.shift) { (true, _) => vec![], (false, true) => vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))], (false, false) => vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))], } } - // The tool takes no action on a triple-click; its third press and release select as a - // click does. - PointerKind::TripleClick => vec![], + PointerKind::TripleClick => { + // The clicks before it already selected the part under the pointer; the + // triple-click selects the part with every part of its group that has its paint. + // With Shift held, the double-click and the third click toggled parts too, and the + // triple-click toggles those back. + let ids = self.same_paint_at(cx, p, ev.mods.alt); + let toggled = std::mem::take(&mut self.toggled); + self.recent = [None; 2]; + if ids.is_empty() { + return vec![]; + } + if !ev.mods.shift { + return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))]; + } + let mut flip = ids; + toggle_ids(&mut flip, &toggled); + if flip.is_empty() { vec![] } else { vec![Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&flip) }))] } + } } } @@ -963,6 +1051,8 @@ impl Tool for LivePaintSelectionTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.drag = None; + self.recent = [None; 2]; + self.toggled.clear(); vec![] } diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index 66db1e550c..75e15da82d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -2573,20 +2573,167 @@ fn live_paint_selection_double_click_selects_connected_faces_or_edges() { assert!(dbl(&mut t, 25.0, 1.0, alt).is_empty()); } +/// A triple-click on a face selects every face of its group with that face's fill, joined to it or +/// not; on an edge (Alt), every edge of its group that draws with that edge's stroke paint. Shift +/// toggles them. Hidden parts, other groups and parts with another paint are left out. #[test] -fn live_paint_selection_triple_click_adds_nothing_to_its_clicks() { +fn live_paint_selection_triple_click_selects_every_part_of_the_same_paint() { let mut d = Document::new(500.0, 500.0); - let (_, [a, ..], _) = live_strip(&mut d); + let (_, [a, b, c], [ab, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let (other, [_, other_top, ..]) = live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0)); + let other_face = faces(d.node(other).unwrap())[0].id; + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + // A and C are red and B is blue; the other group's face is red and its top edge black, too. + for (id, fill) in [(a, &red), (b, &blue), (c, &red), (other_face, &red)] { + d.node_mut(id).unwrap().appearance.set_fill(fill.clone()); + } + d.node_mut(other_top).unwrap().appearance.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK)); + let s = Selection::default(); + let p = paint(); + let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))]; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let tri = |d: &Document, t: &mut LivePaintSelectionTool, x: f64, y: f64, m: Mods| { + let cx = cx(d, &s, &p); + t.pointer(&cx, &ev(PointerKind::TripleClick, x, y).with_mods(m)) + }; + let mut t = LivePaintSelectionTool::default(); + // A face: every face of its group with its fill, even across the stroked edge BC; the clicked face first. + assert_eq!(tri(&d, &mut t, 125.0, 25.0, Mods::default()), set(&[c, a])); + assert_eq!(tri(&d, &mut t, 75.0, 25.0, Mods::default()), set(&[b])); + // An edge: every edge of its group that draws with its stroke paint, joined to it or not. + assert_eq!(tri(&d, &mut t, 25.0, 1.0, alt), set(&[top_a, top_b, bottom])); + assert_eq!(tri(&d, &mut t, 125.0, 1.0, alt), set(&[top_c, bc])); + assert_eq!(tri(&d, &mut t, 50.0, 25.0, alt), set(&[ab])); + // With Shift held, the parts leave the selection or join it. + assert_eq!(tri(&d, &mut t, 25.0, 25.0, shift), toggle(&[a, c])); + assert_eq!(tri(&d, &mut t, 125.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }), toggle(&[top_c, bc])); + // Away from the group, a triple-click adds nothing to what its clicks did. + assert!(tri(&d, &mut t, 400.0, 400.0, Mods::default()).is_empty()); + // A hidden face of the same fill is left out. + d.node_mut(c).unwrap().visible = false; + assert_eq!(tri(&d, &mut t, 25.0, 25.0, Mods::default()), set(&[a])); + // On the other group, a triple-click selects that group's parts alone. + assert_eq!(tri(&d, &mut t, 250.0, 50.0, Mods::default()), set(&[other_face])); + assert_eq!(tri(&d, &mut t, 250.0, 1.0, alt), set(&[other_top])); + // Select Fills off: the triple-click picks the edge nearest the pointer; both off: nothing. + t.set_option("selectFills", &json!(false)); + assert_eq!(tri(&d, &mut t, 25.0, 1.0, Mods::default()), set(&[top_a, top_b, bottom])); + t.set_option("selectStrokes", &json!(false)); + assert!(tri(&d, &mut t, 25.0, 25.0, Mods::default()).is_empty()); + assert!(tri(&d, &mut t, 25.0, 1.0, alt).is_empty()); +} + +/// The whole event series of a triple-click (three presses and releases, the double-click after +/// the second release and the triple-click after the third), applied to a selection: without +/// Shift the selection ends as the parts with the clicked part's paint, and with Shift as the +/// selection from before the clicks with those parts toggled, whatever the double-click reached. +#[test] +fn live_paint_selection_triple_click_series_ends_with_the_same_paint_parts() { + let mut d = Document::new(500.0, 500.0); + let (_, [a, b, c], [_, bc, top_a, top_b, top_c, bottom]) = live_strip(&mut d); + let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)); + let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + // A and C are red and B is blue; a double-click on A also reaches B across the unstroked edge AB. + for (id, fill) in [(a, &red), (b, &blue), (c, &red)] { + d.node_mut(id).unwrap().appearance.set_fill(fill.clone()); + } let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); + let apply = |sel: &mut Vec, acts: Vec| { + for act in acts { + let Action::Exec(cmd, args) = act else { panic!("{act:?}") }; + let ids: Vec = match (args.get("ids"), args.get("id")) { + (Some(v), _) => v.as_array().unwrap().iter().map(|i| i.as_u64().unwrap()).collect(), + (None, Some(i)) => vec![i.as_u64().unwrap()], + (None, None) => vec![], + }; + match cmd.as_str() { + "select.set" => *sel = ids, + "select.none" => sel.clear(), + "select.toggle" => { + for i in ids { + match sel.iter().position(|x| *x == i) { + Some(k) => { + sel.remove(k); + } + None => sel.push(i), + } + } + } + other => panic!("{other}"), + } + } + }; + // The first click at `first`, every later event at (`x`, `y`). + let series = |t: &mut LivePaintSelectionTool, sel: &mut Vec, first: (f64, f64), x: f64, y: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up] { + apply(sel, t.pointer(&cx, &ev(kind, first.0, first.1).with_mods(m))); + } + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick, PointerKind::Down, PointerKind::Up, PointerKind::TripleClick] { + apply(sel, t.pointer(&cx, &ev(kind, x, y).with_mods(m))); + } + }; + let sorted = |ids: &[NodeId]| { + let mut v: Vec = ids.iter().map(|i| i.0).collect(); + v.sort_unstable(); + v + }; + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; let mut t = LivePaintSelectionTool::default(); - // The third click's press and release select the face under the pointer, as a click does. - assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 25.0, 25.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [a.0] }))]); - for m in [Mods::default(), Mods { alt: true, ..Default::default() }, Mods { shift: true, ..Default::default() }] { - assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 25.0).with_mods(m)).is_empty()); - assert!(t.pointer(&cx, &ev(PointerKind::TripleClick, 25.0, 1.0).with_mods(m)).is_empty()); + for (start, first, x, y, m, want) in [ + (vec![], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])), + (vec![top_c.0], (25.0, 25.0), 25.0, 25.0, Mods::default(), sorted(&[a, c])), + (vec![], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, c])), + (vec![top_c.0, c.0], (25.0, 25.0), 25.0, 25.0, shift, sorted(&[a, top_c])), + (vec![], (25.0, 1.0), 25.0, 1.0, Mods { alt: true, shift: true, ..Default::default() }, sorted(&[top_a, top_b, bottom])), + (vec![bc.0], (125.0, 1.0), 125.0, 1.0, alt, sorted(&[top_c, bc])), + // The first click on A and the rest on B, beside it: B alone has B's fill. + (vec![], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b])), + (vec![c.0], (49.0, 25.0), 51.0, 25.0, shift, sorted(&[b, c])), + ] { + let mut sel = start.clone(); + series(&mut t, &mut sel, first, x, y, m); + sel.sort_unstable(); + assert_eq!(sel, want, "from {start:?} at {first:?} then ({x}, {y}) with {m:?}"); } + // Shift first held at the third click: the two clicks and the double-click select A and B, and + // the parts with A's fill are toggled from that selection. + let mut sel = vec![top_c.0]; + for (kind, m) in [ + (PointerKind::Down, Mods::default()), + (PointerKind::Up, Mods::default()), + (PointerKind::Down, Mods::default()), + (PointerKind::Up, Mods::default()), + (PointerKind::DoubleClick, Mods::default()), + (PointerKind::Down, shift), + (PointerKind::Up, shift), + (PointerKind::TripleClick, shift), + ] { + apply(&mut sel, t.pointer(&cx, &ev(kind, 25.0, 25.0).with_mods(m))); + } + sel.sort_unstable(); + assert_eq!(sel, sorted(&[b, c])); + // A Shift-click on C, then a click on A without Shift, which selects A alone, and Shift held + // from the double-click on: the parts with A's fill are toggled from that selection. + let mut sel = vec![]; + for (kind, x, m) in [ + (PointerKind::Down, 125.0, shift), + (PointerKind::Up, 125.0, shift), + (PointerKind::Down, 25.0, Mods::default()), + (PointerKind::Up, 25.0, Mods::default()), + (PointerKind::DoubleClick, 25.0, shift), + (PointerKind::Down, 25.0, shift), + (PointerKind::Up, 25.0, shift), + (PointerKind::TripleClick, 25.0, shift), + ] { + apply(&mut sel, t.pointer(&cx, &ev(kind, x, 25.0).with_mods(m))); + } + assert_eq!(sel, vec![c.0]); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2afe917372..68224c8b69 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1559,7 +1559,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`. As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. - A double-click with the Live Paint Selection tool on a face selects it and the faces of its group that no stroked edge parts from it ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)), and on an edge selects it and the edges joined to it end to end that have its stroke paint ([the help page](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); Shift-double-click adds them to the selection or removes them from it. Triple-click is not handled yet. + A double-click with the Live Paint Selection tool on a face selects it and the faces of its group that no stroked edge parts from it ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)), and on an edge selects it and the edges joined to it end to end that have its stroke paint ([the help page](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)); Shift-double-click adds them to the selection or removes them from it. A triple-click on a face selects every face of its group with that face's fill, and on an edge every edge of its group that has its stroke paint ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)); hidden faces and edges are left out, and Shift-triple-click adds them to the selection or removes them from it. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`.