mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Merge pull request #2373 from dotCooCoo/feat/enclosed-marquee
Selection: pressing E while dragging a marquee selects only the objects inside it
This commit is contained in:
@@ -79,6 +79,7 @@ fn tool_key(name: &str) -> Option<ToolKey> {
|
||||
"[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft,
|
||||
"]" | "bracketright" | "closebracket" => ToolKey::BracketRight,
|
||||
"tab" => ToolKey::Tab,
|
||||
k if k.len() == 1 && k.bytes().all(|b| b.is_ascii_lowercase()) => ToolKey::Letter(k.chars().next()?),
|
||||
k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -204,7 +205,14 @@ impl Headless {
|
||||
let key = s(p, "key").ok_or("missing `key`")?;
|
||||
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
|
||||
let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() };
|
||||
let tk = tool_key(key);
|
||||
// A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while
|
||||
// a selection marquee is dragged); otherwise it is a shortcut.
|
||||
let mut tk = tool_key(key);
|
||||
if let Some(k @ ToolKey::Letter(_)) = tk
|
||||
&& (mods.cmd || mods.ctrl || mods.alt || !self.session.tool_claims_key(k, self.view))
|
||||
{
|
||||
tk = None;
|
||||
}
|
||||
// A busy tool (pen path in progress, shape being dragged) gets its keys first, as do keys
|
||||
// the tool claims (the Gradient tool's selected stop takes Delete and the arrows).
|
||||
if let Some(k) = tk
|
||||
|
||||
@@ -1008,6 +1008,28 @@ fn shift_marquee_gesture_toggles_the_selection() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `press_key {key: "e"}` while a Selection marquee is being dragged makes the marquee select only
|
||||
/// the objects entirely inside it. With nothing dragged, E still chooses its tool.
|
||||
#[test]
|
||||
fn e_during_a_marquee_gesture_selects_only_the_objects_inside() {
|
||||
let mut s = server();
|
||||
let square = |s: &mut Server, id: u64, x: u64| {
|
||||
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50}));
|
||||
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (a, _b) = (square(&mut s, 1, 100), square(&mut s, 2, 200));
|
||||
let begin = json!([{"kind": "down", "x": 80, "y": 80}, {"kind": "drag", "x": 220, "y": 170}]);
|
||||
let r = call(&mut s, 3, "pointer_gesture", json!({"tool": "selection", "events": begin}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 4, "press_key", json!({"key": "e"}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool", "{r}");
|
||||
let end = json!([{"kind": "drag", "x": 230, "y": 170}, {"kind": "up", "x": 230, "y": 170}]);
|
||||
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": "selection", "events": end}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"], json!([a]), "{r}");
|
||||
let r = call(&mut s, 6, "press_key", json!({"key": "e"}));
|
||||
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["handledBy"], "tool.select", "{r}");
|
||||
}
|
||||
|
||||
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
|
||||
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||||
//! the Corners dialog).
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group;
|
||||
//! its marquee works as Direct Selection's.
|
||||
//! its marquee works as Direct Selection's, and E pressed during the drag makes it select only the
|
||||
//! objects entirely inside it (members of groups too), whole.
|
||||
//! In isolation mode both pick only the isolated object and what is inside it: anchors, segments,
|
||||
//! paths and groups outside it stay put.
|
||||
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
|
||||
@@ -80,6 +81,9 @@ enum State {
|
||||
cur: Point,
|
||||
/// Shift: the anchors inside leave the selection if selected, else join it.
|
||||
toggle: bool,
|
||||
/// Group Selection, E pressed an odd number of times during the drag: the marquee selects
|
||||
/// only the objects that lie entirely inside it, whole.
|
||||
enclosed: bool,
|
||||
},
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
@@ -163,6 +167,12 @@ impl DirectSelectionTool {
|
||||
click_segment: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct
|
||||
/// Selection takes no letter.
|
||||
fn claims_letter(&self, key: ToolKey) -> bool {
|
||||
self.group && key == ToolKey::Letter('e') && matches!(self.state, State::Marquee { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` may be picked while `scope` is isolated: it is that object, or it sits inside it.
|
||||
@@ -363,7 +373,15 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
/// Escape during a drag (anchors, handles, segments, objects, type areas, blend spines, a
|
||||
/// marquee) cancels it: what it moved goes back and the tool waits for the next press (#1743).
|
||||
/// With Group Selection, E during a marquee drag makes the marquee select only the objects
|
||||
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if self.claims_letter(key)
|
||||
&& let State::Marquee { enclosed, .. } = &mut self.state
|
||||
{
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape && self.guide.busy() {
|
||||
return self.guide.cancel();
|
||||
}
|
||||
@@ -376,6 +394,9 @@ impl Tool for DirectSelectionTool {
|
||||
(self.click, self.click_segment) = (None, None);
|
||||
vec![Action::Cancel]
|
||||
}
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key)
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
@@ -390,7 +411,7 @@ impl Tool for DirectSelectionTool {
|
||||
return out;
|
||||
}
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
return vec![];
|
||||
};
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
@@ -539,7 +560,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
|
||||
@@ -671,8 +692,8 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
self.mesh.release().unwrap_or_default()
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle };
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle, enclosed };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
@@ -719,12 +740,21 @@ impl Tool for DirectSelectionTool {
|
||||
self.guides.clear();
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||||
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
// With Group Selection's enclosed marquee, the tool selects the objects inside it
|
||||
// (members of groups too), each whole, as a click selects one.
|
||||
if enclosed {
|
||||
let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true);
|
||||
touched.retain(|id| cx.doc.is_editable(*id));
|
||||
let ids: Vec<Value> = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect();
|
||||
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids }));
|
||||
return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect();
|
||||
}
|
||||
// Anchors inside the rect, on editable paths inside isolation mode.
|
||||
let mut sel: Vec<(NodeId, Vec<AnchorRef>)> = vec![];
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
@@ -746,7 +776,7 @@ impl Tool for DirectSelectionTool {
|
||||
sel.retain(|(id, _)| cx.doc.is_editable(*id));
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
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()
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -1226,6 +1256,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside
|
||||
/// it whole, a group's member without its group. Direct Selection does not take E.
|
||||
#[test]
|
||||
fn e_during_a_group_selection_marquee_selects_the_objects_inside() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art());
|
||||
let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
|
||||
d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap();
|
||||
d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let e = ToolKey::Letter('e');
|
||||
let drag = |group: bool, mods: Mods| {
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
assert!(!t.claims_key(&cx, e), "no marquee yet");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(mods));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 250.0).with_mods(mods));
|
||||
assert_eq!(t.claims_key(&cx, e), group);
|
||||
t.key(&cx, e, Mods::default());
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 250.0).with_mods(mods))
|
||||
};
|
||||
assert_eq!(drag(true, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
assert_eq!(drag(true, shift), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0, a.0]}))]);
|
||||
// Direct Selection's marquee still selects the anchors inside it.
|
||||
let anchors = |id: NodeId, list: Value| json!({"id": id.0, "anchors": list});
|
||||
let all = json!([[0, 0], [0, 1], [0, 2], [0, 3]]);
|
||||
assert_eq!(
|
||||
drag(false, Mods::default()),
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": [anchors(id, all.clone()), anchors(a, all)], "mode": "set"}))]
|
||||
);
|
||||
}
|
||||
|
||||
/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5).
|
||||
fn arch_doc() -> (vectorcraft_doc::Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
|
||||
@@ -131,6 +131,9 @@ pub enum ToolKey {
|
||||
/// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular
|
||||
/// to the plane).
|
||||
Digit(u8),
|
||||
/// A letter key a–z, lowercase (E while dragging a Selection or Group Selection marquee makes it
|
||||
/// select only the objects entirely inside it, and E again switches it back).
|
||||
Letter(char),
|
||||
}
|
||||
|
||||
/// What a tool asks the engine to do.
|
||||
|
||||
@@ -35,9 +35,13 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
|
||||
/// 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<Action> {
|
||||
let guides = guides_in_rect(cx, r);
|
||||
/// it, as with a Shift-click on a guide). With `enclosed` (E pressed during the drag), only the
|
||||
/// artboard guides that lie entirely inside the marquee are taken; a canvas guide is never taken.
|
||||
pub fn marquee_select(cx: &ToolContext, r: Rect, enclosed: bool) -> Option<Action> {
|
||||
let mut guides = guides_in_rect(cx, r);
|
||||
if enclosed {
|
||||
guides.retain(|i| cx.doc.guides.get(*i).is_some_and(|g| guide_inside(cx, r, g)));
|
||||
}
|
||||
(!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true })))
|
||||
}
|
||||
|
||||
@@ -62,6 +66,15 @@ fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<usize> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does ruler guide `g`, crossed by marquee `r`, run along its line within the marquee? An artboard
|
||||
/// guide does when the marquee spans its artboard; a canvas guide runs across the whole canvas and
|
||||
/// never does.
|
||||
fn guide_inside(cx: &ToolContext, r: Rect, g: &vectorcraft_doc::Guide) -> bool {
|
||||
let Some((a, b)) = cx.doc.guide_span(g) else { return false };
|
||||
let (lo, hi) = if g.vertical { (r.min_y(), r.max_y()) } else { (r.min_x(), r.max_x()) };
|
||||
lo <= a.min(b) && a.max(b) <= hi
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
+111
-6
@@ -12,6 +12,8 @@
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and
|
||||
//! E pressed again makes it take the objects it touches.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{hit_test, marquee, objects_at};
|
||||
@@ -58,6 +60,9 @@ enum State {
|
||||
cur: Point,
|
||||
/// Shift: the objects inside leave the selection if selected, else join it.
|
||||
toggle: bool,
|
||||
/// E pressed an odd number of times during the drag: the marquee takes only the objects
|
||||
/// that lie entirely inside it.
|
||||
enclosed: bool,
|
||||
},
|
||||
/// Dragging a Live Corners widget.
|
||||
Corner(CornerDrag),
|
||||
@@ -183,6 +188,15 @@ pub fn selection_box(cx: &ToolContext) -> Option<OrientedBox> {
|
||||
cx.doc.oriented_bounds(&cx.selection.objects, cx.preview_bounds)
|
||||
}
|
||||
|
||||
/// The objects of `ids` that lie entirely inside marquee `r`, edges included, measured by their
|
||||
/// geometric bounds with symbol instances measured by their art
|
||||
/// ([`vectorcraft_doc::Node::geometric_bounds_with`]).
|
||||
pub(crate) fn inside(cx: &ToolContext, r: Rect, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let symbols = cx.doc.symbol_boxes();
|
||||
let within = |b: Rect| r.min_x() <= b.min_x() && b.max_x() <= r.max_x() && r.min_y() <= b.min_y() && b.max_y() <= r.max_y();
|
||||
ids.into_iter().filter(|id| cx.doc.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)).is_some_and(within)).collect()
|
||||
}
|
||||
|
||||
/// What of the bounding box is under the pointer.
|
||||
enum BoxHit {
|
||||
Handle(Handle),
|
||||
@@ -247,10 +261,22 @@ impl Tool for SelectionTool {
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
|
||||
/// E during a marquee drag switches the marquee between the objects it touches and only the
|
||||
/// objects inside it.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Letter('e')
|
||||
&& let State::Marquee { enclosed, .. } = &mut self.state
|
||||
{
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape { self.guide.cancel() } else { vec![] }
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Letter('e') && matches!(self.state, State::Marquee { .. })
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
match &self.state {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
@@ -374,7 +400,7 @@ impl Tool for SelectionTool {
|
||||
out
|
||||
}
|
||||
None => {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: m.shift };
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false };
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -417,8 +443,8 @@ impl Tool for SelectionTool {
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))]
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle };
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle, enclosed };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
@@ -460,15 +486,16 @@ impl Tool for SelectionTool {
|
||||
self.targets = None;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
(PointerKind::Up, State::Marquee { start, toggle, .. }) => {
|
||||
(PointerKind::Up, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < Self::drag_threshold(cx) && r.height() < Self::drag_threshold(cx) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
|
||||
let touched = marquee(cx.doc, r, cx.isolation, false);
|
||||
let ids = if enclosed { inside(cx, r, touched) } else { touched };
|
||||
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()
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, enclosed)).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -1010,6 +1037,84 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty());
|
||||
}
|
||||
|
||||
/// E pressed during a marquee drag makes the marquee take only the objects entirely inside it
|
||||
/// (a group whole), and E pressed again makes it take what it touches. Only a marquee takes E.
|
||||
#[test]
|
||||
fn e_during_a_marquee_takes_only_the_objects_inside() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, inside) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art());
|
||||
let (touched, g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 1, rect(touched, Rect::new(250.0, 100.0, 350.0, 200.0))).unwrap();
|
||||
// A group with one member inside the marquee and one outside it.
|
||||
d.insert(Some(l), 2, Node::group(g, vec![])).unwrap();
|
||||
d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap();
|
||||
d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let e = ToolKey::Letter('e');
|
||||
let drag = |presses: usize, mods: Mods| {
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(!t.claims_key(&cx, e), "no marquee yet");
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0).with_mods(mods));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0).with_mods(mods));
|
||||
for _ in 0..presses {
|
||||
assert!(t.claims_key(&cx, e));
|
||||
assert!(t.key(&cx, e, Mods::default()).is_empty());
|
||||
}
|
||||
let out = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0).with_mods(mods));
|
||||
assert!(!t.claims_key(&cx, e), "the marquee is over");
|
||||
out
|
||||
};
|
||||
let set = |ids: &[NodeId]| vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(ids) }))];
|
||||
assert_eq!(drag(0, Mods::default()), set(&[inside, touched, g]));
|
||||
assert_eq!(drag(1, Mods::default()), set(&[inside]));
|
||||
assert_eq!(drag(2, Mods::default()), set(&[inside, touched, g]), "E again: what it touches");
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
assert_eq!(drag(1, shift), vec![Action::Exec("select.toggle".into(), json!({ "ids": [inside.0] }))]);
|
||||
// The next marquee starts out taking what it touches.
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0));
|
||||
t.key(&cx, e, Mods::default());
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0)), set(&[inside, touched, g]));
|
||||
// A press on an object starts a move, which does not take E.
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
assert!(t.busy() && !t.claims_key(&cx, e));
|
||||
}
|
||||
|
||||
/// With E pressed during the drag, a marquee takes only the artboard guides that lie entirely
|
||||
/// inside it, and no canvas guide, which runs across the whole canvas.
|
||||
#[test]
|
||||
fn e_during_a_marquee_takes_only_the_guides_inside() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let board = d.artboards[0].id;
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 150.0));
|
||||
d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) });
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let marquee = |to: f64| {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, -10.0, -10.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, to, to));
|
||||
t.key(&cx, ToolKey::Letter('e'), Mods::default());
|
||||
t.pointer(&cx, &ev(PointerKind::Up, to, to))
|
||||
};
|
||||
let guides = |a: &[Action]| a.iter().find_map(|a| matches!(a, Action::Exec(c, _) if c == "guide.select").then(|| a.clone()));
|
||||
// The artboard runs from 0 to 500: a marquee to (510, 510) holds its guide.
|
||||
let a = marquee(510.0);
|
||||
assert_eq!(a.first(), Some(&Action::Exec("select.set".into(), json!({ "ids": [id.0] }))));
|
||||
assert_eq!(guides(&a), Some(Action::Exec("guide.select".into(), json!({ "indexes": [1], "toggle": true }))));
|
||||
// A marquee to (250, 250) crosses both guides and holds neither.
|
||||
assert_eq!(guides(&marquee(250.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_ruler_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
|
||||
@@ -422,6 +422,27 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
for d in digits {
|
||||
let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view);
|
||||
}
|
||||
// The tool gets each letter it takes (E while dragging a selection marquee) once per press,
|
||||
// without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin
|
||||
// letters is the key at that position on a US layout.
|
||||
let letters: Vec<char> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. }
|
||||
if !(modifiers.command || modifiers.alt || modifiers.ctrl) =>
|
||||
{
|
||||
letter_of(*key)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
for c in letters {
|
||||
if app.session.tool_claims_key(ToolKey::Letter(c), view) {
|
||||
let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop:
|
||||
@@ -647,6 +668,15 @@ fn digit_of(k: Key) -> Option<u8> {
|
||||
DIGITS.iter().position(|d| *d == k).map(|i| i as u8)
|
||||
}
|
||||
|
||||
/// The lowercase letter a letter key A–Z stands for.
|
||||
fn letter_of(k: Key) -> Option<char> {
|
||||
let mut name = k.name().chars();
|
||||
match (name.next(), name.next()) {
|
||||
(Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_lowercase()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1302,6 +1332,42 @@ mod tests {
|
||||
assert_eq!(digit_of(Key::A), None);
|
||||
}
|
||||
|
||||
/// E pressed while the Selection tool drags a marquee makes it select only the objects inside
|
||||
/// it: once per press, without Cmd, and from the key at E's position on a layout without Latin
|
||||
/// letters.
|
||||
#[test]
|
||||
fn e_reaches_a_dragged_marquee_once_per_press() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
let inside = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
let touched = app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
app.select_tool("selection");
|
||||
let e = |key, repeat, modifiers| egui::Event::Key { key, physical_key: Some(Key::E), pressed: true, repeat, modifiers };
|
||||
let mut marquee = |events: Vec<egui::Event>| {
|
||||
let v = app.view_info();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 350.0, 250.0), v).unwrap();
|
||||
frame(&mut app, events);
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 350.0, 250.0), v).unwrap();
|
||||
let ids = app.session.doc().unwrap().selection.objects.iter().map(|i| json!(i.0)).collect::<Vec<_>>();
|
||||
assert_eq!(app.session.tool_id(), "selection");
|
||||
ids
|
||||
};
|
||||
assert_eq!(marquee(vec![]), vec![inside.clone(), touched.clone()]);
|
||||
// A held key repeats: only its first press switches.
|
||||
assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), e(Key::E, true, Modifiers::NONE)]), vec![inside.clone()]);
|
||||
let up = egui::Event::Key { key: Key::E, physical_key: Some(Key::E), pressed: false, repeat: false, modifiers: Modifiers::NONE };
|
||||
let twice = vec![e(Key::E, false, Modifiers::NONE), up, e(Key::E, false, Modifiers::NONE)];
|
||||
assert_eq!(marquee(twice), vec![inside.clone(), touched.clone()], "pressed twice: what it touches");
|
||||
// A Russian layout: egui-winit reports the key at E's position, and the text it types.
|
||||
assert_eq!(marquee(vec![e(Key::E, false, Modifiers::NONE), egui::Event::Text("у".into())]), vec![inside.clone()]);
|
||||
assert_eq!(marquee(vec![e(Key::E, false, Modifiers::COMMAND)]), vec![inside, touched], "Cmd+E is not E");
|
||||
assert_eq!(letter_of(Key::E), Some('e'));
|
||||
assert_eq!(letter_of(Key::Num1), None);
|
||||
assert_eq!(letter_of(Key::Escape), None);
|
||||
}
|
||||
|
||||
/// Cmd+, opens Settings on a Mac only, and never takes a chord a command already has.
|
||||
#[test]
|
||||
fn cmd_comma_opens_settings_on_a_mac() {
|
||||
|
||||
+17
-1
@@ -309,7 +309,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. |
|
||||
| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |
|
||||
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. |
|
||||
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool). |
|
||||
| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee). |
|
||||
| `type_text` | `{text}` | Remote only. |
|
||||
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
|
||||
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
|
||||
@@ -350,6 +350,22 @@ mode: "set"|"add"|"toggle"|"subtract"}`:
|
||||
{"name":"pointer_gesture","arguments":{"tool":"selection","mods":{"shift":true},"events":[{"kind":"down","x":180,"y":80},{"kind":"drag","x":300,"y":200},{"kind":"up","x":380,"y":200}]}}
|
||||
```
|
||||
|
||||
`press_key {key: "e"}` while a Selection or Group Selection marquee is being dragged makes the marquee select only the
|
||||
objects that lie entirely inside it, measured by their geometric bounds, and `e` pressed again switches it back to the default mode. The
|
||||
Selection tool selects a group only when the whole group lies inside; Group Selection selects the objects inside
|
||||
groups, each as a whole object. Both run `select.set {ids}` (`select.toggle {ids}` with `mods.shift`). The marquee
|
||||
also selects the artboard guides that lie entirely inside it, and no canvas guide. Each marquee starts in the default
|
||||
mode. To press the key during a drag, end one
|
||||
`pointer_gesture` after the `drag` event and send the rest in another with the same `tool`, which keeps the drag:
|
||||
|
||||
```json
|
||||
{"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"down","x":80,"y":80},{"kind":"drag","x":220,"y":170}]}}
|
||||
{"name":"press_key","arguments":{"key":"e"}}
|
||||
{"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"drag","x":230,"y":170},{"kind":"up","x":230,"y":170}]}}
|
||||
```
|
||||
|
||||
With nothing being dragged, `e` chooses the tool bound to E.
|
||||
|
||||
Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed
|
||||
in paint order (0 is painted first, the bottom row of the Appearance panel). `paint.setFill`, `paint.setStroke`,
|
||||
`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `transparency.set` take
|
||||
|
||||
@@ -458,6 +458,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Tools:**
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection.
|
||||
A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.
|
||||
With two or more objects selected and no key object, Align to Key Object makes the topmost of them the key, whatever order they were selected in, and the Align panel, the Control bar and the Properties panel align to it. Align To keeps its Selection or Artboard choice and shows it again once the key is cleared.
|
||||
Object › Align aligns objects as the align buttons do: it aligns one object to the active artboard whatever Align To is set to, and otherwise it aligns to the key object while there is one, else to the Align To choice, which is saved with the preferences (`ui.inspect` → `ui.align_to`). With direct-selected anchor points, Object › Align aligns the points to their own bounds. While one object is selected, Align To shows Artboard.
|
||||
|
||||
Reference in New Issue
Block a user