From 8796aa9d85b11d04ebe4db5a2f60348530f08487 Mon Sep 17 00:00:00 2001 From: Murat Vural Date: Wed, 14 Oct 2026 02:02:15 +0300 Subject: [PATCH 1/2] feat: marquee selection selects unlocked ruler guides --- crates/tools/src/direct.rs | 37 +++++++++++++++++- crates/tools/src/rulerguide.rs | 23 +++++++++++- crates/tools/src/select.rs | 69 +++++++++++++++++++++++++++++++++- docs/ui-parity.md | 2 +- 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 35c71926..56c68f0e 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -730,7 +730,22 @@ impl Tool for DirectSelectionTool { } sel.retain(|(id, _)| cx.doc.is_editable(*id)); let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); - vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))] + let guides = crate::rulerguide::guides_in_rect(cx, r); + let mut out = vec![]; + if toggle { + if !items.is_empty() { + out.push(Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "toggle"}))); + } + if !guides.is_empty() { + out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); + } + } else { + out.push(Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))); + if !guides.is_empty() { + out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); + } + } + out } _ => vec![], } @@ -1100,6 +1115,26 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]); } + #[test] + fn marquee_selects_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!( + a, + vec![ + Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})), + Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), + ] + ); + } + /// A path inside an inner group, a sibling path outside it, and the groups' ids. /// The inside path is the rectangle at (100, 100); the outside one at (300, 300). fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) { diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 477ebdc3..53f98bc6 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -9,7 +9,7 @@ //! or locked layer, can't be picked. use serde_json::json; -use vectorcraft_geom::Point; +use vectorcraft_geom::{Point, Rect}; use crate::guides::Targets; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; @@ -32,6 +32,27 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { .map(|(i, _)| i) } +/// The editable ruler guides intersecting marquee rectangle `r`. +pub fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { + if !cx.guides { + return vec![]; + } + cx.doc + .guides + .iter() + .enumerate() + .filter(|(_, g)| cx.doc.guide_editable(g)) + .filter(|(_, g)| { + if g.vertical { + r.min_x() <= g.pos && g.pos <= r.max_x() && cx.doc.guide_span(g).is_none_or(|(y0, y1)| r.max_y() >= y0 && r.min_y() <= y1) + } else { + r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1) + } + }) + .map(|(i, _)| i) + .collect() +} + /// The pointer for a guide: it moves sideways if vertical, up and down if horizontal. fn guide_cursor(vertical: bool) -> Cursor { if vertical { Cursor::ResizeH } else { Cursor::ResizeV } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e828364e..262bf536 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -459,7 +459,22 @@ impl Tool for SelectionTool { return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } let ids: Vec = marquee(cx.doc, r, cx.isolation, false); - vec![Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }))] + let guides = crate::rulerguide::guides_in_rect(cx, r); + let mut out = vec![]; + if toggle { + if !ids.is_empty() { + out.push(Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))); + } + if !guides.is_empty() { + out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); + } + } else { + out.push(Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))); + if !guides.is_empty() { + out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); + } + } + out } _ => vec![], } @@ -993,6 +1008,58 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty()); } + #[test] + fn marquee_selects_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0)); + assert_eq!( + a, + vec![ + Action::Exec("select.set".into(), json!({"ids": [id.0]})), + Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), + ] + ); + } + + #[test] + fn marquee_selects_only_guide() { + let (mut d, _) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 30.0)); + let a = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0)); + assert_eq!( + a, + vec![Action::Exec("select.set".into(), json!({"ids": []})), Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),] + ); + } + + #[test] + fn marquee_ignores_locked_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let mut cx_locked = cx(&d, &s, &p); + cx_locked.guides = false; + let mut t = SelectionTool::default(); + t.pointer(&cx_locked, &ev(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx_locked, &ev(PointerKind::Drag, 120.0, 120.0)); + let a = t.pointer(&cx_locked, &ev(PointerKind::Up, 120.0, 120.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + } + #[test] fn handle_drag_scales() { let (d, id) = doc_with_rect(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a992bede..7f9ed3a1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -100,7 +100,7 @@ tools, never checked side by side) isn't captured row by row. | Tangent and perpendicular snapping for line endpoints (new in 30.0) | missing | | | Snapping panel/popover consolidating the snap options (30.0) | missing | | | Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only | -| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451 | +| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | | Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | | Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on | done | #1534 (#1543, #1583) | From ed96b2ff6575cf2266ced902459972965050715b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 06:22:03 +0700 Subject: [PATCH 2/2] Marquee guide selection: one shared helper for both selection tools --- crates/tools/src/direct.rs | 18 ++---------------- crates/tools/src/rulerguide.rs | 13 +++++++++++-- crates/tools/src/select.rs | 18 ++---------------- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 56c68f0e..f6ed7124 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -730,22 +730,8 @@ impl Tool for DirectSelectionTool { } sel.retain(|(id, _)| cx.doc.is_editable(*id)); let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); - let guides = crate::rulerguide::guides_in_rect(cx, r); - let mut out = vec![]; - if toggle { - if !items.is_empty() { - out.push(Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "toggle"}))); - } - if !guides.is_empty() { - out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); - } - } else { - out.push(Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))); - if !guides.is_empty() { - out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); - } - } - out + let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }})); + std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect() } _ => vec![], } diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 53f98bc6..1683ac51 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -2,7 +2,8 @@ //! made where it is released over the canvas: with the Artboard tool, an artboard guide of the //! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift //! adds it to the selected guides or, released without a drag, takes it out); dragging moves the -//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the +//! selected guides (Alt copies them), and a marquee selects the guides it crosses along with the +//! art ([`marquee_select`]). A dragged guide snaps ([`GuideSnap`]): with Shift to the //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its //! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden @@ -32,8 +33,16 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { .map(|(i, _)| i) } +/// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the +/// art: the guides it crosses join the selection (with a Shift marquee, the selected ones leave +/// it, as with a Shift-click on a guide). +pub fn marquee_select(cx: &ToolContext, r: Rect) -> Option { + let guides = guides_in_rect(cx, r); + (!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))) +} + /// The editable ruler guides intersecting marquee rectangle `r`. -pub fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { +fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { if !cx.guides { return vec![]; } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 262bf536..92826893 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -459,22 +459,8 @@ impl Tool for SelectionTool { return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] }; } let ids: Vec = marquee(cx.doc, r, cx.isolation, false); - let guides = crate::rulerguide::guides_in_rect(cx, r); - let mut out = vec![]; - if toggle { - if !ids.is_empty() { - out.push(Action::Exec("select.toggle".into(), json!({ "ids": json_ids(&ids) }))); - } - if !guides.is_empty() { - out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); - } - } else { - out.push(Action::Exec("select.set".into(), json!({ "ids": json_ids(&ids) }))); - if !guides.is_empty() { - out.push(Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))); - } - } - out + let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) })); + std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect() } _ => vec![], }