Live Paint: a Live Paint Selection marquee selects the faces and edges it touches

The Live Paint Selection tool acted only on the press, so a drag
selected at most the one face or edge under the pointer. The reference
app selects every face and edge a marquee touches, partly enclosed ones
included, and a Shift-drag adds them to the selection or removes them.

A drag of 3 pixels or more now draws a marquee. On release, select.set
selects the faces and edges it touches, or select.none runs when it
touches none. With Shift held at the press, select.toggle runs with
what it touches, and nothing runs when it touches none. A face that holds the whole marquee counts. Select Fills off
leaves faces out, and Select Strokes off leaves edges out.

A click now changes the selection on release instead of on the press,
so Escape between the press and the release, which also removes the
marquee, leaves the selection as it was. The tool no longer highlights
or selects the faces and edges of a Live Paint group that is locked, on
a locked layer or on a locked artboard, or that is outside the isolated
art in isolation mode.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-29 13:00:33 +07:00
committed by bflatastic
parent 996e4a8258
commit fa7df461bc
7 changed files with 367 additions and 49 deletions
+34 -1
View File
@@ -2230,12 +2230,45 @@ fn live_paint_bucket_tool_fills_face() {
let gn = node(&s, g);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(125.0, 50.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
// Live Paint Selection selects the face.
// A Live Paint Selection click selects the face.
s.select_tool("livePaintSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
}
#[test]
fn live_paint_selection_marquee_selects_what_it_touches() {
let mut s = session();
let g = live(&mut s);
let v = ViewInfo::default();
s.select_tool("livePaintSelection", v).unwrap();
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64), m: Mods, escape: bool| {
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(m), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap();
if escape {
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap();
s.doc().unwrap().selection.objects.clone()
};
let gn = node(&s, g);
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id;
let edge = |x: f64| vectorcraft_tools::builder::edge_near(&gn, Point::new(x, 50.0), 0.5).unwrap().id;
let shift = Mods { shift: true, ..Default::default() };
// Across the left side: the left face and that side.
assert_eq!(drag(&mut s, (-10.0, 40.0), (40.0, 60.0), Mods::default(), false), vec![face(25.0), edge(0.0)]);
// Shift-drag across the right side adds the right face and that side.
assert_eq!(drag(&mut s, (160.0, 40.0), (140.0, 60.0), shift, false), vec![face(25.0), edge(0.0), face(125.0), edge(150.0)]);
// Shift-drag across the left side again takes those out.
assert_eq!(drag(&mut s, (-10.0, 40.0), (40.0, 60.0), shift, false), vec![face(125.0), edge(150.0)]);
// Escape during the drag, with or without Shift, from a press on a face or outside the group:
// the selection stays as it was.
for (from, m) in [((-10.0, 40.0), shift), ((25.0, 50.0), Mods::default()), ((25.0, 50.0), shift)] {
assert_eq!(drag(&mut s, from, (40.0, 60.0), m, true), vec![face(125.0), edge(150.0)], "{from:?} {m:?}");
}
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap();
NodeId(r["id"].as_u64().unwrap())
+151 -28
View File
@@ -1,5 +1,5 @@
use super::cache::RegionCache;
use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at};
use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_edge_near, visible_face_at};
use super::map::{HIGHLIGHT_RED, sorted_roots};
use super::{
Action, Arc, BezPath, Cursor, Document, Mods, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Tool, ToolContext, Value, json, json_ids,
@@ -22,9 +22,11 @@ pub(super) enum LpHover {
/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under
/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside
/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the
/// visible Live Paint groups (the front-most group's on a tie). None when no group is found.
fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc<Node>>)> {
if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) {
/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. Only
/// the groups for which `takes` is true are looked at: [`lp_visible`] for the Live Paint Bucket and
/// [`lp_selectable`] for Live Paint Selection.
fn lp_part_at<'a>(doc: &'a Document, p: Point, tol: f64, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> Option<(NodeId, Option<&'a Arc<Node>>)> {
if let Some(g) = live_paint_at_where(doc, p, takes).and_then(|id| doc.node(id)) {
return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) }));
}
if !edge {
@@ -36,7 +38,7 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId,
&& let Some(e) = visible_edge_near(n, p, tol)
&& let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p))
&& best.is_none_or(|b| d <= b.0)
&& doc.is_visible(n.id)
&& takes(n.id)
{
best = Some((d, n.id, e));
}
@@ -44,11 +46,25 @@ fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId,
best.map(|(_, g, e)| (g, Some(e)))
}
/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else
/// with `cache` the region of the selection under `p`. A large selection's regions are found on
/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted.
fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover {
if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) {
/// Whether the Live Paint Bucket takes Live Paint group `id`: it is visible, with every layer and
/// group around it.
fn lp_visible(cx: &ToolContext, id: NodeId) -> bool {
cx.doc.is_visible(id)
}
/// Whether the Live Paint Selection tool takes Live Paint group `id`: it is editable
/// ([`Document::is_editable`]: visible, and not locked, on a locked layer or on a locked artboard),
/// and in isolation mode it is the isolated object or inside it ([`crate::direct::in_scope`]).
fn lp_selectable(cx: &ToolContext, id: NodeId) -> bool {
cx.doc.is_editable(id) && crate::direct::in_scope(cx.doc, cx.isolation, id)
}
/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`], among
/// the groups `takes` takes), else with `cache` the region of the selection under `p`. A large
/// selection's regions are found on another thread ([`RegionCache::ready`]), and until they are, no
/// region is highlighted.
fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> LpHover {
if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, takes) {
return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default();
}
let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint));
@@ -95,14 +111,14 @@ impl Tool for LivePaintBucketTool {
match ev.kind {
PointerKind::Move => {
self.at = Some((p, ev.mods.shift));
self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift);
self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift, &|id| lp_visible(cx, id));
vec![]
}
PointerKind::Down => {
self.hover = LpHover::None;
let point = json!([p.x, p.y]);
if ev.mods.shift {
return match lp_part_at(cx.doc, p, cx.tol(4.0), true) {
return match lp_part_at(cx.doc, p, cx.tol(4.0), true, &|id| lp_visible(cx, id)) {
Some((g, Some(_))) => {
vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))]
}
@@ -143,7 +159,7 @@ impl Tool for LivePaintBucketTool {
loop {
self.cache.settle(wait);
if let Some((p, shift)) = self.at {
self.hover = lp_hover(cx, Some(&mut self.cache), p, shift);
self.hover = lp_hover(cx, Some(&mut self.cache), p, shift, &|id| lp_visible(cx, id));
}
if !wait || !self.cache.waiting() {
break;
@@ -160,12 +176,18 @@ pub const LP_SELECT_WIDTH: f64 = 4.0;
/// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes.
pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0);
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool
/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight
/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select
/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills
/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click
/// selects no face or edge.
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. A drag draws
/// a marquee, and on release the faces and edges it touches ([`lp_parts_in`]) become the selection;
/// with Shift held at the press, each of them leaves the selection if it is selected and joins it
/// otherwise. 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
/// or on a locked artboard, or in isolation mode outside the isolated art. Its Tool Options
/// (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight `color`
/// (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With
/// Select Strokes off, Alt-click selects the face under the pointer as a click does and a marquee
/// takes no edges; with Select Fills off, a click selects the edge nearest the pointer as Alt-click
/// does and a marquee takes no faces; with both off, a click or a marquee selects no face or edge.
pub struct LivePaintSelectionTool {
pub(super) cache: RegionCache,
pub(super) hover: LpHover,
@@ -173,11 +195,44 @@ pub struct LivePaintSelectionTool {
strokes: bool,
color: [u8; 3],
width: f64,
/// The drag in progress, from the press to the release.
drag: Option<LpDrag>,
}
/// A press and drag of the Live Paint Selection tool.
#[derive(Clone, Copy, Debug)]
struct LpDrag {
/// Where the press was.
start: Point,
/// Where the pointer is.
cur: Point,
/// Shift was held at the press.
shift: bool,
/// The face or edge under the press, which a click selects (or with Shift toggles).
hit: Option<NodeId>,
}
impl LpDrag {
/// The marquee from the press to the pointer, None while the drag is shorter than
/// [`CLICK_TOL_PX`] screen pixels both across and down (a click).
fn marquee(&self, cx: &ToolContext) -> Option<Rect> {
let r = Rect::from_points(self.start, self.cur);
let tol = cx.tol(CLICK_TOL_PX);
(r.width() >= tol || r.height() >= tol).then_some(r)
}
}
impl Default for LivePaintSelectionTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH }
Self {
cache: RegionCache::live_paint(),
hover: LpHover::None,
fills: true,
strokes: true,
color: HIGHLIGHT_RED,
width: LP_SELECT_WIDTH,
drag: None,
}
}
}
@@ -193,6 +248,30 @@ impl LivePaintSelectionTool {
}
}
/// The faces (with `faces`) and edges (with `edges`) that marquee `r` touches, among the faces and
/// edges that are not hidden of the Live Paint groups the tool takes ([`lp_selectable`]), in paint
/// order. A face is touched when its outline has an anchor inside `r` or crosses `r`'s sides, or
/// when it holds `r`'s center. An edge is touched when it has an anchor inside `r` or crosses
/// `r`'s sides. A face or edge partly inside `r` is touched.
fn lp_parts_in(cx: &ToolContext, r: Rect, faces: bool, edges: bool) -> Vec<NodeId> {
let mut out = vec![];
cx.doc.walk(|n| {
if !is_live_paint(n) || !lp_selectable(cx, n.id) {
return;
}
for c in n.children().into_iter().flatten().filter(|c| c.visible) {
let Some(pd) = c.path_data() else { continue };
let face = faces && c.name.as_deref() == Some(FACE_NAME);
let edge = edges && c.name.as_deref() == Some(EDGE_NAME);
let touched = vectorcraft_geom::hit::intersects_rect(pd, r) || (face && path_contains(pd, FillRule::NonZero, r.center()));
if (face || edge) && touched {
out.push(c.id);
}
}
});
out
}
/// A highlight color option: `#rrggbb` or a layer color's name (any case).
pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> {
let s = v.as_str()?.trim();
@@ -214,26 +293,70 @@ impl Tool for LivePaintSelectionTool {
match ev.kind {
PointerKind::Move => {
// Only the parts of Live Paint groups highlight, so no map of the selection is built.
self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) {
self.hover = match edge.map(|e| lp_hover(cx, None, p, e, &|id| lp_selectable(cx, id))) {
Some(h @ LpHover::Part(_)) => h,
_ => LpHover::None,
};
vec![]
}
PointerKind::Down => {
let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id);
match (hit, ev.mods.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] }))],
(None, true) => vec![],
(None, false) => vec![Action::Exec("select.none".into(), json!({}))],
let part = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e, &|id| lp_selectable(cx, id)));
let hit = part.and_then(|(_, part)| part).map(|n| n.id);
self.drag = Some(LpDrag { start: p, cur: p, shift: ev.mods.shift, hit });
vec![]
}
PointerKind::Drag => {
if let Some(d) = &mut self.drag {
d.cur = p;
self.hover = LpHover::None;
}
vec![]
}
PointerKind::Up => {
let Some(d) = self.drag.take() else { return vec![] };
let d = LpDrag { cur: p, ..d };
let Some(r) = d.marquee(cx) else {
// A click selects what the press picked.
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] }))],
(None, true) => vec![],
(None, false) => vec![Action::Exec("select.none".into(), json!({}))],
};
};
let ids = lp_parts_in(cx, r, self.fills, self.strokes);
match (ids.is_empty(), d.shift) {
(true, true) => vec![],
(true, false) => vec![Action::Exec("select.none".into(), json!({}))],
(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) }))],
}
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
// Escape during a press or drag drops it; the release then selects nothing.
if key == ToolKey::Escape {
self.drag = None;
}
vec![]
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.drag = None;
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(r) = self.drag.and_then(|d| d.marquee(cx)) {
return vec![Overlay::Marquee(r)];
}
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32)
}
+6 -1
View File
@@ -106,9 +106,14 @@ fn nearest_edge<'a>(edges: impl IntoIterator<Item = &'a Arc<Node>>, p: Point, to
/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order).
pub fn live_paint_at(doc: &Document, p: Point) -> Option<NodeId> {
live_paint_at_where(doc, p, |id| doc.is_visible(id))
}
/// [`live_paint_at`] among the Live Paint groups for which `takes` is true.
pub(super) fn live_paint_at_where(doc: &Document, p: Point, takes: impl Fn(NodeId) -> bool) -> Option<NodeId> {
let mut hit = None;
doc.walk(|n| {
if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() {
if is_live_paint(n) && takes(n.id) && visible_face_at(n, p).is_some() {
hit = Some(n.id);
}
});
+165 -17
View File
@@ -858,6 +858,13 @@ fn live_paint_selection_hover_builds_no_map_of_the_selection() {
assert!(!t.waiting());
}
/// A Live Paint Selection click pressed with `down`: the press does nothing, and the release does
/// what is returned.
fn lp_click(t: &mut LivePaintSelectionTool, cx: &ToolContext, down: PointerEvent) -> Vec<Action> {
assert!(t.pointer(cx, &down).is_empty(), "the press changes nothing");
t.pointer(cx, &PointerEvent { kind: PointerKind::Up, ..down })
}
#[test]
fn live_paint_selection_selects_faces() {
let (d, _gid, f2, e) = live_group_doc();
@@ -865,12 +872,12 @@ fn live_paint_selection_selects_faces() {
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LivePaintSelectionTool::default();
let acts = t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0));
let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [f2.0] }))]);
let alt = Mods { alt: true, ..Default::default() };
let acts = t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt));
let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [e.0] }))]);
let acts = t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 300.0, 300.0));
assert_eq!(acts, vec![Action::Exec("select.none".into(), json!({}))]);
}
@@ -887,15 +894,15 @@ fn live_paint_selection_options_choose_what_a_click_selects() {
assert_eq!(t.options(), json!({"selectFills": true, "selectStrokes": true, "color": "#ff2a2a", "width": 4.0}));
// Select Fills off: a click inside a face selects nothing, and a click by an edge selects it.
t.set_option("selectFills", &json!(false));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0)), none);
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0)), none);
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0)), set(&[e]));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e]));
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0)), set(&[e]));
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[e]));
// Select Strokes off: Alt-click by an edge selects the face under the pointer.
t.set_option("selectFills", &json!(true));
t.set_option("selectStrokes", &json!(false));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2]));
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(alt)), set(&[f2]));
t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(alt));
let ov = t.overlays(&cx);
let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") };
@@ -903,12 +910,12 @@ fn live_paint_selection_options_choose_what_a_click_selects() {
// Both off: nothing is selected or highlighted.
t.set_option("selectFills", &json!(false));
for m in [Mods::default(), alt] {
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none);
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 51.0, 25.0).with_mods(m)), none);
t.pointer(&cx, &ev(PointerKind::Move, 51.0, 25.0).with_mods(m));
assert!(t.overlays(&cx).is_empty());
}
let shift = Mods { shift: true, ..Default::default() };
assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty());
assert!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty());
}
#[test]
@@ -928,15 +935,156 @@ fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights()
let ov = t.overlays(&cx);
let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") };
assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0));
let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m));
let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 102.0, 50.0).with_mods(m));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]);
let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m }));
let acts = lp_click(&mut t, &cx, ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m }));
assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]);
}
// Farther than 4 pixels from every edge: no highlight, and a click selects nothing.
t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0));
assert!(t.overlays(&cx).is_empty());
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
}
/// Presses at `from` with `m`, drags to `to` and releases there; returns what the release does
/// (the press and the drag do nothing).
fn lp_drag(t: &mut LivePaintSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec<Action> {
assert!(t.pointer(cx, &ev(PointerKind::Down, from.0, from.1).with_mods(m)).is_empty());
assert!(t.pointer(cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)).is_empty());
t.pointer(cx, &ev(PointerKind::Up, to.0, to.1).with_mods(m))
}
#[test]
fn live_paint_selection_marquee_selects_the_faces_and_edges_it_touches() {
let mut d = Document::new(500.0, 500.0);
let (_, [left, top, right, bottom]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let face = faces(d.node(d.layers[0].children().unwrap()[0].id).unwrap())[0].id;
live_square(&mut d, Rect::new(200.0, 0.0, 300.0, 100.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let mut t = LivePaintSelectionTool::default();
// While the drag is on, the tool is busy and draws the marquee.
t.pointer(&cx, &ev(PointerKind::Down, -10.0, -10.0));
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0));
assert!(t.busy());
assert_eq!(t.overlays(&cx), vec![Overlay::Marquee(Rect::new(-10.0, -10.0, 50.0, 50.0))]);
// Partly inside the marquee: the face and its left and top sides, not the other group.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)), set(&[face, left, top]));
assert!(!t.busy());
// A marquee inside the face takes the face.
assert_eq!(lp_drag(&mut t, &cx, (20.0, 20.0), (40.0, 40.0), Mods::default()), set(&[face]));
// Across the right side, which ends outside the marquee, and the bottom-right corner.
assert_eq!(lp_drag(&mut t, &cx, (90.0, 40.0), (110.0, 150.0), Mods::default()), set(&[face, right, bottom]));
// A marquee that touches nothing selects nothing.
assert_eq!(lp_drag(&mut t, &cx, (120.0, 120.0), (180.0, 180.0), Mods::default()), vec![Action::Exec("select.none".into(), json!({}))]);
// A drag shorter than 3 pixels is a click: the release selects the face under the press.
assert_eq!(lp_drag(&mut t, &cx, (50.0, 50.0), (52.0, 51.0), Mods::default()), set(&[face]));
// Select Fills off takes no faces, and Select Strokes off no edges.
t.set_option("selectFills", &json!(false));
assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), Mods::default()), set(&[left, top]));
t.set_option("selectFills", &json!(true));
t.set_option("selectStrokes", &json!(false));
assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), Mods::default()), set(&[face]));
}
#[test]
fn live_paint_selection_shift_marquee_adds_and_removes() {
let mut d = Document::new(500.0, 500.0);
let (_, [left, top, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let face = faces(d.node(d.layers[0].children().unwrap()[0].id).unwrap())[0].id;
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let toggle = |ids: &[NodeId]| vec![Action::Exec("select.toggle".into(), json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))];
let shift = Mods { shift: true, ..Default::default() };
let mut t = LivePaintSelectionTool::default();
// Each face and edge the marquee touches leaves the selection or joins it.
assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (50.0, 50.0), shift), toggle(&[face, left, top]));
// From a press on the face, the face is toggled once, with the rest of what the marquee touches.
assert_eq!(lp_drag(&mut t, &cx, (50.0, 50.0), (60.0, -10.0), shift), toggle(&[face, top]));
// From a press by the right side (which a Shift-click there toggles), a marquee that touches
// nothing changes nothing.
let alt_shift = Mods { alt: true, shift: true, ..Default::default() };
assert_eq!(lp_drag(&mut t, &cx, (102.0, 50.0), (102.0, 50.0), alt_shift), vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]);
assert!(lp_drag(&mut t, &cx, (102.0, 50.0), (150.0, 60.0), alt_shift).is_empty());
}
#[test]
fn live_paint_selection_escape_cancels_the_marquee() {
let mut d = Document::new(500.0, 500.0);
live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
// Pressed outside the group and on its face, with and without Shift, and then dragged or not.
for (m, from, to) in [
(Mods::default(), (-10.0, -10.0), (50.0, 50.0)),
(Mods { shift: true, ..Default::default() }, (-10.0, -10.0), (50.0, 50.0)),
(Mods::default(), (50.0, 50.0), (80.0, 80.0)),
(Mods { shift: true, ..Default::default() }, (50.0, 50.0), (80.0, 80.0)),
(Mods::default(), (50.0, 50.0), (50.0, 50.0)),
] {
let mut t = LivePaintSelectionTool::default();
assert!(t.pointer(&cx, &ev(PointerKind::Down, from.0, from.1).with_mods(m)).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)).is_empty());
assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty());
assert!(!t.busy());
assert!(t.overlays(&cx).is_empty(), "no marquee after Escape");
// The rest of the drag and the release select nothing.
t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 60.0).with_mods(m));
assert!(t.overlays(&cx).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Up, 60.0, 60.0).with_mods(m)).is_empty(), "{m:?} {from:?}");
}
}
#[test]
fn live_paint_selection_skips_locked_groups_and_art_outside_isolation() {
let mut d = Document::new(500.0, 500.0);
let (back, [_, _, back_right, back_bottom]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0));
let face = |d: &Document, g: NodeId| faces(d.node(g).unwrap())[0].id;
let back_face = face(&d, back);
d.node_mut(front).unwrap().locked = true;
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::<Vec<_>>() }))];
let none = vec![Action::Exec("select.none".into(), json!({}))];
let part = |ov: &[Overlay]| match ov {
[] => None,
[Overlay::Path { path, .. }] => Some(path.bounding_box()),
_ => panic!("{ov:?}"),
};
let mut t = LivePaintSelectionTool::default();
{
let cx = cx(&d, &s, &p);
// Where the faces overlap, the back group's face under the locked one is highlighted and picked.
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&t.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 50.0)), set(&[back_face]));
// Over the locked group alone, nothing is highlighted or picked.
t.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&t.overlays(&cx)), None);
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 125.0, 50.0)), none);
// A marquee across both groups takes only the back group's face and edges.
assert_eq!(lp_drag(&mut t, &cx, (90.0, 90.0), (140.0, 110.0), Mods::default()), set(&[back_face, back_right, back_bottom]));
}
// With the back group isolated, the front group is not picked either.
d.node_mut(front).unwrap().locked = false;
{
let cx = ToolContext { isolation: Some(back), ..cx(&d, &s, &p) };
t.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&t.overlays(&cx)), None);
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 125.0, 50.0)), none);
assert_eq!(lp_drag(&mut t, &cx, (90.0, 90.0), (140.0, 110.0), Mods::default()), set(&[back_face, back_right, back_bottom]));
}
// On a locked layer, no group is picked.
let layer = d.layers[0].id;
d.node_mut(layer).unwrap().locked = true;
let cx = cx(&d, &s, &p);
assert_eq!(lp_click(&mut t, &cx, ev(PointerKind::Down, 75.0, 50.0)), none);
assert_eq!(lp_drag(&mut t, &cx, (-10.0, -10.0), (200.0, 200.0), Mods::default()), none);
}
#[test]
@@ -965,7 +1113,7 @@ fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() {
Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })),
None => Action::Exec("select.none".into(), json!({})),
};
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]);
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]);
let mut bucket = LivePaintBucketTool::default();
bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift));
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2));
@@ -1008,7 +1156,7 @@ fn live_paint_tools_skip_hidden_edges() {
Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })),
None => Action::Exec("select.none".into(), json!({})),
};
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}");
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.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}");
@@ -1044,19 +1192,19 @@ fn live_paint_tools_skip_hidden_faces() {
let mut sel = LivePaintSelectionTool::default();
sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]);
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]);
// Over the hidden face alone, neither tool highlights or picks a face.
bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&bucket.overlays(&cx)), None);
assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty());
sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0));
assert_eq!(part(&sel.overlays(&cx)), None);
assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]);
// Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side
// as it does outside every face.
sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt));
assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0)));
let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt));
let picked = lp_click(&mut sel, &cx, ev(PointerKind::Down, 101.5, 50.0).with_mods(alt));
assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]);
}
+1 -1
View File
@@ -199,7 +199,7 @@ impl DirectSelectionTool {
/// Whether `id` may be picked while `scope` is isolated: it is that object, or it sits inside it.
/// With no isolation, everything may be picked.
fn in_scope(doc: &vectorcraft_doc::Document, scope: Option<NodeId>, id: NodeId) -> bool {
pub(crate) fn in_scope(doc: &vectorcraft_doc::Document, scope: Option<NodeId>, id: NodeId) -> bool {
scope.is_none_or(|scope| doc.ancestry(id).is_some_and(|anc| anc.contains(&scope)))
}
+8 -1
View File
@@ -1570,7 +1570,14 @@ already walks Image Trace. Short agent entry points:
highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the highlight's width, 1 to 20 screen
pixels; 4). With `selectStrokes` off, Alt-click selects the face under the pointer as a click does; with
`selectFills` off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click selects
no face or edge.
no face or edge. A drag of 3 screen pixels or more draws a marquee, and the release runs `select.set {ids}` with
the faces and edges it touches (a face or edge partly inside counts, and so does a face that holds the whole
marquee), or `select.none` when it touches none. With Shift held at the press, the release runs
`select.toggle {ids}` with the faces and edges it touches, each of which leaves the selection or joins it, and runs
nothing when it touches none. `selectFills` off leaves the faces out, and `selectStrokes` off leaves the edges out. A click and a marquee run their command on the
release, and Escape between the press and the release removes the marquee so that the release runs nothing. The
tool does not highlight or select the faces and edges of a Live Paint group that is locked, on a locked layer or on
a locked artboard, or in isolation mode outside the isolated art.
`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.
+2
View File
@@ -858,6 +858,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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)).
Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`).
A drag 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.
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.
With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`.
Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`). Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. The Space, Move All, Rotate All and Select/Move widgets, Detach, the tool's options dialog and the Properties panel section are missing.