Part of #394: Preferences that act on selection, the wheel, tool tips and the grid
Fifteen Preferences options were stored but read by nothing. They now act, through the existing plumbing (ToolContext for the tools, the prefs read by the canvas for the view), and reach agents through prefs.set: - General: Show Tool Tips (off: no widget's tool tip comes due), 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 (Pen, Eyedropper, Slice and Blend pointers become a crosshair). - Selection & Anchor Display: Tolerance (objects, anchors and handles, in place of the fixed 3 and 4 px), Object Selection by Path Only (compound paths too), Command Click to Select Objects Behind (cycles down the objects under the pointer; not when Cmd only borrows the selection tool), Zoom to Selection (Zoom In/Out centre the selection), anchor Size, Highlight anchors on mouse over (Direct Selection). - Type: Number of Recent Fonts (up to 15 Recent Fonts slots). - Guides & Grid: guide and grid Color and Style (lines or dots), Grids In Back. The canvas's context menu, panel drops and hover highlight pick objects like the selection tools. The control channel gets ui.wheel (with modifiers held like ui.click) so agents can turn the wheel. The rest of #394 stays open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f7a03160d6
commit
47bed05ff4
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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<NodeId>) -> Vec<NodeId> {
|
||||
let mut tops: Vec<NodeId> = 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<Hit> {
|
||||
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<Hit> {
|
|||
}
|
||||
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<NodeId>) -> Option<Hit> {
|
||||
fn hit_children(parent: &Node, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, chain: &mut Vec<NodeId>) -> Option<Hit> {
|
||||
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<NodeId
|
|||
}
|
||||
for c in children.iter().rev() {
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() {
|
||||
if !c.visible || c.locked || c.is_template() || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
|
|
@ -123,13 +142,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
|||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => 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<HitKind> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::<Value>(&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::<Value>(&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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,11 +95,13 @@ pub struct DirectSelectionTool {
|
|||
mesh: MeshEdit,
|
||||
/// Smart guides of the handle being dragged.
|
||||
guides: Vec<Overlay>,
|
||||
/// 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<Action> {
|
||||
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<Overlay> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<ScreenFrame>,
|
||||
/// 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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NodeId> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Drag> = 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<vectorcraft_doc::hit::Hit> {
|
||||
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<Shape> {
|
||||
fn flat(s: Shape, out: &mut Vec<Shape>) {
|
||||
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<f32> = 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};
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ fn key_from(name: &str) -> Option<egui::Key> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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<vectorcraft_doc::OrientedBox> {
|
||||
|
|
@ -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 { .. })) {
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
},
|
||||
id if id.starts_with("type.recentFont") => {
|
||||
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<R
|
|||
"view.snapToPoint" => 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<usize> {
|
|||
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::<usize>().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some()
|
||||
id["type.recentFont".len()..].parse::<usize>().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()));
|
||||
|
|
|
|||
|
|
@ -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<Applied> = ctx.data(|d| d.get_temp::<Option<Applied>>(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<String>) {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
docs/mcp.md
23
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue