diff --git a/ROADMAP.md b/ROADMAP.md index 36ddd657..9c5d49a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396) and the Wayland clipboard with copied-file paste (#398); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398) and the first Preferences options that were stored but unused (#394); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -103,6 +103,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. + - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. They reach agents too (`prefs.set`, then `pointer_gesture`; `ui.wheel` on the control channel). About 55 stored options still do nothing yet (the rest of #394). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected). @@ -210,7 +211,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | -| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 | +| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 55 Preferences options stored but not yet acted on (#394: smart guide display options, type increments and options, units, Show Pixel Grid, Snap to Point, rubber band, handle style, performance, devices…) | 18–28 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | | **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b1b9e60a..58c26a87 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -60,6 +60,25 @@ impl Default for HitOptions { /// Topmost editable object under `p`. pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { + hit_test_skipping(doc, p, opt, &|_| false) +} + +/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost +/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind). +pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option) -> Vec { + let mut tops: Vec = vec![]; + // Each round leaves out the objects found so far (and everything inside them). + while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id)).map(|h| h.top_object(scope)) { + if tops.contains(&top) { + break; + } + tops.push(top); + } + tops +} + +/// [`hit_test`] passing over the nodes `skip` names (and their contents). +fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option { let mut chain = Vec::new(); for layer in doc.layers.iter().rev() { if !layer.visible || layer.locked { @@ -74,7 +93,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { } chain.push(layer.id); let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; - if let Some(mut h) = hit_children(layer, p, opt, &mut chain) { + if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); return Some(h); @@ -99,7 +118,7 @@ fn clip_contains(clip: &Node, p: Point) -> bool { clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p)) } -fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec) -> Option { +fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, chain: &mut Vec) -> Option { let children = parent.children()?; // A clip group (or a layer with a clipping mask) only hits inside its clipping path. if parent.clips() @@ -110,7 +129,7 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec hit_children(c, p, HitOptions { outline: true, ..opt }, chain), - NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain), + NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain), + NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain), // Edit Contents: the envelope's content hits, undistorted. - NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain), + NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain), // A blend's key objects first (Direct and Group Selection pick them); its steps hit // as the blend. - NodeKind::Blend { .. } => hit_children(c, p, opt, chain) + NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain) .or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })), _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }), }; @@ -169,7 +188,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option { if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) { return Some(k); } - (!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) + (!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill) } NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) @@ -366,6 +385,46 @@ mod tests { assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some()); } + /// Object Selection by Path Only: a filled path or compound path hits on its outline only. + #[test] + fn path_only_leaves_out_fills() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (a, c) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap(); + let inner = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default()); + let mut compound = Node::new(c, NodeKind::Compound { children: vec![Arc::new(inner)], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance = Appearance::default_art(); + d.insert(Some(l), 9, compound).unwrap(); + let path_only = HitOptions { path_only: true, ..Default::default() }; + for (id, inside, edge) in [(a, Point::new(25.0, 25.0), Point::new(0.5, 25.0)), (c, Point::new(125.0, 25.0), Point::new(100.5, 25.0))] { + assert_eq!(hit_test(&d, inside, HitOptions::default()).map(|h| h.leaf), Some(id), "the fill hits"); + assert!(hit_test(&d, inside, path_only).is_none(), "path only: the fill of {id:?} doesn't hit"); + assert_eq!(hit_test(&d, edge, path_only).map(|h| h.leaf), Some(id), "path only: the outline does"); + } + } + + /// `objects_at`: every object under the point, topmost first; a group counts once. + #[test] + fn objects_at_lists_the_stack() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let rect = + |d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art()); + let a = rect(&mut d, 0.0); + let (b, c) = (rect(&mut d, 10.0), rect(&mut d, 20.0)); + let (a_id, b_id, c_id, g) = (a.id, b.id, c.id, d.alloc_id()); + d.insert(Some(l), 0, a).unwrap(); + d.insert(Some(l), 9, Node::group(g, vec![Arc::new(b), Arc::new(c)])).unwrap(); + let far = rect(&mut d, 120.0); + d.insert(Some(l), 9, far).unwrap(); + let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope); + assert_eq!(at(30.0, None), vec![g, a_id]); + assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects"); + assert_eq!(at(5.0, None), vec![a_id]); + assert!(at(90.0, None).is_empty()); + } + #[test] fn locked_and_hidden_skip() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 07e45cc2..0570c5cb 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -152,3 +152,78 @@ fn gpu_preference_defaults_to_power_saving_and_validates() { s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap(); assert_eq!(s.prefs.gpu_preference, "powerSaving"); } + +/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to +/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them +/// (#394). +#[test] +fn selection_preferences_drive_the_selection_tool() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |s: &mut Session, x: f64| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()) + }; + // Two filled squares overlapping between x = 150 and 200, `front` on top. + let (back, front) = (rect(&mut s, 100.0), rect(&mut s, 150.0)); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let set = |s: &mut Session, key: &str, value: Value| s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); + let click = |s: &mut Session, x: f64, mods: Mods| { + for k in [PointerKind::Down, PointerKind::Up] { + // Halfway between the bounding box's handles. + s.pointer(&PointerEvent::new(k, x, 125.0).with_mods(mods), ViewInfo::default()).unwrap(); + } + s.doc().unwrap().selection.objects.clone() + }; + let plain = Mods::default(); + // Tolerance: the right edge's stroke ends at x = 250.5. + assert_eq!(click(&mut s, 252.5, plain), vec![front], "2 px out: within the default 3 px"); + assert!(click(&mut s, 256.5, plain).is_empty(), "6 px out: beyond 3 px"); + set(&mut s, "selectionTolerance", json!(8)); + assert_eq!(click(&mut s, 256.5, plain), vec![front], "6 px out: within 8 px"); + set(&mut s, "selectionTolerance", json!(1)); + assert!(click(&mut s, 252.5, plain).is_empty(), "2 px out: beyond 1 px"); + set(&mut s, "selectionTolerance", json!(3)); + // Object Selection by Path Only: the fill no longer selects, the path does. + assert_eq!(click(&mut s, 225.0, plain), vec![front]); + set(&mut s, "objectSelectionByPathOnly", json!(true)); + assert!(click(&mut s, 225.0, plain).is_empty(), "a click inside the fill selects nothing"); + assert_eq!(click(&mut s, 250.0, plain), vec![front], "a click on the path selects it"); + set(&mut s, "objectSelectionByPathOnly", json!(false)); + // Command Click to Select Objects Behind (on by default): each Cmd/Ctrl-click goes one down, + // then back to the top. + let cmd = Mods { cmd: true, ..Mods::default() }; + assert_eq!(click(&mut s, 175.0, plain), vec![front]); + assert_eq!(click(&mut s, 175.0, cmd), vec![back], "the object behind"); + assert_eq!(click(&mut s, 175.0, cmd), vec![front], "back to the topmost"); + set(&mut s, "ctrlClickSelectsBehind", json!(false)); + assert_eq!(click(&mut s, 175.0, cmd), vec![front], "off: a plain click"); + // Double Click To Isolate (on by default) isolates a group; off, it doesn't. + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let double = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 175.0, 150.0), ViewInfo::default()).unwrap(); + s.doc().unwrap().isolation + }; + set(&mut s, "doubleClickToIsolate", json!(false)); + assert_eq!(double(&mut s), None, "off: no isolation"); + set(&mut s, "doubleClickToIsolate", json!(true)); + assert!(double(&mut s).is_some(), "on: the group is isolated"); +} + +/// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection +/// tool's arrow stays. +#[test] +fn use_precise_cursors_makes_drawing_cursors_crosshairs() { + use vectorcraft_tools::{Cursor, Mods}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let cursor = |s: &mut Session, tool: &str| { + s.select_tool(tool, ViewInfo::default()).unwrap(); + s.cursor(vectorcraft_geom::Point::new(300.0, 300.0), Mods::default(), ViewInfo::default()) + }; + assert_eq!(cursor(&mut s, "pen"), Cursor::Pen); + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap(); + assert_eq!(cursor(&mut s, "pen"), Cursor::Crosshair); + assert_eq!(cursor(&mut s, "selection"), Cursor::Arrow); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 80872f59..f12ce62f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -152,6 +152,11 @@ impl Session { slices_hidden: self.menu.slices_hidden, slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, + selection_tolerance: self.prefs.selection_tolerance, + path_only: self.prefs.object_selection_by_path_only, + double_click_isolate: self.prefs.double_click_to_isolate, + select_behind: self.prefs.ctrl_click_selects_behind, + highlight_anchors: self.prefs.highlight_anchors_on_hover, screen: view.screen, plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position), }; @@ -265,8 +270,11 @@ impl Session { v } + /// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing + /// tools' a crosshair, [`Cursor::precise`]). pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor { - self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)) + let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)); + if self.prefs.use_precise_cursors { c.precise() } else { c } } pub fn tool_options(&self) -> Value { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ae33efec..a1671047 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -504,3 +504,28 @@ fn oversized_screenshot_and_png_export_are_errors() { let r = call(&mut s, 4, "screenshot", json!({"scale": 0.01})); assert_eq!(r["isError"], false, "{r}"); } + +/// Preferences reach agents' gestures (#394): `prefs.set` Object Selection by Path Only, then a +/// click inside a filled square selects nothing; Command Click to Select Objects Behind, a +/// Cmd-click selects the square underneath. +#[test] +fn selection_preferences_apply_to_pointer_gestures() { + 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": 100, "height": 100, "fill": "#ff0000"})); + serde_json::from_str::(&text_of(&r)).unwrap()["id"].as_u64().unwrap() + }; + let (back, front) = (square(&mut s, 1, 100), square(&mut s, 2, 150)); + let click = |s: &mut Server, id: u64, x: f64, mods: Value| { + let events = json!([{"kind": "down", "x": x, "y": 125}, {"kind": "up", "x": x, "y": 125}]); + let r = call(s, id, "pointer_gesture", json!({"tool": "selection", "events": events, "mods": mods})); + assert_eq!(r["isError"], false, "{r}"); + serde_json::from_str::(&text_of(&r)).unwrap()["selection"].clone() + }; + assert_eq!(click(&mut s, 3, 175.0, json!({})), json!([front])); + assert_eq!(click(&mut s, 4, 175.0, json!({"cmd": true})), json!([back]), "Cmd-click selects behind"); + let r = call(&mut s, 5, "run_command", json!({"command": "prefs.set", "params": {"key": "objectSelectionByPathOnly", "value": true}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(click(&mut s, 6, 225.0, json!({})), json!([]), "path only: the fill doesn't select"); + assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does"); +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index bbdba3e8..7dc42f0b 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -95,11 +95,13 @@ pub struct DirectSelectionTool { mesh: MeshEdit, /// Smart guides of the handle being dragged. guides: Vec, + /// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is. + hover: Option<(NodeId, Point)>, } impl DirectSelectionTool { pub fn new(group: bool) -> Self { - Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![] } + Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![], hover: None } } } @@ -236,7 +238,7 @@ impl Tool for DirectSelectionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; - let tol = cx.tol(4.0); + let tol = cx.pick_tol(); match (ev.kind, self.state.clone()) { (PointerKind::Down, _) if self.group => { let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { @@ -260,6 +262,10 @@ impl Tool for DirectSelectionTool { } } (PointerKind::DoubleClick, _) if !self.group => corners::double_click(cx, p).into_iter().collect(), + (PointerKind::Move, State::Idle) => { + self.hover = if self.group || !cx.highlight_anchors { None } else { hovered_anchor(cx, p) }; + vec![] + } (PointerKind::Down, _) => { if let Some(c) = CornerDrag::hit(cx, ev) { self.state = State::Corner(c); @@ -492,6 +498,9 @@ impl Tool for DirectSelectionTool { if matches!(self.state, State::Handle { .. }) { out.extend(self.guides.iter().cloned()); } + if let Some((id, p)) = self.hover.filter(|_| matches!(self.state, State::Idle)) { + out.push(Overlay::Anchor { p, color: cx.doc.layer_color(id), filled: false, size: 8.0 }); + } out } } @@ -504,6 +513,17 @@ impl Tool for DirectSelectionTool { } } +/// The anchor within the selection tolerance of `p`, of a selected path or the path under `p`: +/// what Highlight anchors on mouse over marks (a press there picks it). +fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> { + let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf); + let tol = cx.pick_tol(); + cx.selection.objects.iter().copied().chain(under).find_map(|id| { + let path = cx.doc.node(id).and_then(editable_path)?; + path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(_, _, a)| (id, a.p)) + }) +} + /// The frame anchors of the selected area type, which Direct Selection drags. fn frame_overlays(cx: &ToolContext) -> Vec { let mut out = vec![]; @@ -564,6 +584,30 @@ mod tests { (d, g, k1) } + /// Highlight anchors on mouse over (#394): the anchor under the pointer, within the selection + /// tolerance, shows enlarged; none with the preference off or away from anchors. + #[test] + fn the_anchor_under_the_pointer_is_highlighted() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let big = |t: &DirectSelectionTool, cx: &ToolContext| { + t.overlays(cx).into_iter().find_map(|o| match o { + Overlay::Anchor { p, size, .. } if size > 5.0 => Some(p), + _ => None, + }) + }; + let mut t = DirectSelectionTool::new(false); + let on = cx(&d, &s, &p); + t.pointer(&on, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); + assert_eq!(big(&t, &on), Some(Point::new(100.0, 100.0)), "the corner of {id:?}"); + t.pointer(&on, &PointerEvent::new(PointerKind::Move, 150.0, 100.0)); + assert_eq!(big(&t, &on), None, "mid-segment: no anchor"); + let off = ToolContext { highlight_anchors: false, ..cx(&d, &s, &p) }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); + assert_eq!(big(&t, &off), None, "off"); + } + #[test] fn dragging_a_spine_point_moves_it() { let (d, g, _) = blend_doc(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index df3b90a0..48a98ad8 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -253,6 +253,22 @@ pub struct ToolContext<'a> { /// General → Disable Auto Add/Delete is off: the Pen adds an anchor on a selected path's /// segment and deletes one of its anchors. pub auto_add_delete: bool, + /// Selection & Anchor Display → Tolerance: how near (screen pixels) a click must be to a path + /// or an anchor to pick it. + pub selection_tolerance: f64, + /// Selection & Anchor Display → Object Selection by Path Only: a click inside a filled path + /// doesn't pick it, only one on its path does. + pub path_only: bool, + /// General → Double Click To Isolate: a double-click on a group with the Selection tool + /// isolates it. + pub double_click_isolate: bool, + /// Selection & Anchor Display → Command Click to Select Objects Behind: Cmd/Ctrl-click with the + /// Selection tool selects the object under the selected one, the next click the one under + /// that. + pub select_behind: bool, + /// Selection & Anchor Display → Highlight anchors on mouse over: Direct Selection marks the + /// anchor under the pointer. + pub highlight_anchors: bool, /// The document window (none headless): screen-fixed widgets sit in it. pub screen: Option, /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. @@ -276,8 +292,12 @@ impl ToolContext<'_> { pub fn offset_label(&self, dx: f64, dy: f64) -> String { format!("dX: {}\ndY: {}", self.len(dx), self.len(dy)) } + /// The selection tolerance in document units ([`Self::selection_tolerance`]). + pub fn pick_tol(&self) -> f64 { + self.tol(self.selection_tolerance) + } pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { - vectorcraft_doc::hit::HitOptions { tol: self.tol(3.0), outline: self.outline, path_only: false } + vectorcraft_doc::hit::HitOptions { tol: self.pick_tol(), outline: self.outline, path_only: self.path_only } } } @@ -356,6 +376,27 @@ pub enum Cursor { BlendAnchor, } +impl Cursor { + /// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the + /// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot. + pub fn precise(self) -> Self { + match self { + Cursor::Pen + | Cursor::PenAdd + | Cursor::PenDelete + | Cursor::PenClose + | Cursor::PenContinue + | Cursor::PenConvert + | Cursor::Eyedropper + | Cursor::Slice + | Cursor::Blend + | Cursor::BlendObject + | Cursor::BlendAnchor => Cursor::Crosshair, + c => c, + } + } +} + /// A tool state machine. pub trait Tool: Send { fn id(&self) -> &'static str; @@ -524,6 +565,11 @@ pub(crate) mod testutil { slices_hidden: false, slices_locked: false, auto_add_delete: true, + selection_tolerance: 3.0, + path_only: false, + double_click_isolate: true, + select_behind: true, + highlight_anchors: true, screen: None, plane_widget: Some(Default::default()), } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 625b9cd5..0930369d 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,13 +1,14 @@ //! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's corner widget to round its corners (Alt-click cycles their kind, -//! double-click opens the Corners dialog), double-click to enter isolation mode. +//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To +//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind). //! 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. use serde_json::{Value, json}; -use vectorcraft_doc::hit::{hit_test, marquee}; +use vectorcraft_doc::hit::{hit_test, marquee, objects_at}; use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; @@ -114,7 +115,7 @@ impl Tool for SelectionTool { } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { let top = h.top_object(cx.isolation); - if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) { + if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) { return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; } if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { @@ -152,7 +153,14 @@ impl Tool for SelectionTool { None => {} } } - // 2. Objects. + // 2. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). + if m.cmd + && cx.select_behind + && let Some(behind) = object_behind(cx, p) + { + self.state = State::Moving { start: p, began: false, deselect: None }; + return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; + } match hit_test(cx.doc, p, cx.hit_options()) { Some(h) => { let top = h.top_object(cx.isolation); @@ -321,6 +329,14 @@ impl Tool for SelectionTool { } } +/// Select Behind: of the objects under `p` (topmost first), the one below the lowest selected +/// one, back to the topmost after the bottom one; the topmost when none is selected. +fn object_behind(cx: &ToolContext, p: Point) -> Option { + let stack = objects_at(cx.doc, p, cx.hit_options(), cx.isolation); + let next = stack.iter().rposition(|id| cx.selection.contains(*id)).map_or(0, |i| i + 1); + stack.get(next).or(stack.first()).copied() +} + /// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: /// it transforms whole. pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3aca0513..6eb2eeb6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -206,8 +206,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); } } - if app.ui.view.grid { - grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions); + // The grid behind the art (Guides & Grid › Grids In Back), else over it below. + let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs)); + let grids_in_back = app.session.prefs.grids_in_back; + if let Some(look) = grid_look.filter(|_| grids_in_back) { + grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); } // Artwork raster. @@ -287,6 +290,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let b = xf.to_screen(old.to_doc(rect.max)); painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } + if let Some(look) = grid_look.filter(|_| !grids_in_back) { + grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); + } // Artboard edges and names. let active_ab = 0; for (i, ab) in doc.artboards.iter().enumerate() { @@ -308,6 +314,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } if app.ui.view.guides { + let look = LineLook::guides(&app.session.prefs, &t); for g in &doc.guides { let (a, b) = if g.vertical { let x = xf.to_screen(Point::new(g.pos, 0.0)).x; @@ -316,7 +323,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let y = xf.to_screen(Point::new(0.0, g.pos)).y; (pos2(rect.left(), y), pos2(rect.right(), y)) }; - painter.line_segment([a, b], Stroke::new(1.0, t.guide)); + look.guide(&painter, a, b); } } @@ -418,8 +425,10 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - let (pointer, m, space, scroll, zoom_delta) = - ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta())); + let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; + let (pointer, m, space, (factor, scroll)) = + ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height()))); let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); @@ -427,12 +436,8 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let view = app.view_info(); let drag: Option = ui.data(|d| d.get_temp(drag_id())); - // Zoom: pinch / Cmd-scroll / Alt-scroll around the pointer. Plain scroll pans. + // Zoom around the pointer, or scroll ([`wheel`]). if resp.hovered() { - let mut factor = zoom_delta as f64; - if m.alt && scroll.y != 0.0 { - factor *= (scroll.y as f64 * 0.01).exp(); - } if (factor - 1.0).abs() > 1e-6 { if let (Some(p), Some(vm)) = (hover, app.view_mut()) { let before = xf.to_doc(p); @@ -441,8 +446,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let after = nx.to_doc(p); vm.center += before - after; } - } else if (scroll.x != 0.0 || scroll.y != 0.0) - && !m.alt + } else if scroll != egui::Vec2::ZERO && let Some(vm) = app.view_mut() { let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll); @@ -481,7 +485,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: app.select_tool(if is_selection_tool(&last) { &last } else { "selection" }); kind = Drag::TempSelect; } - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; + // Cmd only brought the selection tool: its click is a plain one, not Select Behind. + let mods = Mods { cmd: m.command && kind != Drag::TempSelect, ..mods(m, space) }; + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods, pressure: pen_pressure(ui, true) }; dispatch(app, &ev, view); kind }; @@ -591,6 +597,46 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } } +/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`). +const WHEEL_ZOOM: f64 = 0.01; + +/// What the wheel and a pinch did over the canvas this frame: a zoom factor (about the pointer) +/// and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel zoom. +/// With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel zooms, Shift-wheel scrolls up +/// and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points per wheel line and page. +fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32) -> (f64, egui::Vec2) { + if !wheel_zooms { + // egui makes Cmd-wheel (and a pinch) its zoom and the rest a smoothed scroll. + let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); + return if i.modifiers.alt && scroll.y != 0.0 { (zoom * (f64::from(scroll.y) * WHEEL_ZOOM).exp(), egui::Vec2::ZERO) } else { (zoom, scroll) }; + } + // The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom. + let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta)); + let mut scroll = egui::Vec2::ZERO; + for e in &i.events { + match e { + egui::Event::MouseWheel { unit, delta, modifiers, .. } => { + let d = match unit { + egui::MouseWheelUnit::Point => *delta, + egui::MouseWheelUnit::Line => *delta * line, + egui::MouseWheelUnit::Page => *delta * page, + }; + if modifiers.command { + scroll.x += d.x + d.y; + } else if modifiers.shift { + scroll.y += d.x + d.y; + } else { + zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp(); + scroll.x += d.x; + } + } + egui::Event::Zoom(f) => zoom *= f64::from(*f), + _ => {} + } + } + (zoom, scroll) +} + /// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of /// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it /// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it). @@ -675,28 +721,82 @@ fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::Tex p.add(Shape::mesh(mesh)); } -fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) { +/// The look of the grid or of the guides (Preferences › Guides & Grid › Color and Style). +#[derive(Clone, Copy)] +struct LineLook { + color: Color32, + dots: bool, +} + +impl LineLook { + fn grid(prefs: &vectorcraft_engine::Prefs) -> Self { + Self { color: pref_color(&prefs.grid_color, Color32::from_gray(200)), dots: prefs.grid_style == "dots" } + } + + fn guides(prefs: &vectorcraft_engine::Prefs, t: &Tokens) -> Self { + Self { color: pref_color(&prefs.guide_color, t.guide), dots: prefs.guide_style == "dots" } + } + + /// A guide from `a` to `b`: a line, or dots. + fn guide(self, p: &egui::Painter, a: Pos2, b: Pos2) { + if self.dots { + p.extend(Shape::dotted_line(&[a, b], self.color, 4.0, 0.75)); + } else { + p.line_segment([a, b], Stroke::new(1.0, self.color)); + } + } +} + +/// A `#rrggbb` colour preference, `fallback` when it doesn't parse. +fn pref_color(hex: &str, fallback: Color32) -> Color32 { + vectorcraft_color::Color::from_hex(hex).map_or(fallback, |c| crate::panels::c32(&c)) +} + +/// The grid (View → Show Grid): a gridline every `spacing` and `subdiv` lighter lines between +/// (left out when they would crowd). In the Dots style, a dot where gridlines cross the +/// subdivisions instead. +fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32, look: LineLook) { let r = xf.rect; let a = xf.to_doc(r.min); let b = xf.to_doc(r.max); let sub = spacing / subdiv.max(1) as f64; let step = if sub * xf.zoom >= 6.0 { sub } else { spacing }; - if step * xf.zoom < 4.0 { + // Dots crowd sooner than lines. + if step * xf.zoom < 4.0 || (look.dots && spacing * xf.zoom < 8.0) { return; } - let mut x = (a.x / step).floor() * step; - while x <= b.x { - let major = (x / spacing).round() * spacing == x || (x / spacing - (x / spacing).round()).abs() < 1e-6; - let sx = xf.to_screen(Point::new(x, 0.0)).x; - p.line_segment([pos2(sx, r.top()), pos2(sx, r.bottom())], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 }))); - x += step; + let major = |v: f64| (v / spacing - (v / spacing).round()).abs() < 1e-6; + // The lines across the view, on screen, each with whether it is a gridline. + let lines = |from: f64, to: f64, screen: &dyn Fn(f64) -> f32| { + let mut v = (from / step).floor() * step; + let mut out = vec![]; + while v <= to { + out.push((screen(v), major(v))); + v += step; + } + out + }; + let xs = lines(a.x, b.x, &|x| xf.to_screen(Point::new(x, 0.0)).x); + let ys = lines(a.y, b.y, &|y| xf.to_screen(Point::new(0.0, y)).y); + if look.dots { + let mut mesh = egui::Mesh::default(); + for &(x, mx) in &xs { + for &(y, my) in &ys { + if mx || my { + mesh.add_colored_rect(egui::Rect::from_center_size(pos2(x, y), vec2(1.5, 1.5)), look.color); + } + } + } + p.add(Shape::mesh(mesh)); + return; } - let mut y = (a.y / step).floor() * step; - while y <= b.y { - let major = (y / spacing - (y / spacing).round()).abs() < 1e-6; - let sy = xf.to_screen(Point::new(0.0, y)).y; - p.line_segment([pos2(r.left(), sy), pos2(r.right(), sy)], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 }))); - y += step; + let minor = look.color.gamma_multiply(0.4); + let stroke = |major: bool| Stroke::new(1.0, if major { look.color } else { minor }); + for (x, m) in xs { + p.line_segment([pos2(x, r.top()), pos2(x, r.bottom())], stroke(m)); + } + for (y, m) in ys { + p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], stroke(m)); } } @@ -732,9 +832,9 @@ fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egu app.status(e); } } else if resp.dragged() { - let line = - if vertical { [pos2(p.x, canvas.top()), pos2(p.x, canvas.bottom())] } else { [pos2(canvas.left(), p.y), pos2(canvas.right(), p.y)] }; - ui.painter_at(canvas).line_segment(line, Stroke::new(1.0, t.guide)); + let (a, b) = + if vertical { (pos2(p.x, canvas.top()), pos2(p.x, canvas.bottom())) } else { (pos2(canvas.left(), p.y), pos2(canvas.right(), p.y)) }; + LineLook::guides(&app.session.prefs, t).guide(&ui.painter_at(canvas), a, b); } } } @@ -895,9 +995,15 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { } } -/// The topmost editable object under document point `p` at `zoom` (3 px tolerance). +/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick it +/// (Selection & Anchor Display › Tolerance and Object Selection by Path Only). fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { - let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false }; + let prefs = &app.session.prefs; + let opt = vectorcraft_doc::hit::HitOptions { + tol: prefs.selection_tolerance / zoom, + outline: app.ui.view.outline, + path_only: prefs.object_selection_by_path_only, + }; vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt) } @@ -1090,10 +1196,7 @@ fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { return; } let prefs = &app.session.prefs; - let line = vectorcraft_color::Color::from_hex(&prefs.slice_line_color).map_or(Color32::from_rgb(0xff, 0x3f, 0x3f), |c| { - let [r, g, b, _] = c.to_rgba8(1.0); - Color32::from_rgb(r, g, b) - }); + let line = pref_color(&prefs.slice_line_color, Color32::from_rgb(0xff, 0x3f, 0x3f)); let dim = line.gamma_multiply(0.55); let selected = &st.selection.slices; let objects = &st.selection.objects; @@ -1163,6 +1266,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(st) = app.session.active() else { return }; let tool = app.session.tool_id(); let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature"); + // Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding + // box's handles a point bigger or smaller per step. + let grow = app.session.prefs.anchor_size.clamp(1, 7) as f32 - 3.0; + let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 }; for id in &st.selection.objects { let Some(n) = st.doc.node(*id) else { continue }; let color = c32(st.doc.layer_color(*id)); @@ -1173,7 +1280,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { walk_drawn(n, &mut |c| { if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) { for q in &grid.points { - anchor_square(p, xf.to_screen(q.p), color, false, if direct { 5.0 } else { 4.0 }); + anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct)); } return; } @@ -1189,24 +1296,18 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { if h.distance(a.p) > 1e-6 { let hp = xf.to_screen(h); p.line_segment([sp, hp], Stroke::new(1.0, color)); - p.circle_filled(hp, 2.75, color); + p.circle_filled(hp, 2.75 + grow / 2.0, color); } } } - anchor_square( - p, - sp, - if sel && partial.is_some() { selected_anchor(color) } else { color }, - sel, - if partial.is_some() || direct { 5.0 } else { 4.0 }, - ); + anchor_square(p, sp, if sel && partial.is_some() { selected_anchor(color) } else { color }, sel, anchor(partial.is_some() || direct)); } }); // Centre point (Attributes panel → Show Center). if n.shows_center() && let Some(b) = n.geometric_bounds() { - anchor_square(p, xf.to_screen(b.center()), color, true, 4.0); + anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false)); } // Text: baseline marker (area type shows its frame instead, which may not be a rectangle). if let NodeKind::Text(tx) = &n.kind @@ -1230,7 +1331,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { let color = c32(st.doc.layer_color(b)); stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color)); for (_, _, a) in path.anchors() { - anchor_square(p, xf.to_screen(a.p), color, false, if direct { 5.0 } else { 4.0 }); + anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct)); } } // Live Corners widgets (Selection / Direct Selection on a single live rectangle). @@ -1251,7 +1352,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color))); for h in vectorcraft_tools::bbox::Handle::ALL { let c = xf.to_screen(b.to_doc() * h.pos(b.rect)); - let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0)); + let hr = egui::Rect::from_center_size(c, vec2(6.0 + grow, 6.0 + grow)); p.rect_filled(hr, 0.0, Color32::WHITE); p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } @@ -1635,6 +1736,120 @@ mod tests { assert_eq!(cmd_drag(&mut app, inside, inside), "selection"); } + /// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened. + fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + fn flat(s: Shape, out: &mut Vec) { + match s { + Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v)); + v + } + + /// Guides & Grid (#394): the grid in Grid Color, behind the art or, with Grids In Back off, + /// over it, as dots in the Dots style; the guides in Guides Color and Style. + #[test] + fn grid_and_guides_take_their_preferences() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); + app.ui.view.grid = true; + app.ui.view.guides = true; + let (red, green) = (Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 255, 0)); + app.session.execute("prefs.set", &json!({"values": {"gridColor": "#ff0000", "guideColor": "#00ff00"}})).unwrap(); + let ctx = egui::Context::default(); + let red_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == red); + // → (index of the first red gridline, index of the art's image). + let order = |app: &mut VectorcraftApp| { + let s = shapes(app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); + (s.iter().position(red_line).expect("a gridline in Grid Color"), image, s) + }; + let (grid, art, s) = order(&mut app); + assert!(grid < art, "Grids In Back: under the art"); + assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == green)), "the guide in Guides Color"); + app.session.execute("prefs.set", &json!({"key": "gridsInBack", "value": false})).unwrap(); + let (grid, art, _) = order(&mut app); + assert!(grid > art, "Grids In Back off: over the art"); + app.session.execute("prefs.set", &json!({"values": {"gridStyle": "dots", "guideStyle": "dots"}})).unwrap(); + let s = shapes(&mut app, &ctx); + assert!(!s.iter().any(red_line), "no gridlines in the Dots style"); + let red_dots = |s: &Shape| matches!(s, Shape::Mesh(m) if m.vertices.len() > 40 && m.vertices.iter().all(|v| v.color == red)); + assert!(s.iter().any(red_dots), "the grid's dots"); + assert!(s.iter().any(|s| matches!(s, Shape::Circle(c) if c.fill == green)), "the guide dotted"); + } + + /// Selection & Anchor Display › Size (#394): anchors and the bounding box's handles grow. + #[test] + fn anchor_size_scales_anchors_and_handles() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + // The sizes of the squares drawn (anchors and handles are squares). + let sizes = |app: &mut VectorcraftApp| { + let mut v: Vec = shapes(app, &ctx).iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.rect.width()) } else { None }).collect(); + v.sort_by(f32::total_cmp); + v.dedup(); + v + }; + let small = sizes(&mut app); + assert!(small.contains(&4.0) && small.contains(&6.0), "anchors 4, handles 6 by default: {small:?}"); + app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap(); + let big = sizes(&mut app); + assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}"); + } + + /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, + /// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel + /// scrolls up and down and Cmd/Ctrl-wheel sideways. + #[test] + fn zoom_with_mouse_wheel_preference() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A canvas frame through the host's input hook (it feeds the control channel's input). + let run = |app: &mut VectorcraftApp| { + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + app.raw_input_hook(&mut raw); + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + // One notch up at `at`: → (zoom ratio, scroll in points, how far the point under `at` moved). + let turn = |app: &mut VectorcraftApp, shift: bool, cmd: bool| { + let before = *app.view().unwrap(); + crate::tests_synthetic::control(app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1, "shift": shift, "cmd": cmd})); + // egui spreads a notch over a few frames. + for _ in 0..40 { + run(app); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + let (zoom, scroll, _) = turn(&mut app, false, false); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, _, moved) = turn(&mut app, false, true); + assert!(zoom > 1.01 && moved < 1e-6, "off: Cmd-wheel zooms in about the pointer ({zoom}, {moved})"); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + let (zoom, _, moved) = turn(&mut app, false, false); + assert!(zoom > 1.01 && moved < 1e-6, "on: the wheel zooms in about the pointer ({zoom}, {moved})"); + let (zoom, scroll, _) = turn(&mut app, true, false); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})"); + let (zoom, scroll, _) = turn(&mut app, false, true); + assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})"); + } + #[test] fn cmd_space_zooms_and_space_pans_whatever_the_tool() { use egui::{Event, Modifiers}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 605b8d91..b5c2e6c3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -105,6 +105,13 @@ fn key_from(name: &str) -> Option { }) } +/// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on +/// macOS, Ctrl elsewhere). +fn modifiers(p: &Value) -> egui::Modifiers { + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } +} + pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -159,14 +166,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - let m = egui::Modifiers { - alt: b("alt"), - ctrl: b("ctrl"), - shift: b("shift"), - mac_cmd: b("cmd") && cfg!(target_os = "macos"), - command: b("cmd"), - }; + let m = modifiers(p); app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); // Typed text comes with the press, so it shares the key's frame and modifiers. if let Some(t) = s("text") { @@ -189,14 +189,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, }; - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - let modifiers = egui::Modifiers { - alt: b("alt"), - ctrl: b("ctrl"), - shift: b("shift"), - mac_cmd: b("cmd") && cfg!(target_os = "macos"), - command: b("cmd"), - }; + let modifiers = modifiers(p); let a = egui::pos2(f("x"), f("y")); let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; app.synthetic.push(egui::Event::PointerMoved(a)); @@ -217,6 +210,18 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ctx.request_repaint(); ok(Value::Null) } + "ui.wheel" => { + // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways + // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. + let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; + let unit = if s("unit") == Some("point") { egui::MouseWheelUnit::Point } else { egui::MouseWheelUnit::Line }; + // Notches, not a flood: a turn stays within what a real wheel sends. + let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers: modifiers(p) }); + ctx.request_repaint(); + ok(Value::Null) + } "ui.text" => { app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ctx.request_repaint(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b591b7e9..c85b8a9c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1928,6 +1928,11 @@ Recent Font Fuente reciente Recent Font 1 Fuente reciente 1 Recent Font 10 Fuente reciente 10 + Recent Font 11 Fuente reciente 11 + Recent Font 12 Fuente reciente 12 + Recent Font 13 Fuente reciente 13 + Recent Font 14 Fuente reciente 14 + Recent Font 15 Fuente reciente 15 Recent Font 2 Fuente reciente 2 Recent Font 3 Fuente reciente 3 Recent Font 4 Fuente reciente 4 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 836343c9..50a2c41e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1948,6 +1948,11 @@ Recent Files 最近使ったファイル Recent Font 1 最近使ったフォント 1 Recent Font 10 最近使ったフォント 10 + Recent Font 11 最近使ったフォント 11 + Recent Font 12 最近使ったフォント 12 + Recent Font 13 最近使ったフォント 13 + Recent Font 14 最近使ったフォント 14 + Recent Font 15 最近使ったフォント 15 Recent Font 2 最近使ったフォント 2 Recent Font 3 最近使ったフォント 3 Recent Font 4 最近使ったフォント 4 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d87f42f1..fe630bf2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1739,6 +1739,11 @@ Recent Files Arquivos Recentes Recent Font 1 Fonte Recente 1 Recent Font 10 Fonte Recente 10 + Recent Font 11 Fonte Recente 11 + Recent Font 12 Fonte Recente 12 + Recent Font 13 Fonte Recente 13 + Recent Font 14 Fonte Recente 14 + Recent Font 15 Fonte Recente 15 Recent Font 2 Fonte Recente 2 Recent Font 3 Fonte Recente 3 Recent Font 4 Fonte Recente 4 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 78813f8f..c264d5a6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1903,6 +1903,11 @@ Recent Font 最近字体 Recent Font 1 最近使用的字体 1 Recent Font 10 最近使用的字体 10 + Recent Font 11 最近使用的字体 11 + Recent Font 12 最近使用的字体 12 + Recent Font 13 最近使用的字体 13 + Recent Font 14 最近使用的字体 14 + Recent Font 15 最近使用的字体 15 Recent Font 2 最近使用的字体 2 Recent Font 3 最近使用的字体 3 Recent Font 4 最近使用的字体 4 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 758dd4f7..2f33d43f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1947,6 +1947,11 @@ Recent Font 最近使用的字體 Recent Font 1 最近使用的字體 1 Recent Font 10 最近使用的字體 10 + Recent Font 11 最近使用的字體 11 + Recent Font 12 最近使用的字體 12 + Recent Font 13 最近使用的字體 13 + Recent Font 14 最近使用的字體 14 + Recent Font 15 最近使用的字體 15 Recent Font 2 最近使用的字體 2 Recent Font 3 最近使用的字體 3 Recent Font 4 最近使用的字體 4 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a00e2dc7..ea233b7c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -493,7 +493,7 @@ impl VectorcraftApp { let r = &mut self.ui.recent_fonts; r.retain(|f| f != font); r.insert(0, font.to_string()); - r.truncate(10); + r.truncate(MAX_RECENT_FONTS); } } } @@ -659,7 +659,17 @@ pub fn now_ms() -> f64 { } } +/// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). +pub const MAX_RECENT_FONTS: usize = 15; + impl VectorcraftApp { + /// Type › Recent Fonts: the fonts used last, newest first, as many as Preferences › Type › + /// Number of Recent Fonts says. + pub fn recent_fonts(&self) -> &[String] { + let n = usize::try_from(self.session.prefs.recent_fonts_count).unwrap_or(MAX_RECENT_FONTS).clamp(1, MAX_RECENT_FONTS); + self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts) + } + /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The /// canvas and the transform fields read it every frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option { @@ -788,9 +798,9 @@ impl VectorcraftApp { place::drop_files(self, files, target); } - /// Inject synthetic events (one press/release step per frame). Handlers read the modifiers - /// egui holds (`i.modifiers`), so a synthetic key or button holds its own for the frames it - /// spans (a drag's moves included); the keyboard's come back after. + /// Inject synthetic events (one press/release step or wheel turn per frame). Handlers read the + /// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own + /// for the frames it spans (a drag's moves included); the keyboard's come back after. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { for e in &raw.events { match e { @@ -808,7 +818,7 @@ impl VectorcraftApp { // Pointer events go one per frame so egui sees presses, drags and releases as real input; // keyboard sequences go up to the key release. let n = match first { - egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { @@ -819,7 +829,9 @@ impl VectorcraftApp { let held = now .iter() .find_map(|e| match e { - egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } => Some(*modifiers), + egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } | egui::Event::MouseWheel { modifiers, .. } => { + Some(*modifiers) + } _ => None, }) .or_else(|| match later.iter().find(|e| matches!(e, egui::Event::PointerButton { .. })) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 53f92218..43c02b40 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -124,6 +124,11 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("type.recentFont8", "Recent Font 8", "", "{} apply the 8. most recently used font"), ("type.recentFont9", "Recent Font 9", "", "{} apply the 9. most recently used font"), ("type.recentFont10", "Recent Font 10", "", "{} apply the 10. most recently used font"), + ("type.recentFont11", "Recent Font 11", "", "{} apply the 11. most recently used font"), + ("type.recentFont12", "Recent Font 12", "", "{} apply the 12. most recently used font"), + ("type.recentFont13", "Recent Font 13", "", "{} apply the 13. most recently used font"), + ("type.recentFont14", "Recent Font 14", "", "{} apply the 14. most recently used font"), + ("type.recentFont15", "Recent Font 15", "", "{} apply the 15. most recently used font"), ("file.clearRecent", "Clear Recent Files", "", "{}"), ("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"), ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), @@ -896,7 +901,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - match n.checked_sub(1).and_then(|i| app.ui.recent_fonts.get(i)).cloned() { + match n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned() { Some(font) => app.run("text.setStyle", json!({ "font": font })), None => Err("no such recent font".into()), } @@ -912,9 +917,14 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.snap_to_point), "view.zoomIn" | "view.zoomOut" => { let up = id == "view.zoomIn"; + // Selection & Anchor Display › Zoom to Selection: the selection comes to the middle. + let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None }; match app.view_mut() { Some(v) => { v.zoom = next_zoom(v.zoom, up); + if let Some(c) = focus { + v.center = c; + } Ok(json!({"zoom": v.zoom * 100.0})) } None => Err("no document".into()), @@ -1333,7 +1343,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { }, id if id.starts_with("type.recentFont") => { let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); - n.checked_sub(1).and_then(|i| app.ui.recent_fonts.get(i)).cloned().unwrap_or_else(|| "—".into()) + n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into()) } id if id.starts_with("view.goto") => { let n: usize = id["view.goto".len()..].parse().unwrap_or(0); @@ -1590,8 +1600,8 @@ fn saved_selection_slot(id: &str) -> Option { id.strip_prefix("select.recall")?.parse().ok() } -/// Type → Recent Fonts slots. -const RECENT_FONT_IDS: [&str; 10] = [ +/// Type → Recent Fonts slots (Preferences › Type › Number of Recent Fonts shows up to 15). +const RECENT_FONT_IDS: [&str; crate::MAX_RECENT_FONTS] = [ "type.recentFont1", "type.recentFont2", "type.recentFont3", @@ -1602,6 +1612,11 @@ const RECENT_FONT_IDS: [&str; 10] = [ "type.recentFont8", "type.recentFont9", "type.recentFont10", + "type.recentFont11", + "type.recentFont12", + "type.recentFont13", + "type.recentFont14", + "type.recentFont15", ]; /// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default. @@ -1647,7 +1662,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), "file.clearRecent" => !app.ui.recent_files.is_empty(), id if id.starts_with("type.recentFont") => { - id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some() + id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.recent_fonts().len()) && app.session.active().is_some() } id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).is_some(), id if id.starts_with("view.goto") => { @@ -3455,6 +3470,39 @@ mod tests { assert_eq!(dynamic_label(&app, "view.cornerWidget", ""), "Show Corner Widget"); } + /// Selection & Anchor Display › Zoom to Selection (#394): Zoom In and Zoom Out bring the + /// selection to the middle; off, they keep the view's centre. + #[test] + fn zoom_to_selection_centres_the_selection() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 20})).unwrap(); + let center = |app: &VectorcraftApp| app.view().unwrap().center; + let start = center(&app); + app.run("prefs.set", json!({"key": "zoomToSelection", "value": false})).unwrap(); + app.run("view.zoomIn", json!({})).unwrap(); + assert_eq!(center(&app), start, "off: the view's centre stays"); + app.run("prefs.set", json!({"key": "zoomToSelection", "value": true})).unwrap(); + app.run("view.zoomOut", json!({})).unwrap(); + assert_eq!(center(&app), vectorcraft_geom::Point::new(30.0, 30.0), "on: the selection's centre"); + } + + /// Preferences › Type › Number of Recent Fonts (#394): Type › Recent Fonts lists that many. + #[test] + fn number_of_recent_fonts_sets_how_many_are_listed() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.ui.recent_fonts = (1..=15).map(|i| format!("Font {i}")).collect(); + let listed = |app: &VectorcraftApp| RECENT_FONT_IDS.iter().filter(|id| enabled(app, id)).count(); + assert_eq!(listed(&app), 10, "10 by default"); + for n in [15, 2] { + app.run("prefs.set", json!({"key": "recentFontsCount", "value": n})).unwrap(); + assert_eq!(listed(&app), n); + } + assert_eq!(dynamic_label(&app, "type.recentFont2", "Recent Font"), "Font 2"); + assert!(app.run("type.recentFont3", json!({})).is_err(), "past the count"); + } + #[test] fn recent_files_track_opens_and_saves() { let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id())); diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 016f19af..33c4a7cc 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -77,6 +77,7 @@ struct Applied { brightness: Brightness, white_canvas: bool, threads: i32, + tool_tips: bool, } /// Per frame: push UI-side preferences into egui / the renderer when they change. @@ -87,11 +88,16 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) { { app.ui.brightness = b; } - let want = Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads }; + let want = + Applied { brightness: app.ui.brightness, white_canvas: p.canvas_color == "white", threads: p.render_threads, tool_tips: p.show_tool_tips }; let id = egui::Id::new("dc-applied-prefs"); let prev: Option = ctx.data(|d| d.get_temp::>(id)).flatten(); if prev != Some(want) { theme::apply(ctx, want.brightness); + // General › Show Tool Tips: off, no button or field shows its tool tip (they never come + // due), whichever widget asks for one. + let delay = if want.tool_tips { egui::style::Interaction::default().tooltip_delay } else { f32::INFINITY }; + ctx.global_style_mut(|s| s.interaction.tooltip_delay = delay); if want.white_canvas { let mut t = Tokens::get(ctx); t.pasteboard = egui::Color32::WHITE; @@ -366,6 +372,52 @@ mod tests { assert_eq!(a.ui.engine_prefs["keyboardIncrement"], json!(4.0)); } + /// General › Show Tool Tips: off, a button held under the pointer shows no tool tip; back on, + /// it does again (#394). + #[test] + fn show_tool_tips_off_hides_tool_tips() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut a = app(); + let ctx = egui::Context::default(); + let mut time = 0.0; + // Come from elsewhere and hover the button for two seconds: did its tool tip show? + let mut hover = |a: &mut VectorcraftApp| { + let mut shown = vec![]; + for i in 0..20 { + time += 0.1; + let events = match i { + 0 => vec![egui::Event::PointerGone], + 1 => vec![egui::Event::PointerMoved(egui::pos2(20.0, 12.0))], + _ => vec![], + }; + let raw = egui::RawInput { + events, + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + apply_runtime(a, ui.ctx()); + ui.button("Button").on_hover_text("The tool tip"); + }); + out.textures_delta.clear(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + } + shown.iter().any(|t| t == "The tool tip") + }; + assert!(hover(&mut a), "on by default"); + a.run("prefs.set", json!({"key": "showToolTips", "value": false})).unwrap(); + assert!(!hover(&mut a), "off: no tool tip"); + a.run("prefs.set", json!({"key": "showToolTips", "value": true})).unwrap(); + assert!(hover(&mut a), "on again"); + } + #[test] fn dialog_ok_with_invalid_value_keeps_dialog_and_prefs() { let mut a = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 547f2daa..55f71965 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -24,6 +24,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser | `ui.tool.select` / `ui.tool.list` | `{tool}` | | | `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | +| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel zooms; with the `zoomWithMouseWheel` preference the wheel zooms about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | | `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | @@ -462,7 +463,7 @@ from)}` opens too: dialog `printPreset`, the Print dialog's settings fields plus Export… are `print.presets.delete`, `print.presets.import` and `print.presets.export`; `ui.dialog.confirm` closes. Opening a `.vcprintpresets` file with `app.open` imports its presets. -Modifiers on synthetic input: `ui.key`, `ui.click` and `ui.drag` take `shift`, `alt`, `ctrl` and `cmd` (Command on +Modifiers on synthetic input: `ui.key`, `ui.click`, `ui.drag` and `ui.wheel` take `shift`, `alt`, `ctrl` and `cmd` (Command on macOS, Ctrl elsewhere), and the app holds them for the frames the input spans, as if the keys were down: a key's press, its `text` and its release; a click's press and release; a drag from the press through every move to the release. Every handler sees them (Shift+arrow nudges by the big increment, Alt+arrow nudges a copy, Shift-clicking a diff --git a/docs/mcp.md b/docs/mcp.md index a3609dc5..3ee5598a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1072,6 +1072,29 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","bounds":"preview"}}} ``` +## Selection preferences + +The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse: + +- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor or + handle for Direct Selection. +- `objectSelectionByPathOnly`: a click inside a filled path or compound path doesn't select it, one on its path does. +- `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl + elsewhere) selects the object under the selected one there, the next such click the one under that, then the + topmost again. Cmd held to borrow the selection tool from another tool clicks as usual. +- `doubleClickToIsolate` (on by default): off, a `doubleclick` on a group with the Selection tool no longer isolates it. +- `usePreciseCursors`: the Pen, Eyedropper, Slice and Blend tools' pointers are a crosshair. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"objectSelectionByPathOnly","value":true}}} +{"name":"pointer_gesture","arguments":{"tool":"selection","mods":{"cmd":true},"events":[{"kind":"down","x":175,"y":125},{"kind":"up","x":175,"y":125}]}} +``` + +The preferences for the view need the desktop app (`vectorcraft-cli mcp --connect`): `zoomWithMouseWheel` (the +wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; the control channel's +`ui.wheel` turns the wheel), `zoomToSelection` (on: Zoom In and Zoom Out centre the selection), `showToolTips`, +`anchorSize` (1–7), `gridColor`, `gridStyle`, `gridsInBack`, `guideColor`, `guideStyle` and `recentFontsCount`. + ## Width points `stroke.widthPoint.set {id, t, left, right, index?, adjustAdjoining?}` adds a width point (side widths in points) or,