Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3dbd5369 | ||
|
|
3fbf14a3bf | ||
|
|
9a5429a325 | ||
|
|
e278ccf44d | ||
|
|
a5963b7c76 | ||
|
|
aed5bde38f | ||
|
|
2f710d3acc | ||
|
|
2bcaf08dbd | ||
|
|
e1de706245 | ||
|
|
6952780c1f | ||
|
|
f5183da3dd |
No files matched your search
@@ -63,7 +63,7 @@ jobs:
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
|
||||
+6
-6
@@ -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), 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))._
|
||||
_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), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394) and skeleton reads with `document.find` for large documents over MCP (#416); see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -90,7 +90,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
@@ -103,7 +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).
|
||||
- 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. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 45 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, the smart guide display options, performance, devices…).
|
||||
- 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).
|
||||
@@ -124,7 +124,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
|
||||
@@ -206,12 +206,12 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click) and on layers (dragging existing guides is done, #414) | 3–6 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against more real files (two Illustrator 30 files open as vectors so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| 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), 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 |
|
||||
| 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 45 Preferences options stored but not yet acted on (#394: smart guide display options, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Show Pixel Grid, performance, devices…) | 16–26 |
|
||||
| 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 |
|
||||
|
||||
@@ -181,6 +181,21 @@ fn mcp_protocol_over_stdio() {
|
||||
send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true);
|
||||
|
||||
// Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and
|
||||
// document.find locates nodes without dumping the tree.
|
||||
send(&mut stdin, run(25, "document.inspect", json!({"depth": 0})));
|
||||
let r = recv(&mut lines, &mut notes, 25);
|
||||
let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
|
||||
assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}");
|
||||
send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"})));
|
||||
let r = recv(&mut lines, &mut notes, 26);
|
||||
let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(found["total"], 1, "{found}");
|
||||
assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}");
|
||||
send(&mut stdin, run(27, "document.find", json!({})));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true);
|
||||
|
||||
// The binary installed its logger: records arrive as notifications/message, and only
|
||||
// after the client asked for them.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
|
||||
|
||||
+32
-15
@@ -167,6 +167,18 @@ impl Unit {
|
||||
Unit::Feet => "Feet",
|
||||
}
|
||||
}
|
||||
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
|
||||
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
|
||||
pub fn ruler_step(self, zoom: f64) -> f64 {
|
||||
const STEPS: [f64; 19] =
|
||||
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
|
||||
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
|
||||
}
|
||||
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
|
||||
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
|
||||
let tick = self.ruler_step(zoom) / 10.0 * self.points();
|
||||
(v / tick).round() * tick
|
||||
}
|
||||
pub fn from_pt(self, v: f64) -> f64 {
|
||||
v / self.points()
|
||||
}
|
||||
@@ -902,16 +914,15 @@ impl Document {
|
||||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: unlocked top-level objects (children of
|
||||
/// unlocked layers and sublayers) lying entirely inside it.
|
||||
pub fn art_on_artboard(&self, rect: Rect) -> Vec<NodeId> {
|
||||
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.locked {
|
||||
continue;
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
|
||||
/// sublayers) lying entirely inside it. Locked and hidden objects and layers stay put unless
|
||||
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
|
||||
/// Artboard).
|
||||
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
|
||||
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||||
if c.is_layer() {
|
||||
collect(c, rect, out);
|
||||
collect(c, rect, all, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
&& rect.contains(Point::new(b.x0, b.y0))
|
||||
&& rect.contains(Point::new(b.x1, b.y1))
|
||||
@@ -921,8 +932,8 @@ impl Document {
|
||||
}
|
||||
}
|
||||
let mut art = vec![];
|
||||
for l in self.layers.iter().filter(|l| !l.locked) {
|
||||
collect(l, rect, &mut art);
|
||||
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
|
||||
collect(l, rect, locked_and_hidden, &mut art);
|
||||
}
|
||||
art
|
||||
}
|
||||
@@ -1076,12 +1087,18 @@ mod tests {
|
||||
#[test]
|
||||
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
|
||||
let (mut d, a, b) = doc_with_rects();
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0)), vec![a]);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0)), vec![a], "b only half inside");
|
||||
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
|
||||
d.node_mut(a).unwrap().locked = true;
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)), vec![b]);
|
||||
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
|
||||
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
|
||||
d.node_mut(b).unwrap().visible = false;
|
||||
assert!(d.art_on_artboard(wide, false).is_empty());
|
||||
assert_eq!(d.art_on_artboard(wide, true), vec![a, b]);
|
||||
Arc::make_mut(&mut d.layers[0]).locked = true;
|
||||
assert!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)).is_empty());
|
||||
assert!(d.art_on_artboard(wide, false).is_empty());
|
||||
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+13
-1
@@ -252,6 +252,9 @@ pub struct Scaling {
|
||||
pub keep_type_strokes: bool,
|
||||
/// Live corner radii keep their size.
|
||||
pub keep_corners: bool,
|
||||
/// Pattern fills and strokes move with the art (Transform Patterns; General › Transform
|
||||
/// Pattern Tiles is its default): their tiles transform too, instead of staying put.
|
||||
pub patterns: bool,
|
||||
}
|
||||
|
||||
impl Scaling {
|
||||
@@ -526,6 +529,12 @@ impl Node {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Does this object or layer move with its artboard? Locked and hidden ones only with
|
||||
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
|
||||
/// Artboard).
|
||||
pub fn rides_with_artboard(&self, locked_and_hidden: bool) -> bool {
|
||||
locked_and_hidden || (!self.locked && self.visible)
|
||||
}
|
||||
pub fn is_layer(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { .. })
|
||||
}
|
||||
@@ -721,6 +730,9 @@ impl Node {
|
||||
}
|
||||
}
|
||||
self.appearance.transform_gradients(a);
|
||||
if sc.patterns {
|
||||
crate::pattern::transform_pattern_paints(self, a);
|
||||
}
|
||||
match &mut self.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
@@ -1318,7 +1330,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false };
|
||||
|
||||
#[test]
|
||||
fn uneven_scales_keep_live_corners_circular() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Selection state (objects and, for direct selection, individual anchors).
|
||||
//! Selection state (objects and, for direct selection, individual anchors; ruler guides).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -29,12 +29,20 @@ pub struct Selection {
|
||||
/// clears them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<NodeId>,
|
||||
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are
|
||||
/// selected on their own: any change to the object selection clears them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub guides: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
}
|
||||
/// Is anything selected that Delete and the arrow keys act on: objects or ruler guides?
|
||||
pub fn has_objects_or_guides(&self) -> bool {
|
||||
!self.objects.is_empty() || !self.guides.is_empty()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.objects.len()
|
||||
}
|
||||
@@ -47,6 +55,7 @@ impl Selection {
|
||||
self.key = None;
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
}
|
||||
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
||||
self.clear();
|
||||
@@ -57,6 +66,7 @@ impl Selection {
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -64,6 +74,7 @@ impl Selection {
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
if self.key == Some(id) {
|
||||
@@ -79,6 +90,15 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Select ruler guides `indexes` (see [`Selection::guides`]) and nothing else.
|
||||
pub fn set_guides(&mut self, indexes: impl IntoIterator<Item = usize>) {
|
||||
self.clear();
|
||||
for i in indexes {
|
||||
if !self.guides.contains(&i) {
|
||||
self.guides.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn toggle(&mut self, id: NodeId) {
|
||||
if self.contains(id) { self.remove(id) } else { self.add(id) }
|
||||
}
|
||||
@@ -97,6 +117,7 @@ impl Selection {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
self.guides.retain(|i| *i < doc.guides.len());
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
@@ -138,6 +159,23 @@ mod tests {
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_are_selected_on_their_own() {
|
||||
let mut s = Selection::default();
|
||||
s.add(NodeId(1));
|
||||
s.set_guides([2, 0, 2]);
|
||||
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
|
||||
assert!(s.has_objects_or_guides() && s.is_empty());
|
||||
s.add(NodeId(1));
|
||||
assert!(s.guides.is_empty());
|
||||
// Pruning drops guides the document no longer has.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.guides.push(crate::Guide { vertical: true, pos: 10.0 });
|
||||
s.set_guides([0, 1]);
|
||||
s.prune(&d);
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_changes_clear_the_target() {
|
||||
let mut s = Selection::default();
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText)} → {id}",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -414,8 +414,11 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
super::typecmd::refresh_bounds(&mut t);
|
||||
}
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
}
|
||||
|
||||
|
||||
@@ -155,8 +155,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
clear_guides
|
||||
),
|
||||
cmd!("guide.add", "Add Guide", [], None, "{vertical: bool, pos: pt (x for vertical, y for horizontal)} → {index}", has_doc, guide_add),
|
||||
cmd!("guide.remove", "Remove Guide", [], None, "{index}", guides_unlocked, guide_remove),
|
||||
cmd!("guide.move", "Move Guide", [], None, "{index, pos: pt}", guides_unlocked, guide_move),
|
||||
cmd!(query "guide.list", "Guides", [], None, "{} → [{index, vertical, pos, selected}…] the ruler guides", has_doc, guide_list),
|
||||
cmd!(
|
||||
"guide.select",
|
||||
"Select Guides",
|
||||
[],
|
||||
None,
|
||||
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}",
|
||||
guides_unlocked,
|
||||
guide_select
|
||||
),
|
||||
cmd!(
|
||||
"guide.remove",
|
||||
"Remove Guide",
|
||||
[],
|
||||
None,
|
||||
"{index?} delete ruler guide `index`, else the selected ones (as Delete / edit.clear does) → {count}",
|
||||
guides_unlocked,
|
||||
guide_remove
|
||||
),
|
||||
cmd!(
|
||||
"guide.move",
|
||||
"Move Guide",
|
||||
[],
|
||||
None,
|
||||
"{index, pos: pt} put ruler guide `index` at `pos` | {dx?, dy?: pt, copy?: bool} move the selected guides (vertical ones by dx, horizontal ones by dy); copy leaves them and selects the moved copies",
|
||||
guides_unlocked,
|
||||
guide_move
|
||||
),
|
||||
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
|
||||
cmd!(
|
||||
"file.documentColorMode",
|
||||
@@ -428,6 +454,10 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected.
|
||||
if v && !s.doc()?.selection.guides.is_empty() {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
@@ -458,28 +488,95 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize;
|
||||
fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let row = |(i, g): (usize, &Guide)| json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) });
|
||||
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
|
||||
}
|
||||
|
||||
/// Ruler guide `index` of the active document (`index` validated).
|
||||
fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
|
||||
let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?;
|
||||
if i >= s.doc()?.doc.guides.len() {
|
||||
return Err(bad(cmd, "no such guide"));
|
||||
return Err(bad(cmd, format!("no guide {i}")));
|
||||
}
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = guide_index(s, p, "guide.remove")?;
|
||||
s.edit("Delete Guide", |d, _| {
|
||||
d.guides.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
|
||||
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
|
||||
if bool_or(p, "toggle", false) {
|
||||
let mut now = s.doc()?.selection.guides.clone();
|
||||
for i in picked {
|
||||
if let Some(k) = now.iter().position(|g| *g == i) {
|
||||
now.remove(k);
|
||||
} else {
|
||||
now.push(i);
|
||||
}
|
||||
}
|
||||
s.select(|_, sel| sel.set_guides(now))?;
|
||||
} else {
|
||||
s.select(|_, sel| sel.set_guides(picked))?;
|
||||
}
|
||||
Ok(json!({ "selected": s.doc()?.selection.guides }))
|
||||
}
|
||||
|
||||
fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = guide_index(s, p, "guide.move")?;
|
||||
let pos = f64_req(p, "pos", "guide.move")?;
|
||||
s.edit("Move Guide", |d, _| {
|
||||
d.guides[i].pos = pos;
|
||||
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
|
||||
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gone: Vec<usize> = match p.get("index") {
|
||||
Some(v) => vec![guide_index(s, Some(v), "guide.remove")?],
|
||||
None => s.doc()?.selection.guides.clone(),
|
||||
};
|
||||
if gone.is_empty() {
|
||||
return Err(bad("guide.remove", "no guide selected"));
|
||||
}
|
||||
let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" };
|
||||
s.edit(label, |d, sel| {
|
||||
let kept = |i: &usize| !gone.contains(i);
|
||||
// The guides left selected keep their place among the rest.
|
||||
let left: Vec<usize> = sel.guides.iter().filter(|i| kept(i)).map(|i| i - gone.iter().filter(|g| *g < i).count()).collect();
|
||||
d.guides = std::mem::take(&mut d.guides).into_iter().enumerate().filter(|(i, _)| kept(i)).map(|(_, g)| g).collect();
|
||||
sel.set_guides(left);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": gone.len() }))
|
||||
}
|
||||
|
||||
/// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step.
|
||||
pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("index") {
|
||||
let i = guide_index(s, Some(v), "guide.move")?;
|
||||
let pos = f64_req(p, "pos", "guide.move")?;
|
||||
s.edit("Move Guide", |d, _| {
|
||||
if let Some(g) = d.guides.get_mut(i) {
|
||||
g.pos = pos;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let moving = s.doc()?.selection.guides.clone();
|
||||
if moving.is_empty() {
|
||||
return Err(bad("guide.move", "no guide selected (or give an index)"));
|
||||
}
|
||||
let (dx, dy, copy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0), bool_or(p, "copy", false));
|
||||
s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| {
|
||||
let mut copies = vec![];
|
||||
for &i in &moving {
|
||||
let Some(g) = d.guides.get_mut(i) else { continue };
|
||||
let moved = Guide { vertical: g.vertical, pos: g.pos + if g.vertical { dx } else { dy } };
|
||||
if copy {
|
||||
copies.push(moved);
|
||||
} else {
|
||||
*g = moved;
|
||||
}
|
||||
}
|
||||
if copy {
|
||||
let first = d.guides.len();
|
||||
d.guides.extend(copies);
|
||||
sel.set_guides(first..d.guides.len());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -24,8 +24,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree, artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group, Path, Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
@@ -85,7 +86,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_clipboard,
|
||||
|s, p| paste(s, p, PasteMode::AllArtboards)
|
||||
),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?}", has_selection, clear),
|
||||
cmd!(
|
||||
"edit.clear",
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?} delete the objects `ids`, else the selection (direct-selected anchors, else objects, else ruler guides)",
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
|
||||
]
|
||||
}
|
||||
@@ -105,7 +114,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let opts = slice_opts(p)?;
|
||||
let opts = slice_opts(p, "document.node")?;
|
||||
let summary = bool_or(p, "summary", false);
|
||||
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
|
||||
// The full object JSON is never truncated: a slice without `summary` would
|
||||
@@ -117,14 +126,33 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
|
||||
/// A present value that is not a non-negative integer is rejected rather than
|
||||
/// silently reinterpreted.
|
||||
fn slice_opts(p: &Value) -> Result<inspect::SummaryOpts> {
|
||||
let opt = |key: &str| match p.get(key) {
|
||||
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
|
||||
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")? })
|
||||
}
|
||||
|
||||
/// An optional count: absent (or null) is `None`; a present value that is not a
|
||||
/// non-negative integer is rejected rather than silently reinterpreted.
|
||||
fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result<Option<u64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("document.node", format!("`{key}` must be a non-negative integer"))),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a non-negative integer"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn doc_find(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A filter of the wrong type is an error, not dropped: dropping it would widen the search.
|
||||
let needle = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v.to_lowercase()).filter(|v| !v.is_empty())),
|
||||
Some(_) => Err(bad("document.find", format!("`{key}` must be a string"))),
|
||||
};
|
||||
Ok(inspect::SummaryOpts { depth: opt("depth")?, child_limit: opt("childLimit")? })
|
||||
let filter = inspect::FindFilter { name: needle("name")?, kind: needle("kind")?, text: needle("text")? };
|
||||
if filter.is_empty() {
|
||||
return Err(bad("document.find", "give at least one of `name`, `kind`, `text`"));
|
||||
}
|
||||
let limit = opt_u64(p, "document.find", "limit")?.unwrap_or(100);
|
||||
let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit);
|
||||
Ok(json!({"matches": matches, "total": total}))
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
@@ -135,7 +163,7 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
fn typing_in_progress(s: &Session) -> bool {
|
||||
pub(crate) fn typing_in_progress(s: &Session) -> bool {
|
||||
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
|
||||
}
|
||||
|
||||
@@ -224,6 +252,10 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
|
||||
return super::path::delete_anchors(s, p);
|
||||
}
|
||||
// Selected ruler guides (selected on their own).
|
||||
if ids_param(p, "ids").is_none() && s.doc()?.selection.is_empty() {
|
||||
return super::docmenu::guide_remove(s, &json!({}));
|
||||
}
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform Each…",
|
||||
["Object", "Transform"],
|
||||
Some("Cmd+Alt+Shift+D"),
|
||||
"{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences)} transform every selected object about its own reference point → {ids}",
|
||||
"{scaleH?: % (100), scaleV?: % (100), moveH?: pt, moveV?: pt (down = +), rotate?: deg (counter-clockwise), reflectX?: bool (flip vertically), reflectY?: bool (flip horizontally), random?: bool, seed?: n, reference?: 0..8 (9-point grid, 4 = centre), copy?: bool, strokes?: bool (Scale Strokes & Effects), corners?: bool (Scale Corners; both default to the preferences), patterns?: bool (Transform Patterns; default: prefs transformPatternTiles)} transform every selected object about its own reference point → {ids}",
|
||||
has_selection,
|
||||
transform_each
|
||||
),
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rearrange All Artboards…",
|
||||
["Object", "Artboards"],
|
||||
None,
|
||||
"{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true} lay artboards out in a grid",
|
||||
"{columns?: n (2), spacing?: pt (20), byColumn?: false, moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard)} lay artboards out in a grid",
|
||||
has_doc,
|
||||
rearrange_artboards
|
||||
),
|
||||
@@ -288,7 +288,8 @@ fn transform_each(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
}
|
||||
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
|
||||
let sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||||
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||||
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
|
||||
let ids = s.edit("Transform Each", |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
|
||||
for id in &targets {
|
||||
@@ -742,6 +743,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let by_col = bool_or(p, "byColumn", false);
|
||||
let move_art = bool_or(p, "moveArtwork", true);
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let scale_strokes = false;
|
||||
s.edit("Rearrange Artboards", |d, _| {
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
@@ -753,7 +755,9 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tops: Vec<(NodeId, Point)> = d
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.rides_with_artboard(locked_and_hidden))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.rides_with_artboard(locked_and_hidden))
|
||||
.filter_map(|n| Some((n.id, n.geometric_bounds()?.center())))
|
||||
.collect();
|
||||
for (id, c) in tops {
|
||||
|
||||
@@ -144,6 +144,11 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Objects or ruler guides are selected (what Delete and the arrow keys act on).
|
||||
pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.has_objects_or_guides() { Ok(()) } else { Err("nothing selected".into()) }
|
||||
}
|
||||
pub fn has_anchors(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.anchors.values().all(|a| a.is_empty()) { Err("no anchor points selected".into()) } else { Ok(()) }
|
||||
|
||||
@@ -21,17 +21,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform",
|
||||
[],
|
||||
None,
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
has_doc,
|
||||
transform
|
||||
),
|
||||
cmd!("object.move", "Move…", ["Object", "Transform"], Some("Cmd+Shift+M"), "{dx, dy, copy?}", has_selection, move_cmd),
|
||||
cmd!(
|
||||
"object.move",
|
||||
"Move…",
|
||||
["Object", "Transform"],
|
||||
Some("Cmd+Shift+M"),
|
||||
"{dx, dy, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_selection,
|
||||
move_cmd
|
||||
),
|
||||
cmd!(
|
||||
"object.rotate",
|
||||
"Rotate…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}",
|
||||
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} → {ids}",
|
||||
has_selection,
|
||||
rotate
|
||||
),
|
||||
@@ -40,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Scale…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale)} (strokes/corners default to the preferences)",
|
||||
"{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} (strokes/corners default to the preferences)",
|
||||
has_selection,
|
||||
scale
|
||||
),
|
||||
@@ -49,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reflect…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?}",
|
||||
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_selection,
|
||||
reflect
|
||||
),
|
||||
@@ -58,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shear…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?}",
|
||||
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)}",
|
||||
has_selection,
|
||||
shear
|
||||
),
|
||||
@@ -68,8 +76,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Nudge",
|
||||
[],
|
||||
None,
|
||||
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment",
|
||||
has_selection,
|
||||
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)",
|
||||
has_selection_or_guides,
|
||||
nudge
|
||||
),
|
||||
cmd!("object.arrange.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| arrange(
|
||||
@@ -208,15 +216,39 @@ pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
|
||||
let corners = bool_or(p, "corners", s.prefs.scale_corners);
|
||||
s.note_journal("strokes", json!(strokes));
|
||||
s.note_journal("corners", json!(corners));
|
||||
Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners }
|
||||
Scaling {
|
||||
strokes,
|
||||
effects: strokes.then_some(vectorcraft_render::effects::scale_effect),
|
||||
keep_type_strokes: !strokes,
|
||||
keep_corners: !corners,
|
||||
..Scaling::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform Patterns: do the pattern fills and strokes of `ids` transform with them (`patterns`
|
||||
/// param, else General › Transform Pattern Tiles)? Noted in the journal when they use a pattern,
|
||||
/// so a replay moves their tiles alike whatever the preference is then.
|
||||
pub(crate) fn transform_patterns(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<bool> {
|
||||
let on = bool_or(p, "patterns", s.prefs.transform_pattern_tiles);
|
||||
let d = &s.doc()?.doc;
|
||||
let mut uses = false;
|
||||
for n in ids.iter().filter_map(|id| d.node(*id)) {
|
||||
n.walk(&mut |c| uses |= vectorcraft_doc::pattern::uses_pattern(c, None));
|
||||
}
|
||||
if uses {
|
||||
s.note_journal("patterns", json!(on));
|
||||
}
|
||||
Ok(on)
|
||||
}
|
||||
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
|
||||
/// `typeAreas`: see [`resize_type_area`]). Records Transform Again.
|
||||
/// `patterns`: see [`transform_patterns`]; `typeAreas`: see [`resize_type_area`]). Records
|
||||
/// Transform Again.
|
||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
sc.patterns = transform_patterns(s, p, &ids)?;
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
@@ -268,6 +300,9 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.doc()?.selection.anchors.is_empty() {
|
||||
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
|
||||
}
|
||||
if s.doc()?.selection.is_empty() {
|
||||
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
@@ -30,26 +31,50 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
|
||||
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
|
||||
cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many),
|
||||
cmd!("select.same.fillColor", "Fill Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||||
s,
|
||||
cmd!(
|
||||
"select.same.fillColor",
|
||||
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint()
|
||||
)),
|
||||
cmd!("select.same.strokeColor", "Stroke Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||||
s,
|
||||
"Fill Color",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
same(s, "select.same.fillColor", |a, b| same_paint(&a.appearance.fill_paint(), &b.appearance.fill_paint(), tint))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"select.same.strokeColor",
|
||||
|a, b| a.appearance.stroke_paint() == b.appearance.stroke_paint()
|
||||
)),
|
||||
"Stroke Color",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects stroked with the first selected object's stroke colour (tints as select.same.fillColor)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
same(s, "select.same.strokeColor", |a, b| same_paint(&a.appearance.stroke_paint(), &b.appearance.stroke_paint(), tint))
|
||||
}
|
||||
),
|
||||
cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||||
s,
|
||||
"select.same.strokeWeight",
|
||||
|a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9
|
||||
)),
|
||||
cmd!("select.same.fillAndStroke", "Fill & Stroke", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||||
s,
|
||||
cmd!(
|
||||
"select.same.fillAndStroke",
|
||||
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint() && a.appearance.stroke_paint() == b.appearance.stroke_paint()
|
||||
)),
|
||||
"Fill & Stroke",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects with the first selected object's fill and stroke colours (tints as select.same.fillColor)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
same(s, "select.same.fillAndStroke", |a, b| {
|
||||
let (x, y) = (&a.appearance, &b.appearance);
|
||||
same_paint(&x.fill_paint(), &y.fill_paint(), tint) && same_paint(&x.stroke_paint(), &y.stroke_paint(), tint)
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("select.same.opacity", "Opacity", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.opacity", |a, b| (a
|
||||
.opacity
|
||||
- b.opacity)
|
||||
@@ -255,7 +280,16 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
/// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot
|
||||
/// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too.
|
||||
fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
|
||||
match (a, b) {
|
||||
(Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn))
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, placeholder?: bool (placeholder text instead, as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -58,6 +58,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
fit_headline
|
||||
),
|
||||
cmd!(
|
||||
"type.step",
|
||||
"Step Type",
|
||||
[],
|
||||
None,
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift). The range of `id`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
|
||||
has_doc,
|
||||
type_step
|
||||
),
|
||||
cmd!(
|
||||
"type.size.increase",
|
||||
"Increase Font Size",
|
||||
[],
|
||||
Some("Cmd+Shift+."),
|
||||
"{by?: steps (1)} type.step {attribute: size}",
|
||||
has_selection,
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": f64_or(p, "by", 1.0)}))
|
||||
),
|
||||
cmd!(
|
||||
"type.size.decrease",
|
||||
"Decrease Font Size",
|
||||
[],
|
||||
Some("Cmd+Shift+,"),
|
||||
"{by?: steps (1)} type.step {attribute: size, by: -by}",
|
||||
has_selection,
|
||||
|s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)}))
|
||||
),
|
||||
cmd!(
|
||||
"text.discardEmpty",
|
||||
"Discard Empty Type",
|
||||
@@ -332,6 +359,45 @@ impl CharChange {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
|
||||
/// font has no glyph for, but their font `before` the change had, keep that font.
|
||||
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) {
|
||||
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
|
||||
runs.iter()
|
||||
.scan(0, |at, r| {
|
||||
let start = *at;
|
||||
*at += r.text.len();
|
||||
Some(r.text.char_indices().map(move |(i, c)| (start + i, c, &r.style)))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut faces = std::collections::HashMap::new();
|
||||
let mut covers = |st: &CharStyle, c: char| {
|
||||
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
|
||||
face.as_ref().map(|f| f.covers(c))
|
||||
};
|
||||
// Byte ranges that keep a font (family, style).
|
||||
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
|
||||
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
|
||||
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
|
||||
if !changed || c.is_whitespace() || c.is_control() || covers(new, c) != Some(false) || covers(old, c) != Some(true) {
|
||||
continue;
|
||||
}
|
||||
let end = at + c.len_utf8();
|
||||
match keep.last_mut() {
|
||||
Some((_, e, st)) if *e == at && (&st.font_family, &st.font_style) == (&old.font_family, &old.font_style) => *e = end,
|
||||
_ => keep.push((at, end, old)),
|
||||
}
|
||||
}
|
||||
for (a, b, old) in keep {
|
||||
edit::style_range(runs, a, b, |st| {
|
||||
st.font_family.clone_from(&old.font_family);
|
||||
st.font_style.clone_from(&old.font_style);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setRangeStyle";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
@@ -339,6 +405,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
|
||||
let t = text_ref(s, id)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs));
|
||||
let n = s.edit("Character", |d, _| {
|
||||
@@ -347,7 +414,11 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// An empty range inside text styles nothing (Illustrator keeps it for the next typing).
|
||||
return Ok(t.runs.len());
|
||||
}
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
edit::style_range(&mut t.runs, a, b, |st| change.apply(st));
|
||||
if let Some(before) = before {
|
||||
protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(t.runs.len())
|
||||
})?;
|
||||
@@ -397,7 +468,11 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
wrap: Vec::new(),
|
||||
cached_bounds: None,
|
||||
};
|
||||
refresh_bounds(&mut t);
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
refresh_bounds(&mut t);
|
||||
}
|
||||
let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| {
|
||||
let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?;
|
||||
d.remove(pid)?;
|
||||
@@ -413,6 +488,95 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
/// What `type.step` changes.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Step {
|
||||
Size,
|
||||
Leading,
|
||||
Tracking,
|
||||
Kerning,
|
||||
BaselineShift,
|
||||
}
|
||||
|
||||
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
|
||||
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
|
||||
let o = s.tool_options();
|
||||
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
|
||||
if super::edit::typing_in_progress(s) {
|
||||
s.set_tool_option("commitTyping", &Value::Bool(true));
|
||||
s.commit_interaction()?;
|
||||
}
|
||||
let at = |k: &str| byte_param(&o, k).unwrap_or(0);
|
||||
Ok(Some((id, at("start"), at("end"))))
|
||||
}
|
||||
|
||||
fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.step";
|
||||
let step = match str_param(p, "attribute") {
|
||||
Some("size") => Step::Size,
|
||||
Some("leading") => Step::Leading,
|
||||
Some("tracking") => Step::Tracking,
|
||||
Some("kerning") => Step::Kerning,
|
||||
Some("baselineShift") => Step::BaselineShift,
|
||||
_ => return Err(bad(C, "attribute must be size|leading|tracking|kerning|baselineShift")),
|
||||
};
|
||||
let by = f64_or(p, "by", 1.0);
|
||||
if !by.is_finite() {
|
||||
return Err(bad(C, "by must be a number"));
|
||||
}
|
||||
let prefs = &s.prefs;
|
||||
let inc = by.clamp(-1000.0, 1000.0)
|
||||
* match step {
|
||||
Step::Size | Step::Leading => prefs.type_size_increment,
|
||||
Step::Tracking | Step::Kerning => prefs.tracking_increment,
|
||||
Step::BaselineShift => prefs.baseline_shift_increment,
|
||||
};
|
||||
// (text, its range; None: all of it).
|
||||
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") {
|
||||
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs))))],
|
||||
None => match editing_range(s)? {
|
||||
Some((id, a, b)) => vec![(id, Some((a.min(b), a.max(b))))],
|
||||
None => super::typecmd::text_targets(s, p, C)?.into_iter().map(|id| (id, None)).collect(),
|
||||
},
|
||||
};
|
||||
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
|
||||
// the paragraph's; the rest wait for a selection.
|
||||
let mut ranges = vec![];
|
||||
for (id, range) in targets {
|
||||
let text = text_ref(s, id)?.plain_text();
|
||||
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
|
||||
let (a, b) = match step {
|
||||
_ if a < b => (a, b),
|
||||
Step::Kerning => (edit::prev_char(&text, a), a),
|
||||
Step::Leading => {
|
||||
let para = edit::paragraph_at(&text, a);
|
||||
(para.start, para.end)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if a < b {
|
||||
ranges.push((id, a, b));
|
||||
}
|
||||
}
|
||||
if !ranges.is_empty() {
|
||||
s.edit("Character", |d, _| {
|
||||
for &(id, a, b) in &ranges {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
edit::style_range(&mut t.runs, a, b, |st| match step {
|
||||
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
|
||||
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
|
||||
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
|
||||
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
|
||||
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
|
||||
});
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "ids": ranges.iter().map(|(id, ..)| id.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Tracking (1/1000 em) that makes the first paragraph of `t` exactly `target` wide, if it fits
|
||||
/// on one line at all.
|
||||
fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
|
||||
@@ -238,9 +238,11 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
for r in &mut t.runs {
|
||||
let st = &mut r.style;
|
||||
if let Some(f) = &font {
|
||||
@@ -265,6 +267,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.features = f.clone();
|
||||
}
|
||||
}
|
||||
if let Some(before) = before {
|
||||
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if let Some(j) = justify {
|
||||
t.para.justify = j;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! commands of the Edit menu (Find and Replace, Find Next, Paste without Formatting).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
@@ -388,36 +388,44 @@ fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = texts(s, p, "type.fillPlaceholder")?;
|
||||
s.edit("Fill with Placeholder Text", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
let text = match &t.kind {
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.bounds().unwrap_or_default();
|
||||
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
|
||||
let lines = (b.height() / style.effective_leading()).max(1.0);
|
||||
let want = (chars_per_line * lines) as usize;
|
||||
let mut out = String::new();
|
||||
let words: Vec<&str> = PLACEHOLDER.split(' ').collect();
|
||||
let mut i = 0;
|
||||
while out.len() < want && i < 10_000 {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(words[i % words.len()]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
|
||||
};
|
||||
t.runs = vec![TextRun { text, style }];
|
||||
refresh_bounds(t);
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
fill_with_placeholder(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// Replace `t`'s text with placeholder text in its first style: area type is filled to its frame,
|
||||
/// other type gets a sentence (Type › Fill with Placeholder Text, and new type with Preferences ›
|
||||
/// Type › Fill New Type Objects With Placeholder Text).
|
||||
pub(crate) fn fill_with_placeholder(t: &mut TextObject) {
|
||||
let style = t.first_style();
|
||||
let text = match &t.kind {
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.bounds().unwrap_or_default();
|
||||
let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0);
|
||||
let lines = (b.height() / style.effective_leading()).max(1.0);
|
||||
let want = (chars_per_line * lines) as usize;
|
||||
let mut out = String::new();
|
||||
for (i, w) in PLACEHOLDER.split(' ').cycle().enumerate() {
|
||||
if out.len() >= want || i >= 10_000 {
|
||||
break;
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(w);
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => PLACEHOLDER.split(". ").next().unwrap_or(PLACEHOLDER).to_string() + ".",
|
||||
};
|
||||
t.runs = vec![TextRun { text, style }];
|
||||
refresh_bounds(t);
|
||||
}
|
||||
|
||||
pub(crate) fn special_char(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"bullet" => "•",
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the unlocked art fully inside it); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
|
||||
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -374,7 +374,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
let st = s.doc()?;
|
||||
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let art = if move_art { st.doc.art_on_artboard(rect) } else { vec![] };
|
||||
let art = if move_art { st.doc.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] };
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Agent-friendly document summaries and view-models for panels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -98,6 +98,12 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
|
||||
}
|
||||
|
||||
pub fn document(s: &Session) -> Value {
|
||||
document_opts(s, SummaryOpts::default())
|
||||
}
|
||||
|
||||
/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and
|
||||
/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts.
|
||||
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
|
||||
let Some(st) = s.active() else { return Value::Null };
|
||||
let d = &st.doc;
|
||||
json!({
|
||||
@@ -109,7 +115,7 @@ pub fn document(s: &Session) -> Value {
|
||||
"colorMode": format!("{:?}", d.color_mode),
|
||||
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
|
||||
// Top of the stack first, like the Layers panel.
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts)).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
@@ -129,6 +135,69 @@ pub fn document(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// What `document.find` matches: every given filter must hit (AND). All matching is
|
||||
/// case-insensitive; `name` and `text` are substrings, `kind` is the exact panel label
|
||||
/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...).
|
||||
pub struct FindFilter {
|
||||
pub name: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
impl FindFilter {
|
||||
/// True when no effective filter was given (empty strings count as absent).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
[self.name.as_ref(), self.kind.as_ref(), self.text.as_ref()].iter().all(|f| f.is_none())
|
||||
}
|
||||
|
||||
fn hits(&self, n: &Node) -> bool {
|
||||
if let Some(needle) = &self.name
|
||||
&& !n.display_name().to_lowercase().contains(needle)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(kind) = &self.kind
|
||||
&& !n.kind_label().eq_ignore_ascii_case(kind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(needle) = &self.text {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
if !t.plain_text().to_lowercase().contains(needle) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Search the whole layer tree, top of the stack first like the Layers panel.
|
||||
/// Returns at most `limit` hits as `{id, name, kind, path}` (`path` is the ancestor
|
||||
/// chain, layer first) plus the total hit count, so a capped reply stays explicit.
|
||||
pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec<Value>, u64) {
|
||||
use std::sync::Arc;
|
||||
let mut out = Vec::new();
|
||||
let mut total = 0u64;
|
||||
let mut ancestors: Vec<u64> = Vec::new();
|
||||
fn walk(nodes: &[Arc<Node>], ancestors: &mut Vec<u64>, filter: &FindFilter, limit: u64, out: &mut Vec<Value>, total: &mut u64) {
|
||||
for n in nodes.iter().rev() {
|
||||
if filter.hits(n) {
|
||||
*total += 1;
|
||||
if (out.len() as u64) < limit {
|
||||
out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": ancestors}));
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
ancestors.push(n.id.0);
|
||||
walk(ch, ancestors, filter, limit, out, total);
|
||||
ancestors.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(&doc.layers, &mut ancestors, filter, limit, &mut out, &mut total);
|
||||
(out, total)
|
||||
}
|
||||
|
||||
/// Cap names as `stroke.set` takes them.
|
||||
pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")];
|
||||
/// Join names as `stroke.set` takes them.
|
||||
|
||||
@@ -293,7 +293,9 @@ fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
d.artboards.iter().all(|a| ok(a.rect)) && sel.objects.iter().all(|id| d.node(*id).and_then(|n| n.geometric_bounds()).is_none_or(ok))
|
||||
d.artboards.iter().all(|a| ok(a.rect))
|
||||
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
|
||||
&& sel.objects.iter().all(|id| d.node(*id).and_then(|n| n.geometric_bounds()).is_none_or(ok))
|
||||
}
|
||||
|
||||
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
|
||||
|
||||
@@ -414,6 +414,62 @@ fn document_node_summary_slices_with_depth_and_limit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.inspect` slices the layer tree with the same options; artboards and the
|
||||
/// rest always come whole, and the default output is unchanged.
|
||||
#[test]
|
||||
fn document_inspect_slices_the_layer_tree() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
|
||||
let full = s.execute("document.inspect", &json!({})).unwrap();
|
||||
assert!(full["layers"][0].get("childCount").is_none(), "{full}");
|
||||
let flat = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
|
||||
assert!(flat["layers"][0].get("children").is_none(), "{flat}");
|
||||
assert_eq!(flat["layers"][0]["childCount"], 1);
|
||||
assert_eq!(flat["artboards"], full["artboards"]);
|
||||
assert_eq!(flat["objects"], full["objects"]);
|
||||
let one = s.execute("document.inspect", &json!({"childLimit": 0})).unwrap();
|
||||
assert_eq!(one["layers"][0]["children"], json!([]));
|
||||
assert_eq!(one["layers"][0]["childCount"], 1);
|
||||
assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err());
|
||||
}
|
||||
|
||||
/// `document.find` searches names, kinds and type content across the whole tree and
|
||||
/// answers ids with ancestor paths plus the total, so capped replies stay explicit.
|
||||
#[test]
|
||||
fn document_find_searches_names_kinds_and_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
s.execute("object.setProps", &json!({"ids": [a], "name": "Hero Banner"})).unwrap();
|
||||
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut find = |p: Value| s.execute("document.find", &p).unwrap();
|
||||
|
||||
let v = find(json!({"name": "hero"}));
|
||||
assert_eq!(v["total"], 1);
|
||||
assert_eq!(v["matches"][0]["id"], a);
|
||||
assert_eq!(v["matches"][0]["path"], json!([1, group]));
|
||||
|
||||
let v = find(json!({"kind": "group"}));
|
||||
assert_eq!(v["total"], 1);
|
||||
assert_eq!(v["matches"][0]["id"], group);
|
||||
|
||||
// `limit: 0` counts without listing; a missing filter is rejected, not a full dump.
|
||||
let v = find(json!({"kind": "rectangle", "limit": 0}));
|
||||
assert_eq!(v["matches"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(v["total"], 2);
|
||||
assert!(s.execute("document.find", &json!({})).is_err());
|
||||
assert!(s.execute("document.find", &json!({"name": ""})).is_err());
|
||||
assert!(s.execute("document.find", &json!({"name": "hero", "limit": "many"})).is_err());
|
||||
// A filter of the wrong type is rejected, not dropped (dropping it would widen the search).
|
||||
assert!(s.execute("document.find", &json!({"kind": "group", "name": 5})).is_err());
|
||||
assert_eq!(s.execute("document.find", &json!({"kind": "nope"})).unwrap()["total"], 0);
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
|
||||
@@ -8,7 +8,9 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
// Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off).
|
||||
let mut s = Session::new();
|
||||
s.prefs.placeholder_text = false;
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
@@ -740,6 +740,99 @@ fn ruler_guides_and_lock() {
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
fn guides(s: &Session) -> Vec<(bool, f64)> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
|
||||
}
|
||||
|
||||
fn selected_guides(s: &Session) -> Vec<usize> {
|
||||
s.doc().unwrap().selection.guides.clone()
|
||||
}
|
||||
|
||||
/// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands.
|
||||
#[test]
|
||||
fn ruler_guides_select_move_and_delete() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
for (vertical, pos) in [(true, 100.0), (false, 50.0), (true, 300.0)] {
|
||||
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap();
|
||||
}
|
||||
sel(&mut s, &[a]);
|
||||
// Selecting guides deselects the art; toggle adds and removes.
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true}));
|
||||
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
|
||||
// A move takes each selected guide along its own axis, in one undo step.
|
||||
let n = undo_len(&s);
|
||||
s.execute("guide.move", &json!({"dx": 10, "dy": -5})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 110.0), (false, 45.0), (true, 300.0)]);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// Alt: copies, which are then the selection.
|
||||
s.execute("guide.move", &json!({"dx": 20, "copy": true})).unwrap();
|
||||
assert_eq!(guides(&s).len(), 5);
|
||||
assert_eq!(&guides(&s)[3..], [(true, 130.0), (false, 45.0)]);
|
||||
assert_eq!(selected_guides(&s), [3, 4]);
|
||||
// The arrow keys nudge them.
|
||||
s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "big": true})).unwrap();
|
||||
assert_eq!(guides(&s)[3], (true, 140.0));
|
||||
// Delete (edit.clear) deletes the selected guides; the others keep their places.
|
||||
s.execute("guide.select", &json!({"indexes": [1, 3]})).unwrap();
|
||||
assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 5);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(guides(&s), [(true, 110.0), (true, 300.0), (false, 45.0)]);
|
||||
assert!(selected_guides(&s).is_empty());
|
||||
assert!(s.doc().unwrap().doc.node(a).is_some(), "the art stays");
|
||||
// Removing one keeps the rest of the selection pointing at the same guides.
|
||||
s.execute("guide.select", &json!({"indexes": [1, 2]})).unwrap();
|
||||
assert_eq!(s.execute("guide.remove", &json!({"index": 0})).unwrap()["count"], 1);
|
||||
assert_eq!((guides(&s), selected_guides(&s)), (vec![(true, 300.0), (false, 45.0)], vec![0, 1]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![1, 2]));
|
||||
// Out of range positions are refused.
|
||||
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 1e300})).is_err());
|
||||
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": -1e300})).is_err());
|
||||
// Locking deselects them, and they can't be picked again until unlocked.
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
assert!(selected_guides(&s).is_empty());
|
||||
assert!(matches!(s.execute("guide.select", &json!({"indexes": [0]})), Err(EngineError::Disabled(..))));
|
||||
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// #414: the Selection tool picks a ruler guide over the art and drags it in one undo step; with
|
||||
/// guides hidden or locked the press goes to the art.
|
||||
#[test]
|
||||
fn selection_tool_drags_a_ruler_guide() {
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 150})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let drag = |s: &mut Session, v: ViewInfo, from: (f64, f64), to: (f64, f64)| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
|
||||
};
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, v, (151.0, 120.0), (171.0, 140.0));
|
||||
assert_eq!(guides(&s), [(true, 170.0)]);
|
||||
assert_eq!(selected_guides(&s), [0]);
|
||||
assert!(selected(&s).is_empty());
|
||||
assert_eq!(bounds(&s, a).x0, 100.0, "the art stays");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move Guide");
|
||||
// Hidden guides: the press takes the art.
|
||||
let hidden = ViewInfo { guides: false, ..v };
|
||||
drag(&mut s, hidden, (170.0, 120.0), (180.0, 120.0));
|
||||
assert_eq!((guides(&s), bounds(&s, a).x0), (vec![(true, 170.0)], 110.0));
|
||||
// Locked guides too.
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
drag(&mut s, v, (170.0, 120.0), (180.0, 120.0));
|
||||
assert_eq!((guides(&s), bounds(&s, a).x0), (vec![(true, 170.0)], 120.0));
|
||||
}
|
||||
|
||||
// ---------- File ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`.
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate};
|
||||
@@ -227,3 +227,261 @@ fn use_precise_cursors_makes_drawing_cursors_crosshairs() {
|
||||
assert_eq!(cursor(&mut s, "pen"), Cursor::Crosshair);
|
||||
assert_eq!(cursor(&mut s, "selection"), Cursor::Arrow);
|
||||
}
|
||||
|
||||
// ---------- #394, second batch ----------
|
||||
|
||||
fn new_doc() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn square(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn set_pref(s: &mut Session, key: &str, value: Value) {
|
||||
s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
fn gesture(s: &mut Session, v: ViewInfo, events: &[(vectorcraft_tools::PointerKind, f64, f64)]) {
|
||||
for (k, x, y) in events {
|
||||
s.pointer(&vectorcraft_tools::PointerEvent::new(*k, *x, *y), v).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn top_left(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
/// View › Snap to Point with Selection & Anchor Display › Snap to Point's distance (#394): with
|
||||
/// Smart Guides off, the point a selection is dragged by lands on an anchor within 2 px.
|
||||
#[test]
|
||||
fn snap_to_point_lands_a_dragged_selection_on_anchors() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let mut s = new_doc();
|
||||
let a = square(&mut s, 100.0, 100.0);
|
||||
square(&mut s, 300.0, 100.0);
|
||||
let v = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
// Grabbed by its centre and dropped 1.5, 1 px from the other square's top-left anchor.
|
||||
let drag = |s: &mut Session, v: ViewInfo| {
|
||||
gesture(s, v, &[(Down, 125.0, 125.0), (Drag, 200.0, 110.0), (Drag, 301.5, 101.0), (Up, 301.5, 101.0)]);
|
||||
let at = top_left(s, a);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
at
|
||||
};
|
||||
assert_eq!(drag(&mut s, v), (275.0, 75.0), "the centre lands on the anchor");
|
||||
set_pref(&mut s, "snapToPointTolerance", json!(1));
|
||||
assert_eq!(drag(&mut s, v), (276.5, 76.0), "1.8 px is beyond 1 px");
|
||||
set_pref(&mut s, "snapToPointTolerance", json!(2));
|
||||
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
|
||||
}
|
||||
|
||||
/// Enable Rubber Band for Pen Tool / Curvature Tool (#394): off, no segment follows the pointer.
|
||||
#[test]
|
||||
fn rubber_band_preferences_hide_the_preview_to_the_pointer() {
|
||||
use vectorcraft_tools::Overlay;
|
||||
use vectorcraft_tools::PointerKind::{Down, Move, Up};
|
||||
let v = ViewInfo::default();
|
||||
for (tool, key) in [("pen", "penRubberBand"), ("curvature", "curvatureRubberBand")] {
|
||||
let mut s = new_doc();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 50.0, 50.0), (Up, 50.0, 50.0), (Down, 150.0, 50.0), (Up, 150.0, 50.0), (Move, 200.0, 150.0)]);
|
||||
let bands = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, Overlay::Path { .. })).count();
|
||||
assert_eq!(bands(&mut s), 1, "{tool}: the rubber band");
|
||||
set_pref(&mut s, key, json!(false));
|
||||
assert_eq!(bands(&mut s), 0, "{tool}: off");
|
||||
}
|
||||
}
|
||||
|
||||
/// Move Locked and Hidden Artwork with Artboard (#394): off, an artboard moved with its art
|
||||
/// leaves locked and hidden objects where they are; on, they move too.
|
||||
#[test]
|
||||
fn move_locked_and_hidden_artwork_with_artboard() {
|
||||
let mut s = new_doc();
|
||||
let (free, locked, hidden) = (square(&mut s, 10.0, 10.0), square(&mut s, 100.0, 10.0), square(&mut s, 200.0, 10.0));
|
||||
for (id, cmd) in [(locked, "object.lock"), (hidden, "object.hide")] {
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
}
|
||||
let moved = |s: &mut Session| {
|
||||
s.execute("artboard.move", &json!({"index": 0, "dx": 30, "dy": 0, "moveArt": true})).unwrap();
|
||||
let xs = [free, locked, hidden].map(|id| top_left(s, id).0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
xs
|
||||
};
|
||||
assert_eq!(moved(&mut s), [40.0, 100.0, 200.0]);
|
||||
set_pref(&mut s, "moveLockedWithArtboard", json!(true));
|
||||
assert_eq!(moved(&mut s), [40.0, 130.0, 230.0]);
|
||||
}
|
||||
|
||||
/// General › Transform Pattern Tiles (#394) is what transforms do with pattern fills unless their
|
||||
/// `patterns` param says otherwise (the dialogs' Transform Patterns).
|
||||
#[test]
|
||||
fn transform_pattern_tiles_is_the_transforms_default() {
|
||||
use vectorcraft_color::Paint;
|
||||
let mut s = new_doc();
|
||||
let tile = square(&mut s, 0.0, 0.0);
|
||||
s.execute("select.set", &json!({"ids": [tile.0]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
let big = square(&mut s, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [big.0]})).unwrap();
|
||||
let tiles = |s: &Session| match s.doc().unwrap().doc.node(big).unwrap().appearance.fill_paint() {
|
||||
Paint::Pattern { xf, .. } => xf.as_coeffs(),
|
||||
p => panic!("not a pattern: {p:?}"),
|
||||
};
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "off: the tiles stay");
|
||||
set_pref(&mut s, "transformPatternTiles", json!(true));
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "on: they move with the art");
|
||||
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "the journal keeps the choice");
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0], "patterns": false})).unwrap();
|
||||
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "the param wins");
|
||||
s.execute("object.scale", &json!({"sx": 50, "origin": [0, 0]})).unwrap();
|
||||
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
|
||||
}
|
||||
|
||||
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
|
||||
/// swatch; on, only the same tint.
|
||||
#[test]
|
||||
fn select_same_tint_percent() {
|
||||
let mut s = new_doc();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
|
||||
// Before the others: new art takes the last fill applied.
|
||||
square(&mut s, 300.0, 10.0);
|
||||
let ids: Vec<NodeId> = [(10.0, 40), (100.0, 40), (200.0, 80)]
|
||||
.into_iter()
|
||||
.map(|(x, tint)| {
|
||||
let id = square(&mut s, x, 10.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "swatch": "Ink", "tint": tint})).unwrap();
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
let same = |s: &mut Session| {
|
||||
s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap();
|
||||
s.execute("select.same.fillColor", &json!({})).unwrap();
|
||||
s.doc().unwrap().selection.objects.len()
|
||||
};
|
||||
assert_eq!(same(&mut s), 3, "every tint of Ink");
|
||||
set_pref(&mut s, "selectSameTintPercent", json!(true));
|
||||
assert_eq!(same(&mut s), 2, "only Ink 40%");
|
||||
}
|
||||
|
||||
fn style_at(s: &Session, id: NodeId, byte: usize) -> vectorcraft_doc::CharStyle {
|
||||
let Some(vectorcraft_doc::NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
|
||||
let mut at = 0;
|
||||
for r in &t.runs {
|
||||
if byte < at + r.text.len() {
|
||||
return r.style.clone();
|
||||
}
|
||||
at += r.text.len();
|
||||
}
|
||||
panic!("byte {byte} past the text")
|
||||
}
|
||||
|
||||
/// Type › Size/Leading, Tracking and Baseline Shift (#394): the increments `type.step` and the
|
||||
/// font size shortcuts step by, on selected type objects and on the Type tool's selected text
|
||||
/// (Alt+arrows; Cmd/Ctrl too: five steps).
|
||||
#[test]
|
||||
fn type_increments_step_the_type() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Up};
|
||||
use vectorcraft_tools::{Mods, ToolKey};
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("type.size.increase", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).size, 22.0, "Size/Leading is 2 pt");
|
||||
set_pref(&mut s, "typeSizeIncrement", json!(3));
|
||||
s.execute("type.size.decrease", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 0).size, 19.0);
|
||||
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
|
||||
assert!((style_at(&s, id, 0).leading.unwrap() - (19.0 * 1.2 + 3.0)).abs() < 1e-9, "from Auto");
|
||||
set_pref(&mut s, "baselineShiftIncrement", json!(1.5));
|
||||
s.execute("type.step", &json!({"attribute": "baselineShift", "by": -2})).unwrap();
|
||||
assert_eq!(style_at(&s, id, 10).baseline_shift, -3.0);
|
||||
// The Type tool: Alt+→ tracks the selected text by Tracking (20/1000 em), Cmd-Alt five times.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
|
||||
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let select = |s: &mut Session, a: usize, b: usize| s.set_tool_option("select", &json!({"start": a, "end": b}));
|
||||
select(&mut s, 0, 5);
|
||||
s.tool_key(ToolKey::Right, alt, v).unwrap();
|
||||
assert_eq!((style_at(&s, id, 0).tracking, style_at(&s, id, 6).tracking), (20.0, 0.0));
|
||||
s.tool_key(ToolKey::Left, Mods { cmd: true, ..alt }, v).unwrap();
|
||||
assert_eq!(style_at(&s, id, 4).tracking, -80.0);
|
||||
// At a caret: Alt+← kerns the character before it, Alt+↓ opens up the paragraph's leading.
|
||||
select(&mut s, 3, 3);
|
||||
s.tool_key(ToolKey::Left, alt, v).unwrap();
|
||||
assert_eq!((style_at(&s, id, 2).kerning, style_at(&s, id, 3).kerning), (Some(-20.0), None));
|
||||
let leading = style_at(&s, id, 0).leading.unwrap();
|
||||
s.tool_key(ToolKey::Down, alt, v).unwrap();
|
||||
assert_eq!(style_at(&s, id, 8).leading, Some(leading + 3.0));
|
||||
// Alt+Shift+↑ raises the selected text's baseline.
|
||||
select(&mut s, 6, 11);
|
||||
s.tool_key(ToolKey::Up, Mods { shift: true, ..alt }, v).unwrap();
|
||||
assert_eq!((style_at(&s, id, 0).baseline_shift, style_at(&s, id, 6).baseline_shift), (-3.0, -1.5));
|
||||
// The font size shortcut steps the selected text while the tool edits.
|
||||
s.execute("type.size.increase", &json!({})).unwrap();
|
||||
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (19.0, 22.0));
|
||||
}
|
||||
|
||||
/// Type › Fill New Type Objects With Placeholder Text (#394, on by default): type the Type tool
|
||||
/// places starts with placeholder text, selected, so typing replaces it; off, it starts empty.
|
||||
#[test]
|
||||
fn new_type_starts_with_placeholder_text() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let v = ViewInfo::default();
|
||||
let mut s = new_doc();
|
||||
s.select_tool("type", v).unwrap();
|
||||
let text = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
match &st.doc.node(st.selection.objects[0]).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
};
|
||||
gesture(&mut s, v, &[(Down, 50.0, 50.0), (Up, 50.0, 50.0)]);
|
||||
let placeholder = text(&s);
|
||||
assert!(placeholder.len() > 10, "{placeholder}");
|
||||
assert_eq!((s.tool_options()["start"].clone(), s.tool_options()["end"].clone()), (json!(0), json!(placeholder.len())), "selected");
|
||||
s.tool_text("Hi", v).unwrap();
|
||||
assert_eq!(text(&s), "Hi");
|
||||
// Area type is filled to its frame.
|
||||
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
|
||||
assert!(text(&s).len() > placeholder.len());
|
||||
set_pref(&mut s, "placeholderText", json!(false));
|
||||
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
/// Type › Enable Missing Glyph Protection (#394, on by default): characters a new font has no
|
||||
/// glyph for keep the font that has one; off, they take the new font.
|
||||
#[test]
|
||||
fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() {
|
||||
let (from, to, c) = ("Source Sans 3", "JetBrains Mono", '\u{180}');
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let covers = |f: &str| db.face(f, "Regular").is_some_and(|face| face.covers(c));
|
||||
if !covers(from) || covers(to) {
|
||||
return; // Installed fonts of these names cover other characters.
|
||||
}
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": from})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let fonts = |s: &Session| [0, 1, 3].map(|b| style_at(s, id, b).font_family);
|
||||
s.execute("text.setStyle", &json!({"font": to})).unwrap();
|
||||
assert_eq!(fonts(&s), [to, from, to], "the character keeps its font");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 4, "font": to})).unwrap();
|
||||
assert_eq!(fonts(&s), [from, from, to]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_pref(&mut s, "missingGlyphProtection", json!(false));
|
||||
s.execute("text.setStyle", &json!({"font": to})).unwrap();
|
||||
assert_eq!(fonts(&s), [to, to, to]);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
// Type placed by the Type tools starts empty (Fill New Type Objects With Placeholder Text off).
|
||||
let mut s = Session::new();
|
||||
s.prefs.placeholder_text = false;
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ViewInfo {
|
||||
pub zoom: f64,
|
||||
pub outline: bool,
|
||||
pub smart_guides: bool,
|
||||
/// View → Show Guides: the ruler guides show (and, unlocked, can be picked).
|
||||
pub guides: bool,
|
||||
pub snap_to_grid: bool,
|
||||
pub show_bbox: bool,
|
||||
/// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi).
|
||||
@@ -31,6 +33,7 @@ impl Default for ViewInfo {
|
||||
zoom: 1.0,
|
||||
outline: false,
|
||||
smart_guides: true,
|
||||
guides: true,
|
||||
snap_to_grid: false,
|
||||
show_bbox: true,
|
||||
snap_to_pixel: false,
|
||||
@@ -134,6 +137,7 @@ impl Session {
|
||||
paint: &self.paint,
|
||||
outline: view.outline,
|
||||
smart_guides: view.smart_guides,
|
||||
guides: view.guides && !self.menu.guides_locked,
|
||||
snap_to_grid: view.snap_to_grid,
|
||||
show_bbox: view.show_bbox,
|
||||
snap_to_pixel: view.snap_to_pixel,
|
||||
@@ -157,6 +161,13 @@ impl Session {
|
||||
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,
|
||||
snap_tolerance: self.prefs.snap_to_point_tolerance,
|
||||
handles_multiple: self.prefs.show_handles_multiple_anchors,
|
||||
corner_widget_max_angle: self.prefs.hide_corner_widget_above,
|
||||
move_locked_with_artboard: self.prefs.move_locked_with_artboard,
|
||||
pen_rubber_band: self.prefs.pen_rubber_band,
|
||||
curvature_rubber_band: self.prefs.curvature_rubber_band,
|
||||
placeholder_text: self.prefs.placeholder_text,
|
||||
screen: view.screen,
|
||||
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
|
||||
};
|
||||
|
||||
@@ -271,7 +271,7 @@ impl Backend for Headless {
|
||||
self.exec(&id, ¶ms)
|
||||
}
|
||||
"engine.commands" => Ok(self.commands()),
|
||||
"document.inspect" => self.exec("document.inspect", &json!({})),
|
||||
"document.inspect" => self.exec("document.inspect", p),
|
||||
"document.node" => self.exec("document.node", p),
|
||||
"document.json" => self.exec("document.json", &json!({})),
|
||||
"ui.tool.select" => self.select_tool(p),
|
||||
|
||||
@@ -175,6 +175,15 @@ fn headless_end_to_end() {
|
||||
assert_eq!(rect["fill"], "#ff0000", "{rect}");
|
||||
assert_eq!(rect["stroke"], "None");
|
||||
|
||||
// A depth-0 inspect is the skeleton: top layers with counts, nothing below them;
|
||||
// a bad slice is a tool error, not a full dump.
|
||||
let r = call(&mut s, 41, "inspect_document", json!({"depth": 0}));
|
||||
let skel: Value = serde_json::from_str(&text_of(&r)).unwrap();
|
||||
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
|
||||
assert_eq!(skel["layers"][0]["childCount"], layer["children"].as_array().unwrap().len());
|
||||
assert_eq!(skel["artboards"], doc["artboards"]);
|
||||
assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true);
|
||||
|
||||
// Screenshot returns image content (base64 PNG) and text.
|
||||
let shot_path = tmp("shot.png");
|
||||
let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()}));
|
||||
@@ -529,3 +538,29 @@ fn selection_preferences_apply_to_pointer_gestures() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
|
||||
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
|
||||
#[test]
|
||||
fn type_preferences_apply_to_agents() {
|
||||
let mut s = server();
|
||||
let events = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "up", "x": 100, "y": 100}]);
|
||||
let r = call(&mut s, 1, "pointer_gesture", json!({"tool": "type", "events": events}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].as_u64().unwrap();
|
||||
let style = |s: &mut Server, n: u64| {
|
||||
let r = call(s, n, "run_command", json!({"command": "text.getRange", "params": {"id": id}}));
|
||||
let v = serde_json::from_str::<Value>(&text_of(&r)).unwrap();
|
||||
assert!(v["text"].as_str().unwrap().len() > 10, "placeholder text: {v}");
|
||||
let st = &v["runs"][0]["style"];
|
||||
(st["size"].as_f64().unwrap(), st["tracking"].as_f64().unwrap())
|
||||
};
|
||||
let (size, tracking) = style(&mut s, 2);
|
||||
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.set", "params": {"values": {"typeSizeIncrement": 4, "trackingIncrement": 50}}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 4, "press_key", json!({"key": "Right", "mods": {"alt": true}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 5, "press_key", json!({"key": ".", "mods": {"cmd": true, "shift": true}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
|
||||
}
|
||||
+14
-3
@@ -100,8 +100,14 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"inspect_document",
|
||||
"Inspect document",
|
||||
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint.",
|
||||
empty(),
|
||||
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node.",
|
||||
obj(
|
||||
json!({
|
||||
"depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"},
|
||||
"childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
@@ -683,7 +689,12 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
|
||||
let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" };
|
||||
j(b.call(method, json!({"command": cmd, "params": params}))?)
|
||||
}
|
||||
"inspect_document" => j(b.call("document.inspect", json!({}))?),
|
||||
"inspect_document" => {
|
||||
// Only the slicing options reach the command, which validates them.
|
||||
let slice: Map<String, Value> =
|
||||
a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
j(b.call("document.inspect", Value::Object(slice))?)
|
||||
}
|
||||
"inspect_ui" => {
|
||||
need_ui(b, name)?;
|
||||
j(b.call("ui.inspect", json!({}))?)
|
||||
|
||||
@@ -281,7 +281,10 @@ fn is_japanese_letter(c: char) -> bool {
|
||||
return true;
|
||||
}
|
||||
is_cjk(c)
|
||||
&& !matches!(c as u32, 0x3000..=0x303F | 0xAC00..=0xD7AF | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65)
|
||||
&& !matches!(
|
||||
c as u32,
|
||||
0x3000..=0x303F | 0x3130..=0x318F | 0xAC00..=0xD7AF | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 | 0xFFA0..=0xFFDC
|
||||
)
|
||||
&& c != '・'
|
||||
&& punct(c).is_none()
|
||||
}
|
||||
@@ -818,7 +821,8 @@ fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_p
|
||||
for c in ['あ', 'カ', '漢', '々', '〆', '〇', 'A'] {
|
||||
assert!(is_japanese_letter(c), "{c}");
|
||||
}
|
||||
for c in ['한', '。', '「', '・', '、', 'a'] {
|
||||
// Hangul in every form: syllables, compatibility jamo (ㄱ ㅏ), half-width jamo (ᄀ).
|
||||
for c in ['한', 'ㄱ', 'ㅏ', '\u{FFA1}', '。', '「', '・', '、', 'a'] {
|
||||
assert!(!is_japanese_letter(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,27 @@ impl CornerWidgets {
|
||||
Some(Self { id, w, h, radii, kinds, xf, points, shown: live.picked_corners(selection.partial(id)) })
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
|
||||
/// corners wider than `max` degrees (a skewed rectangle's obtuse ones) hide; none if all do.
|
||||
pub fn within_angle(mut self, max: f64) -> Option<Self> {
|
||||
// The angle between the shape's sides where they meet at the top-left corner; the
|
||||
// top-right and bottom-left corners are its supplement.
|
||||
let c = self.xf.as_coeffs();
|
||||
let (u, v) = (Vec2::new(c[0], c[1]), Vec2::new(c[2], c[3]));
|
||||
let a = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
|
||||
for (k, shown) in self.shown.iter_mut().enumerate() {
|
||||
let angle = if k % 2 == 0 { a } else { 180.0 - a };
|
||||
*shown &= angle <= max + 1e-9;
|
||||
}
|
||||
self.shown.contains(&true).then_some(self)
|
||||
}
|
||||
|
||||
/// The widgets the active tool can drag (View → Show Corner Widget on).
|
||||
pub fn for_tool(cx: &ToolContext) -> Option<Self> {
|
||||
if !cx.corner_widgets {
|
||||
return None;
|
||||
}
|
||||
Self::of(cx.doc, cx.selection, cx.zoom)
|
||||
Self::of(cx.doc, cx.selection, cx.zoom)?.within_angle(cx.corner_widget_max_angle)
|
||||
}
|
||||
|
||||
/// The centres of the widgets that show.
|
||||
@@ -318,6 +333,27 @@ mod tests {
|
||||
assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below
|
||||
/// 90°; sheared, only its acute corners keep their widgets.
|
||||
#[test]
|
||||
fn corners_wider_than_the_preference_hide_their_widgets() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.within_angle(177.0).map(|w| w.shown), Some([true; 4]));
|
||||
assert_eq!(w.within_angle(90.0).map(|w| w.shown), Some([true; 4]));
|
||||
assert!(w.within_angle(89.0).is_none());
|
||||
// Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others.
|
||||
let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]);
|
||||
let (d, id) = live_rect(0.0, shear);
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.within_angle(100.0).map(|w| w.shown), Some([true, false, true, false]));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) };
|
||||
assert!(over_widget(&c, w.points[0]));
|
||||
assert!(!over_widget(&c, w.points[1]), "the 120° corner's widget is hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_off_or_elsewhere_keep_the_tools_behaviour() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
|
||||
//! the Corners dialog).
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
|
||||
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
|
||||
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
|
||||
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
|
||||
//! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`):
|
||||
//! the text reflows at its size.
|
||||
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
|
||||
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||||
//!
|
||||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||||
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
|
||||
@@ -27,6 +27,7 @@ use vectorcraft_geom::{PathData, Point, Rect};
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::select::{is_area_type, matrix_json};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
@@ -97,11 +98,12 @@ pub struct DirectSelectionTool {
|
||||
guides: Vec<Overlay>,
|
||||
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
|
||||
hover: Option<(NodeId, Point)>,
|
||||
guide: GuideEdit,
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
pub fn new(group: bool) -> Self {
|
||||
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![], hover: None }
|
||||
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![], hover: None, guide: GuideEdit::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +189,17 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<A
|
||||
})
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, handles show
|
||||
/// (and drag) only while a single anchor is selected.
|
||||
pub fn handles_shown(selection: &vectorcraft_doc::Selection, multiple: bool) -> bool {
|
||||
multiple || selection.anchors.values().map(|set| set.len()).sum::<usize>() <= 1
|
||||
}
|
||||
|
||||
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
|
||||
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
|
||||
if !handles_shown(cx.selection, cx.handles_multiple) {
|
||||
return None;
|
||||
}
|
||||
for (id, set) in &cx.selection.anchors {
|
||||
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
for &(si, ai) in set {
|
||||
@@ -234,13 +245,20 @@ impl Tool for DirectSelectionTool {
|
||||
if self.group { "groupSelection" } else { "directSelection" }
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle)
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
let p = ev.pos;
|
||||
let tol = cx.pick_tol();
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::Down, _) if self.group => {
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
|
||||
return vec![];
|
||||
@@ -309,6 +327,10 @@ impl Tool for DirectSelectionTool {
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
// A segment of a path that isn't selected as a whole: its two anchors get
|
||||
// selected, and dragging it reshapes it if it's curved, else moves it. Alt picks
|
||||
@@ -501,6 +523,7 @@ impl Tool for DirectSelectionTool {
|
||||
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.extend(self.guide.overlays(cx));
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -509,7 +532,8 @@ impl Tool for DirectSelectionTool {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
Cursor::ArrowHollow
|
||||
// A press on the highlighted anchor picks it, not the guide.
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,6 +594,26 @@ mod tests {
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
/// #414: a ruler guide is picked over the art, but an anchor on it is picked first.
|
||||
#[test]
|
||||
fn direct_and_group_selection_pick_ruler_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide { vertical: true, pos: 100.0 });
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y));
|
||||
let pick = vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))];
|
||||
for group in [false, true] {
|
||||
let mut t = DirectSelectionTool::new(group);
|
||||
assert_eq!(down(&mut t, 150.0), pick, "on the rect's edge (group: {group})");
|
||||
assert!(t.busy());
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0));
|
||||
assert_eq!(t.cursor(&c, Point::new(101.0, 300.0), Mods::default()), Cursor::ResizeH);
|
||||
}
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]);
|
||||
}
|
||||
|
||||
/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected.
|
||||
fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Each click adds a point and the path curves smoothly through all points. Alt-click or
|
||||
//! double-click a point toggles it between smooth and corner; drag a point to move it; click the
|
||||
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
|
||||
//! A rubber band shows the curve to the cursor.
|
||||
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
@@ -167,7 +167,8 @@ impl Tool for CurvatureTool {
|
||||
return vec![];
|
||||
}
|
||||
let mut o = vec![];
|
||||
if !self.closed
|
||||
if cx.curvature_rubber_band
|
||||
&& !self.closed
|
||||
&& self.drag.is_none()
|
||||
&& let Some(h) = self.hover
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ enum Kind {
|
||||
Edge,
|
||||
Artboard,
|
||||
Bleed,
|
||||
Guide,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -27,6 +28,7 @@ impl Kind {
|
||||
Kind::Edge => "path",
|
||||
Kind::Artboard => "artboard",
|
||||
Kind::Bleed => "bleed",
|
||||
Kind::Guide => "guide",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +97,27 @@ impl Targets {
|
||||
t
|
||||
}
|
||||
|
||||
/// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and
|
||||
/// unlocked, as the selection tools pick them) pull the pointer into line.
|
||||
fn for_snap_to_point(mut self, cx: &ToolContext) -> Self {
|
||||
self.points.retain(|(_, k)| *k == Kind::Anchor);
|
||||
self.xs.clear();
|
||||
self.ys.clear();
|
||||
if cx.guides {
|
||||
for g in &cx.doc.guides {
|
||||
let (lines, at) = if g.vertical { (&mut self.xs, Point::new(g.pos, 0.0)) } else { (&mut self.ys, Point::new(0.0, g.pos)) };
|
||||
lines.push((g.pos, at, Kind::Guide));
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
|
||||
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
|
||||
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
|
||||
(cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx))
|
||||
}
|
||||
|
||||
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
|
||||
/// reach into `near`.
|
||||
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
|
||||
@@ -142,6 +165,23 @@ impl Targets {
|
||||
(out, ov)
|
||||
}
|
||||
|
||||
/// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line
|
||||
/// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there.
|
||||
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
|
||||
let along = |q: &Point| if vertical { q.x } else { q.y };
|
||||
let lines = if vertical { &self.xs } else { &self.ys };
|
||||
let best = lines
|
||||
.iter()
|
||||
.map(|(t, from, k)| (*t, *from, *k))
|
||||
.chain(self.points.iter().map(|(q, k)| (along(q), *q, *k)))
|
||||
.filter(|(t, ..)| (t - v).abs() <= tol)
|
||||
.min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()));
|
||||
match best {
|
||||
Some((t, from, k)) => (t, vec![Overlay::Label { p: from, text: k.label().into(), color: MAGENTA }]),
|
||||
None => (v, vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a dragged point that carries others along at `offsets` (a resize handle and the
|
||||
/// bleed edge beyond it): onto an anchor or centre near the point itself, otherwise into line
|
||||
/// with targets, each axis on whichever of them comes nearest.
|
||||
@@ -292,19 +332,22 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
|
||||
snap_with(cx, p, || Targets::collect(cx.doc, exclude, None))
|
||||
}
|
||||
|
||||
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`.
|
||||
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`, else (Snap
|
||||
/// to Point) to their anchors and the ruler guides within the Snap to Point distance.
|
||||
fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec<Overlay>) {
|
||||
if cx.snap_to_pixel {
|
||||
return (Point::new(p.x.round(), p.y.round()), vec![]);
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
let s = cx.doc.grid.spacing / cx.doc.grid.subdivisions.max(1) as f64;
|
||||
return (vectorcraft_geom::snap::snap_point_to_grid(p, s), vec![]);
|
||||
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
|
||||
}
|
||||
if !cx.smart_guides {
|
||||
return (p, vec![]);
|
||||
if cx.smart_guides {
|
||||
return targets().snap_point(p, cx.tol(5.0));
|
||||
}
|
||||
targets().snap_point(p, cx.tol(5.0))
|
||||
if cx.snap_to_point {
|
||||
return targets().for_snap_to_point(cx).snap_point(p, cx.tol(cx.snap_tolerance));
|
||||
}
|
||||
(p, vec![])
|
||||
}
|
||||
|
||||
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
|
||||
@@ -334,7 +377,7 @@ pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
if !(cx.snap_to_point || cx.smart_guides) {
|
||||
return (p, vec![]);
|
||||
}
|
||||
let tol = cx.tol(5.0);
|
||||
let tol = cx.tol(if cx.smart_guides { 5.0 } else { cx.snap_tolerance });
|
||||
let mut roots = cx.selection.objects.clone();
|
||||
roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf));
|
||||
let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
|
||||
|
||||
+44
-1
@@ -23,6 +23,7 @@ pub mod params;
|
||||
pub mod pen;
|
||||
pub mod place;
|
||||
pub mod printtiling;
|
||||
pub mod rulerguide;
|
||||
pub mod select;
|
||||
pub mod settings;
|
||||
pub mod shape;
|
||||
@@ -201,6 +202,13 @@ impl ScreenFrame {
|
||||
pub fn at(&self, x: f64, y: f64) -> Point {
|
||||
self.origin + self.right * x + self.down * y
|
||||
}
|
||||
/// Where the document point `p` is, in screen pixels from the window's top-left corner (the
|
||||
/// inverse of [`Self::at`]); none for a degenerate frame.
|
||||
pub fn to_screen(&self, p: Point) -> Option<(f64, f64)> {
|
||||
let (r, d, v) = (self.right, self.down, p - self.origin);
|
||||
let det = r.x * d.y - d.x * r.y;
|
||||
(det.is_finite() && det.abs() > 1e-12).then(|| ((v.x * d.y - d.x * v.y) / det, (r.x * v.y - v.x * r.y) / det))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only context a tool sees.
|
||||
@@ -213,12 +221,16 @@ pub struct ToolContext<'a> {
|
||||
pub paint: &'a PaintDefaults,
|
||||
pub outline: bool,
|
||||
pub smart_guides: bool,
|
||||
/// View → Show Guides with Lock Guides off: the selection tools pick and drag ruler guides.
|
||||
pub guides: bool,
|
||||
pub snap_to_grid: bool,
|
||||
/// Bounding box shown (View → Show/Hide Bounding Box).
|
||||
pub show_bbox: bool,
|
||||
/// View → Snap to Pixel.
|
||||
pub snap_to_pixel: bool,
|
||||
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
|
||||
/// View → Snap to Point: with Smart Guides off, dragged selections, drawn points and picked
|
||||
/// points (a transform's reference point) land on anchors and ruler guides within
|
||||
/// [`Self::snap_tolerance`].
|
||||
pub snap_to_point: bool,
|
||||
/// View → Show Corner Widget: live rectangles show draggable Live Corners widgets.
|
||||
pub corner_widgets: bool,
|
||||
@@ -269,6 +281,25 @@ pub struct ToolContext<'a> {
|
||||
/// Selection & Anchor Display → Highlight anchors on mouse over: Direct Selection marks the
|
||||
/// anchor under the pointer.
|
||||
pub highlight_anchors: bool,
|
||||
/// Selection & Anchor Display → Snap to Point (screen pixels): how near an anchor or a ruler
|
||||
/// guide pulls the pointer while View → Snap to Point is on.
|
||||
pub snap_tolerance: f64,
|
||||
/// Selection & Anchor Display → Show handles when multiple anchors are selected: off, Direct
|
||||
/// Selection shows and drags handles only while a single anchor is selected.
|
||||
pub handles_multiple: bool,
|
||||
/// Selection & Anchor Display → Hide Corner Widget for angles greater than (degrees): corners
|
||||
/// wider than this show no Live Corners widget.
|
||||
pub corner_widget_max_angle: f64,
|
||||
/// Selection & Anchor Display → Move Locked and Hidden Artwork with Artboard.
|
||||
pub move_locked_with_artboard: bool,
|
||||
/// Selection & Anchor Display → Enable Rubber Band for Pen Tool: the Pen previews the next
|
||||
/// segment to the pointer.
|
||||
pub pen_rubber_band: bool,
|
||||
/// Selection & Anchor Display → Enable Rubber Band for Curvature Tool.
|
||||
pub curvature_rubber_band: bool,
|
||||
/// Type → Fill New Type Objects With Placeholder Text: type the Type tools place starts with
|
||||
/// placeholder text, selected.
|
||||
pub placeholder_text: 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.
|
||||
@@ -292,6 +323,10 @@ impl ToolContext<'_> {
|
||||
pub fn offset_label(&self, dx: f64, dy: f64) -> String {
|
||||
format!("dX: {}\ndY: {}", self.len(dx), self.len(dy))
|
||||
}
|
||||
/// What Snap to Grid snaps to: the grid's subdivisions (or its lines without any).
|
||||
pub fn grid_step(&self) -> f64 {
|
||||
self.doc.grid.spacing / self.doc.grid.subdivisions.max(1) as f64
|
||||
}
|
||||
/// The selection tolerance in document units ([`Self::selection_tolerance`]).
|
||||
pub fn pick_tol(&self) -> f64 {
|
||||
self.tol(self.selection_tolerance)
|
||||
@@ -547,6 +582,7 @@ pub(crate) mod testutil {
|
||||
paint: p,
|
||||
outline: false,
|
||||
smart_guides: true,
|
||||
guides: true,
|
||||
snap_to_grid: false,
|
||||
show_bbox: true,
|
||||
snap_to_pixel: false,
|
||||
@@ -570,6 +606,13 @@ pub(crate) mod testutil {
|
||||
double_click_isolate: true,
|
||||
select_behind: true,
|
||||
highlight_anchors: true,
|
||||
snap_tolerance: 2.0,
|
||||
handles_multiple: true,
|
||||
corner_widget_max_angle: 177.0,
|
||||
move_locked_with_artboard: false,
|
||||
pen_rubber_band: true,
|
||||
curvature_rubber_band: true,
|
||||
placeholder_text: false,
|
||||
screen: None,
|
||||
plane_widget: Some(Default::default()),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! Clicking the first anchor closes the path. Clicking the last one retracts its outgoing handle, so
|
||||
//! the next segment leaves it as a corner; dragging from it pulls a new one out on its own.
|
||||
//! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues
|
||||
//! it. The rubber-band preview shows the next segment. Auto Add/Delete: between paths, a click on a
|
||||
//! it. The rubber-band preview shows the next segment (Enable Rubber Band for Pen Tool). Auto Add/Delete: between paths, a click on a
|
||||
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
|
||||
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
|
||||
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
|
||||
@@ -201,7 +201,7 @@ impl Tool for PenTool {
|
||||
vec![]
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.drawing || self.drag.is_some() || self.handle.is_some() {
|
||||
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] };
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Ruler guides on the canvas, for the selection tools. A press on a guide selects it (Shift adds
|
||||
//! it to the selected guides or, released without a drag, takes it out); dragging moves the
|
||||
//! selected guides (Alt copies them). The dragged guide snaps: with Shift to the ruler's ticks,
|
||||
//! else as a drawn point does (whole pixels, the grid, or with Smart Guides the art's edges,
|
||||
//! centres and anchors). Dropped off the canvas onto its ruler, the guide is deleted. Hidden or
|
||||
//! locked guides (View › Guides) can't be picked.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::guides::Targets;
|
||||
use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext};
|
||||
|
||||
/// The ruler guide under `p`: the nearest within the selection tolerance (the newest of equals).
|
||||
pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
if !cx.guides {
|
||||
return None;
|
||||
}
|
||||
let tol = cx.pick_tol();
|
||||
let off = |pos: f64, vertical: bool| (if vertical { p.x } else { p.y } - pos).abs();
|
||||
cx.doc
|
||||
.guides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| (i, off(g.pos, g.vertical)))
|
||||
.filter(|(_, d)| *d <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0)))
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The pointer for a guide: it moves sideways if vertical, up and down if horizontal.
|
||||
fn guide_cursor(vertical: bool) -> Cursor {
|
||||
if vertical { Cursor::ResizeH } else { Cursor::ResizeV }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Drag {
|
||||
index: usize,
|
||||
vertical: bool,
|
||||
/// Where the guide was, and where it is now (x of a vertical guide, y of a horizontal one).
|
||||
from: f64,
|
||||
at: f64,
|
||||
start: Point,
|
||||
pointer: Point,
|
||||
began: bool,
|
||||
/// Shift-pressed on a selected guide: released without a drag, it leaves the selection.
|
||||
deselect: bool,
|
||||
}
|
||||
|
||||
/// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for
|
||||
/// the rest of the gesture.
|
||||
#[derive(Default)]
|
||||
pub struct GuideEdit {
|
||||
drag: Option<Drag>,
|
||||
/// Smart Guides targets, gathered when the drag starts.
|
||||
targets: Option<Targets>,
|
||||
/// The smart guide the dragged guide snapped to.
|
||||
snapped: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl GuideEdit {
|
||||
/// Is a guide pressed or being dragged?
|
||||
pub fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
/// A press `ev` on a ruler guide: it gets selected and the drag starts. None (and nothing
|
||||
/// done) when no guide is there.
|
||||
pub fn press(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
let index = guide_at(cx, ev.pos)?;
|
||||
let g = cx.doc.guides.get(index)?;
|
||||
let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&index));
|
||||
self.drag = Some(Drag {
|
||||
index,
|
||||
vertical: g.vertical,
|
||||
from: g.pos,
|
||||
at: g.pos,
|
||||
start: ev.pos,
|
||||
pointer: ev.pos,
|
||||
began: false,
|
||||
deselect: shift && selected,
|
||||
});
|
||||
self.targets = None;
|
||||
self.snapped.clear();
|
||||
Some(match (selected, shift) {
|
||||
(true, _) => vec![],
|
||||
(false, true) => vec![Action::Exec("guide.select".into(), json!({ "indexes": [index], "toggle": true }))],
|
||||
(false, false) => vec![Action::Exec("guide.select".into(), json!({ "indexes": [index] }))],
|
||||
})
|
||||
}
|
||||
|
||||
/// The drag and release of a guide [`Self::press`] picked; None when no guide is pressed.
|
||||
pub fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
let mut d = self.drag?;
|
||||
Some(match ev.kind {
|
||||
PointerKind::Drag => {
|
||||
let out = self.drag_to(cx, &mut d, ev);
|
||||
self.drag = Some(d);
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.drag = None;
|
||||
self.targets = None;
|
||||
self.snapped.clear();
|
||||
if d.began && on_ruler(cx, d.vertical, ev.pos) {
|
||||
// Dropped on its ruler: the guide goes (a copy is just not made).
|
||||
let mut out = vec![Action::Cancel];
|
||||
if !ev.mods.alt {
|
||||
out.push(Action::Exec("guide.remove".into(), json!({ "index": d.index })));
|
||||
}
|
||||
out
|
||||
} else if d.began {
|
||||
vec![Action::Commit]
|
||||
} else if d.deselect {
|
||||
vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
// Another press ends the gesture.
|
||||
_ => {
|
||||
self.drag = None;
|
||||
return None;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn drag_to(&mut self, cx: &ToolContext, d: &mut Drag, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
let mut out = vec![];
|
||||
if !d.began {
|
||||
if p.distance(d.start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
d.began = true;
|
||||
out.push(Action::Begin(if ev.mods.alt { "Copy Guide" } else { "Move Guide" }.into()));
|
||||
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &[], None));
|
||||
}
|
||||
let moved = p - d.start;
|
||||
let (at, snapped) = self.snap(cx, d.vertical, d.from + if d.vertical { moved.x } else { moved.y }, ev.mods.shift);
|
||||
(d.at, d.pointer, self.snapped) = (at, p, snapped);
|
||||
// The other selected guides follow the pointer: vertical ones across, horizontal ones down.
|
||||
let (dx, dy) = if d.vertical { (at - d.from, moved.y) } else { (moved.x, at - d.from) };
|
||||
out.push(Action::Preview("guide.move".into(), json!({ "dx": dx, "dy": dy, "copy": ev.mods.alt })));
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a guide dragged to `v` goes: with Shift on the nearest ruler tick, else on whole
|
||||
/// pixels, the grid or a smart guide (as a drawn point snaps).
|
||||
fn snap(&self, cx: &ToolContext, vertical: bool, v: f64, shift: bool) -> (f64, Vec<Overlay>) {
|
||||
if shift {
|
||||
return (cx.unit.snap_to_ruler_tick(v, cx.zoom), vec![]);
|
||||
}
|
||||
if cx.snap_to_pixel {
|
||||
return (v.round(), vec![]);
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return (vectorcraft_geom::snap::snap_to_grid(v, cx.grid_step(), 0.0), vec![]);
|
||||
}
|
||||
match &self.targets {
|
||||
Some(t) => t.snap_guide(vertical, v, cx.tol(5.0)),
|
||||
None => (v, vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer over a guide, or while one is dragged.
|
||||
pub fn cursor(&self, cx: &ToolContext, p: Point) -> Option<Cursor> {
|
||||
if let Some(d) = &self.drag {
|
||||
return Some(guide_cursor(d.vertical));
|
||||
}
|
||||
guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical))
|
||||
}
|
||||
|
||||
/// While a guide is dragged: the smart guide it snapped to and its position by the pointer.
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(d) = self.drag.filter(|d| d.began) else { return vec![] };
|
||||
let mut o = self.snapped.clone();
|
||||
let axis = if d.vertical { "X" } else { "Y" };
|
||||
o.push(Overlay::Measure { p: d.pointer, text: format!("{axis}: {}", cx.len(d.at)) });
|
||||
o
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` off the canvas on the side of the ruler a guide comes from (the top one for a
|
||||
/// horizontal guide, the left one for a vertical guide)? Never without a window.
|
||||
fn on_ruler(cx: &ToolContext, vertical: bool, p: Point) -> bool {
|
||||
cx.screen.and_then(|s| s.to_screen(p)).is_some_and(|(x, y)| if vertical { x < 0.0 } else { y < 0.0 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{Mods, ScreenFrame};
|
||||
use vectorcraft_doc::{Guide, Selection};
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y).with_mods(mods)
|
||||
}
|
||||
|
||||
fn guides_doc() -> vectorcraft_doc::Document {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides = vec![Guide { vertical: true, pos: 300.0 }, Guide { vertical: false, pos: 400.0 }];
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() {
|
||||
let (d, s, p) = (guides_doc(), Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_eq!(guide_at(&c, Point::new(302.0, 50.0)), Some(0));
|
||||
assert_eq!(guide_at(&c, Point::new(50.0, 398.0)), Some(1));
|
||||
// Where they cross, the nearer one.
|
||||
assert_eq!(guide_at(&c, Point::new(301.0, 398.0)), Some(0));
|
||||
assert_eq!(guide_at(&c, Point::new(305.0, 50.0)), None, "beyond the 3 px tolerance");
|
||||
let hidden = ToolContext { guides: false, ..cx(&d, &s, &p) };
|
||||
assert_eq!(guide_at(&hidden, Point::new(300.0, 50.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_moves_the_selected_guides_in_one_step() {
|
||||
let d = guides_doc();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut g = GuideEdit::default();
|
||||
let m = Mods::default();
|
||||
assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]));
|
||||
// The tool sees the guide selected from now on.
|
||||
let mut s = s;
|
||||
s.set_guides([0]);
|
||||
let c = cx(&d, &s, &p);
|
||||
let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap();
|
||||
assert_eq!(a[0], Action::Begin("Move Guide".into()));
|
||||
assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 40.0, "dy": 10.0, "copy": false})));
|
||||
assert!(matches!(g.overlays(&c).last(), Some(Overlay::Measure { text, .. }) if text == "X: 340.00 pt"));
|
||||
assert_eq!(g.cursor(&c, Point::ZERO), Some(Cursor::ResizeH));
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 60.0, m)), Some(vec![Action::Commit]));
|
||||
assert!(!g.busy() && g.pointer(&c, &ev(PointerKind::Drag, 0.0, 0.0, m)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() {
|
||||
let d = guides_doc();
|
||||
let mut s = Selection::default();
|
||||
s.set_guides([0]);
|
||||
let p = paint();
|
||||
let drag_to = |c: &ToolContext, x: f64, mods: Mods| {
|
||||
let mut g = GuideEdit::default();
|
||||
g.press(c, &ev(PointerKind::Down, 300.0, 50.0, Mods::default()));
|
||||
let a = g.pointer(c, &ev(PointerKind::Drag, x, 50.0, mods)).unwrap();
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
(300.0 + v["dx"].as_f64().unwrap(), g.overlays(c))
|
||||
};
|
||||
// Smart Guides: onto the rect's right edge (x = 200), labelled.
|
||||
let (x, ov) = drag_to(&cx(&d, &s, &p), 203.0, Mods::default());
|
||||
assert_eq!(x, 200.0);
|
||||
assert!(matches!(&ov[0], Overlay::Label { .. }));
|
||||
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
|
||||
assert_eq!(drag_to(&off, 203.0, Mods::default()).0, 203.0);
|
||||
// Shift: the ruler's ticks (labels every 50 pt at 100 %, ticks every 5 pt).
|
||||
assert_eq!(drag_to(&off, 223.0, Mods { shift: true, ..Default::default() }).0, 225.0);
|
||||
let zoomed = ToolContext { zoom: 5.0, ..cx(&d, &s, &p) };
|
||||
assert_eq!(drag_to(&zoomed, 223.4, Mods { shift: true, ..Default::default() }).0, 223.0, "every point at 500 %");
|
||||
let grid = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
assert_eq!(drag_to(&grid, 226.0, Mods::default()).0, 225.0, "the grid's 9 pt subdivisions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_and_alt_drag_copies() {
|
||||
let d = guides_doc();
|
||||
let mut s = Selection::default();
|
||||
s.set_guides([0, 1]);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() });
|
||||
let mut g = GuideEdit::default();
|
||||
assert_eq!(g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, shift)), Some(vec![]));
|
||||
let deselect = Action::Exec("guide.select".into(), json!({"indexes": [1], "toggle": true}));
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, 400.0, shift)), Some(vec![deselect]));
|
||||
g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, alt));
|
||||
let a = g.pointer(&c, &ev(PointerKind::Drag, 50.0, 420.0, alt)).unwrap();
|
||||
assert_eq!(a[0], Action::Begin("Copy Guide".into()));
|
||||
assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_on_its_ruler_the_guide_is_deleted() {
|
||||
let d = guides_doc();
|
||||
let p = paint();
|
||||
let mut s = Selection::default();
|
||||
s.set_guides([1]);
|
||||
// The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler.
|
||||
let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) };
|
||||
let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) };
|
||||
let m = Mods::default();
|
||||
let gesture = |to: Point, mods: Mods| {
|
||||
let mut g = GuideEdit::default();
|
||||
g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m));
|
||||
g.pointer(&c, &ev(PointerKind::Drag, to.x, to.y, mods));
|
||||
g.pointer(&c, &ev(PointerKind::Up, to.x, to.y, mods)).unwrap()
|
||||
};
|
||||
assert_eq!(gesture(Point::new(50.0, -6.0), m), vec![Action::Cancel, Action::Exec("guide.remove".into(), json!({"index": 1}))]);
|
||||
assert_eq!(gesture(Point::new(50.0, -6.0), Mods { alt: true, ..m }), vec![Action::Cancel], "a copy is just not made");
|
||||
// Off the left side: not its ruler.
|
||||
assert_eq!(gesture(Point::new(-6.0, 300.0), m), vec![Action::Commit]);
|
||||
// Headless (no window): no ruler to drop it on.
|
||||
let mut g = GuideEdit::default();
|
||||
let c = cx(&d, &s, &p);
|
||||
g.press(&c, &ev(PointerKind::Down, 50.0, 400.0, m));
|
||||
g.pointer(&c, &ev(PointerKind::Drag, 50.0, -6.0, m));
|
||||
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 50.0, -6.0, m)), Some(vec![Action::Commit]));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
|
||||
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains; Smart
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! 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 To
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind).
|
||||
//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]).
|
||||
//! 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.
|
||||
@@ -14,6 +16,7 @@ use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -53,7 +56,11 @@ pub struct SelectionTool {
|
||||
measure: Option<(Point, String)>,
|
||||
guides: Vec<Overlay>,
|
||||
targets: Option<crate::guides::Targets>,
|
||||
/// View → Snap to Point (Smart Guides off): the anchors and ruler guides the grabbed point
|
||||
/// lands on while moving.
|
||||
points: Option<crate::guides::Targets>,
|
||||
start_bounds: Option<Rect>,
|
||||
guide: GuideEdit,
|
||||
}
|
||||
|
||||
pub fn matrix_json(a: Affine) -> Value {
|
||||
@@ -101,10 +108,13 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle)
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
let p = ev.pos;
|
||||
let m = ev.mods;
|
||||
match (ev.kind, self.state.clone()) {
|
||||
@@ -113,6 +123,9 @@ impl Tool for SelectionTool {
|
||||
if let Some(a) = corners::double_click(cx, p) {
|
||||
return vec![a];
|
||||
}
|
||||
if crate::rulerguide::guide_at(cx, p).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
let top = h.top_object(cx.isolation);
|
||||
if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) {
|
||||
@@ -153,7 +166,12 @@ impl Tool for SelectionTool {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// 2. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling).
|
||||
// 2. Ruler guides (over the art, as they are drawn).
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
// 3. 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)
|
||||
@@ -198,8 +216,15 @@ impl Tool for SelectionTool {
|
||||
if !began {
|
||||
self.start_bounds = selection_bounds(cx);
|
||||
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
|
||||
self.points = crate::guides::Targets::snap_to_point(cx, &cx.selection.objects);
|
||||
}
|
||||
self.guides.clear();
|
||||
// Snap to Point: the point the selection was grabbed by lands on an anchor or guide.
|
||||
if let Some(t) = &self.points {
|
||||
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
|
||||
d = q - start;
|
||||
self.guides = ov;
|
||||
}
|
||||
if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) {
|
||||
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
|
||||
d += adj;
|
||||
@@ -255,6 +280,7 @@ impl Tool for SelectionTool {
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
self.points = None;
|
||||
match (began, deselect) {
|
||||
(true, _) => vec![Action::Commit],
|
||||
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
@@ -293,6 +319,7 @@ impl Tool for SelectionTool {
|
||||
_ => {}
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
o.extend(self.guide.overlays(cx));
|
||||
if let Some((p, t)) = &self.measure {
|
||||
o.push(Overlay::Measure { p: *p, text: t.clone() });
|
||||
}
|
||||
@@ -307,6 +334,9 @@ impl Tool for SelectionTool {
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
_ => {}
|
||||
}
|
||||
if self.guide.busy() {
|
||||
return self.guide.cursor(cx, p).unwrap_or_default();
|
||||
}
|
||||
if over_widget(cx, p) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
@@ -319,6 +349,9 @@ impl Tool for SelectionTool {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.guide.cursor(cx, p) {
|
||||
return c;
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
if cx.selection.contains(h.top_object(cx.isolation)) || m.alt {
|
||||
return Cursor::Move;
|
||||
@@ -378,6 +411,27 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
/// #414: a press on a ruler guide picks it over the art, and drags it.
|
||||
#[test]
|
||||
fn a_press_on_a_ruler_guide_picks_it_over_the_art() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide { vertical: false, pos: 150.0 });
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.cursor(&cx, Point::new(160.0, 151.0), Mods::default()), Cursor::ResizeV);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Down, 160.0, 151.0));
|
||||
assert_eq!(a, vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]);
|
||||
assert!(t.busy());
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 171.0));
|
||||
assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": false})));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 160.0, 171.0)), vec![Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
// A double-click there doesn't go through to the art.
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_moves_with_single_undo() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
//! undo step per typing session: the tool keeps the styled runs locally and previews a single
|
||||
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
|
||||
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
|
||||
//! selection from [`Tool::options`]).
|
||||
//! selection from [`Tool::options`]); Alt+arrows step it by the Preferences › Type increments
|
||||
//! (`type.step`). New type starts with placeholder text, selected, when Fill New Type Objects With
|
||||
//! Placeholder Text is on.
|
||||
//!
|
||||
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
|
||||
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
|
||||
@@ -263,7 +265,7 @@ impl TypeTool {
|
||||
let mode = if on_path { "onPath" } else { "area" };
|
||||
out.push(Action::Exec(
|
||||
"text.createInPath".into(),
|
||||
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}),
|
||||
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical, "placeholder": cx.placeholder_text}),
|
||||
));
|
||||
out.push(Action::Notify("text.editNew".into()));
|
||||
return out;
|
||||
@@ -275,6 +277,7 @@ impl TypeTool {
|
||||
None => json!({"x": start.x, "y": start.y, "text": ""}),
|
||||
};
|
||||
params["vertical"] = json!(self.vertical);
|
||||
params["placeholder"] = json!(cx.placeholder_text);
|
||||
out.push(Action::Exec("text.create".into(), params));
|
||||
out.push(Action::Notify("text.editNew".into()));
|
||||
out
|
||||
@@ -303,6 +306,18 @@ impl TypeTool {
|
||||
|
||||
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
|
||||
/// `None` when `r` runs past the end of `s` or backwards.
|
||||
/// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking;
|
||||
/// ↑/↓ leading (down opens it up); Shift+↑/↓ baseline shift.
|
||||
fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> {
|
||||
let sign = |up: bool| if up { 1.0 } else { -1.0 };
|
||||
Some(match (key, shift) {
|
||||
(ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)),
|
||||
(ToolKey::Up | ToolKey::Down, false) => ("leading", sign(key == ToolKey::Down)),
|
||||
(ToolKey::Up | ToolKey::Down, true) => ("baselineShift", sign(key == ToolKey::Up)),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
|
||||
let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) };
|
||||
let (a, b) = (byte(r.start)?, byte(r.end)?);
|
||||
@@ -454,6 +469,16 @@ impl Tool for TypeTool {
|
||||
self.caret = self.caret.min(len);
|
||||
self.anchor = self.anchor.min(len);
|
||||
let (a, b) = self.sel();
|
||||
// Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps).
|
||||
if mods.alt
|
||||
&& let Some(id) = self.editing
|
||||
&& let Some((attribute, by)) = step_key(key, mods.shift, a == b)
|
||||
{
|
||||
let mut out = self.commit();
|
||||
let by = if mods.cmd { by * 5.0 } else { by };
|
||||
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
|
||||
return out;
|
||||
}
|
||||
let word = mods.cmd || mods.alt;
|
||||
let lay = self.layout(&t);
|
||||
let key = if lay.vertical {
|
||||
@@ -623,7 +648,10 @@ impl Tool for TypeTool {
|
||||
&& let Some(id) = cx.selection.objects.first().copied()
|
||||
{
|
||||
self.start_editing(id, 0);
|
||||
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
|
||||
let t = Self::text(cx, id);
|
||||
self.fresh = t.filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
|
||||
// Placeholder text comes selected: typing replaces it.
|
||||
self.caret = t.map_or(0, |t| edit::runs_len(&t.runs));
|
||||
}
|
||||
}
|
||||
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ArtboardTool {
|
||||
self.guides.clear();
|
||||
self.targets = cx.smart_guides.then(|| {
|
||||
let art = match cx.doc.artboards.get(index) {
|
||||
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect),
|
||||
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect, cx.move_locked_with_artboard),
|
||||
_ => vec![],
|
||||
};
|
||||
Targets::for_artboard(cx.doc, index, &art)
|
||||
@@ -96,7 +96,7 @@ impl ArtboardTool {
|
||||
return Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return vectorcraft_geom::snap::snap_point_to_grid(p, grid_step(cx));
|
||||
return vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step());
|
||||
}
|
||||
let Some(t) = &self.targets else { return p };
|
||||
let offsets: &[Vec2] = if bleed == Vec2::ZERO { &[] } else { &[bleed] };
|
||||
@@ -114,7 +114,7 @@ impl ArtboardTool {
|
||||
return Vec2::new((tl.x + d.x).round() - tl.x, (tl.y + d.y).round() - tl.y);
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return vectorcraft_geom::snap::snap_point_to_grid(tl + d, grid_step(cx)) - tl;
|
||||
return vectorcraft_geom::snap::snap_point_to_grid(tl + d, cx.grid_step()) - tl;
|
||||
}
|
||||
let Some(t) = (if copy { &self.copy_targets } else { &self.targets }) else { return d };
|
||||
let moved = rect + d;
|
||||
@@ -144,10 +144,6 @@ fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 {
|
||||
}
|
||||
|
||||
/// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses.
|
||||
fn grid_step(cx: &ToolContext) -> f64 {
|
||||
cx.doc.grid.spacing / cx.doc.grid.subdivisions.max(1) as f64
|
||||
}
|
||||
|
||||
impl Tool for ArtboardTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"artboard"
|
||||
|
||||
+181
-23
@@ -315,7 +315,10 @@ 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 {
|
||||
// Selected guides in the selection colour.
|
||||
let picked = LineLook { color: t.selection, ..look };
|
||||
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
|
||||
for (i, g) in doc.guides.iter().enumerate() {
|
||||
let (a, b) = if g.vertical {
|
||||
let x = xf.to_screen(Point::new(g.pos, 0.0)).x;
|
||||
(pos2(x, rect.top()), pos2(x, rect.bottom()))
|
||||
@@ -323,7 +326,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))
|
||||
};
|
||||
look.guide(&painter, a, b);
|
||||
if selected.contains(&i) { picked } else { look }.guide(&painter, a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +352,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
|
||||
if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator {
|
||||
let overlays = app.session.overlays(view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t);
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
|
||||
}
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
@@ -817,23 +820,32 @@ fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
}
|
||||
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released: a horizontal
|
||||
/// one from the top ruler, a vertical one from the left ruler. Released anywhere else, it makes
|
||||
/// none.
|
||||
/// one from the top ruler, a vertical one from the left ruler; with Shift on the nearest ruler
|
||||
/// tick. Released anywhere else, it makes none.
|
||||
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf, t: &Tokens) {
|
||||
let [top, left, _] = ruler_rects(full);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::drag());
|
||||
let Some(p) = resp.interact_pointer_pos().filter(|p| canvas.contains(*p)) else { continue };
|
||||
let d = xf.to_doc(p);
|
||||
let mut pos = if vertical { d.x } else { d.y };
|
||||
if shift {
|
||||
pos = app.session.general_unit().snap_to_ruler_tick(pos, xf.zoom);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let d = xf.to_doc(p);
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
if let Err(e) = app.run("guide.add", json!({ "vertical": vertical, "pos": if vertical { d.x } else { d.y } })) {
|
||||
if let Err(e) = app.run("guide.add", json!({ "vertical": vertical, "pos": pos })) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if resp.dragged() {
|
||||
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)) };
|
||||
let at = xf.to_screen(if vertical { Point::new(pos, d.y) } else { Point::new(d.x, pos) });
|
||||
let (a, b) = if vertical {
|
||||
(pos2(at.x, canvas.top()), pos2(at.x, canvas.bottom()))
|
||||
} else {
|
||||
(pos2(canvas.left(), at.y), pos2(canvas.right(), at.y))
|
||||
};
|
||||
LineLook::guides(&app.session.prefs, t).guide(&ui.painter_at(canvas), a, b);
|
||||
}
|
||||
}
|
||||
@@ -853,8 +865,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
|
||||
// Pick a label step (in `unit`) that gives ≥ 50 px between labels; positions below are in `unit`
|
||||
// (`per` points each).
|
||||
let per = unit.points();
|
||||
let steps = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
|
||||
let step = steps.iter().copied().find(|s| s * per * xf.zoom >= 50.0).unwrap_or(10000.0);
|
||||
let step = unit.ruler_step(xf.zoom);
|
||||
let minor = step / 10.0;
|
||||
let font = egui::FontId::proportional(9.5);
|
||||
let a = xf.to_doc(top.left_top());
|
||||
@@ -1107,6 +1118,31 @@ fn selected_anchor(c: Color32) -> Color32 {
|
||||
if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c }
|
||||
}
|
||||
|
||||
/// How anchors and handles look: Selection & Anchor Display › Size and Handles.
|
||||
#[derive(Clone, Copy)]
|
||||
struct HandleLook<'a> {
|
||||
/// Points bigger (or smaller) than the default size 3, per step of Size (1–7).
|
||||
grow: f32,
|
||||
/// `solid` (the default), `hollow` or `large`.
|
||||
style: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> HandleLook<'a> {
|
||||
fn of(prefs: &'a vectorcraft_engine::Prefs) -> Self {
|
||||
Self { grow: prefs.anchor_size.clamp(1, 7) as f32 - 3.0, style: &prefs.handle_style }
|
||||
}
|
||||
|
||||
/// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one.
|
||||
fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) {
|
||||
let r = 2.75 + self.grow / 2.0;
|
||||
let hollow = self.style == "hollow";
|
||||
p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color });
|
||||
if hollow {
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||||
let r = egui::Rect::from_center_size(c, vec2(size, size));
|
||||
if filled {
|
||||
@@ -1268,8 +1304,12 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
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 look = HandleLook::of(&app.session.prefs);
|
||||
let grow = look.grow;
|
||||
let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 };
|
||||
// The selected anchors' handles (drawn once they are counted: Show handles when multiple
|
||||
// anchors are selected off shows them for a single one only).
|
||||
let (mut handles, mut with_handles) = (vec![], 0);
|
||||
for id in &st.selection.objects {
|
||||
let Some(n) = st.doc.node(*id) else { continue };
|
||||
let color = c32(st.doc.layer_color(*id));
|
||||
@@ -1292,13 +1332,8 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
};
|
||||
let sp = xf.to_screen(a.p);
|
||||
if sel && (direct || partial.is_some()) {
|
||||
for h in [a.h_in, a.h_out] {
|
||||
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 + grow / 2.0, color);
|
||||
}
|
||||
}
|
||||
with_handles += 1;
|
||||
handles.extend([a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6).map(|h| (sp, xf.to_screen(h), color)));
|
||||
}
|
||||
anchor_square(p, sp, if sel && partial.is_some() { selected_anchor(color) } else { color }, sel, anchor(partial.is_some() || direct));
|
||||
}
|
||||
@@ -1320,6 +1355,12 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
p.circle_filled(o, 2.5, color);
|
||||
}
|
||||
}
|
||||
if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 {
|
||||
for (sp, hp, color) in handles {
|
||||
p.line_segment([sp, hp], Stroke::new(1.0, color));
|
||||
look.draw(p, hp, color);
|
||||
}
|
||||
}
|
||||
// The spine of each selected blend (or of the blend a selected key object belongs to).
|
||||
let mut spines = vec![];
|
||||
for id in &st.selection.objects {
|
||||
@@ -1338,6 +1379,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
if matches!(tool, "selection" | "directSelection")
|
||||
&& app.ui.view.corner_widgets
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
|
||||
.and_then(|w| w.within_angle(app.session.prefs.hide_corner_widget_above))
|
||||
{
|
||||
let color = c32(st.doc.layer_color(w.id));
|
||||
for sp in w.visible().map(|q| xf.to_screen(q)) {
|
||||
@@ -1387,7 +1429,7 @@ fn names_an_artboard(text: &str) -> bool {
|
||||
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
|
||||
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
|
||||
for o in overlays {
|
||||
match o {
|
||||
Overlay::Marquee(r) => {
|
||||
@@ -1417,9 +1459,7 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
|
||||
}
|
||||
}
|
||||
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
|
||||
Overlay::Handle { p: pt, color } => {
|
||||
p.circle_filled(xf.to_screen(*pt), 2.8, c32(*color));
|
||||
}
|
||||
Overlay::Handle { p: pt, color } => look.draw(p, xf.to_screen(*pt), c32(*color)),
|
||||
Overlay::Label { p: pt, text, color } => {
|
||||
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
|
||||
p.text(
|
||||
@@ -1693,6 +1733,69 @@ mod tests {
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
|
||||
}
|
||||
|
||||
/// #414: guides dragged out of the rulers are picked and dragged with the Selection tool
|
||||
/// (highlighted while selected), go with Delete or Backspace, and dragged back onto their
|
||||
/// ruler.
|
||||
#[test]
|
||||
fn the_selection_tool_moves_and_deletes_ruler_guides() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
// A square under the guides: the press takes the guide.
|
||||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 300, "height": 200})).unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let art = app.session.active().unwrap().doc.art_bounds();
|
||||
app.ui.view.rulers = true;
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||||
frame(app, &ctx, vec![button(from, true)]);
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false)]);
|
||||
};
|
||||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||||
// Two guides out of the rulers.
|
||||
let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y));
|
||||
drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h);
|
||||
drag(&mut app, pos2(rect.left() - RULER / 2.0, v.y), v);
|
||||
assert_eq!(guides(&app), [(false, xf.to_doc(h).y), (true, xf.to_doc(v).x)]);
|
||||
// Drag the horizontal one down 30 px (across the vertical one: the nearer is picked).
|
||||
let to = pos2(h.x + 10.0, h.y + 30.0);
|
||||
drag(&mut app, h, to);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]);
|
||||
assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![]));
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Move Guide");
|
||||
let picked = Tokens::get(&ctx).selection;
|
||||
let s = shapes(&mut app, &ctx);
|
||||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == picked && points[0].y == to.y)), "highlighted");
|
||||
// Backspace deletes the selected guide; Delete too.
|
||||
let press = |app: &mut VectorcraftApp, key| {
|
||||
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
press(&mut app, egui::Key::Backspace);
|
||||
assert_eq!(guides(&app), [(true, xf.to_doc(v).x)]);
|
||||
app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||||
press(&mut app, egui::Key::Delete);
|
||||
assert!(guides(&app).is_empty());
|
||||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
// Hide Guides deselects them.
|
||||
app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||||
app.run("view.guides", json!({})).unwrap();
|
||||
assert!(app.session.active().unwrap().selection.guides.is_empty());
|
||||
app.run("view.guides", json!({})).unwrap();
|
||||
// Dragged back onto its ruler, the vertical guide goes.
|
||||
drag(&mut app, v, pos2(rect.left() - RULER / 2.0, v.y + 20.0));
|
||||
assert!(guides(&app).is_empty(), "{:?}", guides(&app));
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), art, "the square stayed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -1807,6 +1910,59 @@ mod tests {
|
||||
assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}");
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display › Handles and Show handles when multiple anchors are selected
|
||||
/// (#394): handle ends drawn solid, hollow or large; with the latter off, a second selected
|
||||
/// anchor hides the handles.
|
||||
#[test]
|
||||
fn handle_style_and_handles_of_multiple_anchors() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||||
app.session.select_tool("directSelection", app.view_info()).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let select = |app: &mut VectorcraftApp, anchors: serde_json::Value| {
|
||||
app.session.execute("select.anchors", &json!({"id": id, "anchors": anchors, "mode": "set"})).unwrap();
|
||||
};
|
||||
// The handle ends: (radius, filled white).
|
||||
let dots = |app: &mut VectorcraftApp| -> Vec<(f32, bool)> {
|
||||
shapes(app, &ctx)
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::Circle(c) if c.fill != Color32::TRANSPARENT => Some((c.radius, c.fill == Color32::WHITE)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
select(&mut app, json!([[0, 0]]));
|
||||
assert_eq!(dots(&mut app), [(2.75, false); 2], "solid by default");
|
||||
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "hollow"})).unwrap();
|
||||
assert_eq!(dots(&mut app), [(2.75, true); 2]);
|
||||
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "large"})).unwrap();
|
||||
assert_eq!(dots(&mut app), [(4.25, false); 2]);
|
||||
select(&mut app, json!([[0, 0], [0, 1]]));
|
||||
assert_eq!(dots(&mut app).len(), 4, "both anchors' handles");
|
||||
app.session.execute("prefs.set", &json!({"key": "showHandlesMultipleAnchors", "value": false})).unwrap();
|
||||
assert!(dots(&mut app).is_empty(), "off: none for two anchors");
|
||||
select(&mut app, json!([[0, 1]]));
|
||||
assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them");
|
||||
}
|
||||
|
||||
/// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their
|
||||
/// widgets below 90°.
|
||||
#[test]
|
||||
fn corner_widgets_hide_above_the_preference_angle() {
|
||||
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": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let widgets = |app: &mut VectorcraftApp| {
|
||||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||||
};
|
||||
assert_eq!(widgets(&mut app), 4);
|
||||
app.session.execute("prefs.set", &json!({"key": "hideCornerWidgetAbove", "value": 80})).unwrap();
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -2200,6 +2356,8 @@ mod tests {
|
||||
#[test]
|
||||
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
// The type placed starts empty (Fill New Type Objects With Placeholder Text off).
|
||||
app.session.prefs.placeholder_text = false;
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
// Not editing: the IME stays off (single-key tool shortcuts keep working).
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
wrap(app.run(id, params))
|
||||
}
|
||||
"engine.commands" => ok(all_commands(app)),
|
||||
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
|
||||
"document.inspect" => wrap(app.run("document.inspect", p.clone())),
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
|
||||
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Copy; Scale also has Uniform,
|
||||
//! Scale Corners and Scale Strokes & Effects).
|
||||
//! Transform dialogs: Move, Rotate, Scale, Reflect and Shear (with Transform Patterns and Copy;
|
||||
//! Scale also has Uniform, Scale Corners and Scale Strokes & Effects).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -26,10 +26,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::check(ui, d, "uniform", tl!("Uniform"));
|
||||
scale_options(app, ui, d);
|
||||
}
|
||||
d.fields.insert("patterns".into(), Value::Bool(transform_patterns(app, d)));
|
||||
form::check(ui, d, "patterns", tl!("Transform Patterns"));
|
||||
form::check(ui, d, "copy", tl!("Copy (make a transformed copy)"));
|
||||
false
|
||||
}
|
||||
|
||||
/// Transform Patterns: the dialog's field, else General › Transform Pattern Tiles.
|
||||
fn transform_patterns(app: &VectorcraftApp, d: &Dialog) -> bool {
|
||||
d.fields.get("patterns").and_then(Value::as_bool).unwrap_or(app.session.prefs.transform_pattern_tiles)
|
||||
}
|
||||
|
||||
/// The scale options: (dialog field, preference, label).
|
||||
const SCALE_OPTIONS: [(&str, &str, &str); 2] = [("corners", "scaleCorners", "Scale Corners"), ("strokes", "scaleStrokes", "Scale Strokes & Effects")];
|
||||
|
||||
@@ -74,17 +81,21 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value {
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let copy = d.bool("copy");
|
||||
let (copy, patterns) = (d.bool("copy"), transform_patterns(app, d));
|
||||
let (id, params) = match d.kind.as_str() {
|
||||
"move" => return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
|
||||
"rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})),
|
||||
"move" => {
|
||||
return run_and_close(app, "object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy, "patterns": patterns}));
|
||||
}
|
||||
"rotate" => ("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy, "patterns": patterns})),
|
||||
"scale" => {
|
||||
let sx = d.f64("sx", 100.0);
|
||||
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
|
||||
("object.scale", with_scale_options(app, d, json!({"sx": sx, "sy": sy, "copy": copy}))?)
|
||||
("object.scale", with_scale_options(app, d, json!({"sx": sx, "sy": sy, "copy": copy, "patterns": patterns}))?)
|
||||
}
|
||||
"reflect" => ("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy})),
|
||||
_ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})),
|
||||
"reflect" => {
|
||||
("object.reflect", json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy, "patterns": patterns}))
|
||||
}
|
||||
_ => ("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy, "patterns": patterns})),
|
||||
};
|
||||
run_and_close(app, id, origin_params(d, params))
|
||||
}
|
||||
@@ -121,4 +132,27 @@ mod tests {
|
||||
let (cmd, p) = app.session.journal.last().unwrap();
|
||||
assert_eq!((cmd.as_str(), &p["strokes"], &p["corners"]), ("object.scale", &json!(false), &json!(true)));
|
||||
}
|
||||
|
||||
/// Transform Patterns starts from General › Transform Pattern Tiles (#394) and goes with the
|
||||
/// command.
|
||||
#[test]
|
||||
fn transform_patterns_starts_from_the_preference() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
for on in [false, true] {
|
||||
app.run("prefs.set", json!({"key": "transformPatternTiles", "value": on})).unwrap();
|
||||
crate::menus::invoke(&mut app, "object.move", json!({}));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.fields["patterns"], json!(on));
|
||||
d.fields.insert("dx".into(), json!(10));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let (cmd, p) = app.session.journal.last().unwrap();
|
||||
assert_eq!((cmd.as_str(), &p["patterns"]), ("object.move", &json!(on)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -912,6 +912,7 @@
|
||||
Find Font Buscar fuente
|
||||
Find Font… Buscar fuente…
|
||||
Find Next Buscar siguiente
|
||||
Find Objects Buscar objetos
|
||||
First Page Primera página
|
||||
First-line Left Indent Sangría izquierda en primera línea
|
||||
Fisheye… Ojo de pez…
|
||||
@@ -921,6 +922,9 @@
|
||||
Fit Artboard in Window Encajar mesa de trabajo en la ventana
|
||||
Fit dashes to corners and ends Ajustar guiones a esquinas y extremos
|
||||
Fit Headline Ajustar titular
|
||||
Decrease Font Size Reducir tamaño de fuente
|
||||
Increase Font Size Aumentar tamaño de fuente
|
||||
Step Type Ajustar texto por incrementos
|
||||
Fit in View Encajar en la vista
|
||||
Fit in Window Encajar en la ventana
|
||||
Fit on Screen Encajar en pantalla
|
||||
@@ -2273,6 +2277,7 @@
|
||||
Select art and use New Brush to make one. Selecciona una ilustración y usa Nuevo pincel para crear uno.
|
||||
Select Freeform Point Seleccionar punto libre
|
||||
Select Gradient Stop Seleccionar parada de degradado
|
||||
Select Guides Seleccionar guías
|
||||
Select Objects Seleccionar objetos
|
||||
Select objects to see their CSS, or Generate CSS for the whole document. Selecciona objetos para ver su CSS, o usa Generar CSS para todo el documento.
|
||||
Select Same Tint % Seleccionar mismo % de matiz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -85,6 +85,7 @@
|
||||
Clear 消去
|
||||
Find and Replace… 検索と置換…
|
||||
Find Next 次を検索
|
||||
Find Objects オブジェクトを検索
|
||||
Spelling スペルチェック
|
||||
Auto Spell Check 自動スペルチェック
|
||||
Check Spelling… スペルチェック…
|
||||
@@ -289,6 +290,9 @@
|
||||
Release Selection 選択部分をリリース
|
||||
Remove Threading スレッドを解除
|
||||
Fit Headline ヘッドラインを合わせる
|
||||
Decrease Font Size フォントサイズを小さく
|
||||
Increase Font Size フォントサイズを大きく
|
||||
Step Type 文字を段階的に調整
|
||||
Find Font… フォントの検索と置換…
|
||||
Change Case 大文字と小文字の変更
|
||||
UPPERCASE すべて大文字
|
||||
@@ -2844,6 +2848,7 @@
|
||||
Search 検索
|
||||
Security セキュリティ
|
||||
Select All Pins すべてのピンを選択
|
||||
Select Guides ガイドを選択
|
||||
Select text or click into text with the Type tool to insert glyphs 字形を挿入するには、テキストを選択するか、文字ツールでテキスト内をクリックしてください
|
||||
Server-side (.map) サーバーサイド (.map)
|
||||
Set 1 セット 1
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -872,6 +872,9 @@
|
||||
Fit All in Window Ajustar Tudo na Janela
|
||||
Fit Artboard in Window Ajustar Mesa de Trabalho na Janela
|
||||
Fit Headline Ajustar Manchete
|
||||
Decrease Font Size Diminuir Tamanho da Fonte
|
||||
Increase Font Size Aumentar Tamanho da Fonte
|
||||
Step Type Ajustar Texto em Incrementos
|
||||
Fit dashes to corners and ends Ajustar tracejados aos cantos e extremidades
|
||||
Fit in Window Ajustar na Janela
|
||||
Fit on Screen Ajustar na Tela
|
||||
@@ -2013,6 +2016,7 @@
|
||||
Select Appearance Item Selecionar Item de Aparência
|
||||
Select Freeform Point Selecionar Ponto Livre
|
||||
Select Gradient Stop Selecionar Parada de Gradiente
|
||||
Select Guides Selecionar Guias
|
||||
Select Objects Selecionar Objetos
|
||||
Select Same Tint % Selecionar Mesmo Tom %
|
||||
Select Slices Selecionar Fatias
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -906,6 +906,7 @@
|
||||
Find Font 查找字体
|
||||
Find Font… 查找字体…
|
||||
Find Next 查找下一个
|
||||
Find Objects 查找对象
|
||||
First Page 第一页
|
||||
First-line Left Indent 首行左缩进
|
||||
Fisheye… 鱼眼…
|
||||
@@ -915,6 +916,9 @@
|
||||
Fit Artboard in Window 使画板适合窗口
|
||||
Fit dashes to corners and ends 让虚线贴合转角与端点
|
||||
Fit Headline 适合标题
|
||||
Decrease Font Size 减小字体大小
|
||||
Increase Font Size 增大字体大小
|
||||
Step Type 按增量调整文字
|
||||
Fit in View 适合视图
|
||||
Fit in Window 适合窗口
|
||||
Fit on Screen 显示全页
|
||||
@@ -3189,6 +3193,7 @@
|
||||
Release to Layers (Build) 释放到图层 (累积)
|
||||
Release to Layers (Sequence) 释放到图层 (顺序)
|
||||
Select Art 选择图稿
|
||||
Select Guides 选择参考线
|
||||
Show All Layers 显示所有图层
|
||||
Unlock All Layers 解锁所有图层
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -928,6 +928,7 @@
|
||||
Find Font 尋找字體
|
||||
Find Font… 尋找字體…
|
||||
Find Next 尋找下一個
|
||||
Find Objects 尋找物件
|
||||
First Page 第一頁
|
||||
First-line Left Indent 首行左縮排
|
||||
Fisheye… 魚眼…
|
||||
@@ -937,6 +938,9 @@
|
||||
Fit Artboard in Window 使工作區域符合視窗
|
||||
Fit dashes to corners and ends 讓虛線貼合轉角與端點
|
||||
Fit Headline 符合標題
|
||||
Decrease Font Size 縮小字體大小
|
||||
Increase Font Size 放大字體大小
|
||||
Step Type 依增量調整文字
|
||||
Fit in View 符合檢視
|
||||
Fit in Window 符合視窗
|
||||
Fit on Screen 顯示全頁
|
||||
@@ -2292,6 +2296,7 @@
|
||||
Select art and use New Brush to make one. 選取圖稿,然後使用「新增筆刷」來製作。
|
||||
Select Freeform Point 選取任意形狀點
|
||||
Select Gradient Stop 選取漸層色標
|
||||
Select Guides 選取參考線
|
||||
Select Objects 選取物件
|
||||
Select objects to see their CSS, or Generate CSS for the whole document. 選取物件以檢視其 CSS,或對整份文件「產生 CSS」。
|
||||
Select Same Tint % 選取相同色調 %
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -423,6 +423,7 @@ impl VectorcraftApp {
|
||||
zoom: self.view().map(|v| v.zoom).unwrap_or(1.0),
|
||||
outline: self.ui.view.outline,
|
||||
smart_guides: self.ui.view.smart_guides,
|
||||
guides: self.ui.view.guides,
|
||||
snap_to_grid: self.ui.view.snap_to_grid,
|
||||
snap_to_pixel: self.ui.view.snap_to_pixel,
|
||||
show_bbox: self.ui.view.bounding_box,
|
||||
|
||||
@@ -910,7 +910,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"view.artboards" => flag(&mut app.ui.view.artboards),
|
||||
"view.rulers" => flag(&mut app.ui.view.rulers),
|
||||
"view.boundingBox" => flag(&mut app.ui.view.bounding_box),
|
||||
"view.guides" => flag(&mut app.ui.view.guides),
|
||||
"view.guides" => {
|
||||
let r = flag(&mut app.ui.view.guides);
|
||||
// Hidden guides can't stay selected (selected, they are all that is).
|
||||
if !app.ui.view.guides && app.session.active().is_some_and(|d| !d.selection.guides.is_empty()) {
|
||||
// Deselecting a document that is open can't fail.
|
||||
let _ = app.run("select.none", json!({}));
|
||||
}
|
||||
r
|
||||
}
|
||||
"view.smartGuides" => flag(&mut app.ui.view.smart_guides),
|
||||
"view.grid" => flag(&mut app.ui.view.grid),
|
||||
"view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid),
|
||||
|
||||
@@ -386,12 +386,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||||
for (k, dx, dy) in arrows {
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||||
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
|
||||
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
|
||||
}
|
||||
}
|
||||
// Delete / Backspace clear the selection.
|
||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides())
|
||||
{
|
||||
let _ = app.run("edit.clear", json!({}));
|
||||
}
|
||||
// Single-key tool shortcuts (no Cmd/Ctrl/Alt).
|
||||
|
||||
@@ -17,7 +17,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
|
||||
|---|---|---|
|
||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||
| `document.inspect` | | layer tree, selection, history, paint defaults |
|
||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
|
||||
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
|
||||
+67
-1
@@ -91,6 +91,16 @@ that shows fewer children than it has reports their total as `childCount`, so tr
|
||||
nothing truncated the reply is the plain summary. Both must be non-negative integers and need `summary: true`
|
||||
(the full object JSON is never truncated); anything else is an error.
|
||||
|
||||
On a large document, drill instead of dumping: `inspect_document {depth: 0}` (or `run_command document.inspect
|
||||
{depth?, childLimit?}`, same rules) is the skeleton (artboards with top layers and counts), `run_command
|
||||
document.find {name?, kind?, text?, limit?}` locates nodes across the whole tree, and `document.node` reads the
|
||||
interesting ones sliced. `document.find` needs at least one filter and all given filters must match; matching
|
||||
ignores case, `name` is a substring of the Layers panel name, `text` a substring of type content and `kind` the
|
||||
exact panel label (`Layer`, `Group`, `Clip Group`, `Path`, `Compound Path`, `Type`, `Image`, `Rectangle`, ...).
|
||||
It answers `{matches: [{id, name, kind, path}], total}` top of the stack first, where `path` is the ancestor
|
||||
chain (layer first) and `limit` caps `matches` (default 100; `0` only counts). `document.json` stays whole: it is
|
||||
the fidelity path, not the way to look around.
|
||||
|
||||
### Completions
|
||||
|
||||
`completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable
|
||||
@@ -156,7 +166,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
|---|---|---|
|
||||
| `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. |
|
||||
| `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. |
|
||||
| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
|
||||
| `inspect_document` | `{depth?, childLimit?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
|
||||
| `inspect_ui` | `{}` | UI state. Remote mode only. |
|
||||
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. |
|
||||
@@ -549,6 +559,29 @@ names must stay unique. In the desktop app the saved selections are listed at th
|
||||
(`select.recall1` … `select.recall25`). A file's saved selections are checked when it opens: past 25, blank or
|
||||
repeated names, and ids the document doesn't have are dropped.
|
||||
|
||||
## Ruler guides
|
||||
|
||||
Ruler guides are numbered in the order they were made. `guide.add {vertical, pos}` makes one (the x of a vertical
|
||||
guide, the y of a horizontal one, in points) → `{index}`; `guide.list` → `[{index, vertical, pos, selected}…]`.
|
||||
`guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or
|
||||
removes them) → `{selected}`. `guide.move {index, pos}` puts one guide somewhere, `guide.move {dx?, dy?, copy?}`
|
||||
moves the selected ones (vertical guides by `dx`, horizontal ones by `dy`; `copy` leaves them and selects the
|
||||
moved copies), and `guide.remove {index?}` deletes one guide or the selected ones → `{count}`; each is one undo
|
||||
step. With guides selected, `edit.clear` (Delete) deletes them and `object.nudge` (the arrow keys) nudges them.
|
||||
View › Guides › Lock Guides (`view.guides.lock`) deselects them and refuses these commands until unlocked.
|
||||
|
||||
The Selection, Direct Selection and Group Selection tools pick a guide within the selection tolerance, over the art
|
||||
(an anchor on it comes first with Direct Selection), and drag the selected guides: `mods.alt` copies them,
|
||||
`mods.shift` snaps the dragged guide to the ruler's ticks, and otherwise it snaps to whole pixels, the grid or, with
|
||||
Smart Guides, the art's edges, centres and anchors. In the desktop app a guide dropped off the canvas onto its
|
||||
ruler is deleted, and hidden guides (View › Guides › Hide Guides) can't be picked.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"guide.add","params":{"vertical":true,"pos":100}}}
|
||||
{"name":"pointer_gesture","arguments":{"tool":"selection","events":[{"kind":"down","x":100,"y":50},{"kind":"drag","x":140,"y":50},{"kind":"up","x":140,"y":50}]}}
|
||||
{"name":"run_command","arguments":{"command":"guide.list","params":{}}}
|
||||
```
|
||||
|
||||
## Graphic styles
|
||||
|
||||
A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout.
|
||||
@@ -1084,6 +1117,22 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
|
||||
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.
|
||||
- `snapToPointTolerance` (1–8 px, 2 by default): with View › Snap to Point on and Smart Guides off (desktop app), the
|
||||
point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler
|
||||
guide that near.
|
||||
- `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and
|
||||
hidden art with the artboard too; off (the default) it stays where it is.
|
||||
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
|
||||
the pointer.
|
||||
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection shows and drags direction handles only while a
|
||||
single anchor is selected. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
|
||||
- `hideCornerWidgetAbove` (177° by default): corners wider than this show no Live Corners widget (a rectangle's right
|
||||
angles hide below 90°).
|
||||
- `transformPatternTiles` (off by default): the default of the transforms' `patterns` param (`object.transform`,
|
||||
`object.move`, `object.rotate`, `object.scale`, `object.reflect`, `object.shear`, `object.transformEach`, the
|
||||
Selection and transform tools, the dialogs' Transform Patterns): pattern fills and strokes move with the art.
|
||||
- `selectSameTintPercent` (off by default): `select.same.fillColor`, `strokeColor` and `fillAndStroke` take every tint
|
||||
of a global or spot swatch; on, only the same tint.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"objectSelectionByPathOnly","value":true}}}
|
||||
@@ -1095,6 +1144,23 @@ wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel 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`.
|
||||
|
||||
## Type preferences
|
||||
|
||||
- `placeholderText` (on by default): type the Type tools place (`pointer_gesture` with `type`, `areaType`…) starts with
|
||||
placeholder text, selected, so `type_text` replaces it; `text.create` and `text.createInPath` take `placeholder: true`.
|
||||
- `typeSizeIncrement`, `trackingIncrement`, `baselineShiftIncrement`: what `type.step {attribute: "size" | "leading" |
|
||||
"tracking" | "kerning" | "baselineShift", by?}` steps by, on the text range given, the Type tool's selected text or the
|
||||
selected type objects. `type.size.increase` / `type.size.decrease` (Cmd+Shift+. and Cmd+Shift+,) step the size; the
|
||||
Type tool's Alt+arrows (`press_key {key: "Right", mods: {alt: true}}`) step kerning at a caret or tracking of the
|
||||
selection (←/→), leading (↑/↓) and baseline shift (Shift+↑/↓), five steps with Cmd/Ctrl too.
|
||||
- `missingGlyphProtection` (on by default): `text.setStyle` and `text.setRangeStyle` changing the font leave the
|
||||
characters the new font has no glyph for in the font that had one.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeSizeIncrement":4,"trackingIncrement":50}}}}
|
||||
{"name":"run_command","arguments":{"command":"type.step","params":{"attribute":"tracking","by":-2}}}
|
||||
```
|
||||
|
||||
## Width points
|
||||
|
||||
`stroke.widthPoint.set {id, t, left, right, index?, adjustAdjoining?}` adds a width point (side widths in points) or,
|
||||
|
||||
Reference in new issue
Block a user