Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 262caec2d6 Fix #505: Illustrator EPS type with drop shadows and glows opens as vectors
Text effects from stock sites are Illustrator EPS with live type whose
appearance (several fills and strokes, offset paths, drop shadows, glows) is
flattened into many small images and clipped type. Made here with
Illustrator 2026 (graphic styles and live effects on type, live and
outlined, formats 2026, CS6, 10 and 8), every one fell back to its preview:

- Illustrator's procsets keep their own graphics-state stack in a
  dictionary, count each `save` in it and raise `limitcheck` at 64, relying
  on `restore` to put the count back. `restore` didn't undo dictionary
  changes, so a file with more than 64 images (each drawn in a save)
  stopped. Now `restore` puts dictionaries back as they were at the `save`,
  as restoring virtual memory does: while a save is open, `def`, `store`,
  `put`, `undef` and dictionary `copy` keep the old value in a journal
  (counted against the memory budget), undone newest first. A save that was
  restored already (or through an outer one) can't be restored again
  (`invalidrestore`).
- The Illustrator 8 format's pattern fills divide by the font cache's
  largest glyph (`cachestatus`), which was 0: it now reports a printer's
  sizes.
- Type in Illustrator's reencoded font copies (`/Impact*1`) was set in the
  fallback font: the `*1` suffix is dropped when the family is looked up.

The images of those effects also came in banded on main; that is #498's
fix, which this builds on.

Tests: 100 saves counted in a dictionary and restored, every dictionary
change undone, saves restored twice, `cachestatus`, reencoded font names.
The import fuzz vocabulary has saves and restores around dictionary
changes. The generated files (not committed) now open with no warnings and
look like their previews; the Illustrator 8-format ones draw what their
PostScript holds (that format writes effects as opaque rasters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:15:18 +07:00
bflatasticandClaude Opus 5.5 a303add775 Fix #498: EPS colour images whose inks are read in turn aren't banded
Illustrator CS and later write a colour image (in their own and in every
CS-to-CC compatibility format) with one data source per ink: four
procedures that each read one row of their ink from the same file, as
`image` calls them in turn (C, M, Y, K, C, M, Y, K …). The reader drained
each source on its own, so the cyan plane took the first rows of all four
inks and the image came in as sheared horizontal bands (the map in #498).

- Image data sources are read in turn: a procedure once a round, a file or
  string a row a round, until each plane is full or its source ends.
  Single sources, filters over procedures and masked images share the same
  reader (`read_sources`); a string source starts each round where it left
  off without walking it from its start.
- Tests: CMYK procedures reading one file (dictionary form) and RGB ones
  (`colorimage` operand form), separate string sources, rows padded to a
  byte at 1, 2, 4, 12 and 16 bits with an odd width (one and several
  sources), PNG predictors of every row filter at an odd width. The first
  fails without the fix; the others pin down the stride rules the report
  suggested. The import fuzz vocabulary has several sources reading one
  file and sources of uneven lengths.
- Checked against EPS saved here by Illustrator 2026 with an embedded
  1001 × 667 RGB raster (and grey and RGBA ones, in RGB and CMYK
  documents, compatibility formats 3, 8, 10, CS6 and 2026): the 10, CS6 and
  2026 ones came in banded and now match the source image; those files
  aren't committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:15:18 +07:00
79 changed files with 1043 additions and 8344 deletions

No files matched your search

+4 -10
View File
@@ -6,19 +6,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Start every session here
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
## Non-negotiables
- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
- Never launch, run, script, automate (COM, AppleScript, ExtendScript, UI automation), screenshot or measure Illustrator or any other Adobe application, and never compare our output against one. Don't make test files with them, not even "synthetic" ones.
- Never read, disassemble or copy anything inside an Adobe install (binaries, plug-ins, presets, procsets, resources, help files, fonts, ICC profiles).
- Never copy Adobe-authored code or text into this repo, tests included: no PostScript procsets or prologs (AGM, CoolType, the Illustrator prologs), no sample files. Write test inputs from scratch from the public specifications (PostScript Language Reference, PDF / ISO 32000, TIFF, EPSF/DCS).
- Behaviour comes only from public documentation (helpx, published specs, public forum posts), cited by URL, and from files users send us that they own (used locally, never committed). Never copy Adobe icons, artwork, presets or wording beyond feature names.
- If a task seems to need any of this, stop and say so instead. `cargo xtask cleanroom` (run by `cargo xtask ci`) fails on Adobe copyright notices and on fixtures made with Adobe applications.
- Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Assets: no Adobe iconography or images — ever (absolute rule, from the project owner).**
- Never add, copy, trace, redraw-from, embed or ship any icon, image, artwork, cursor, preset, swatch/brush/symbol/pattern/style library, ICC profile or screenshot from Adobe products or from any other source whose licence doesn't allow it.
- Every image, icon, font or other asset must be one of:
@@ -50,7 +44,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with the public documentation (`plan/illustrator/02-ui-ux.md` notes). `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **Performance:** `vectorcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `vectorcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
- MCP: `vectorcraft-cli mcp` (see `docs/mcp.md`).
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
@@ -59,7 +53,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Roadmap
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
## Contributor credits (About window)
+4 -10
View File
@@ -6,19 +6,13 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Start every session here
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
## Non-negotiables
- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
- Never launch, run, script, automate (COM, AppleScript, ExtendScript, UI automation), screenshot or measure Illustrator or any other Adobe application, and never compare our output against one. Don't make test files with them, not even "synthetic" ones.
- Never read, disassemble or copy anything inside an Adobe install (binaries, plug-ins, presets, procsets, resources, help files, fonts, ICC profiles).
- Never copy Adobe-authored code or text into this repo, tests included: no PostScript procsets or prologs (AGM, CoolType, the Illustrator prologs), no sample files. Write test inputs from scratch from the public specifications (PostScript Language Reference, PDF / ISO 32000, TIFF, EPSF/DCS).
- Behaviour comes only from public documentation (helpx, published specs, public forum posts), cited by URL, and from files users send us that they own (used locally, never committed). Never copy Adobe icons, artwork, presets or wording beyond feature names.
- If a task seems to need any of this, stop and say so instead. `cargo xtask cleanroom` (run by `cargo xtask ci`) fails on Adobe copyright notices and on fixtures made with Adobe applications.
- Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Assets: no Adobe iconography or images — ever (absolute rule, from the project owner).**
- Never add, copy, trace, redraw-from, embed or ship any icon, image, artwork, cursor, preset, swatch/brush/symbol/pattern/style library, ICC profile or screenshot from Adobe products or from any other source whose licence doesn't allow it.
- Every image, icon, font or other asset must be one of:
@@ -50,7 +44,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with the public documentation (`plan/illustrator/02-ui-ux.md` notes). `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **Performance:** `vectorcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `vectorcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
- MCP: `vectorcraft-cli mcp` (see `docs/mcp.md`).
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
@@ -59,5 +53,5 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Roadmap
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
+4 -6
View File
@@ -163,11 +163,9 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
On machines with two graphics processors, VectorCraft renders on the power-saving (integrated) one by default on
Windows and macOS, and on the one the desktop runs on under Linux. To choose, use **Preferences › Performance ›
Graphics Processor** and restart. If a graphics processor can't show the window, VectorCraft starts again on the
next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low`), or `WGPU_ADAPTER_NAME` to part of
its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
@@ -194,7 +192,7 @@ milestones, and honest time-to-parity estimates.
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish and Italian (and Czech and Brazilian Portuguese in the menus).
speaks English, Japanese, Traditional and Simplified Chinese and Spanish (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
+15 -17
View File
@@ -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), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking intLine truncated
_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), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), Illustrator CS-and-later EPS images no longer banded (Line truncated
## Where we are
@@ -42,7 +42,7 @@ since 2026-10-01.
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -99,15 +99,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish and Italian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
- 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; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), 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. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 30 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Spacing Guides, performance, devices…).
- 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; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), 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. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 32 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -117,13 +116,12 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles; drawing then goes on.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
@@ -131,13 +129,13 @@ 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) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), 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, each with a chip of its colour, 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, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; 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; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) 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 edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto 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.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; 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 (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) 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 edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto 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. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472).
- **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. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale) and Italian (every `it-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish and Italian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish and Italian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), the Image Trace panel and Expand, plus Release in Properties; the View modes are not done), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
@@ -154,11 +152,11 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), colour images whose ink planes are procedures reading one file in turn, as Illustrator CS and later write them (they came in banded, #498), `restore` putting dictionaries back as they were at the `save` (Illustrator's procsets count saves and stopped files with more than 64 images, such as the rasters of drop shadows and glows on type, #505), type in Illustrator's reencoded fonts (`Impact*1`) set in that font, Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
@@ -213,12 +211,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 (an on-canvas crop box), Image Trace View modes (Outlines, Source Image) (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 | 84% | global/video rulers, Spacing Guides, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506) | 2–4 |
| Guides, grids, smart guides, snapping, rulers | 3 | 82% | global/video rulers, smart-guide preference depth, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451) | 3–5 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files (Illustrator 2026 and Inkscape files open as vectors; CorelDRAW, Affinity, LibreOffice, matplotlib and Ghostscript output checked with synthetic programs only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), 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, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 30 Preferences options stored but not yet acted on (#394: Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| 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 32 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| 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 |
@@ -238,7 +236,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
-52
View File
@@ -70,49 +70,6 @@ fn load_average() -> Option<f64> {
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_geom::{Anchor, PathData, SubPath, Vec2};
let mut doc = synthetic(n)?;
let l = doc.layers.first().map(|l| l.id);
let id = doc.alloc_id();
// Anchors 2 pt apart, up and down by 20 pt, their handles 0.6 pt long.
let anchor = |i: usize| {
let p = Point::new(i as f64 * 2.0, if i.is_multiple_of(2) { 500.0 } else { 520.0 });
Anchor::smooth(p, p + Vec2::new(0.6, 0.0))
};
let path = PathData::single(SubPath::new((0..5000).map(anchor).collect(), false));
doc.insert(l, usize::MAX, Node::path(id, path, Appearance::default_art())).map_err(|e| e.to_string())?;
let mut s = vectorcraft_engine::Session::new();
s.add_document(doc, None);
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut drag = |down: Point| -> Result<f64, String> {
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
s.select_tool("directSelection", view).map_err(err)?;
let undo = s.doc().map_err(err)?.history.undo.len();
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut y = 0.0;
let ms = median_ms(9, || {
y += 3.0;
// A failed move shows as the missing undo step below.
let _ = s.pointer(&ev(PointerKind::Drag, y), view);
});
s.pointer(&ev(PointerKind::Up, y), view).map_err(err)?;
if s.doc().map_err(err)?.history.undo.len() != undo + 1 {
return Err(format!("the drag from {down:?} made no undo step"));
}
Ok(ms)
};
// Anchor 400 is at (800, 500): its outgoing handle's end, then its segment to anchor 401.
let handle = drag(Point::new(800.6, 500.0))?;
let segment = drag(Point::new(801.0, 510.0))?;
Ok((handle, segment))
}
pub fn run(args: &[String]) -> Result<(), String> {
let mut n = 50_000usize;
let mut it = args.iter();
@@ -188,15 +145,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
});
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
// Path editing: one pointer move of a Direct Selection drag (the tool event and the preview it
// applies), on a 5,000-anchor path among the synthetic paths.
let (handle, segment) = edit_drags(n).unwrap_or_else(|e| {
failed = Some(e);
(0.0, 0.0)
});
rows.push(("handle drag, 5,000-anchor path (per move)", handle, 8.0));
rows.push(("segment drag, 5,000-anchor path (per move)", segment, 8.0));
if let Some(e) = failed {
return Err(e);
}
-470
View File
@@ -1,470 +0,0 @@
//! Which graphics adapter the window renders with (#306, #502), and what happens when it can't
//! show the window.
//!
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, and the power
//! preference ([`power_preference`]) among the rest. eframe is handed [`selector`], which takes
//! the first of that order.
//!
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
//! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while
//! configuring the surface (#502). Nothing in the process can show a window after that, so when
//! the graphics fail while the window is starting up, [`finish`] starts the app again without
//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
//! adapter, so it ends when none is left.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
use eframe::wgpu::{self, Backend, DeviceType, PowerPreference};
/// The adapters a restart leaves out, those the window failed on, as comma-separated
/// `backend:vendor:device` keys (`Vulkan:1002:164e`). Set by the app itself when it starts again.
pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
/// wgpu's variable for choosing an adapter by (part of) its name, any case.
const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
/// Where to read how to choose another adapter, for the error that ends the app.
const HELP: &str =
"to choose a graphics adapter, see https://github.com/storytold/vectorcraft/blob/main/docs/development.md#desktop-graphics-processor";
/// Frames the UI ran before the failure, below which it was still starting up. The failure in
/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
/// window is something else (such as the compositor restarting).
const STARTUP_FRAMES: u64 = 10;
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
/// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid
/// laptops), and a Wayland compositor may not show frames from another GPU (#502).
pub const AUTOMATIC: PowerPreference = if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None };
/// The power preference that orders the adapters: the `WGPU_POWER_PREF` environment variable
/// (`low`, `high`, `none`) when set, otherwise Preferences › Performance › Graphics Processor
/// (`gpuPreference`). Automatic, and any value this version doesn't know (0.5.0's default
/// `powerSaving`, a newer or damaged preference file), is [`AUTOMATIC`].
pub fn power_preference(pref: Option<&str>, env: Option<PowerPreference>) -> PowerPreference {
match (env, pref) {
(Some(p), _) => p,
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
(None, Some("lowPower")) => PowerPreference::LowPower,
(None, _) => AUTOMATIC,
}
}
/// One adapter, as [`adapter_order`] sees it.
#[derive(Clone, Debug)]
pub struct Candidate {
/// Identifies the adapter across a restart: `backend:vendor:device` ([`key`]).
pub key: String,
pub name: String,
pub backend: Backend,
pub device_type: DeviceType,
/// Reports that it can present to the window's surface.
pub presents: bool,
}
impl Candidate {
fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self {
let info = adapter.get_info();
Self {
key: key(info.backend, info.vendor, info.device),
presents: surface.is_none_or(|s| adapter.is_surface_supported(s)),
name: info.name,
backend: info.backend,
device_type: info.device_type,
}
}
}
/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
fn key(backend: Backend, vendor: u32, device: u32) -> String {
format!("{backend:?}:{vendor:04x}:{device:04x}")
}
/// The order to try `candidates` in (their indices): only those that present to the window and
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
/// software; native backends before OpenGL; then by `power`, keeping the system's order among
/// equals (all of it for [`PowerPreference::None`]).
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec<usize> {
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
// Stable, so equals keep the system's order.
order.sort_by_key(|(_, c)| {
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power))
});
order.into_iter().map(|(i, _)| i).collect()
}
/// wgpu's own ranking for a power preference: the preferred kind of GPU, the other kind, unknown,
/// virtual, software.
fn power_rank(t: DeviceType, power: PowerPreference) -> u8 {
match (power, t) {
(PowerPreference::None, _) => 0,
(PowerPreference::LowPower, DeviceType::IntegratedGpu) | (PowerPreference::HighPerformance, DeviceType::DiscreteGpu) => 0,
(_, DeviceType::IntegratedGpu | DeviceType::DiscreteGpu) => 1,
(_, DeviceType::Other) => 2,
(_, DeviceType::VirtualGpu) => 3,
(_, DeviceType::Cpu) => 4,
}
}
/// The adapters [`SKIP_ENV`] lists.
pub fn skipped() -> Vec<String> {
std::env::var(SKIP_ENV).map(|v| parse_skip(&v)).unwrap_or_default()
}
fn parse_skip(v: &str) -> Vec<String> {
v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
}
/// What the window's start-up leaves for [`finish`].
#[derive(Default)]
pub struct Startup {
/// The key of the adapter [`selector`] picked.
adapter: OnceLock<String>,
/// The UI's context, which counts the frames run.
ui: OnceLock<egui::Context>,
}
impl Startup {
/// The app was created on `ctx`.
pub fn created(&self, ctx: &egui::Context) {
// Set once; the window is created once.
let _ = self.ui.set(ctx.clone());
}
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, `WGPU_ADAPTER_NAME` and the
/// adapters a restart left out. Every adapter and the choice are logged, for bug reports.
pub fn selector(power: PowerPreference, startup: Arc<Startup>) -> NativeAdapterSelectorMethod {
let named = std::env::var(NAME_ENV).ok();
let skip = skipped();
Arc::new(move |adapters, surface| {
let candidates: Vec<Candidate> = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
let order = adapter_order(&candidates, power, named.as_deref(), &skip);
let listed: Vec<String> = candidates
.iter()
.map(|c| {
let note = if !c.presents {
", can't show the window"
} else if skip.contains(&c.key) {
", failed before"
} else {
""
};
format!("{} ({:?}, {:?}, {}{note})", c.name.trim(), c.backend, c.device_type, c.key)
})
.collect();
log::info!("graphics adapters: {}", listed.join("; "));
let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
let Some((adapter, chosen)) = first else {
return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
};
// Set once; eframe picks the adapter once.
let _ = startup.adapter.set(chosen.key.clone());
Ok(adapter.clone())
})
}
/// The last panic came from the graphics stack (see [`watch_panics`]).
static GRAPHICS_PANIC: AtomicBool = AtomicBool::new(false);
/// Note whether each panic comes from the graphics stack, so [`finish`] can tell a failing adapter
/// from a bug. The default hook still prints it.
pub fn watch_panics() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
GRAPHICS_PANIC.store(info.location().is_some_and(|l| in_graphics_stack(l.file())), Ordering::Relaxed);
default(info);
}));
}
/// A source file of wgpu (`wgpu-core-30.0.1/src/…`, `wgpu-hal-…`), egui-wgpu or naga.
fn in_graphics_stack(file: &str) -> bool {
file.split(['/', '\\']).any(|dir| ["wgpu-", "egui-wgpu-", "naga-"].iter().any(|p| dir.starts_with(p)))
}
/// The adapter to start again without: the one picked, when the graphics failed while the window
/// was starting up and it wasn't already left out.
fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a str>, skip: &[String]) -> Option<&'a str> {
picked.filter(|p| graphics_failed && frames < STARTUP_FRAMES && !skip.iter().any(|s| s == p))
}
/// After the window's run: `Ok` when it closed normally or the app started again (see the module
/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
/// ended it.
pub fn finish(outcome: Result<eframe::Result, String>, startup: &Startup) -> Result<(), String> {
let (why, graphics_failed) = match outcome {
Ok(Ok(())) => return Ok(()),
Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
Ok(Err(e)) => (e.to_string(), false),
Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)),
};
let frames = startup.ui.get().map_or(0, egui::Context::cumulative_frame_nr);
let mut skip = skipped();
let Some(failed) = restart_without(graphics_failed, frames, startup.adapter.get().map(String::as_str), &skip) else {
return Err(if graphics_failed { format!("{why} ({HELP})") } else { why });
};
log::error!("the window failed on graphics adapter {failed} ({why}): starting again without it");
skip.push(failed.to_string());
restart(&skip.join(",")).map_err(|e| format!("{why}; starting again failed: {e}"))
}
/// Start the app again with the same arguments, leaving out the adapters in `skip`. On Unix the
/// new app replaces this process (same process id, so an AppImage keeps its files mounted), and
/// this returns only on failure; on Windows it starts beside this one, which then exits.
fn restart(skip: &str) -> std::io::Result<()> {
log::logger().flush();
let mut c = std::process::Command::new(std::env::current_exe()?);
c.args(std::env::args_os().skip(1)).env(SKIP_ENV, skip);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
Err(c.exec())
}
#[cfg(not(unix))]
c.spawn().map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate {
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, presents: true }
}
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
order.iter().map(|&i| c[i].name.clone()).collect()
}
/// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's
/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software
/// renderer, and OpenGL.
fn issue_502() -> Vec<Candidate> {
vec![
gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu),
gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu),
gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu),
gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other),
]
}
#[test]
fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() {
let c = issue_502();
let order = adapter_order(&c, power_preference(None, None), None, &[]);
let first = &c[order[0]].name;
if cfg!(any(windows, target_os = "macos")) {
assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
} else {
// #502: the integrated GPU couldn't show frames to a compositor on the NVIDIA GPU.
assert_eq!(first, "NVIDIA GeForce RTX 5070");
}
}
#[test]
fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() {
let c = issue_502();
let order = |power| names(&c, &adapter_order(&c, power, None, &[]));
assert_eq!(
order(PowerPreference::None),
[
"NVIDIA GeForce RTX 5070",
"AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
"NVIDIA GeForce RTX 5070/PCIe/SSE2",
"llvmpipe (LLVM 20.1.8, 256 bits)"
]
);
assert_eq!(
order(PowerPreference::LowPower),
[
"AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
"NVIDIA GeForce RTX 5070",
"NVIDIA GeForce RTX 5070/PCIe/SSE2",
"llvmpipe (LLVM 20.1.8, 256 bits)"
]
);
assert_eq!(
order(PowerPreference::HighPerformance),
[
"NVIDIA GeForce RTX 5070",
"AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
"NVIDIA GeForce RTX 5070/PCIe/SSE2",
"llvmpipe (LLVM 20.1.8, 256 bits)"
]
);
}
/// A restart leaves out the adapter the window failed on, so Power Saving on the machine of
/// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try.
#[test]
fn a_restart_tries_the_next_adapter() {
let c = issue_502();
let mut skip = vec![];
let mut tried = vec![];
while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, None, &skip).first() {
tried.push(c[i].name.clone());
skip.push(c[i].key.clone());
}
assert_eq!(
tried,
[
"AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
"NVIDIA GeForce RTX 5070",
"NVIDIA GeForce RTX 5070/PCIe/SSE2",
"llvmpipe (LLVM 20.1.8, 256 bits)"
]
);
}
#[test]
fn adapters_that_cannot_present_to_the_window_are_never_tried() {
let mut c = issue_502();
c[0].presents = false;
c[3].presents = false;
for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] {
let order = adapter_order(&c, power, None, &[]);
assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}");
assert_eq!(order.len(), 2);
}
for p in &mut c {
p.presents = false;
}
assert!(adapter_order(&c, PowerPreference::None, None, &[]).is_empty());
assert!(adapter_order(&[], PowerPreference::HighPerformance, None, &[]).is_empty());
}
#[test]
fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() {
let c = issue_502();
let order = adapter_order(&c, PowerPreference::HighPerformance, Some("radv"), &[]);
assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
assert_eq!(order.len(), 4);
// Any case; every match comes before the rest, natively first.
let order = adapter_order(&c, PowerPreference::LowPower, Some("GeForce"), &[]);
assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]);
// An empty name or one nothing matches changes nothing.
for named in ["", "no such gpu"] {
assert_eq!(adapter_order(&c, PowerPreference::LowPower, Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, None, &[]));
}
// A named adapter that failed is left out like any other.
let skip = vec![c[1].key.clone()];
assert_eq!(adapter_order(&c, PowerPreference::None, Some("radv"), &skip)[0], 0);
}
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
/// integrated GPU first, as wgpu's own choice did (#306).
#[test]
fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() {
let c = vec![
gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu),
gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu),
gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu),
gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
];
let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
assert_eq!(order, [1, 3, 0, 2, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [0, 2, 1, 3, 4]);
}
#[test]
fn power_preference_follows_the_environment_then_the_preference() {
assert_eq!(power_preference(None, None), AUTOMATIC);
assert_eq!(power_preference(Some("automatic"), None), AUTOMATIC);
assert_eq!(power_preference(Some("lowPower"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
// 0.5.0's default, and values this version doesn't know (a newer or damaged file).
for old in ["powerSaving", "turbo", ""] {
assert_eq!(power_preference(Some(old), None), AUTOMATIC, "{old}");
}
// WGPU_POWER_PREF wins over the preference, either way.
assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::None)), PowerPreference::None);
assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None });
}
/// The preference the engine saves is the one the app reads back before the window opens.
#[test]
fn the_saved_engine_preference_is_found() {
let mut prefs = vectorcraft_engine::Prefs::default();
let saved = prefs.to_json();
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("automatic"));
for (value, power) in [("lowPower", PowerPreference::LowPower), ("highPerformance", PowerPreference::HighPerformance)] {
prefs.gpu_preference = value.into();
let saved = prefs.to_json();
assert_eq!(power_preference(saved.get("gpuPreference").and_then(serde_json::Value::as_str), None), power);
}
// Every choice Preferences offer means something here.
for (value, _) in vectorcraft_engine::cmd::prefscmds::GPU_PREFERENCES {
assert!(["automatic", "lowPower", "highPerformance"].contains(value), "{value}");
}
}
#[test]
fn skipped_adapters_parse_from_a_comma_separated_list() {
assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]);
assert!(parse_skip("").is_empty());
assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e), "Vulkan:1002:164e");
assert_eq!(key(Backend::Gl, 0x10de, 0x2f04), "Gl:10de:2f04");
}
#[test]
fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() {
for file in [
"/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs",
r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs",
"/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs",
"naga-30.0.0/src/back/spv/writer.rs",
] {
assert!(in_graphics_stack(file), "{file}");
}
for file in [
"apps/vectorcraft/src/main.rs",
"/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs",
"egui-0.36.2/src/context.rs",
"winit-0.30.12/src/x.rs",
] {
assert!(!in_graphics_stack(file), "{file}");
}
}
#[test]
fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() {
let amd = Some("Vulkan:1002:164e");
assert_eq!(restart_without(true, 0, amd, &[]), amd);
assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd);
// After start-up the adapter has shown frames; a lost window is something else.
assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None);
// A bug outside the graphics, or no adapter picked: nothing another adapter would change.
assert_eq!(restart_without(false, 0, amd, &[]), None);
assert_eq!(restart_without(true, 0, None, &[]), None);
// Never the same adapter twice, so restarts end.
assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None);
}
#[test]
fn a_normal_exit_or_another_failure_does_not_restart() {
let startup = Startup::default();
assert_eq!(finish(Ok(Ok(())), &startup), Ok(()));
// No adapter was picked (none can show the window): the error is returned, nothing restarts.
let none =
eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into()));
assert_eq!(
finish(Ok(Err(none)), &startup),
Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})"))
);
let _ = startup.adapter.set("Vulkan:1002:164e".into());
GRAPHICS_PANIC.store(false, Ordering::Relaxed);
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
}
}
+100 -87
View File
@@ -16,8 +16,6 @@ compile_error!("the win7 target requires --no-default-features --features window
mod clipboard;
mod control_server;
#[cfg(feature = "wgpu")]
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -29,7 +27,7 @@ mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
struct App {
app: VectorcraftApp,
@@ -256,10 +254,6 @@ fn services() -> Services {
write_shared: Some(std::sync::Arc::new(write_file)),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
// clipboard holds and macOS asks the pasteboard server, so they check in line.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
@@ -303,6 +297,23 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// The power preference wgpu picks the window's graphics adapter with (#306): the
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
/// GPU both preferences pick it.
#[cfg(feature = "wgpu")]
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
use eframe::wgpu::PowerPreference;
match (env, pref) {
(Some(p), _) => p,
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
(None, _) => PowerPreference::LowPower,
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
@@ -313,7 +324,7 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// macOS keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
fn main() -> std::process::ExitCode {
fn main() -> eframe::Result {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
@@ -325,7 +336,7 @@ fn main() -> std::process::ExitCode {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
return Ok(());
}
_ => files.push(a),
}
@@ -349,9 +360,7 @@ fn main() -> std::process::ExitCode {
#[cfg(feature = "wgpu")]
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
#[cfg(feature = "wgpu")]
let power = gpu::power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
#[cfg(feature = "wgpu")]
let startup = std::sync::Arc::new(gpu::Startup::default());
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
@@ -374,94 +383,86 @@ fn main() -> std::process::ExitCode {
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
// Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.native_adapter_selector = Some(gpu::selector(power, startup.clone()));
create.power_preference = power;
}
options
};
#[cfg(feature = "wgpu")]
gpu::watch_panics();
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
#[cfg(feature = "wgpu")]
let created = startup.clone();
// A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
let outcome = vectorcraft_engine::guard::catch_panic(|| {
eframe::run_native(
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
}
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
let graphics_loss = GraphicsLoss::default();
#[cfg(feature = "wgpu")]
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("rendering with {summary} (power preference {power:?})");
created.created(&cc.egui_ctx);
if !gpu::skipped().is_empty() {
app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}"));
}
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
#[cfg(feature = "windows7")]
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
eframe::run_native(
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
}
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
let graphics_loss = GraphicsLoss::default();
#[cfg(feature = "wgpu")]
if let Some(rs) = &cc.wgpu_render_state {
let summary = adapter_summary(&rs.adapter.get_info());
log::info!("rendering with {summary} (power preference {power:?})");
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
#[cfg(feature = "windows7")]
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
#[cfg(target_os = "macos")]
open_documents::set_ui(&cc.egui_ctx);
open_files(&mut app, files);
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
#[cfg(target_os = "macos")]
open_documents::set_ui(&cc.egui_ctx);
open_files(&mut app, files);
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
#[cfg(target_os = "macos")]
menu: None,
}))
}),
)
});
#[cfg(feature = "wgpu")]
let outcome = gpu::finish(outcome, &startup);
#[cfg(not(feature = "wgpu"))]
let outcome = outcome.and_then(|r| r.map_err(|e| e.to_string()));
match outcome {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
log::error!("VectorCraft stopped: {e}");
std::process::ExitCode::FAILURE
}
}
menu: None,
}))
}),
)
}
#[cfg(all(test, feature = "wgpu"))]
mod tests {
use super::*;
use eframe::wgpu::PowerPreference;
#[test]
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
// A value this version doesn't know (a newer or damaged preference file) is the default.
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
// WGPU_POWER_PREF wins over the preference, either way.
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
@@ -491,4 +492,16 @@ mod tests {
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
}
/// The preference the engine saves is the one the app reads back before the window opens.
#[test]
fn the_saved_engine_preference_is_found() {
let mut prefs = vectorcraft_engine::Prefs::default();
let saved = prefs.to_json();
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
prefs.gpu_preference = "highPerformance".into();
let saved = prefs.to_json();
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
}
}
-4
View File
@@ -21,7 +21,3 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
sync_github_name = false
+5 -8
View File
@@ -701,16 +701,13 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
// smooth: its other handle turns with the moved one.
// Moving both inner control points by v moves B(t) by 3t(1-t)·v.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
if let Some(a) = sp.anchors.get_mut(i0) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = sp.anchors.get_mut(i1) {
b.set_handle(false, b.h_in + v, false);
}
let a = sp.anchors[i0];
sp.anchors[i0] = Anchor::with_handles(a.p, a.h_in, a.h_out + v);
let b = sp.anchors[i1];
sp.anchors[i1] = Anchor::with_handles(b.p, b.h_in + v, b.h_out);
Ok(())
})?;
ok()
+1 -1
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
+2 -5
View File
@@ -82,11 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[
("feet", "Feet"),
];
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup. The
/// values are WebGPU's power preferences plus `automatic`; 0.5.0's default, `powerSaving`, reads as
/// `automatic` (#502).
pub const GPU_PREFERENCES: &[(&str, &str)] =
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup.
pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
+6 -21
View File
@@ -446,13 +446,10 @@ pub struct Prefs {
pub gpu_performance: bool,
pub animated_zoom: bool,
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
/// restart): `automatic`, `lowPower` (the integrated GPU on hybrid-graphics machines) or
/// `highPerformance` (the discrete one). The canvas is rasterized on the CPU and only
/// composited on the GPU, so the integrated GPU is plenty; presenting from the discrete GPU
/// through the integrated one made some hybrid laptops flicker (#306). Automatic is power
/// saving on Windows and macOS and the system's default GPU elsewhere, the one the desktop
/// runs on: a Wayland compositor may not show frames from another GPU (#502). Single-GPU
/// machines are unaffected.
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
pub gpu_preference: String,
pub history_states: u32,
pub real_time_drawing: bool,
@@ -634,7 +631,7 @@ impl Default for Prefs {
object_highlighting: true,
transform_tools_guides: true,
construction_guides: true,
construction_angles: s(vectorcraft_tools::guides::DEFAULT_CONSTRUCTION_ANGLES),
construction_angles: s("90° & 45° Angles"),
anchor_path_labels: true,
measurement_labels: true,
spacing_guides: true,
@@ -656,7 +653,7 @@ impl Default for Prefs {
interface_language: s("auto"),
gpu_performance: true,
animated_zoom: true,
gpu_preference: s("automatic"),
gpu_preference: s("powerSaving"),
history_states: 500,
real_time_drawing: true,
render_threads: -1,
@@ -721,12 +718,6 @@ pub struct Session {
/// Executed commands (for actions and debugging).
pub journal: Vec<(String, Value)>,
pub(crate) tool: Box<dyn Tool>,
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
/// back as it was at the release ([`Session::pointer`]).
pub(crate) lender: Option<Box<dyn Tool>>,
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
/// lends the other tools. None until one is chosen.
pub(crate) last_selection_tool: Option<&'static str>,
pub(crate) last_view: ViewInfo,
depth: u32,
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
@@ -806,8 +797,6 @@ impl Session {
clipboard: Clipboard::default(),
journal: vec![],
tool: vectorcraft_tools::create("selection"),
lender: None,
last_selection_tool: None,
last_view: ViewInfo::default(),
depth: 0,
tool_panic: None,
@@ -866,8 +855,6 @@ impl Session {
return;
}
let view = self.last_view;
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
let _ = self.give_back_tool(view);
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let _ = self.apply_actions(acts);
// A batch leaves its interaction open in the document it leaves, to keep or roll back with
@@ -1477,8 +1464,6 @@ mod tests_scalestrokes;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
#[cfg(test)]
mod tests_strokegeom;
#[cfg(test)]
mod tests_strokegradient;
+10 -141
View File
@@ -57,20 +57,6 @@ fn click(s: &mut Session, tool: &str, x: f64, y: f64) -> Vec<UiRequest> {
drag(s, tool, &[(x, y)], Mods::default())
}
/// A gesture of the active tool, `m` held throughout: press at the first point, drag through the
/// others, release at the last. The undo steps it made.
fn gesture(s: &mut Session, pts: &[(f64, f64)], m: Mods) -> usize {
let v = view();
let undo = undo_steps(s);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
undo_steps(s) - undo
}
/// A wavy stroke sampled every 2 pt.
fn wave(x0: f64, x1: f64, y: f64) -> Vec<(f64, f64)> {
let n = ((x1 - x0) / 2.0) as usize;
@@ -636,6 +622,16 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
let gesture = |s: &mut Session, pts: &[(f64, f64)], m: Mods| {
let undo = undo_steps(s);
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
undo_steps(s) - undo
};
// Alt-drag the out handle: it moves alone, the in handle stays.
assert_eq!(gesture(&mut s, &[(250.0, 100.0), (260.0, 130.0), (275.0, 165.0)], alt), 1);
assert!(!s.in_interaction());
@@ -663,130 +659,3 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
assert_eq!(path(&s, new).subpaths[0].anchors.len(), 3);
assert_eq!(paths(&s).len(), 2);
}
/// The anchor's handles point opposite ways.
fn smooth(a: vectorcraft_geom::Anchor) -> bool {
let (i, o) = (a.h_in - a.p, a.h_out - a.p);
(i.x * o.y - i.y * o.x).abs() < 1e-6 && i.x * o.x + i.y * o.y < 0.0
}
#[test]
fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
// #494: Cmd (Ctrl) held with the Pen drags with the selection tool used last (Direct Selection
// until one is chosen): handles, segments and anchors of the path being drawn; released, the
// Pen goes on drawing that path. Through pointer events, so agents reach it too.
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(100.0, 300.0)], none);
gesture(&mut s, &[(200.0, 300.0), (250.0, 300.0)], none);
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(s.cursor(Point::new(250.0, 300.0), none, v), vectorcraft_tools::Cursor::Pen);
assert_eq!(s.cursor(Point::new(250.0, 300.0), cmd, v), vectorcraft_tools::Cursor::ArrowHollow, "Direct Selection's");
// The new anchor's outgoing handle: it moves, its incoming one turning with it.
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 300.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "directSelection");
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "pen");
let a = path(&s, id).subpaths[0].anchors[1];
assert!(a.h_out == Point::new(250.0, 260.0) && smooth(a), "{a:?}");
// The segment between the two anchors bends, the smooth anchor staying smooth.
let before = path(&s, id).subpaths[0].anchors[1].h_in;
gesture(&mut s, &[(120.0, 300.0), (120.0, 270.0)], cmd);
let a = path(&s, id).subpaths[0].anchors[1];
assert!(a.h_in != before && smooth(a), "{a:?}");
assert_eq!(s.tool_id(), "pen");
// A plain click goes on drawing the same path.
gesture(&mut s, &[(300.0, 350.0)], none);
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 3));
// A Cmd-click away from the art deselects: the path is done, the next click starts another.
gesture(&mut s, &[(600.0, 100.0)], cmd);
gesture(&mut s, &[(400.0, 500.0)], none);
assert_eq!(paths(&s).len(), 2);
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
// With the Selection tool chosen last, Cmd lends it.
s.select_tool("selection", v).unwrap();
s.select_tool("pen", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "selection");
s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0).with_mods(cmd), v).unwrap();
assert_eq!(s.tool_id(), "pen");
// Choosing a tool mid-gesture keeps it: the Pen doesn't come back over it.
s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
s.select_tool("rectangle", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0), v).unwrap();
assert_eq!(s.tool_id(), "rectangle");
}
#[test]
fn reshaping_a_segment_keeps_smooth_anchors_smooth() {
// #494: the anchors at the ends of a dragged segment keep their kind: a smooth one's other
// handle turns with the moved one (at its length), a corner's stays.
let mut s = session();
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300, "in": [160, 300], "out": [240, 300]}, {"x": 300, "y": 300}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.execute("path.reshapeSegment", &json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": -30})).unwrap();
let sp = &path(&s, id).subpaths[0];
let a = sp.anchors[1];
assert!(near(a.h_in, Point::new(160.0, 260.0)) && smooth(a), "{a:?}");
assert!((a.h_out.distance(a.p) - 40.0).abs() < 1e-9, "its length kept");
assert!(near(sp.anchors[0].h_out, Point::new(100.0, 260.0)) && !sp.anchors[0].has_in(), "the corner end: {:?}", sp.anchors[0]);
}
#[test]
fn pen_drag_from_the_first_anchor_closes_the_path() {
// #501: closing a path by dragging out of its first anchor flickered between a close and a new
// path, and released at the wrong moment left the shape open. The press decides: the drag
// shapes the closing curve, symmetric, and the release closes the path wherever it ends.
let none = Mods::default();
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
for (x, y) in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
gesture(&mut s, &[(x, y)], none);
}
let id = s.doc().unwrap().selection.objects[0];
let pts: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
let p = path(&s, id);
assert!(p.subpaths[0].closed && paths(&s).len() == 1, "closed throughout the drag, at ({x}, {y})");
}
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 252.0), v).unwrap();
assert!(!s.in_interaction());
let sp = &path(&s, id).subpaths[0];
assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
let a = sp.anchors[0];
assert_eq!((a.h_in, a.h_out), (Point::new(40.0, 348.0), Point::new(160.0, 252.0)), "symmetric");
assert_eq!(paths(&s).len(), 1);
// The next click starts a new path.
gesture(&mut s, &[(500.0, 500.0)], none);
assert_eq!(paths(&s).len(), 2);
}
#[test]
fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() {
// #504: while drawing, Alt-click on a smooth anchor of the path makes it a corner and Alt-drag
// on a corner one pulls out smooth handles; the next click goes on drawing the path.
let (none, alt) = (Mods::default(), Mods { alt: true, ..Mods::default() });
let v = view();
let mut s = session();
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(100.0, 300.0)], none);
gesture(&mut s, &[(200.0, 300.0), (240.0, 300.0)], none);
gesture(&mut s, &[(300.0, 400.0)], none);
gesture(&mut s, &[(400.0, 300.0)], none);
let id = s.doc().unwrap().selection.objects[0];
assert!(smooth(path(&s, id).subpaths[0].anchors[1]));
assert_eq!(gesture(&mut s, &[(200.0, 300.0)], alt), 1);
let a = path(&s, id).subpaths[0].anchors[1];
assert!(!a.has_in() && !a.has_out(), "a corner: {a:?}");
assert_eq!(gesture(&mut s, &[(300.0, 400.0), (320.0, 400.0), (340.0, 400.0)], alt), 1);
let a = path(&s, id).subpaths[0].anchors[2];
assert!(smooth(a) && a.h_out == Point::new(340.0, 400.0), "smooth: {a:?}");
gesture(&mut s, &[(500.0, 400.0)], none);
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 5));
}
+13 -16
View File
@@ -123,37 +123,34 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
assert_eq!(partial.corner_radius, 12.0);
}
/// Performance › Graphics Processor (#306, #502): automatic by default, set by value or label,
/// kept through a save/load round trip, and a bad value (0.5.0's `powerSaving` among them) is an
/// error that changes nothing.
/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
/// through a save/load round trip, and a bad value is an error that changes nothing.
#[test]
fn gpu_preference_defaults_to_automatic_and_validates() {
fn gpu_preference_defaults_to_power_saving_and_validates() {
let mut s = Session::new();
assert_eq!(s.prefs.gpu_preference, "automatic");
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("automatic"));
assert_eq!(s.prefs.gpu_preference, "powerSaving");
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "highPerformance");
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "lowPower");
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "automatic"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "automatic");
for bad in [json!("powerSaving"), json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
assert_eq!(s.prefs.gpu_preference, "powerSaving");
for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
assert_eq!(s.prefs.gpu_preference, "automatic");
assert_eq!(s.prefs.gpu_preference, "powerSaving");
}
let p = Prefs { gpu_preference: "lowPower".into(), ..Default::default() };
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back.gpu_preference, "lowPower");
assert_eq!(back.gpu_preference, "highPerformance");
// Preference files written before the preference existed get the default.
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
assert_eq!(old.gpu_preference, "automatic");
assert_eq!(old.gpu_preference, "powerSaving");
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
assert_eq!(row["category"], "Performance");
assert_eq!(row["options"], json!(["automatic", "lowPower", "highPerformance"]));
assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "automatic");
assert_eq!(s.prefs.gpu_preference, "powerSaving");
}
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
-109
View File
@@ -1,109 +0,0 @@
//! Smart Guides for the drawing tools, driven through the session (#506): where the Rectangle
//! tool's corners and the Pen's anchors land, and the guides they show.
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
use super::*;
use crate::tooling::ViewInfo;
/// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s
}
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
}
fn labels(s: &mut Session) -> Vec<(String, Point)> {
s.overlays(ViewInfo::default()).into_iter().filter_map(|o| if let Overlay::Label { text, p, .. } = o { Some((text, p)) } else { None }).collect()
}
/// The bounds of the object the last gesture made (it is selected).
fn made(s: &Session) -> Rect {
let d = s.doc().unwrap();
let id: NodeId = d.selection.objects[0];
d.doc.node(id).unwrap().geometric_bounds().unwrap()
}
#[test]
fn a_rectangle_corner_lands_on_another_objects_anchor() {
let mut s = session();
s.select_tool("rectangle", ViewInfo::default()).unwrap();
// Hovering near the square's corner already says where the press goes.
pointer(&mut s, PointerKind::Move, 302.0, 297.0);
pointer(&mut s, PointerKind::Move, 202.0, 203.0);
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
pointer(&mut s, PointerKind::Down, 302.0, 297.0);
pointer(&mut s, PointerKind::Drag, 250.0, 250.0);
pointer(&mut s, PointerKind::Drag, 202.0, 203.0);
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
let measure = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None });
assert!(measure.as_deref().is_some_and(|t| t.starts_with("W: 102") && t.contains("H: 100")), "{measure:?}");
pointer(&mut s, PointerKind::Up, 202.0, 203.0);
// The start lined up with the artboard's centre (y = 300), the corner on the anchor.
assert_eq!(made(&s), Rect::new(200.0, 200.0, 302.0, 300.0));
}
/// The corner being dragged never snaps to the rectangle's own preview: dragged slowly, it follows
/// the pointer instead of sticking where the last step left it.
#[test]
fn a_rectangle_does_not_snap_to_itself_while_drawn() {
let mut s = session();
s.select_tool("rectangle", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 320.0, 420.0);
for x in 370..=380 {
pointer(&mut s, PointerKind::Drag, f64::from(x), 470.0 + f64::from(x - 370));
}
pointer(&mut s, PointerKind::Up, 380.0, 480.0);
assert_eq!(made(&s), Rect::new(320.0, 420.0, 380.0, 480.0));
}
#[test]
fn a_pen_anchor_lines_up_with_another_objects_centre() {
let mut s = session();
s.select_tool("pen", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 300.0, 420.0);
pointer(&mut s, PointerKind::Up, 300.0, 420.0);
// Hovering: in line with the square's centre (150, 150), the guide running from it.
pointer(&mut s, PointerKind::Move, 151.5, 431.0);
let ov = s.overlays(ViewInfo::default());
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if *a == Point::new(150.0, 150.0) && *b == Point::new(150.0, 431.0))), "{ov:?}");
assert_eq!(labels(&mut s), [("align".to_string(), Point::new(150.0, 431.0))]);
pointer(&mut s, PointerKind::Down, 151.5, 431.0);
pointer(&mut s, PointerKind::Up, 151.5, 431.0);
// Shift: along the 45° step from it, slid into line with the square's left side (x = 100).
s.pointer(&PointerEvent::new(PointerKind::Down, 103.0, 386.0).with_mods(Mods { shift: true, ..Mods::default() }), ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Up, 103.0, 386.0);
let d = s.doc().unwrap();
let path = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap();
let pts: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts[..2], [Point::new(300.0, 420.0), Point::new(150.0, 431.0)]);
assert!((pts[2] - Point::new(100.0, 381.0)).hypot() < 1e-9, "{pts:?}");
}
/// Preferences › Smart Guides › Construction Guides: on (the default), a Pen anchor near the
/// 45° guide through the last one lands on it; off, it stays where it was clicked.
#[test]
fn construction_guides_follow_the_preference() {
for on in [true, false] {
let mut s = session();
s.execute("prefs.set", &json!({"key": "constructionGuides", "value": on})).unwrap();
s.select_tool("pen", ViewInfo::default()).unwrap();
pointer(&mut s, PointerKind::Down, 500.0, 500.0);
pointer(&mut s, PointerKind::Up, 500.0, 500.0);
pointer(&mut s, PointerKind::Down, 561.0, 438.0);
pointer(&mut s, PointerKind::Up, 561.0, 438.0);
let d = s.doc().unwrap();
let last = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[1].p;
let want = if on { Point::new(561.5, 438.5) } else { Point::new(561.0, 438.0) };
assert!((last - want).hypot() < 1e-9, "{on}: {last:?}");
}
}
+6 -68
View File
@@ -82,7 +82,6 @@ impl Session {
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
/// perspective grid stays hidden), finishing any pending tool work first.
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
self.give_back_tool(view)?;
if self.tool.id() == id {
return Ok(());
}
@@ -90,47 +89,9 @@ impl Session {
self.apply_actions(acts)?;
self.keep_tool_settings();
self.tool = self.make_tool(id);
if vectorcraft_tools::catalog::is_selection_tool(id) {
self.last_selection_tool = Some(self.tool.id());
}
Ok(())
}
/// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is,
/// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others.
/// None for the selection tools themselves and the view tools.
fn lent_tool(&self, id: &str) -> Option<&'static str> {
use vectorcraft_tools::catalog::{edits_anchors, is_selection_tool};
if is_selection_tool(id) || matches!(id, "hand" | "zoom" | "rotateView") {
return None;
}
Some(self.last_selection_tool.unwrap_or(if edits_anchors(id) { "directSelection" } else { "selection" }))
}
/// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the
/// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click,
/// not Select Behind). The tool lent to stays as it was, unaware, until the release.
fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent {
if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() {
return *ev;
}
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
let lent = self.make_tool(id);
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
}
/// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the
/// Pen goes on drawing its path).
pub(crate) fn give_back_tool(&mut self, view: ViewInfo) -> Result<()> {
let Some(lender) = self.lender.take() else { return Ok(()) };
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let r = self.apply_actions(acts);
self.keep_tool_settings();
self.tool = lender;
r.map(drop)
}
/// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this
/// session or a saved one.
pub(crate) fn make_tool(&self, id: &str) -> Box<dyn Tool> {
@@ -178,7 +139,6 @@ impl Session {
let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit());
let cx = ToolContext {
doc: &st.doc,
revision: (st.uid, st.revision),
selection: &st.selection,
zoom: view.zoom,
isolation: st.isolation,
@@ -223,11 +183,6 @@ impl Session {
measurement_labels: self.prefs.measurement_labels,
transform_tools_guides: self.prefs.transform_tools_guides,
snapping_tolerance: self.prefs.snapping_tolerance,
construction_angles: if self.prefs.construction_guides {
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)
} else {
&[]
},
screen: view.screen,
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
};
@@ -257,19 +212,13 @@ impl Session {
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
let ev = &self.lend_selection_tool(ev);
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
let r = self.take_tool_panic().and_then(|()| {
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
if ev.kind == PointerKind::Up {
self.keep_tool_settings();
}
self.apply_actions(acts)
});
self.take_tool_panic()?;
// A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
if ev.kind == PointerKind::Up {
self.give_back_tool(view)?;
self.keep_tool_settings();
}
r
self.apply_actions(acts)
}
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
@@ -376,20 +325,9 @@ impl Session {
}
/// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing
/// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
/// press would borrow ([`Self::lend_selection_tool`]).
/// tools' a crosshair, [`Cursor::precise`]).
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id()) } else { None };
let c = match lent {
Some(id) => {
let lent = self.make_tool(id);
let own = std::mem::replace(&mut self.tool, lent);
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, Mods { cmd: false, ..mods }));
self.tool = own;
c
}
None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)),
};
let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods));
if self.prefs.use_precise_cursors { c.precise() } else { c }
}
+7 -5
View File
@@ -488,10 +488,6 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
"/Sh0 sh",
"/Sh1 sh",
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
// Lines wrapped in a frame, digits set apart by gaps, glyph outlines stroked (#508).
"BT /F1 10 Tf 10 80 Td (Wrapped line ) Tj 0 -12 Td (and its next ) Tj 0 -12 Td (end.) Tj ET",
"BT /F1 10 Tf 10 40 Td (3) Tj 20 0 Td (5331) Tj 40 0 Td (0106) Tj ET",
"0.5 w 10 10 m 14 18 l 18 10 l h S",
"7 Tr",
"q 10 10 50 50 re W n",
"Q",
@@ -953,7 +949,13 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"/T4 << /FontType 3 /FontMatrix [1 0 0 1 0 0] /BuildGlyph { pop pop (x) show } /Encoding [/a] >> definefont setfont",
"glyphshow", "xshow", "xyshow", "awidthshow", "kshow", "/a", "[1 2 3]",
// Executable strings and integer keys (Illustrator 8's procsets).
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
// Several data sources reading one file in turn, and strings of other lengths.
"/f currentfile /ASCIIHexDecode filter def", "{f 3 string readstring pop}", "{f 0 string readstring pop}", "true 3 colorimage",
"3 2 8 [3 0 0 2 0 0] {f 5 string readstring pop} {(ab)} {f 1 string readstring pop} true 3 colorimage",
"<< /ImageType 1 /Width 3 /Height 2 /BitsPerComponent 4 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [3 0 0 2 0 0] /MultipleDataSources true /DataSource [(a) (bc) {f 2 string readstring pop} (d)] >>",
// Virtual memory restored: dictionary changes undone, saves restored twice.
"/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store",
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
])
.prop_map(str::to_string),
]
+42 -29
View File
@@ -635,33 +635,50 @@ impl Interp<'_> {
/// The bytes a data procedure gives, called until it gives an empty string or `n` bytes.
fn gather(&mut self, p: &Obj, n: usize) -> Res<Vec<u8>> {
let mut out = vec![];
while out.len() < n {
self.call(p.clone())?;
let s = self.pop_str()?;
let s = s.borrow();
if s.is_empty() {
break;
}
self.alloc(s.len())?;
out.extend_from_slice(&s);
}
out.truncate(n);
Ok(out)
Ok(self.read_sources(&[Source::Proc(p.clone())], n, n)?.pop().unwrap_or_default())
}
/// `n` bytes of image data from `src` (zeros past its end).
fn source_bytes(&mut self, src: &Source, n: usize) -> Res<Vec<u8>> {
let mut v = match src {
Source::File(f) => self.read(f, n)?,
Source::Str(s) if !s.is_empty() => s.iter().copied().cycle().take(n).collect(),
Source::Str(_) => vec![],
Source::Proc(p) => self.gather(p, n)?,
};
let mut v = self.read_sources(std::slice::from_ref(src), n, n)?.pop().unwrap_or_default();
v.resize(n, 0);
Ok(v)
}
/// Up to `n` bytes from each of `sources`, read in turn as an image reads its data sources:
/// each procedure called once a round (its string, whatever its length), each file and
/// string giving `step` bytes a round, until each has given `n` bytes or ended. Procedures
/// reading one file (the planes of an image, row by row) get their own rows that way.
fn read_sources(&mut self, sources: &[Source], n: usize, step: usize) -> Res<Vec<Vec<u8>>> {
let mut out: Vec<Vec<u8>> = vec![vec![]; sources.len()];
let mut ended = vec![false; sources.len()];
while out.iter().zip(&ended).any(|(o, e)| o.len() < n && !e) {
for ((src, o), e) in sources.iter().zip(out.iter_mut()).zip(ended.iter_mut()) {
if *e || o.len() >= n {
continue;
}
let want = step.min(n - o.len());
let chunk = match src {
Source::File(f) => self.read(f, want)?,
// A string gives its bytes over and over.
Source::Str(s) => {
let at = o.len() % s.len().max(1);
s.get(at..).unwrap_or_default().iter().chain(s.iter().cycle()).take(want).copied().collect()
}
Source::Proc(p) => {
self.call(p.clone())?;
let s = self.pop_str()?.to_vec();
self.alloc(s.len())?;
s
}
};
*e = chunk.is_empty();
o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default());
}
}
Ok(out)
}
/// `eexec`: an encrypted font program follows. It is skipped (type is drawn in the app's
/// fonts, by name): the font dictionary it would complete is defined as it is.
fn eexec(&mut self) -> Res {
@@ -849,16 +866,12 @@ impl Interp<'_> {
let multi = s.sources.len() > 1;
let row_bits = if multi { s.w * bpc } else { s.w * n * bpc };
let row = row_bits.div_ceil(8);
let planes: Vec<Vec<u8>> = if multi {
let mut v = vec![];
for src in &s.sources {
v.push(self.source_bytes(src, row * s.h)?);
}
v
} else {
let src = s.sources.first().ok_or(PsError::Ps("undefined", "DataSource".into()))?;
vec![self.source_bytes(src, row * s.h)?]
};
// Several sources are read a row at a time each, in turn (zeros past their ends).
let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? };
let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?;
for p in &mut planes {
p.resize(row * s.h, 0);
}
let max = ((1u32 << bpc.min(16)) - 1) as f64;
let sample = |plane: &[u8], y: usize, i: usize| -> u32 {
let bit = y * row * 8 + i * bpc;
+62 -20
View File
@@ -49,6 +49,11 @@ pub(crate) struct Interp<'a> {
/// Bytes asked for so far (see [`MAX_MEMORY`]).
allocated: usize,
seed: u32,
/// The `save`s not restored yet, oldest first.
saves: Vec<SaveMark>,
/// What dictionary entries were before they changed since the oldest `save` (dictionary,
/// key, old value): `restore` puts them back, as restoring virtual memory does.
journal: Vec<(DictRef, Key, Option<Obj>)>,
/// Where the error that is unwinding was raised (see [`Fault`]).
pub fault: Option<Fault>,
/// The width a Type 3 glyph procedure gave (`setcachedevice`, `setcharwidth`).
@@ -72,6 +77,13 @@ pub(crate) struct Fault {
/// Most procedure names a [`Fault`] keeps.
const FAULT_PROCS: usize = 3;
/// Where a `save` was made: the graphics states saved and the journal's length then.
#[derive(Clone, Copy, Debug)]
struct SaveMark {
gstates: usize,
journal: usize,
}
fn new_dict() -> DictRef {
Rc::new(RefCell::new(Dict::new()))
}
@@ -143,6 +155,8 @@ impl<'a> Interp<'a> {
ops: 0,
allocated: 0,
seed: 1,
saves: vec![],
journal: vec![],
fault: None,
glyph_width: None,
g,
@@ -190,6 +204,7 @@ impl<'a> Interp<'a> {
}
self.dicts.clear();
self.stack.clear();
self.journal.clear();
}
// ---------- the operand stack ----------
@@ -879,7 +894,7 @@ impl<'a> Interp<'a> {
}
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
insert(&d, key, v)?;
self.insert(&d, key, Some(v))?;
true
}
_ => return ps_err("typecheck", ""),
@@ -968,7 +983,7 @@ impl<'a> Interp<'a> {
let v = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
insert(&d, k, v)?;
self.insert(&d, k, Some(v))?;
}
Load => {
let k = self.pop()?;
@@ -981,7 +996,7 @@ impl<'a> Interp<'a> {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let d = self.dicts.iter().rev().find(|d| d.borrow().contains_key(&k)).or(self.dicts.last()).cloned();
let d = d.ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
insert(&d, k, v)?;
self.insert(&d, k, Some(v))?;
}
Known => {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
@@ -1001,7 +1016,8 @@ impl<'a> Interp<'a> {
}
Undef => {
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", String::new()))?;
self.pop_dict()?.borrow_mut().remove(&k);
let d = self.pop_dict()?;
self.insert(&d, k, None)?;
}
CurrentDict => {
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", String::new()))?;
@@ -1034,11 +1050,23 @@ impl<'a> Interp<'a> {
}
Save => {
self.gsave()?;
self.push(Obj::Save(self.saved.len()))?;
self.push(Obj::Save(self.saves.len()))?;
self.saves.push(SaveMark { gstates: self.saved.len(), journal: self.journal.len() });
}
Restore => {
let Obj::Save(level) = self.pop()? else { return ps_err("typecheck", "") };
while self.saved.len() >= level && !self.saved.is_empty() {
let Obj::Save(i) = self.pop()? else { return ps_err("typecheck", "") };
// A save restored already (or through an outer one) can't be restored again.
let mark = self.saves.get(i).copied().ok_or(PsError::Ps("invalidrestore", String::new()))?;
self.saves.truncate(i);
// The dictionaries as they were at the save, newest change undone first.
for (d, k, old) in self.journal.drain(mark.journal..).rev() {
let mut d = d.borrow_mut();
match old {
Some(v) => d.insert(k, v),
None => d.remove(&k),
};
}
while self.saved.len() >= mark.gstates && !self.saved.is_empty() {
self.grestore();
}
}
@@ -1083,9 +1111,11 @@ impl<'a> Interp<'a> {
self.push(Obj::Int(1 << 20))?;
}
}
// An empty font cache with a printer's sizes: bytes used and most, fonts used and most,
// glyphs used and most, and the largest glyph cached (programs divide by it).
CacheStatus => {
for _ in 0..7 {
self.push(Obj::Int(0))?;
for v in [0, 1 << 20, 0, 1000, 0, 10_000, 25_000] {
self.push(Obj::Int(v))?;
}
}
StartJob => {
@@ -1143,7 +1173,9 @@ impl<'a> Interp<'a> {
}
Obj::Dict(d) => {
let from = self.pop_dict()?.borrow().clone();
d.borrow_mut().extend(from);
for (k, v) in from {
self.insert(&d, k, Some(v))?;
}
self.push(Obj::Dict(d))?;
}
_ => return ps_err("typecheck", "copy"),
@@ -1219,6 +1251,26 @@ impl<'a> Interp<'a> {
}
}
/// Put `v` under `k` in `d` (`None`: remove it), within [`MAX_ALLOC`] entries; while a `save`
/// is open the old value is kept for `restore`.
fn insert(&mut self, d: &DictRef, k: Key, v: Option<Obj>) -> Res {
let old = {
let mut dm = d.borrow_mut();
if v.is_some() && dm.len() >= MAX_ALLOC && !dm.contains_key(&k) {
return Err(PsError::Limit("a dictionary grew too large"));
}
match v {
Some(v) => dm.insert(k.clone(), v),
None => dm.remove(&k),
}
};
if !self.saves.is_empty() {
self.alloc(2 * std::mem::size_of::<Obj>())?;
self.journal.push((d.clone(), k, old));
}
Ok(())
}
// ---------- resources ----------
/// The resources of `category`.
@@ -1293,16 +1345,6 @@ impl<'a> Interp<'a> {
}
}
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
fn insert(d: &DictRef, k: Key, v: Obj) -> Res {
let mut d = d.borrow_mut();
if d.len() >= MAX_ALLOC && !d.contains_key(&k) {
return Err(PsError::Limit("a dictionary grew too large"));
}
d.insert(k, v);
Ok(())
}
/// An array or string index.
fn index(k: &Obj) -> Res<usize> {
match *k {
+2
View File
@@ -39,6 +39,8 @@ mod tests_fontnames;
mod tests_generators;
#[cfg(test)]
mod tests_illustrator;
#[cfg(test)]
mod tests_images;
use std::sync::Arc;
+4
View File
@@ -280,6 +280,10 @@ fn type_becomes_point_type_in_the_font_named() {
assert!(t2.xf.translation().x > 15.0);
assert_eq!(crate::family_style("ABCDEF+TimesNewRomanPS-BoldItalicMT"), ("Times New Roman".into(), "Bold Italic".into()));
assert_eq!(crate::family_style("Times-Roman"), ("Times".into(), "Regular".into()));
// Illustrator's reencoded copies (#505).
assert_eq!(crate::family_style("Impact*1"), ("Impact".into(), "Regular".into()));
assert_eq!(crate::family_style("MyriadPro-Bold*12"), ("Myriad Pro".into(), "Bold".into()));
assert_eq!(crate::family_style("Odd*Name"), ("Odd*Name".into(), "Regular".into()));
}
#[test]
+25 -1
View File
@@ -42,7 +42,7 @@ fn a85_flate(data: &[u8]) -> String {
}
/// Pixel `(x, y)` (straight RGBA) of the first image in `d`.
fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] {
pub(super) fn pixel(d: &Document, x: u32, y: u32) -> [u8; 4] {
let im = all(d)
.into_iter()
.find_map(|n| match &n.kind {
@@ -991,3 +991,27 @@ showpage"##;
assert!(all(d).iter().any(|n| fill(n).and_then(Paint::color) == Some(Color::rgb(0.0, 1.0, 0.0))));
assert_eq!(objects(d).len(), 3);
}
/// `restore` puts dictionaries back as they were at the `save`, as restoring virtual memory does:
/// Illustrator's procsets count `save`s in a dictionary and raise `limitcheck` at 64, so every file
/// with more than 64 images (the rasters of drop shadows and glows on type, #505) fell back to
/// its preview while the count only grew.
#[test]
fn restore_puts_dictionaries_back() {
let r = read(
"/AGM 4 dict def AGM /n 0 put \
/mysave { save AGM /n get 1 add dup 64 ge { limitcheck } if AGM exch /n exch put } def \
1 1 100 { pop mysave 0 0 1 1 rectfill restore } for AGM /n get 0 eq { 10 10 5 5 rectfill } if",
);
clean(&r);
assert_eq!(objects(&r.document).len(), 101);
// def, store, put, undef and copy are undone, newest first; a save restored can't be again.
super::tests::check(
"/a 1 def /d 2 dict def d /k 1 put save /a 2 def /b 3 def d /k 2 put d /k undef 1 dict dup /z 9 put d copy pop \
/a 4 store restore a 1 eq /b where not and d /k get 1 eq and d /z known not and",
);
super::tests::check("save dup restore { restore } stopped");
super::tests::check("save save exch restore { restore } stopped");
// The font cache's sizes are numbers programs divide by (Illustrator 8's pattern fills).
super::tests::check("cachestatus 7 1 roll 6 { pop } repeat 0 gt");
}
+128
View File
@@ -0,0 +1,128 @@
//! Image data read the way images read it: several data sources in turn, rows padded to a byte at
//! every bit depth and odd widths, predictors undone row by row (#498).
use super::tests::read;
use super::tests_generators::pixel;
/// Each pixel of the first image, row by row.
fn pixels(r: &crate::Imported, w: u32, h: u32) -> Vec<[u8; 4]> {
assert!(!r.preview && r.warnings.is_empty(), "{:?}", r.warnings);
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| pixel(&r.document, x, y)).collect()
}
/// A CMYK image whose four data sources are procedures reading one file, a row of one ink each in
/// turn (how Illustrator CS and later write colour images): each source gets its own rows. Read
/// one after the other, the cyan source took the first four rows of all four inks and the image
/// came out in horizontal bands (#498).
#[test]
fn procedures_reading_one_file_are_read_in_turn() {
// 3 × 2 pixels; rows: C M Y K of row 0, then of row 1.
let rows = ["ff0000", "00ff00", "000000", "000000", "000000", "000000", "ffff00", "0000ff"];
let r = read(&format!(
"/DeviceCMYK setcolorspace 30 20 scale /f currentfile /ASCIIHexDecode filter def \
<< /ImageType 1 /Width 3 /Height 2 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [3 0 0 -2 0 2] \
/MultipleDataSources true /DataSource [ {{f 3 string readstring pop}} {{f 3 string readstring pop}} \
{{f 3 string readstring pop}} {{f 3 string readstring pop}} ] >> image\n{}>",
rows.concat()
));
let cyan = [0, 174, 239, 255];
let magenta = [236, 0, 140, 255];
let yellow = [255, 242, 0, 255];
let black = [35, 31, 32, 255];
let white = [255, 255, 255, 255];
let px = pixels(&r, 3, 2);
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 40);
let want = [cyan, magenta, white, yellow, yellow, black];
assert!(px.iter().zip(want).all(|(a, b)| near(*a, b)), "{px:?}");
// The operand form: `{r} {g} {b} true 3 colorimage`, procedures reading one file.
let r = read(
"30 20 scale /f currentfile /ASCIIHexDecode filter def \
3 2 8 [3 0 0 -2 0 2] {f 3 string readstring pop} {f 3 string readstring pop} {f 3 string readstring pop} true 3 colorimage\n\
ff0000 00ff00 0000ff 102030 405060 708090>",
);
assert_eq!(pixels(&r, 3, 2), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [16, 64, 112, 255], [32, 80, 128, 255], [48, 96, 144, 255]]);
}
/// Separate strings and files as the sources: each is read for its own plane, whatever the row.
#[test]
fn separate_sources_keep_their_planes() {
let r = read("30 20 scale 3 2 8 [3 0 0 -2 0 2] <ff0000102030> <00ff00405060> <0000ff708090> true 3 colorimage");
assert_eq!(pixels(&r, 3, 2), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [16, 64, 112, 255], [32, 80, 128, 255], [48, 96, 144, 255]]);
}
/// Rows of an odd width at 1, 2, 4, 12 and 16 bits a sample start at a byte (their last byte
/// padded), in one source and in several.
#[test]
fn rows_of_every_depth_start_at_a_byte() {
// 3 pixels a row, two rows: 0, max, mid / max, 0, 0 (grey), each row padded.
let cases = [
(1, "a0 80"), // 101(00000) 100(00000)
(2, "cc c0"), // 11 00 11 (00), 11 00 00 (00)
(4, "f0 80 f0 00"), // f 0 8 (0), f 0 0 (0)
(12, "fff0008000 fff0000000"), // fff 000 800 (0), fff 000 000 (0)
(16, "ffff00008000 ffff00000000"),
];
for (bpc, data) in cases {
let data = data.replace(' ', "");
let r = read(&format!("30 20 scale 3 2 {bpc} [3 0 0 -2 0 2] <{data}> image"));
let px: Vec<u8> = pixels(&r, 3, 2).iter().map(|p| p[0]).collect();
let want: &[u8] = match bpc {
1 => &[255, 0, 255, 255, 0, 0],
2 => &[255, 0, 255, 255, 0, 0],
4 => &[255, 0, 136, 255, 0, 0],
12 => &[255, 0, 128, 255, 0, 0],
_ => &[255, 0, 128, 255, 0, 0],
};
assert_eq!(px, want, "{bpc} bits");
}
// Several 4-bit sources, odd width: each plane's rows padded on their own.
let r = read("30 20 scale 3 2 4 [3 0 0 -2 0 2] <f0f000f0> <0f00f000> <00f00ff0> true 3 colorimage");
let (on, off) = (255, 0);
let want = [[on, off, off], [off, on, off], [on, off, on], [off, on, off], [off, off, on], [on, off, on]];
assert_eq!(pixels(&r, 3, 2), want.map(|[r, g, b]| [r, g, b, 255]));
}
/// A PNG-predicted Flate image of an odd width: every row filter (None, Sub, Up, Average, Paeth)
/// undone on its own row.
#[test]
fn predicted_rows_of_an_odd_width() {
// 3 RGB pixels a row, five rows; each row predicted with its own filter.
let raw: Vec<[u8; 9]> = (0..5u8).map(|y| std::array::from_fn(|i| (i as u8).wrapping_mul(29).wrapping_add(y.wrapping_mul(53)))).collect();
let mut encoded = vec![];
let mut prev = [0u8; 9];
for (kind, row) in raw.iter().enumerate() {
encoded.push(kind as u8);
for i in 0..9 {
let a = if i >= 3 { row[i - 3] } else { 0 };
let b = prev[i];
let c = if i >= 3 { prev[i - 3] } else { 0 };
let pred = match kind {
1 => a,
2 => b,
3 => ((u16::from(a) + u16::from(b)) / 2) as u8,
4 => {
let p = i16::from(a) + i16::from(b) - i16::from(c);
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
_ => 0,
};
encoded.push(row[i].wrapping_sub(pred));
}
prev = *row;
}
let r = read(&format!(
"/DeviceRGB setcolorspace 30 50 scale << /ImageType 1 /Width 3 /Height 5 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] /ImageMatrix [3 0 0 -5 0 5] \
/DataSource currentfile /ASCII85Decode filter << /Predictor 15 /Colors 3 /Columns 3 >> /FlateDecode filter >> image\n{}",
crate::ps::ascii85(&crate::ps::deflate(&encoded))
));
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
assert_eq!(pixels(&r, 3, 5), want);
}
+6 -1
View File
@@ -20,12 +20,17 @@ const DEFAULT_FONT: &str = "Helvetica";
/// A font's family and style from its PostScript name: `Helvetica-BoldOblique` → (`Helvetica`,
/// `Bold Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`), without a subset
/// prefix (`ABCDEF+`).
/// prefix (`ABCDEF+`) or a reencoded copy's suffix (`*1`).
pub fn family_style(name: &str) -> (String, String) {
let name = match name.split_once('+') {
Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => name,
};
// A copy the program reencoded (`Impact*1`, as Illustrator's type procsets name them).
let name = match name.rsplit_once('*') {
Some((font, n)) if !font.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => font,
_ => name,
};
let (family, style) = name.split_once('-').unwrap_or((name, ""));
let trim = |s: &str| -> String {
let s = ["PSMT", "MT", "PS"].iter().find_map(|suffix| s.strip_suffix(suffix)).unwrap_or(s);
-2
View File
@@ -49,8 +49,6 @@ mod tests_links;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_pen;
#[cfg(test)]
mod tests_persp;
#[cfg(test)]
mod tests_place;
-27
View File
@@ -588,30 +588,3 @@ fn type_preferences_apply_to_agents() {
assert_eq!(r["isError"], false, "{r}");
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
}
/// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn
/// from near another object's corner starts on it, and a Pen anchor placed beside that object's
/// centre lines up with it.
#[test]
fn drawing_gestures_snap_to_smart_guides() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 800, "height": 600}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
assert_eq!(r["isError"], false, "{r}");
let bounds = |s: &mut Server, n: u64, events: Value, tool: &str| {
let r = call(s, n, "pointer_gesture", json!({"tool": tool, "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].clone();
let doc: Value = serde_json::from_str(&text_of(&call(s, n + 1, "inspect_document", json!({})))).unwrap();
let made = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("drawn").clone();
let b = &made["bounds"];
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
};
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let events = json!([{"kind": "down", "x": 300, "y": 420}, {"kind": "up", "x": 300, "y": 420}, {"kind": "move", "x": 151.5, "y": 431}, {"kind": "down", "x": 151.5, "y": 431}, {"kind": "up", "x": 151.5, "y": 431}]);
assert_eq!(bounds(&mut s, 6, events, "pen"), [150.0, 420.0, 150.0, 11.0]);
}
-66
View File
@@ -1,66 +0,0 @@
//! The Pen over MCP, headless: `pointer_gesture` with the modifiers held as on the desktop.
use serde_json::{Value, json};
use crate::{Backend, Headless, call_tool};
fn text(r: &crate::ToolResult) -> Value {
assert!(!r.is_error, "{r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
}
/// A press at the first point, drags through the others, the release at the last.
fn gesture(pts: &[(f64, f64)]) -> Value {
let (first, last) = (pts[0], pts[pts.len() - 1]);
let mut events = vec![json!({"kind": "down", "x": first.0, "y": first.1})];
events.extend(pts[1..].iter().map(|(x, y)| json!({"kind": "drag", "x": x, "y": y})));
events.push(json!({"kind": "up", "x": last.0, "y": last.1}));
json!({ "events": events })
}
/// The anchors of each path, in paint order.
fn paths(h: &Headless) -> Vec<Vec<vectorcraft_geom::Anchor>> {
let mut v = vec![];
h.session.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
v.push(p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect());
}
});
v
}
/// #494: Cmd held at the press borrows Direct Selection from the Pen; the path goes on afterwards.
#[test]
fn cmd_with_the_pen_edits_the_path_being_drawn() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(100.0, 300.0)])));
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(200.0, 300.0), (250.0, 300.0)])));
let mut drag = gesture(&[(250.0, 300.0), (250.0, 280.0), (250.0, 263.0)]);
drag["mods"] = json!({"cmd": true});
let r = text(&call_tool(&mut h, "pointer_gesture", &drag));
assert_eq!(r["tool"], "pen", "{r}");
assert_eq!(paths(&h)[0][1].h_out, vectorcraft_geom::Point::new(250.0, 263.0));
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(300.0, 350.0)])));
assert_eq!(paths(&h).iter().map(Vec::len).collect::<Vec<_>>(), [3], "the same path, three anchors");
}
/// #501: a drag out of the first anchor closes the path, shaping the closing curve.
#[test]
fn a_drag_from_the_first_anchor_closes_the_path() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
for p in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
text(&call_tool(&mut h, "pointer_gesture", &gesture(&[p])));
}
let close: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
text(&call_tool(&mut h, "pointer_gesture", &gesture(&close)));
let d = &h.session.doc().unwrap().doc;
let mut closed = vec![];
d.walk(|n| closed.extend(n.path_data().map(|p| p.is_closed())));
assert_eq!(closed, [true], "one path, closed");
let first = paths(&h)[0][0];
assert_eq!((first.h_in, first.h_out), (vectorcraft_geom::Point::new(40.0, 348.0), vectorcraft_geom::Point::new(160.0, 252.0)));
}
+5 -74
View File
@@ -23,7 +23,7 @@ use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
/// The name of the paths text imports as.
@@ -76,33 +76,6 @@ fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec<Slot>, nesting: &mut Nesti
slots.push(Slot::Group(child));
}
/// The top-level group that group `g` is listed under (`g` itself when it isn't a sublayer).
fn top_group(g: usize, ocgs: &Ocgs) -> usize {
let mut top = g;
for _ in 0..=MAX_NESTING {
match ocgs.list.get(top).and_then(|o| o.parent) {
Some(parent) => top = parent,
None => break,
}
}
top
}
/// Merge the top-level groups a page marks (`page`, in the order it first marks each, its empty
/// ones too) into the stacking order of the pages before (`order`, bottom first; `known`: its
/// groups). A group new to it goes right below the first group the page marks after it that it
/// has: a layer whose art starts on a later page keeps its place among the others (#508).
fn merge_order(order: &mut Vec<usize>, known: &mut HashSet<usize>, page: &[usize]) {
let mut above = None;
for &g in page.iter().rev() {
if known.insert(g) {
let at = above.and_then(|a| order.iter().position(|&x| x == a)).unwrap_or(order.len());
order.insert(at, g);
}
above = Some(g);
}
}
/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read.
fn group_layer(
b: &mut Builder<'_>,
@@ -193,8 +166,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let mut slots: Vec<Slot> = vec![];
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
let mut nesting = Nesting::new();
// The top-level groups in the order the pages mark them, bottom first.
let (mut order, mut known) = (vec![], HashSet::new());
for (i, &number) in picked.iter().enumerate() {
let Some(page) = pages.get(number) else { continue };
// The chosen box sits at (x, 0); the page draws round it.
@@ -204,11 +175,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
if route {
let mut seen = HashSet::new();
let marked: Vec<usize> = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect();
merge_order(&mut order, &mut known, &marked);
}
b.begin_page(scan);
interpret_page(page, &mut ctx, &mut b);
let mut parts = b.end_page();
@@ -273,25 +239,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
if placeholder {
return Err(PdfError::PlaceholderOnly);
}
// A group the pages mark but draw nothing in is an empty layer, as the editor had it.
for &g in &order {
if !nesting.contains_key(&g) {
slots.push(Slot::Group(g));
}
}
// The groups' layers stack as the pages mark them (the art outside them stays where it was
// drawn): a group that first paints on a later page doesn't go on top of the others.
let rank: HashMap<usize, usize> = order.iter().enumerate().map(|(i, &g)| (g, i)).collect();
let at: Vec<usize> = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect();
let mut stacked: Vec<usize> = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect();
stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX));
for (i, g) in at.into_iter().zip(stacked) {
if let Some(s) = slots.get_mut(i) {
*s = Slot::Group(g);
}
}
for (n, slot) in slots.into_iter().enumerate() {
let mut layer = match slot {
let layer = match slot {
Slot::Page(l) => *l,
// Sublayers take their top-level layer's colour.
Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) {
@@ -299,9 +248,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
None => continue,
},
};
if let Some(art) = layer.children_mut() {
crate::import_lines::rebuild(art, &b.lines);
}
doc.layers.push(Arc::new(layer));
}
for (k, blob) in b.images.drain() {
@@ -621,8 +567,6 @@ struct Builder<'p> {
nested: u32,
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
last_fill: Option<(NodeId, BezPath)>,
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
lines: HashMap<NodeId, LineFacts>,
}
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
@@ -757,7 +701,6 @@ impl<'p> Builder<'p> {
pending: false,
nested: 0,
last_fill: None,
lines: HashMap::new(),
}
}
@@ -843,11 +786,10 @@ impl<'p> Builder<'p> {
let current = self.current_group();
let Some(f) = self.stack.last_mut() else { return };
f.children.push(n);
// A frame's art goes where its first art was drawn (a clip can end in a later group).
if root {
self.root_groups.push(group.or(current));
} else if f.group.is_none() {
f.group = group.or(current);
f.group = group;
}
}
@@ -1013,10 +955,9 @@ impl<'p> Builder<'p> {
}
fn flush_text(&mut self) {
let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return };
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
self.lines.insert(n.id, facts);
self.push_node(n);
}
@@ -1286,14 +1227,6 @@ impl<'p> Builder<'p> {
place(&mut line);
self.text = Some(line);
}
// Its ink: a stroke the file draws as outlines of the glyphs is told by it.
let mut ink = o.outline();
if let Some(line) = &mut self.text
&& !ink.elements().is_empty()
{
ink.apply_affine(m);
line.ink.push(ink.bounding_box());
}
true
}
}
@@ -1637,10 +1570,8 @@ impl<'a> Device<'a> for Builder<'_> {
if self.nested > 0 {
return;
}
// The runs gathered within a layer's sequence are its art: finish them before it ends.
if self.marked.last().copied().flatten().is_some() {
if self.marked.pop().flatten().is_some() {
self.flush();
}
self.marked.pop();
}
}
-254
View File
@@ -1,254 +0,0 @@
//! What a file's lines of type were, beyond what each line says on its own (#508). An editor
//! writes what PDF text can't say as more art next to the text, and the type it wrapped in a
//! frame as lines:
//! - a stroke added to a type object (above or below its characters) comes as the outline of each
//! glyph stroked, one path per glyph, right after the type (or right before it): it is the
//! type's stroke again, so the type stays one object;
//! - area type comes as a line of point type per line, each wrapped line ending in a space. Lines
//! in a row sharing a left edge, font, size and leading (and paragraph spacing) are one area
//! type object again, aligned left or justified, where laying its text out in the frame breaks
//! it where the file does: else, as where its font isn't installed, they stay lines.
use std::collections::HashMap;
use std::sync::Arc;
use kurbo::{Point, Rect, Shape};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::shapes;
use crate::import_text::LineFacts;
/// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`).
pub(crate) fn rebuild(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
if lines.is_empty() {
return;
}
for n in nodes.iter_mut() {
if n.children().is_some_and(|c| !c.is_empty())
&& let Some(children) = Arc::make_mut(n).children_mut()
{
rebuild(children, lines);
}
}
join_strokes(nodes, lines);
join_paragraphs(nodes, lines);
}
/// The stroke paths `nodes` draw when they are the outlines of the glyphs whose ink boxes are
/// `ink`, stroked one by one alike (no fill, mask or blending, fully opaque).
fn outline_stroke(nodes: &[Arc<Node>], ink: &[Rect]) -> Option<AppearanceItem> {
let first = nodes.first()?;
let [stroke @ AppearanceItem::Stroke(_)] = first.appearance.items.as_slice() else { return None };
let on_glyph = |n: &Node, r: &Rect| {
let tol = 0.01 * (r.width() + r.height()) + 0.01;
let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false };
let b = path.to_bezpath().bounding_box();
n.appearance == first.appearance
&& n.mask.is_none()
&& n.opacity >= 0.999
&& n.blend == BlendMode::Normal
&& [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol)
};
(nodes.len() == ink.len() && nodes.iter().zip(ink).all(|(n, r)| on_glyph(n, r))).then(|| stroke.clone())
}
/// Each type object among `nodes` whose glyphs' outlines are stroked by the paths right after it
/// (or right before it) takes that stroke above (or below) its characters, and the paths go.
fn join_strokes(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
let mut i = 0;
while i < nodes.len() {
let ink = nodes
.get(i)
.filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.appearance.items.is_empty())
.and_then(|n| lines.get(&n.id))
.map_or(&[][..], |f| f.ink.as_slice());
let k = ink.len();
if k > 0 {
if let Some(stroke) = nodes.get(i + 1..i + 1 + k).and_then(|after| outline_stroke(after, ink)) {
nodes.drain(i + 1..i + 1 + k);
if let Some(t) = nodes.get_mut(i) {
Arc::make_mut(t).appearance.items.push(stroke);
}
} else if let Some(stroke) = i.checked_sub(k).and_then(|from| nodes.get(from..i)).and_then(|before| outline_stroke(before, ink)) {
nodes.drain(i - k..i);
i -= k;
if let Some(t) = nodes.get_mut(i) {
let ap = &mut Arc::make_mut(t).appearance;
ap.items.insert(0, stroke);
ap.set_contents_at(1);
}
}
}
i += 1;
}
}
/// `n` as a line that can be part of area type: horizontal point type, with what its line told.
fn line<'a>(n: &'a Node, lines: &'a HashMap<NodeId, LineFacts>) -> Option<(&'a TextObject, &'a LineFacts)> {
let NodeKind::Text(t) = &n.kind else { return None };
let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty();
point.then_some(())?;
Some((t, lines.get(&n.id)?))
}
/// Lines of type in a row that may be one area type object.
struct Block {
/// The step from a wrapped line's baseline to the next.
leading: f64,
/// The extra step after a line ending a paragraph.
space: f64,
/// Each line's baseline below the first (text space).
ys: Vec<f64>,
}
/// The lines from `nodes`' first on that share its left edge, angle, font and size, its look
/// (opacity, blending, appearance) and steps down by one leading after a wrapped line and by
/// another (the leading and paragraph spacing) after a line ending a paragraph: how many, and
/// the block they make unless none of them was wrapped (0 and `None`: the first isn't a line).
fn block(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>) -> (usize, Option<Block>) {
let Some((head, (t0, f0))) = nodes.first().and_then(|n| Some((n, line(n, lines)?))) else { return (0, None) };
let st = t0.first_style();
let size = st.size;
let inv = t0.xf.inverse();
let lin = |t: &TextObject| {
let [a, b, c, d, ..] = t.xf.as_coeffs();
[a, b, c, d]
};
let (mut ys, mut wraps) = (vec![0.0], f0.wraps);
let (mut leading, mut para): (Option<f64>, Option<f64>) = (None, None);
for n in nodes.iter().skip(1) {
let Some((t, f)) = line(n, lines) else { break };
let s = t.first_style();
let alike = n.opacity == head.opacity
&& n.blend == head.blend
&& n.appearance == head.appearance
&& s.font_family == st.font_family
&& s.font_style == st.font_style
&& (s.size - size).abs() <= size * 0.01
&& t.para == t0.para
&& lin(t).iter().zip(lin(t0)).all(|(a, b)| (a - b).abs() < 1e-6);
let at = inv * (t.xf * Point::ZERO);
let step = at.y - ys.last().copied().unwrap_or_default();
if !alike || at.x.abs() > size * 0.02 || !(step > size * 0.5 && step < size * 4.0) {
break;
}
let slot = if wraps { &mut leading } else { &mut para };
match *slot {
Some(s) if (s - step).abs() > size * 0.01 => break,
Some(_) => {}
None => *slot = Some(step),
}
ys.push(at.y);
wraps = f.wraps;
}
let count = ys.len();
let Some(leading) = leading else { return (count, None) };
let space = para.map_or(0.0, |p| p - leading);
(count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys }))
}
/// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it
/// out breaks its lines where the file does.
fn area_type(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>, b: &Block) -> Option<TextObject> {
let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::<Option<_>>()?;
let (first, _) = texts.first()?;
let size = first.first_style().size;
// Each line's tracking (a line with spaces tracked apart was set by hand).
let trackings: Vec<f64> = texts
.iter()
.map(|(t, _)| {
let mut all = t.runs.iter().map(|r| r.style.tracking);
let one = all.next()?;
all.all(|x| x == one).then_some(one)
})
.collect::<Option<_>>()?;
let median = |mut v: Vec<f64>| {
v.sort_by(f64::total_cmp);
v.get(v.len() / 2).copied()
};
let wrapped: Vec<f64> = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect();
let longest = |v: &[f64]| v.iter().copied().fold(0.0, f64::max);
let mut tries = vec![];
// Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked as the
// lines that end paragraphs are (the others are spaced out).
let full = longest(&wrapped);
if wrapped.len() >= 2 && wrapped.iter().all(|l| (full - l).abs() <= size * 0.02) {
let ends: Vec<f64> = texts.iter().zip(&trackings).filter(|((_, f), _)| !f.wraps).map(|(_, t)| *t).collect();
let tracking = median(ends).or_else(|| trackings.iter().copied().reduce(f64::min))?;
tries.push((Justify::JustifyLeft, full, tracking));
}
let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect();
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?));
tries.into_iter().find_map(|(justify, width, tracking)| laid_out(&texts, b, justify, width, tracking))
}
/// The area type of lines `texts` (block `b`): `width` wide, aligned `justify`, its characters
/// tracked `tracking`; `None` when it doesn't lay out as the lines did.
fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64) -> Option<TextObject> {
let (first, _) = texts.first()?;
let size = first.first_style().size;
// Leading as set, unless it is Auto's (120% of the size).
let explicit =
(b.leading - size * 1.2).abs() > size * 1e-3 || texts.iter().any(|(t, _)| t.runs.iter().any(|r| (r.style.size - size).abs() > 1e-9));
let mut runs: Vec<TextRun> = vec![];
for (k, (t, f)) in texts.iter().enumerate() {
for r in &t.runs {
let mut style = r.style.clone();
style.tracking = tracking;
style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0);
match runs.last_mut() {
Some(last) if last.style == style => last.text.push_str(&r.text),
_ => runs.push(TextRun { text: r.text.clone(), style }),
}
}
// A wrapped line's space, or the end of a paragraph.
if k + 1 < texts.len()
&& let Some(last) = runs.last_mut()
{
last.text.push(if f.wraps { ' ' } else { '\n' });
}
}
let mut t = (*first).clone();
t.runs = runs;
t.para.justify = justify;
t.para.space_before = b.space;
let last = b.ys.last().copied()?;
let db = vectorcraft_text::FontDb::global();
// The frame's top puts the first baseline where the file has it.
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, last + b.leading.max(size) + size)) };
let top = -vectorcraft_text::layout(db, &t).lines.first()?.baseline;
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, top, width, last + b.leading.max(size))) };
let laid = vectorcraft_text::layout(db, &t);
let plain = t.plain_text();
let same = !laid.overflow
&& laid.lines.len() == texts.len()
&& laid.lines.iter().zip(texts).zip(&b.ys).all(|((l, (line, f)), y)| {
let text = plain.get(l.start..l.end).unwrap_or_default();
text.trim() == line.plain_text().trim()
&& (l.baseline - y).abs() <= size * 0.05
&& (justify != Justify::JustifyLeft || !f.wraps || (l.x1 - f.length).abs() <= size * 0.05)
});
t.cached_bounds = Some(laid.bounds);
same.then_some(t)
}
/// Lines of point type in a row among `nodes` that were wrapped in a frame become one area type
/// object again (see [`area_type`]).
fn join_paragraphs(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
let mut i = 0;
while i < nodes.len() {
let (count, b) = nodes.get(i..).map_or((0, None), |rest| block(rest, lines));
let area = b.filter(|_| count > 1).and_then(|b| area_type(nodes.get(i..i + count)?, lines, &b));
if let Some(area) = area
&& let Some(head) = nodes.get_mut(i)
{
Arc::make_mut(head).kind = NodeKind::Text(Box::new(area));
nodes.drain(i + 1..i + count);
i += 1;
} else {
// Lines that don't make area type: none of them starts it either.
i += count.max(1);
}
}
}
+12 -116
View File
@@ -9,10 +9,9 @@
use std::collections::HashMap;
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
use kurbo::{Affine, BezPath, Point, Vec2};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
use vectorcraft_text::TextLayout;
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
#[derive(Clone, Copy, Debug)]
@@ -104,8 +103,6 @@ pub(crate) struct TextLine {
next: Point,
/// The last glyph's origin.
last: Point,
/// Where the last glyph that isn't a space ends.
visible: Point,
/// The last glyph's baseline direction.
last_dir: Vec2,
/// Each glyph's baseline origin, then where the last one ends.
@@ -115,24 +112,6 @@ pub(crate) struct TextLine {
upright: Option<Upright>,
/// The glyphs make a column (vertical type), each below the last.
column: bool,
/// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are
/// left out.
pub ink: Vec<Rect>,
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
/// the end of the glyph before to the start of the next (points).
gaps: Vec<(usize, f64)>,
}
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
#[derive(Clone, Debug, Default)]
pub(crate) struct LineFacts {
/// [`TextLine::ink`].
pub ink: Vec<Rect>,
/// Horizontal point type: it ended in a space (as a line an editor wrapped in a frame does).
pub wraps: bool,
/// Horizontal point type: how long the file set it, from its first glyph's origin to the end
/// of its last glyph that isn't a space (points, along the baseline).
pub length: f64,
}
/// The most a glyph on a curve turns from the one before it (about 20°).
@@ -140,20 +119,7 @@ const CURVE_TURN_COS: f64 = 0.94;
impl TextLine {
pub fn new(at: Placement, opacity: f32) -> Self {
Self {
at,
opacity,
next: at.origin,
last: at.origin,
visible: at.origin,
last_dir: at.dir,
baseline: vec![],
runs: vec![],
upright: None,
column: false,
ink: vec![],
gaps: vec![],
}
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![], upright: None, column: false }
}
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
@@ -205,7 +171,6 @@ impl TextLine {
if self.column || self.upright.is_some() {
return false;
}
let bytes: usize = self.runs.iter().map(|(_, t)| t.len()).sum();
if let Some((last, run)) = self.runs.last_mut() {
let size = self.at.size.max(at.size);
let gap = (at.origin - self.next).dot(self.last_dir);
@@ -227,9 +192,6 @@ impl TextLine {
// A gap wider than a fifth of an em reads as a space.
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
run.push(' ');
if on_line {
self.gaps.push((bytes, gap));
}
}
if last.takes(look) {
run.push_str(text);
@@ -240,9 +202,6 @@ impl TextLine {
self.runs.push((look.clone(), text.to_string()));
}
self.next = at.origin + at.dir * advance;
if !text.trim().is_empty() {
self.visible = self.next;
}
self.last = at.origin;
self.last_dir = at.dir;
self.baseline.push(at.origin);
@@ -288,12 +247,10 @@ impl TextLine {
true
}
/// The point type object, its opacity and what else the line tells.
pub fn finish(mut self) -> Option<(TextObject, f32, LineFacts)> {
let mut facts = LineFacts { ink: std::mem::take(&mut self.ink), ..LineFacts::default() };
/// The point type object and its opacity.
pub fn finish(mut self) -> Option<(TextObject, f32)> {
// Turned along a curve: type on a path through the glyphs.
let path = if self.curved() { self.baseline_path() } else { None };
facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(_, t)| t.ends_with(' '));
if let Some((_, last)) = self.runs.last_mut() {
last.truncate(last.trim_end().len());
}
@@ -315,7 +272,7 @@ impl TextLine {
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
return Some((t, self.opacity, facts));
return Some((t, self.opacity));
}
let (start, along) = match self.upright {
// Vertical point type (a column, or a single upright glyph) is anchored at the top
@@ -329,87 +286,26 @@ impl TextLine {
};
// Tracking that makes the line as long as in the file: the PDF placed each glyph, the
// layout sets them by their advances. Spread over the gaps between characters (tracking
// follows each character, the last one's past the end of the line). A horizontal line is
// measured to its last glyph that isn't a space: the spaces after it were dropped.
let length = (if t.vertical { self.next } else { self.visible } - start).dot(along);
if !t.vertical {
facts.length = length;
}
// follows each character, the last one's past the end of the line).
let length = (self.next - start).dot(along);
let laid = vectorcraft_text::layout(db, &t);
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
let size = self.at.size;
let mut bounds = laid.bounds;
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
// The spaces read from gaps keep their own place only in text left in the file's order.
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
let gaps: Vec<(usize, f64)> =
if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size);
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
let tracking = ((length - natural) / (chars - 1) as f64 / size * 1000.0).clamp(-1000.0, 1000.0).round();
for r in &mut t.runs {
r.style.tracking = tracking;
}
// Tracked: laid out again for its bounds.
bounds = vectorcraft_text::layout(db, &t).bounds;
}
t.cached_bounds = Some(bounds);
Some((t, self.opacity, facts))
Some((t, self.opacity))
}
}
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs
/// after them more than a tenth of an em off (a barcode's digits, set in groups apart). Those take
/// their own tracking, given with their byte offsets.
fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) {
let pairs = chars.saturating_sub(1).max(1) as f64;
// The space and the character before it take `need` (the gap less the space) between them;
// tracking gives them twice its share.
let space = |b: usize| laid.glyphs.iter().find(|g| g.byte == b).map(|g| g.advance);
let needs: Vec<(usize, f64)> = gaps.iter().filter_map(|&(b, gap)| Some((b, gap - space(b)?))).collect();
let mut own: Vec<(usize, f64)> = vec![];
let mut tracking = extra / pairs;
// Each space taken apart changes the others' share: until no more are.
loop {
let more: Vec<(usize, f64)> =
needs.iter().filter(|(b, need)| !own.iter().any(|(o, _)| o == b) && (need - 2.0 * tracking).abs() > size * 0.1).copied().collect();
if more.is_empty() {
break;
}
own.extend(more);
let rest = pairs - 2.0 * own.len() as f64;
tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 };
}
own.sort_by_key(|(b, _)| *b);
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
(em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect())
}
/// `runs` with tracking `tracking`, and each space at a byte offset of `spaces` (in order) a run
/// of its own with the tracking given with it.
fn tracked(runs: Vec<TextRun>, tracking: f64, spaces: &[(usize, f64)]) -> Vec<TextRun> {
let mut out = Vec::with_capacity(runs.len() + 2 * spaces.len());
let mut off = 0;
for r in runs {
let style = CharStyle { tracking, ..r.style };
let end = off + r.text.len();
let mut from = 0;
for &(b, own) in spaces.iter().filter(|(b, _)| (off..end).contains(b)) {
let at = b - off;
let (Some(before), Some(space)) = (r.text.get(from..at), r.text.get(at..at + 1)) else { continue };
if !before.is_empty() {
out.push(TextRun { text: before.to_string(), style: style.clone() });
}
out.push(TextRun { text: space.to_string(), style: CharStyle { tracking: own, ..style.clone() } });
from = at + 1;
}
if let Some(rest) = r.text.get(from..).filter(|t| !t.is_empty()) {
out.push(TextRun { text: rest.to_string(), style });
}
off = end;
}
out
}
fn round(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
-3
View File
@@ -48,7 +48,6 @@ mod images;
mod import;
mod import_color;
mod import_image;
mod import_lines;
mod import_mask;
mod import_scan;
mod import_shading;
@@ -232,8 +231,6 @@ mod tests_import_fidelity;
#[cfg(test)]
mod tests_import_layers;
#[cfg(test)]
mod tests_import_lines;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_layers;
+3 -5
View File
@@ -242,13 +242,11 @@ fn text_becomes_point_type() {
// The baseline origin, in y-down page space.
let o = t[0].xf * kurbo::Point::ORIGIN;
assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}");
// A gap of an em between words reads as a space, tracked on its own to keep the gap's width
// (#508); another font on the line is another run.
// A gap of an em between words reads as a space; another font on the line is another run.
assert_eq!(text(&t[1]), "Next line in bold");
let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect();
assert_eq!(runs, [("Next", "Regular"), (" ", "Regular"), ("line", "Regular"), (" in bold", "Bold")]);
assert!(t[1].runs[1].style.tracking > t[1].runs[0].style.tracking + 100.0, "{:?}", t[1].runs);
assert_eq!(t[1].runs[3].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert_eq!(runs, [("Next line", "Regular"), (" in bold", "Bold")]);
assert_eq!(t[1].runs[1].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
// As outlines: paths, no type.
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
-203
View File
@@ -1,203 +0,0 @@
//! What a file's lines of type were (#508): the layer art ending a group's marked content belongs
//! to, layers ordered as the pages mark them, a stroke written as stroked glyph outlines, spaces
//! read from wide gaps, and lines wrapped in a frame rebuilt as area type.
use kurbo::{BezPath, PathEl, Point, Shape};
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Affine, Rect, shapes};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
use crate::*;
const HELVETICA: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
/// Groups "A" and "B" (A painted first) for a file of `pages` pages, and the catalog entries.
fn groups(pages: usize) -> ([String; 2], String, String) {
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
let objs = ["<< /Type /OCG /Name (A) >>".to_string(), "<< /Type /OCG /Name (B) >>".to_string()];
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{b} 0 R {a} 0 R] >> >> ");
let resources = format!("/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> {HELVETICA}");
(objs, catalog, resources)
}
/// A file of 100 × 100 pt pages drawing `contents`, with groups A (`/MC0`) and B (`/MC1`).
fn layered(contents: &[&str]) -> Document {
let (objs, catalog, resources) = groups(contents.len());
let pages: Vec<PdfPage> = contents.iter().map(|c| PdfPage { resources: resources.clone(), ..PdfPage::new(100.0, 100.0, c) }).collect();
import(&pdf_with_catalog(&pages, &[&objs[0], &objs[1]], &catalog, None)).unwrap()
}
/// The layers' names, bottom first, and the kinds of their art.
fn layers(d: &Document) -> Vec<(String, Vec<&'static str>)> {
let kind = |n: &std::sync::Arc<Node>| match n.kind {
NodeKind::Text(_) => "text",
NodeKind::Group { .. } => "group",
_ => "path",
};
d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().map_or(vec![], |c| c.iter().map(kind).collect()))).collect()
}
fn texts(d: &Document) -> Vec<TextObject> {
let mut v = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
v.push((**t).clone());
}
});
v
}
fn named(layers: &[(&str, &[&'static str])]) -> Vec<(String, Vec<&'static str>)> {
layers.iter().map(|(n, k)| (n.to_string(), k.to_vec())).collect()
}
#[test]
fn type_ending_a_layers_marked_content_stays_in_its_layer() {
// The line is still being gathered when the group's sequence ends.
let d = layered(&["/OC /MC0 BDC 0 0 1 rg 0 0 10 10 re f BT /F1 12 Tf 10 50 Td (Lead) Tj ET EMC /OC /MC1 BDC EMC"]);
assert_eq!(layers(&d), named(&[("A", &["path", "text"]), ("B", &[])]));
}
#[test]
fn a_clip_ending_in_a_later_group_stays_with_its_art() {
// The clip opens in A, its art is drawn in A, and it closes after B's (empty) sequence.
let d = layered(&["/OC /MC0 BDC q 0 0 50 50 re W n 1 0 0 rg 40 40 20 20 re f EMC /OC /MC1 BDC EMC Q"]);
assert_eq!(layers(&d), named(&[("A", &["group"]), ("B", &[])]));
}
#[test]
fn layers_stack_as_the_pages_mark_them_when_one_first_paints_on_a_later_page() {
// A, the bottom layer, is empty on page 1 and has art on page 2 only.
let d = layered(&["/OC /MC0 BDC EMC /OC /MC1 BDC 1 0 0 rg 0 0 10 10 re f EMC", "/OC /MC0 BDC 0 0 1 rg 0 0 10 10 re f EMC /OC /MC1 BDC EMC"]);
assert_eq!(layers(&d), named(&[("A", &["path"]), ("B", &["path"])]));
// A group marked with no art on any page is an empty layer in its place.
let d = layered(&["/OC /MC0 BDC EMC /OC /MC1 BDC 1 0 0 rg 0 0 10 10 re f EMC"]);
assert_eq!(layers(&d), named(&[("A", &[]), ("B", &["path"])]));
}
/// A 100 × 100 pt page drawing `content` in Helvetica.
fn page(content: &str) -> Vec<u8> {
vectorcraft_testkit::pdf::pdf(&[PdfPage { resources: HELVETICA.into(), ..PdfPage::new(100.0, 100.0, content) }], None)
}
/// The outline of glyph `c` drawn at 24 pt from (x, 50), in PDF operators (y up), stroked.
fn outline(c: char, x: f64) -> String {
let opts = ImportOptions { text_as: TextAs::Outlines, ..Default::default() };
let d = import_with_report(&page(&format!("BT /F1 24 Tf {x} 50 Td ({c}) Tj ET")), &opts).unwrap().document;
let mut p: Option<BezPath> = None;
d.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
p = Some(path.to_bezpath());
}
});
let pt = |q: Point| format!("{:.3} {:.3}", q.x, 100.0 - q.y);
let ops: String = p
.unwrap()
.elements()
.iter()
.map(|e| match e {
PathEl::MoveTo(a) => format!("{} m ", pt(*a)),
PathEl::LineTo(a) => format!("{} l ", pt(*a)),
PathEl::QuadTo(a, b) => format!("{} {} {} c ", pt(*a), pt(*b), pt(*b)),
PathEl::CurveTo(a, b, c) => format!("{} {} {} c ", pt(*a), pt(*b), pt(*c)),
PathEl::ClosePath => "h ".into(),
})
.collect();
ops + "S "
}
#[test]
fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
// "Hi": the H is 722 units wide in Helvetica.
let strokes = format!("0 0 1 RG 0.5 w {}{}", outline('H', 10.0), outline('i', 10.0 + 0.722 * 24.0));
let text = "BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET ";
for (content, below) in [(format!("{text}{strokes}"), false), (format!("{strokes}{text}"), true)] {
let d = import(&page(&content)).unwrap();
let art = d.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "one object: {art:?}");
let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) };
assert_eq!(t.plain_text(), "Hi");
assert_eq!(t.first_style().fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let ap = &art[0].appearance;
let [AppearanceItem::Stroke(st)] = ap.items.as_slice() else { panic!("{ap:?}") };
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert!((st.width - 0.5).abs() < 1e-6);
assert_eq!(ap.contents_at(), usize::from(below), "a stroke drawn first paints below the characters");
}
// A stroke that isn't on the glyphs stays a path.
let d = import(&page(&format!("{text}0 0 1 RG 0.5 w {}", outline('H', 40.0)))).unwrap();
assert_eq!(d.layers[0].children().unwrap().len(), 2);
}
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
crate::tests_live_text::text_pdf(lines, false)
}
#[test]
fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() {
// A barcode's digits in three groups, set apart by more than a space.
let (x1, x2) = (29.0, 69.0);
let bytes = source_sans(&[(20.0, 50.0, 10.0, "3"), (x1, 50.0, 10.0, "533121"), (x2, 50.0, 10.0, "010629")]);
let t = texts(&import(&bytes).unwrap());
assert_eq!(t.len(), 1);
assert_eq!(t[0].plain_text(), "3 533121 010629");
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]);
let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x;
assert!((at(2) - f64::from(x1)).abs() < 0.05, "the second group starts where the file has it: {} vs {x1}", at(2));
assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9));
}
/// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30).
fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document {
let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
let mut t = TextObject::point(Point::ORIGIN, text, style);
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, width, 200.0)) };
t.xf = Affine::translate((20.0, 30.0));
t.para.justify = justify;
t.para.space_before = space_before;
let mut d = Document::new(300.0, 300.0);
let l = d.layers[0].id;
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
d.insert(Some(l), 0, n).unwrap();
d
}
/// `d` exported (real text) and imported again.
fn round_trip(d: &Document) -> Document {
let settings: PdfSettings = serde_json::from_value(json!({"compression": {"compressText": false}, "advanced": {"outlineText": false}})).unwrap();
let bytes = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
import(&bytes).unwrap()
}
const PROSE: &str = "Stop losing your sinkers on the cast: the clip holds the lead until a snag pulls it free, \
so the fish swims on without the weight.\nIt works on every running rig and leaves the line clear.";
#[test]
fn lines_wrapped_in_a_frame_come_back_as_area_type() {
for (justify, space) in [(Justify::Left, 0.0), (Justify::JustifyLeft, 4.0)] {
let d = round_trip(&area_doc(PROSE, justify, 120.0, space));
let t = texts(&d);
assert_eq!(t.len(), 1, "{justify:?}: one object, not a line each: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
assert_eq!(t[0].plain_text(), PROSE);
assert_eq!(t[0].para.justify, justify);
assert!((t[0].para.space_before - space).abs() < 0.01, "{}", t[0].para.space_before);
let TextKind::Area { frame } = &t[0].kind else { panic!("{:?}", t[0].kind) };
let w = frame.to_bezpath().bounding_box().width();
assert!(w <= 120.5 && w > 100.0, "{w}");
let origin = t[0].xf * Point::ZERO;
assert!((origin.x - 20.0).abs() < 0.05, "{origin:?}");
}
}
#[test]
fn lines_that_were_not_wrapped_stay_point_type() {
// A list: each line its own, none ending in a space.
let bytes = source_sans(&[(20.0, 50.0, 10.0, "Hooks"), (20.0, 62.0, 10.0, "Swivels"), (20.0, 74.0, 10.0, "Leads")]);
assert_eq!(texts(&import(&bytes).unwrap()).len(), 3);
// Wrapped lines whose breaks a frame doesn't give again (a line far short of the others).
let bytes = source_sans(&[(20.0, 50.0, 10.0, "One two three four five six "), (20.0, 62.0, 10.0, "a "), (20.0, 74.0, 10.0, "seven")]);
assert_eq!(texts(&import(&bytes).unwrap()).len(), 3);
}
+3 -3
View File
@@ -13,7 +13,7 @@ const SOURCE_SERIF: &[u8] = include_bytes!("../../../assets/fonts/SourceSerif4-R
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the
/// same rotated 90° when `rotated`.
pub(crate) fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
@@ -269,7 +269,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
let (t, ..) = line.finish().unwrap();
let (t, _) = line.finish().unwrap();
assert!(t.vertical, "one upright glyph is vertical type");
assert_eq!(t.plain_text(), "§");
}
@@ -294,7 +294,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
let y = 100.0 + 30.0 * i as f64;
assert!(line.push_upright(&look, at(y), 1.0, Upright { top: Point::new(110.0, y - 18.0) }, c));
}
let (t, ..) = line.finish().unwrap();
let (t, _) = line.finish().unwrap();
assert!(t.vertical);
assert_eq!(t.plain_text(), "§§§", "letter spacing isn't a space");
}
-13
View File
@@ -154,17 +154,6 @@ pub fn group_of(id: &str) -> Option<usize> {
TOOL_GROUPS.iter().position(|g| g.iter().any(|t| t.id == id))
}
/// Selection, Direct Selection and Group Selection: the tools Cmd held lends the others for a drag.
pub fn is_selection_tool(id: &str) -> bool {
matches!(id, "selection" | "directSelection" | "groupSelection")
}
/// The tools that show a selected path's anchors and handles as Direct Selection does, and drag
/// them: a Cmd drag with one of them is a Direct Selection drag until a selection tool is chosen.
pub fn edits_anchors(id: &str) -> bool {
matches!(id, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -189,7 +178,5 @@ mod tests {
assert_eq!(tool_for_shortcut("V").unwrap().id, "selection");
assert_eq!(tool_for_shortcut("Shift+M").unwrap().id, "shapeBuilder");
assert_eq!(group_of("star"), group_of("rectangle"));
assert!(is_selection_tool("groupSelection") && !is_selection_tool("pen"));
assert!(edits_anchors("pen") && !edits_anchors("rectangle"));
}
}
+22 -88
View File
@@ -28,11 +28,11 @@ use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{HandleSnap, PointSnap, Targets};
use crate::guides::{PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
@@ -112,8 +112,6 @@ pub struct DirectSelectionTool {
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// What the dragged handle snaps to.
handle_snap: HandleSnap,
/// What the objects being moved snap to, gathered when the move begins.
move_snap: Option<MoveSnap>,
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
@@ -130,7 +128,6 @@ impl DirectSelectionTool {
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
hover: None,
guide: GuideEdit::default(),
@@ -220,37 +217,30 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<A
})
}
/// The anchors whose direction handles show, and drag, with the tools that edit anchors
/// ([`crate::catalog::edits_anchors`]): the direct-selected anchors of a path, every anchor of a
/// path selected as a whole. Selection & Anchor Display → Show handles when multiple anchors are
/// selected off, none while more than one anchor is selected.
fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
let mut v = vec![];
for &id in &cx.selection.objects {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
let shown = cx.selection.partial(id);
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| (id, si, ai, *a)));
}
if !cx.handles_multiple && v.len() > 1 {
v.clear();
}
v
/// 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
}
/// The end of a shown direction handle ([`handle_anchors`]) nearest to `p` within `tol`, and
/// nearer to it than the handle's anchor (a short handle leaves its anchor to be picked): (path,
/// subpath, anchor, is_out).
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
let mut best: Option<((NodeId, usize, usize, bool), f64)> = None;
for (id, si, ai, a) in handle_anchors(cx) {
for (out, has, h) in [(true, a.has_out(), a.h_out), (false, a.has_in(), a.h_in)] {
let d = h.distance(p);
if has && d <= tol && d < a.p.distance(p) && best.is_none_or(|b| d < b.1) {
best = Some(((id, si, ai, out), d));
/// 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 {
let Some(a) = pd.subpaths.get(si).and_then(|s| s.anchors.get(ai)) else { continue };
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
}
}
}
best.map(|b| b.0)
None
}
fn anchors_json(v: &[AnchorRef]) -> Value {
@@ -345,7 +335,6 @@ impl Tool for DirectSelectionTool {
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out };
self.handle_snap = HandleSnap::default();
return vec![Action::Begin("Reshape".into())];
}
if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) {
@@ -482,7 +471,7 @@ impl Tool for DirectSelectionTool {
out
}
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
self.guides = guides;
vec![Action::Preview(
"path.setHandle".into(),
@@ -882,61 +871,6 @@ mod tests {
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
}
/// #494: the handles shown are the handles dragged. A path selected as a whole (the one the
/// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles
/// only otherwise; none with Show handles when multiple anchors are selected off and several
/// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and
/// the tolerance is the preference's, in screen pixels at any zoom.
#[test]
fn the_handles_shown_are_the_handles_dragged() {
let (d, arch) = arch_doc();
let p = paint();
// Does a press at (x, y) take a handle?
let handle = |c: &ToolContext, x: f64, y: f64| {
DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())]
};
let mut whole = Selection::default();
whole.set([arch]);
let c = cx(&d, &whole, &p);
assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole");
let mut t = DirectSelectionTool::new(false);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0));
assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}");
// Only the second anchor direct-selected: the first one's handle is hidden.
let second = anchors_of(arch, &[(0, 1)]);
assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0));
assert!(handle(&cx(&d, &second, &p), 201.0, 251.0));
// Show handles when multiple anchors are selected off: the whole path's are hidden.
assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0));
assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0));
// The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%.
let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) };
assert!(!handle(&zoomed, 101.0, 251.0));
assert!(handle(&zoomed, 100.5, 250.5));
assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0));
}
/// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor.
#[test]
fn a_short_handle_leaves_its_anchor_to_be_picked() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
sp.anchors[0].h_out = Point::new(102.0, 300.0);
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
let s = anchors_of(id, &[(0, 0)]);
let p = paint();
let c = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected");
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0));
assert_eq!(a[0], Action::Begin("Move".into()));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
}
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, a) = doc_with_rect();
+1
View File
@@ -15,6 +15,7 @@ use vectorcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use crate::{Action, Tool, ToolContext};
pub use anchor::AnchorTool;
pub(crate) use anchor::hit_handle;
pub use curvature::CurvatureTool;
pub use family::FamilyTool;
pub use gesture::GestureTool;
+25 -45
View File
@@ -12,8 +12,6 @@ use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{hit_anchor, hit_segment, insert_anchor, remove_anchor};
use crate::direct::hit_handle;
use crate::guides::HandleSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, Debug)]
@@ -27,9 +25,8 @@ enum State {
pub struct AnchorTool {
id: &'static str,
state: State,
/// Smart guides of the handle being dragged, and what it snaps to.
/// Smart guides of the handle being dragged.
guides: Vec<Overlay>,
snap: HandleSnap,
}
impl AnchorTool {
@@ -40,18 +37,36 @@ impl AnchorTool {
"scissors" => "scissors",
_ => "addAnchor",
};
Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default() }
Self { id, state: State::Idle, guides: vec![] }
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_segment(cx, p, cx.pick_tol()).map(insert_anchor).into_iter().collect()
hit_segment(cx, p, cx.tol(4.0)).map(insert_anchor).into_iter().collect()
}
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
hit_anchor(cx, p, cx.pick_tol()).map(remove_anchor).into_iter().collect()
hit_anchor(cx, p, cx.tol(4.0)).map(remove_anchor).into_iter().collect()
}
}
/// A direction handle of a selected path under `p`, among those shown (a path with anchors
/// direct-selected shows theirs only): (id, subpath, anchor, is_out).
pub(crate) fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let shown = cx.selection.partial(*id);
for (si, ai, a) in pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))) {
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
}
}
}
None
}
impl Tool for AnchorTool {
fn id(&self) -> &'static str {
self.id
@@ -61,7 +76,7 @@ impl Tool for AnchorTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let tol = cx.pick_tol();
let tol = cx.tol(4.0);
match (self.id, ev.kind) {
("addAnchor", PointerKind::Down) => {
if ev.mods.alt {
@@ -89,7 +104,6 @@ impl Tool for AnchorTool {
("anchorPoint", PointerKind::Down) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out, began: false };
self.snap = HandleSnap::default();
} else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
self.state = State::Convert { id, si, ai, start: p, began: false };
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
@@ -120,7 +134,7 @@ impl Tool for AnchorTool {
json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}),
),
State::Handle { id, si, ai, out, .. } => {
let (q, guides) = self.snap.snap(cx, (id, si, ai), p, ev.mods.shift);
let (q, guides) = crate::guides::snap_handle(cx, (id, si, ai), p, ev.mods.shift);
self.guides = guides;
Action::Preview(
"path.setHandle".into(),
@@ -178,7 +192,7 @@ impl Tool for AnchorTool {
"deleteAnchor" if !m.alt => Cursor::PenDelete,
"deleteAnchor" => Cursor::PenAdd,
"scissors" => {
if hit_segment(cx, p, cx.pick_tol()).is_some() {
if hit_segment(cx, p, cx.tol(4.0)).is_some() {
Cursor::Crosshair
} else {
Cursor::NotAllowed
@@ -243,38 +257,4 @@ mod tests {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0));
}
/// #494: the Anchor Point tool picks within Selection & Anchor Display → Tolerance, and drags
/// only the handles shown.
#[test]
fn anchor_point_picks_within_the_tolerance_and_drags_shown_handles() {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(300.0, 300.0)], false);
sp.anchors[1] = vectorcraft_geom::Anchor::smooth(Point::new(200.0, 300.0), Point::new(240.0, 300.0));
let path = vectorcraft_geom::PathData::single(sp);
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.set([id]);
let p = paint();
let press = |c: &ToolContext, x: f64, y: f64| {
let mut t = AnchorTool::new("anchorPoint");
t.pointer(c, &PointerEvent::new(PointerKind::Down, x, y));
t.pointer(c, &PointerEvent::new(PointerKind::Drag, x + 10.0, y + 30.0))
};
let dragged = |a: Vec<Action>| match a.as_slice() {
[Action::Begin(_), Action::Preview(c, _)] => c.clone(),
_ => String::new(),
};
// The segment 6 px off: picked with an 8 px tolerance, not with the default 3 px.
assert_eq!(dragged(press(&cx(&d, &s, &p), 150.0, 306.0)), "");
let wide = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) };
assert_eq!(dragged(press(&wide, 150.0, 306.0)), "path.reshapeSegment");
// The middle anchor's handle drags; with Show handles when multiple anchors are selected
// off it's hidden (three anchors selected) and the press finds the segment under it.
assert_eq!(dragged(press(&cx(&d, &s, &p), 240.0, 301.0)), "path.setHandle");
let single = ToolContext { handles_multiple: false, ..cx(&d, &s, &p) };
assert_eq!(dragged(press(&single, 240.0, 301.0)), "path.reshapeSegment");
}
}
+10 -45
View File
@@ -3,15 +3,13 @@
//! 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 (Enable Rubber Band for Curvature Tool). Each point
//! placed or dragged snaps to Smart Guides ([`DrawSnap`]), as the Pen's anchors do.
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool).
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, Selection};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{FEEDBACK, catmull_rom};
use crate::guides::{DrawSnap, Leave, Targets};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
@@ -24,7 +22,6 @@ pub struct CurvatureTool {
/// Last touched point (Backspace removes it).
current: Option<usize>,
hover: Option<Point>,
snap: DrawSnap,
}
impl CurvatureTool {
@@ -58,12 +55,6 @@ impl CurvatureTool {
self.id = None;
self.drag = None;
self.current = None;
self.snap.clear();
}
/// The segment to the next point leaves the last one.
fn leave(&self, cx: &ToolContext) -> Option<Leave> {
self.pts.last().map(|(p, _)| Leave::segment(cx, *p, false))
}
}
@@ -82,12 +73,11 @@ impl Tool for CurvatureTool {
}
match ev.kind {
PointerKind::Move => {
let from = active.and_then(|_| self.leave(cx));
self.hover = Some(self.snap.hover(cx, p, active.as_slice(), from.as_ref()));
self.hover = Some(p);
vec![]
}
PointerKind::Down => {
if let Some(id) = active {
if active.is_some() {
if let Some(i) = self.hit_point(cx, p) {
self.current = Some(i);
if ev.mods.alt {
@@ -98,15 +88,10 @@ impl Tool for CurvatureTool {
self.closed = true;
return vec![Action::Exec("path.curvature".into(), self.params())];
}
// The point dragged and the curve through it move: the rest pull.
let sel = Selection { objects: vec![id], anchors: [(id, [(0, i)].into())].into(), ..Selection::default() };
self.snap.hold(cx, || Targets::for_anchor_drag(cx.doc, &sel));
self.drag = Some((i, p, false));
return vec![];
}
if !self.closed {
let from = self.leave(cx);
let p = self.snap.press(cx, p, &[id], from.as_ref());
self.pts.push((p, ev.mods.alt));
self.current = Some(self.pts.len() - 1);
return vec![Action::Exec("path.curvature".into(), self.params())];
@@ -114,7 +99,6 @@ impl Tool for CurvatureTool {
}
// Start a new path.
self.reset();
let p = self.snap.press(cx, p, &[], None);
self.pts.push((p, false));
self.current = Some(0);
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
@@ -129,20 +113,16 @@ impl Tool for CurvatureTool {
out.push(Action::Begin("Curvature".into()));
self.drag = Some((i, start, true));
}
let p = self.snap.drag(cx, p, None);
if let Some(pt) = self.pts.get_mut(i) {
pt.0 = p;
}
out.push(Action::Preview("path.curvature".into(), self.params()));
out
}
PointerKind::Up => {
self.snap.clear();
match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
}
}
PointerKind::Up => match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
},
PointerKind::DoubleClick => {
if active.is_none() {
return vec![];
@@ -183,10 +163,10 @@ impl Tool for CurvatureTool {
if dragging { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if self.active(cx).is_none() {
return o;
return vec![];
}
let mut o = vec![];
if cx.curvature_rubber_band
&& !self.closed
&& self.drag.is_none()
@@ -237,19 +217,4 @@ mod tests {
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0));
assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5));
}
/// Points snap to Smart Guides (#506): the first lands on another object's anchor and says so.
#[test]
fn points_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = CurvatureTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0));
assert!(matches!(&a[0], Action::Exec(_, v) if v["points"][0]["x"] == 200.0 && v["points"][0]["y"] == 100.0), "{a:?}");
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor")));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0));
assert!(t.overlays(&cx).is_empty());
}
}
+23 -49
View File
@@ -1,16 +1,13 @@
//! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid.
//!
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog. The
//! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too.
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog.
//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→
//! change grid columns / radial dividers.
use serde_json::{Value, json};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Point, Rect, Vec2};
use crate::guides::{DrawSnap, Leave, square};
use crate::shape::drag_rect;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct FamilyTool {
@@ -27,7 +24,6 @@ pub struct FamilyTool {
pub rows: u32,
/// Columns / radial dividers.
pub columns: u32,
snap: DrawSnap,
}
impl FamilyTool {
@@ -50,7 +46,6 @@ impl FamilyTool {
clockwise: true,
rows: 5,
columns: 5,
snap: DrawSnap::default(),
}
}
@@ -63,10 +58,13 @@ impl FamilyTool {
}
}
/// How the dragged point keeps to the start: a spiral's radius goes anywhere, the arc's and
/// the grids' corner keeps to a diagonal with Shift.
fn leave(&self, start: Point, m: Mods) -> Option<Leave> {
(self.id != "spiral").then(|| Leave::diagonal(start, m.shift))
fn rect(start: Point, p: Point, m: Mods) -> Rect {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
}
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
}
/// The command for a drag from `start` to `p`.
@@ -80,21 +78,25 @@ impl FamilyTool {
)
}
"rectangularGrid" => {
let r = drag_rect(start, p, m);
let r = Self::rect(start, p, m);
(
"shape.rectangularGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
)
}
"polarGrid" => {
let r = drag_rect(start, p, m);
let r = Self::rect(start, p, m);
(
"shape.polarGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
)
}
_ => {
let d = if m.shift { square(p - start) } else { p - start };
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
}
let (a, b) = if m.alt { (start - d, start + d) } else { (start, start + d) };
("shape.arc".into(), json!({"x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y, "closed": self.closed}))
}
@@ -111,12 +113,8 @@ impl Tool for FamilyTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
vec![]
}
PointerKind::Down => {
let p = self.snap.press(cx, ev.pos, &[], None);
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.start = Some(p);
self.last = p;
self.began = false;
@@ -124,8 +122,6 @@ impl Tool for FamilyTool {
}
PointerKind::Drag => {
let Some(mut s) = self.start else { return vec![] };
let pos = self.snap.drag(cx, ev.pos, self.leave(s, ev.mods).as_ref());
let ev = &PointerEvent { pos, ..*ev };
crate::shape::space_moves(&mut s, &mut self.last, ev, self.began);
self.start = Some(s);
self.mods = ev.mods;
@@ -143,7 +139,6 @@ impl Tool for FamilyTool {
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
self.snap.clear();
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] }
}
_ => vec![],
@@ -184,21 +179,19 @@ impl Tool for FamilyTool {
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
self.snap.clear();
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
// The size as drawn (from the centre with Alt, square with Shift).
let r = drag_rect(s, self.last, self.mods);
let (w, h) = if self.id == "spiral" { ((self.last - s).x.abs(), (self.last - s).y.abs()) } else { (r.width(), r.height()) };
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
match self.start {
Some(s) if self.began && cx.measurement_labels => {
let d = self.last - s;
vec![Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }]
}
_ => vec![],
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
@@ -274,23 +267,4 @@ mod tests {
assert_eq!(c, "shape.arc");
assert_eq!(v, json!({"x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 10.0, "closed": false}));
}
/// The start and the dragged corner snap to Smart Guides (#506), Shift sliding the corner of
/// a square grid along its diagonal into line.
#[test]
fn corners_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = FamilyTool::new("rectangularGrid");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0));
let shift = Mods { shift: true, ..Mods::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 255.0, 147.0).with_mods(shift));
assert!(
matches!(&a[..], [Action::Begin(_), Action::Preview(_, v)] if v["x"] == 200.0 && v["y"] == 100.0 && v["width"] == 50.0 && v["height"] == 50.0),
"{a:?}"
);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")));
}
}
+6 -58
View File
@@ -3,17 +3,14 @@
//!
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
//! between follow the hand. The other tools collect the drag polyline (shown as an overlay) and run
//! one command on release.
//! near an end of a selected open path continues it. The other tools collect the drag polyline
//! (shown as an overlay) and run one command on release.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use super::{FEEDBACK, points_json, polyline};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct GestureTool {
@@ -33,8 +30,6 @@ pub struct GestureTool {
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
/// Smart Guides for the Pencil's ends.
snap: DrawSnap,
}
impl GestureTool {
@@ -61,15 +56,9 @@ impl GestureTool {
fill: false,
edit_within: 12.0,
close_within: 15.0,
snap: DrawSnap::default(),
}
}
/// Do the ends of its strokes snap (the Pencil)?
fn snaps(&self) -> bool {
self.id == "pencil"
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
@@ -158,27 +147,17 @@ impl Tool for GestureTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Move if self.snaps() => {
self.snap.hover(cx, p, &[], None);
vec![]
}
PointerKind::Down => {
self.points = vec![p];
self.active = true;
self.began = false;
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
// A path continued starts at its end; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.points = vec![start];
vec![]
}
PointerKind::Drag => {
if !self.active {
return vec![];
}
if self.snaps() {
// Where the stroke would end: shown, taken on release.
self.snap.drag(cx, p, None);
}
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
@@ -198,16 +177,8 @@ impl Tool for GestureTool {
if !self.active {
return vec![];
}
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.points.push(end);
} else if self.snaps()
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
{
// The last step was where the button went up: it moves where that snaps.
*last = end;
self.points.push(p);
}
let out = self.finish(cx, ev.mods);
self.active = false;
@@ -223,7 +194,6 @@ impl Tool for GestureTool {
match key {
ToolKey::Escape if self.active => {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
@@ -242,24 +212,21 @@ impl Tool for GestureTool {
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let began = self.began;
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if !self.active || self.points.len() < 2 || self.began {
return o;
return vec![];
}
let width = match self.id {
"blobBrush" | "eraser" => (self.size * cx.zoom) as f32,
_ => 1.0,
};
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" });
o
vec![Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
@@ -302,25 +269,6 @@ mod tests {
v
}
/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where
/// the hand went.
#[test]
fn pencil_ends_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("pencil");
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default());
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]]));
// The Paintbrush follows the hand all the way.
let mut t = GestureTool::new("paintbrush");
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default());
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
assert_eq!(v["points"][0], json!([102.0, 98.0]));
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();
+50 -548
View File
@@ -1,23 +1,15 @@
//! Smart Guides: snapping to anchors, path segments (for dragged anchors and drawn points), object
//! bounds (edges/centres), artboards, with the magenta construction lines and labels Illustrator
//! users expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]),
//! how near a target pulls (`ToolContext::snapping_tolerance`) and the angles of the construction
//! guides drawn points land on (`ToolContext::construction_angles`).
//!
//! The drawing tools (Pen, Curvature, Pencil, the shape tools, Type, Artboard) all snap the points
//! they place through one [`DrawSnap`]: their targets are gathered once per document state, sorted,
//! and each pointer move looks up the few near the pointer by bisection.
use std::collections::HashSet;
use std::sync::Arc;
//! Smart Guides: snapping to anchors, path segments (for dragged anchors), object bounds
//! (edges/centres), artboards, with the magenta construction lines and labels Illustrator users
//! expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]) and
//! how near a target pulls (`ToolContext::snapping_tolerance`).
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_geom::kurbo::ParamCurveNearest;
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2, constrain_angle_from};
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2};
use crate::bbox::Handle;
use crate::{Overlay, ScreenFrame, ToolContext};
use crate::{Overlay, ToolContext};
pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc];
@@ -110,93 +102,9 @@ impl Ruler {
/// How many anchors, and how many segments, the targets gather at most.
const BUDGET: usize = 20_000;
/// What [`Targets::collect_inner`] gathers from each path besides its anchors and bounds.
#[derive(Clone, Copy, PartialEq)]
enum Gather {
/// Nothing more: a moved or resized selection lines up with bounds.
Bounds,
/// Its segments, which a dragged anchor lands on.
Segments,
/// Its segments, and its anchors as lines too: a drawn point lines up with them.
Drawing,
}
/// The entries of `sorted` (ascending by `key`) whose key is within `tol` of `v`, found by
/// bisection.
fn within<T>(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] {
let lo = sorted.partition_point(|e| key(e) < v - tol);
let hi = sorted.partition_point(|e| key(e) <= v + tol);
sorted.get(lo..hi).unwrap_or_default()
}
/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those:
/// one pass, however many are skipped.
fn walk_but<'a>(nodes: &'a [Arc<Node>], skip: &HashSet<NodeId>, f: &mut impl FnMut(&'a Node)) {
for n in nodes.iter().filter(|n| !skip.contains(&n.id)) {
f(n);
if let Some(ch) = n.children() {
walk_but(ch, skip, f);
}
}
}
/// The lines of `lines` (sorted by where they run) within `tol` of `v`.
fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point, Kind)] {
within(lines, |l| l.0, v, tol)
}
impl Targets {
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
}
/// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its
/// anchors, centres, edges and path segments, its anchors in line too. The anchors and
/// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets.
pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing);
t.push_paths(doc, exclude, true);
t.index()
}
/// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id`
/// (its bounds and the segments through the handle move with it, so they would chase it).
pub fn for_handle(doc: &Document, id: NodeId) -> Self {
let mut t = Self::collect_inner(doc, &[id], None, None, Gather::Bounds);
t.push_paths(doc, &[id], false);
t.index()
}
/// The anchors of the paths `ids` as targets, on them and in line, and their segments too
/// when `segments`: at most [`BUDGET`] of each.
fn push_paths(&mut self, doc: &Document, ids: &[NodeId], segments: bool) {
let (mut anchors, mut segment_budget) = (BUDGET, if segments { BUDGET } else { 0 });
for path in ids.iter().filter_map(|id| doc.node(*id)?.path_data()) {
for sp in &path.subpaths {
for a in sp.anchors.iter().take(anchors) {
self.push_anchor(a.p);
anchors -= 1;
}
self.push_segments(sp, |_| false, &mut segment_budget);
}
}
}
/// Sort the targets so that [`within`] finds those near a point by bisection: the anchors
/// and centres by x, the lines by where they run. Every constructor ends with it (the sort
/// is stable: of targets alike, the first gathered still wins).
fn index(mut self) -> Self {
self.points.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
self.xs.sort_by(|a, b| a.0.total_cmp(&b.0));
self.ys.sort_by(|a, b| a.0.total_cmp(&b.0));
self
}
/// Anchor `a` as a target: a point lands on it and lines up with it.
fn push_anchor(&mut self, a: Point) {
self.points.push((a, Kind::Anchor));
self.xs.push((a.x, a, Kind::Anchor));
self.ys.push((a.y, a, Kind::Anchor));
Self::collect_inner(doc, exclude, None, visible, false)
}
/// The same targets drawing their guides as the Smart Guides preferences in `cx` ask
@@ -219,27 +127,29 @@ impl Targets {
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
/// art that moves along with it).
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
Self::collect_inner(doc, exclude, Some(index), None, Gather::Bounds)
Self::collect_inner(doc, exclude, Some(index), None, false)
}
/// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a
/// whole): the other art, its path segments too, and the anchors and segments that stay put on
/// the paths being reshaped (their bounds move along, so they are no targets).
pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self {
let mut t = Self::collect_inner(doc, &sel.objects, None, None, Gather::Segments);
let mut t = Self::collect_inner(doc, &sel.objects, None, None, true);
let (mut anchors, mut segments) = (BUDGET, BUDGET);
for (id, set) in &sel.anchors {
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
let moving = |ai: usize| set.contains(&(si, ai));
for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) {
t.push_anchor(a);
t.points.push((a, Kind::Anchor));
t.xs.push((a.x, a, Kind::Anchor));
t.ys.push((a.y, a, Kind::Anchor));
anchors -= 1;
}
t.push_segments(sp, moving, &mut segments);
}
}
t.index()
t
}
/// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at
@@ -261,8 +171,9 @@ impl Targets {
}
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, gather: Gather) -> Self {
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, segments: bool) -> Self {
let mut t = Targets::default();
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
let c = r.center();
for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] {
@@ -283,8 +194,8 @@ impl Targets {
}
}
let (mut budget, mut segment_budget) = (BUDGET, BUDGET);
walk_but(&doc.layers, &exclude.iter().copied().collect(), &mut |n| {
if budget == 0 || n.is_container() || !n.visible {
doc.walk(|n| {
if budget == 0 || n.is_container() || !n.visible || excluded(n.id) {
return;
}
let Some(b) = n.geometric_bounds() else { return };
@@ -296,14 +207,10 @@ impl Targets {
}
if let NodeKind::Path { path, .. } = &n.kind {
for (_, _, a) in path.anchors() {
if gather == Gather::Drawing {
t.push_anchor(a.p);
} else {
t.points.push((a.p, Kind::Anchor));
}
t.points.push((a.p, Kind::Anchor));
budget = budget.saturating_sub(1);
}
if gather != Gather::Bounds {
if segments {
for sp in &path.subpaths {
t.push_segments(sp, |_| false, &mut segment_budget);
}
@@ -311,7 +218,7 @@ impl Targets {
}
add_rect(&mut t, b, Kind::Edge);
});
t.index()
t
}
/// Only these targets' anchors pull (View → Snap to Point).
@@ -358,55 +265,25 @@ impl Targets {
t.points.push((b.center(), Kind::Center));
});
}
t.index()
t
}
/// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else
/// into line with targets. Returns the snapped point and guide overlays.
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
self.snap_from(p, None, tol)
}
/// Snap a drawn point `p` as [`Self::snap_point`] does, and when it leaves `from` (the anchor
/// before it, a shape's start): with Shift held it keeps to its angles round it, sliding along
/// that way into line with the nearest target; else, before lining up, it lands on a
/// construction guide through it, where a target's line crosses the guide if one does nearby.
pub fn snap_from(&self, p: Point, from: Option<&Leave>, tol: f64) -> (Point, Vec<Overlay>) {
if let Some(f) = from
&& let Some(q) = f.constrained(p)
{
return match self.slide(f.at, q - f.at, q, tol, true) {
Some((q, target)) => (q, self.lined_up(q, target)),
None => (q, vec![]),
};
}
if let Some((q, k)) = self.nearest_point(p, tol) {
return (q, self.label(q, k.label()).into_iter().collect());
}
if let Some(q) = self.on_segment(p, tol) {
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
}
if let Some(f) = from
&& let Some((dir, q)) = f.construction(p, tol)
{
let construction = |q: Point| Overlay::Line { a: f.at, b: q, color: self.style.color, dashed: false };
return match self.slide(f.at, dir, q, tol, false) {
Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()),
None => (q, vec![construction(q)]),
};
}
let mut out = p;
let mut ov = vec![];
// The targets' lines along one axis and the ruler guides running past `p`, the nearest
// within reach of `v`.
let nearest = |lines: &[(f64, Point, Kind)], vertical: bool, v: f64| {
let rulers = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(p, tol));
lines_near(lines, v, tol)
.iter()
.copied()
.chain(rulers)
.filter(|(t, ..)| (t - v).abs() <= tol)
.min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
lines.iter().copied().chain(rulers).filter(|(t, ..)| (t - v).abs() <= tol).min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
};
let mut aligned = false;
if let Some((x, from, _)) = nearest(&self.xs, true, p.x) {
@@ -427,47 +304,7 @@ impl Targets {
/// The anchor or centre nearest `p`, within `tol`.
fn nearest_point(&self, p: Point, tol: f64) -> Option<(Point, Kind)> {
within(&self.points, |q| q.0.x, p.x, tol)
.iter()
.copied()
.filter(|(q, _)| q.distance(p) <= tol)
.min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
}
/// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where
/// the nearest target line crosses it, at most twice `tol` away: where it lands and the
/// target's own point (where its line starts).
fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> {
if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 {
return None;
}
let reach = 2.0 * tol;
let mut best: Option<(f64, Point, Point)> = None;
for (vertical, lines, d, a, v) in [(true, &self.xs, dir.x, at.x, q.x), (false, &self.ys, dir.y, at.y, q.y)] {
if d.abs() <= 1e-9 {
continue;
}
// A line that far across the way crosses it at least that far along.
for (t, from, _) in lines_near(lines, v, reach) {
let s = (t - a) / d;
// Exactly on the line, whatever the rounding along the way.
let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) };
let moved = hit.distance(q);
if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) {
best = Some((moved, hit, *from));
}
}
}
best.map(|(_, hit, from)| (hit, from))
}
/// The guides of a point `q` slid into line with the target at `from`: the line from it and
/// "align", or the target's own label when `q` sits right on it.
fn lined_up(&self, q: Point, from: Point) -> Vec<Overlay> {
if let Some((on, k)) = self.nearest_point(q, 1e-9) {
return self.label(on, k.label()).into_iter().collect();
}
self.line(from, q).into_iter().chain(self.label(q, "align")).collect()
self.points.iter().copied().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
}
/// The nearest point within `tol` of `p` on a target segment.
@@ -486,7 +323,7 @@ impl Targets {
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_near(lines, v, tol)
let best = lines
.iter()
.map(|(t, from, k)| (*t, *from, *k))
.chain(self.points.iter().map(|(q, k)| (along(q), *q, *k)))
@@ -533,7 +370,7 @@ impl Targets {
rects
.iter()
.flat_map(|r| along(r).into_iter().map(move |v| (v, *r)))
.flat_map(|(v, r)| lines_near(targets, v, tol).iter().map(move |(t, from, _)| (t - v, *from, v, r)))
.flat_map(|(v, r)| targets.iter().map(move |(t, from, _)| (t - v, *from, v, r)))
.filter(|(d, ..)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()))
};
@@ -573,11 +410,7 @@ impl Targets {
// A scale that collapses or flips the box is no snap.
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
lines_near(ts, v, tol)
.iter()
.map(|(t, from, _)| (*t, *from))
.filter(|(t, _)| (t - v).abs() <= tol)
.min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
ts.iter().map(|(t, from, _)| (*t, *from)).filter(|(t, _)| (t - v).abs() <= tol).min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
};
let (mut nx, mut ny) = (sx, sy);
let mut snap: Snap = (None, None, None);
@@ -707,240 +540,38 @@ impl PointSnap {
/// Where `p` goes, and the guides showing why.
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
self.snap_from(cx, p, None)
}
/// Where a drawn point `p` leaving `from` goes ([`Targets::snap_from`]), and the guides
/// showing why. Pixels and the grid round it before Shift turns it onto its angle.
pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec<Overlay>) {
let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q);
if cx.snap_to_pixel {
return (keep(Point::new(p.x.round(), p.y.round())), vec![]);
return (Point::new(p.x.round(), p.y.round()), vec![]);
}
if cx.snap_to_grid {
return (keep(vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step())), vec![]);
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
}
match &self.targets {
Some((t, tol)) => t.snap_from(p, from, cx.tol(*tol)),
None => (keep(p), vec![]),
}
}
}
/// The Construction Guides angle set new preferences start with.
pub const DEFAULT_CONSTRUCTION_ANGLES: &str = "90° & 45° Angles";
/// The angles (degrees) of the construction guides of a Smart Guides › Angles set: "90°" gives the
/// horizontal and the vertical, "45°" the diagonals, "60°" and "30°" their multiples between those.
pub fn construction_angles(set: &str) -> &'static [f64] {
match set {
"30° Angles" => &[30.0, 60.0, 120.0, 150.0],
"45° Angles" => &[45.0, 135.0],
"60° Angles" => &[60.0, 120.0],
"90° Angles" => &[0.0, 90.0],
"90° & 45° & 30° Angles" => &[0.0, 30.0, 45.0, 60.0, 90.0, 120.0, 135.0, 150.0],
_ => &[0.0, 45.0, 90.0, 135.0],
}
}
/// How Shift keeps a drawn point to the point it leaves from.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Keep {
/// At steps of `step` degrees counted from `base` (a segment).
Angle { step: f64, base: f64 },
/// On a diagonal, as far along as the pointer's farther axis (a square's corner).
Square,
}
/// The point a drawn point leaves from (the anchor before it, a shape's start) and how it keeps
/// to it: with Shift held at fixed angles round it, and onto the construction guides through it
/// (Smart Guides › Construction Guides).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Leave {
pub at: Point,
/// Shift held: how the point keeps to `at`.
shift: Option<Keep>,
/// The angles of the construction guides through `at` (none with them off).
angles: &'static [f64],
}
impl Leave {
/// A segment from `at` (Pen, Curvature, Line Segment): Shift keeps it at 45° steps from
/// General › Constrain Angle; with Smart Guides on it lands on the construction guides.
pub fn segment(cx: &ToolContext, at: Point, shift: bool) -> Self {
Self {
at,
shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }),
angles: if cx.smart_guides { cx.construction_angles } else { &[] },
}
}
/// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids,
/// Artboard): Shift keeps the dragged corner on a diagonal, the box a square.
pub fn diagonal(at: Point, shift: bool) -> Self {
Self { at, shift: shift.then_some(Keep::Square), angles: &[] }
}
/// Where Shift puts `p`: none without it.
fn constrained(&self, p: Point) -> Option<Point> {
let v = p - self.at;
Some(match self.shift? {
Keep::Angle { step, base } => self.at + constrain_angle_from(v, step, base),
Keep::Square => self.at + square(v),
})
}
/// The construction guide through `at` nearest `p`, within `tol` of it (and `p` farther than
/// that from `at`, where every guide meets): its direction and where `p` lands on it.
fn construction(&self, p: Point, tol: f64) -> Option<(Vec2, Point)> {
let v = p - self.at;
if v.hypot() <= tol {
return None;
}
self.angles
.iter()
// Counter-clockwise as seen on the y-down page.
.map(|a| Vec2::new(a.to_radians().cos(), -a.to_radians().sin()))
.map(|d| (d, self.at + d * v.dot(d)))
.map(|(d, q)| (d, q, q.distance(p)))
.filter(|(.., dist)| *dist <= tol)
.min_by(|a, b| a.2.total_cmp(&b.2))
.map(|(d, q, _)| (d, q))
}
}
/// `d` made square: as long on both axes as on its longer one, each keeping its sign.
pub fn square(d: Vec2) -> Vec2 {
let s = d.x.abs().max(d.y.abs());
Vec2::new(s * d.x.signum(), s * d.y.signum())
}
/// Smart Guides for the points a drawing tool places: the Pen's and Curvature tool's anchors,
/// the Pencil's ends, the shape tools' start and dragged corner, the Type tool's click and the
/// Artboard tool's new artboard. Each such tool keeps one and asks it for every point, hovering
/// too, so they all snap alike, and shows its [`Self::guides`].
///
/// The targets ([`Targets::for_drawing`], the art in the window) are gathered once per document
/// state: hovering gathers them again only after the document, the selection, what is left out,
/// the window or the snapping options changed; a press always gathers them afresh, and the drag
/// it starts keeps them, the document then showing the drag's own previews.
#[derive(Default)]
pub struct DrawSnap {
/// The targets, and what they were gathered for (none: for one drag only).
targets: Option<(Option<SnapKey>, PointSnap)>,
guides: Vec<Overlay>,
}
/// What a [`DrawSnap`]'s targets were gathered for: the document state, the art left out, the
/// window and every option [`PointSnap::new`] reads.
#[derive(PartialEq)]
struct SnapKey {
revision: (u64, u64),
exclude: Vec<NodeId>,
screen: Option<ScreenFrame>,
view: [bool; 5],
style: GuideStyle,
tolerances: [f64; 2],
}
impl SnapKey {
fn of(cx: &ToolContext, exclude: &[NodeId]) -> Self {
Self {
revision: cx.revision,
exclude: exclude.to_vec(),
screen: cx.screen,
view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides],
style: GuideStyle::of(cx),
tolerances: [cx.snapping_tolerance, cx.snap_tolerance],
}
}
}
/// The part of the document the window shows (none headless: all of it).
fn visible(cx: &ToolContext) -> Option<Rect> {
let s = cx.screen?;
let (w, h) = s.size;
Some(Rect::from_points(s.at(0.0, 0.0), s.at(w, h)).union_pt(s.at(w, 0.0)).union_pt(s.at(0.0, h)))
}
impl DrawSnap {
/// Snap the pointer hovering at `p` (the art but `exclude` as targets, the anchors of
/// `exclude` still pulling), reusing the targets while nothing they depend on changed.
pub fn hover(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point {
let key = SnapKey::of(cx, exclude);
if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) {
self.gather(cx, key);
}
self.snap(cx, p, from)
}
/// Snap the point pressed at `p`, the targets gathered afresh: the drag that follows keeps
/// them ([`Self::drag`]).
pub fn press(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point {
self.gather(cx, SnapKey::of(cx, exclude));
self.snap(cx, p, from)
}
/// Gather `targets` other than the art for the drag a press starts (a point being dragged
/// leaves itself out); the next hover gathers its own again.
pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) {
self.targets = Some((None, PointSnap::new(cx, targets)));
self.guides.clear();
}
/// Snap `p`, dragged since the press, with the targets the press gathered.
pub fn drag(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
if self.targets.is_none() {
self.gather(cx, SnapKey::of(cx, &[]));
}
self.snap(cx, p, from)
}
/// The guides of the last snap.
pub fn guides(&self) -> &[Overlay] {
&self.guides
}
/// Hide the guides (the gesture ended).
pub fn clear(&mut self) {
self.guides.clear();
}
fn gather(&mut self, cx: &ToolContext, key: SnapKey) {
let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx)));
self.targets = Some((Some(key), snap));
}
fn snap(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
let (q, guides) = match &self.targets {
Some((_, t)) => t.snap_from(cx, p, from),
Some((t, tol)) => t.snap_point(p, cx.tol(*tol)),
None => (p, vec![]),
};
self.guides = guides;
q
}
}
}
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
/// snaps as a drawn point does ([`DrawSnap`]). The targets ([`Targets::for_handle`]) are gathered
/// on the first move that needs them and kept for the rest of the drag.
#[derive(Default)]
pub struct HandleSnap(DrawSnap);
impl HandleSnap {
/// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`,
/// and the guides showing why.
pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
let path = cx.doc.node(id).and_then(|n| n.path_data());
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
}
if self.0.targets.is_none() {
self.0.hold(cx, || Targets::for_handle(cx.doc, id));
}
let q = self.0.drag(cx, p, None);
(q, self.0.guides().to_vec())
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
/// pointer at `p`: with Shift at a multiple of 45° (from the Constrain Angle) round its anchor,
/// else snapped as a drawn point is (smart guides, the grid, pixels).
pub fn snap_handle(cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
let path = cx.doc.node(id).and_then(|n| n.path_data());
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
}
snap_with(cx, p, || {
// The path's bounds move with the handle, so they would chase it: its anchors (which stay
// put) are the targets instead, the handle lining up with them too.
let mut t = Targets::collect(cx.doc, &[id], None);
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
t.points.push((a.p, Kind::Anchor));
t.xs.push((a.p.x, a.p, Kind::Anchor));
t.ys.push((a.p.y, a.p, Kind::Anchor));
}
t
})
}
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the
@@ -1117,138 +748,9 @@ mod tests {
s.anchors.insert(id, [(0, 0)].into_iter().collect());
let t = Targets::for_anchor_drag(&d, &s);
let anchors: Vec<Point> = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect();
assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x");
assert_eq!(anchors, [Point::new(200.0, 100.0), Point::new(200.0, 200.0), Point::new(100.0, 200.0)]);
assert_eq!(t.segments.len(), 2);
assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves");
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
}
fn labels(ov: &[Overlay]) -> Vec<&str> {
ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect()
}
fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> {
ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect()
}
/// A drawn point (#506): on another object's anchors, in line with them too (not only with its
/// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction
/// guide through the point it leaves from it lands on the guide, where a target's line crosses
/// it if one does nearby.
#[test]
fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let t = Targets::for_drawing(&d, &[], None).styled(&c);
// The rect is 100..200: its corner, a point on its side, and in line with an anchor.
let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"]));
let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"]));
let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"]));
// Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200.
let shift = Leave::segment(&c, Point::new(300.0, 300.0), true);
let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0);
assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}");
assert_eq!(labels(&ov), ["align"]);
assert_eq!(lines(&ov).len(), 1, "{ov:?}");
// Nothing in reach: on its angle, as far as the pointer.
let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0);
assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0));
// Construction guides through (300, 320): onto the 135° one…
let from = Leave::segment(&c, Point::new(300.0, 320.0), false);
let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0);
assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}");
assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]);
// …where x = 200 crosses it.
let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0);
assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}");
assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"]));
// Construction Guides off (or Smart Guides off): in line, not on a guide.
let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) };
let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0);
assert_eq!(q, Point::new(352.0, 373.0));
// A box's corner keeps to a diagonal with Shift, the farther axis giving its size.
let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0);
assert_eq!(q, Point::new(340.0, 340.0));
}
/// The angle sets of Preferences › Smart Guides › Construction Guides.
#[test]
fn construction_angle_sets() {
assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]);
assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]);
assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]);
assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8);
assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES));
}
/// The sorted targets find by bisection just what a scan of all of them finds, whatever
/// gathered them.
#[test]
fn bisection_finds_what_a_scan_finds() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, id) = doc_with_rect();
let l = d.layers[0].id;
for i in 0..60 {
let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64);
let tri = vectorcraft_geom::PathData::single(SubPath::polyline(
&[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)],
true,
));
let n = d.alloc_id();
d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap();
}
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, [(0, 1)].into_iter().collect());
for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None), Targets::for_anchor_drag(&d, &s)] {
for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) {
let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q)));
assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}");
for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] {
let mut near: Vec<f64> = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect();
let mut all: Vec<f64> = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect();
near.sort_by(f64::total_cmp);
all.sort_by(f64::total_cmp);
assert_eq!(near, all);
}
}
}
}
/// A drawing tool's snapper reuses its targets while hovering over the same document state, and
/// gathers them again when it changes, and on every press.
#[test]
fn draw_snap_gathers_once_per_document_state() {
let (d, _) = doc_with_rect();
let empty = vectorcraft_doc::Document::new(500.0, 500.0);
let (s, p) = (Selection::default(), paint());
let at = Point::new(202.0, 198.0);
let mut snap = DrawSnap::default();
let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0));
assert_eq!(labels(snap.guides()), ["anchor"]);
// The same revision: the targets are the ones gathered (the document isn't read again).
let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) };
assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0));
// A new revision, or a press, reads it.
let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) };
assert_eq!(snap.hover(&c, at, &[], None), at);
assert!(snap.guides().is_empty());
let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0));
// The drag keeps what the press gathered, though the document now shows its preview.
let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) };
assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0));
snap.clear();
assert!(snap.guides().is_empty());
// Snap to Grid wins over Smart Guides, Shift still keeping the angle.
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
let from = Leave::segment(&c, Point::ZERO, true);
let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from));
assert!((q.y - 0.0).abs() < 1e-9, "{q:?}");
}
}
-10
View File
@@ -216,10 +216,6 @@ impl ScreenFrame {
/// Read-only context a tool sees.
pub struct ToolContext<'a> {
pub doc: &'a Document,
/// Which open document `doc` is (its process-unique id) and its revision, which moves with
/// every change of the document or the selection: what a tool keeps between pointer events
/// for the document as it is (the snap targets of hovering) is keyed on it.
pub revision: (u64, u64),
pub selection: &'a Selection,
/// Screen pixels per document point.
pub zoom: f64,
@@ -323,10 +319,6 @@ pub struct ToolContext<'a> {
/// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the
/// pointer, a dragged edge or a drawn point.
pub snapping_tolerance: f64,
/// Smart Guides → Construction Guides: the angles (degrees, counter-clockwise from the x axis
/// as seen on the page) of the lines through the point a drawn point leaves from, onto which a
/// point near one lands ([`guides::construction_angles`]); none with the option off.
pub construction_angles: &'static [f64],
/// 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.
@@ -639,7 +631,6 @@ pub(crate) mod testutil {
pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> {
ToolContext {
doc: d,
revision: (0, 0),
selection: s,
zoom: 1.0,
isolation: None,
@@ -684,7 +675,6 @@ pub(crate) mod testutil {
measurement_labels: true,
transform_tools_guides: true,
snapping_tolerance: 4.0,
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
screen: None,
plane_widget: Some(Default::default()),
}
+48 -203
View File
@@ -3,41 +3,29 @@
//! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt breaks
//! them: the incoming handle stays where it was when Alt went down and only the outgoing one
//! follows the pointer; Space held moves the anchor, handles and all); Shift constrains to 45°.
//! A press on the first anchor closes the path, the drag shaping the closing curve. 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.
//! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too)
//! works as the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes
//! it a corner, dragging an anchor pulls out new symmetric handles and dragging a segment reshapes
//! it. Cmd held lends the selection tool used last for a drag (the engine's
//! `Session::pointer`); the path being drawn goes on afterwards while it stays selected.
//! 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.
//! Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as
//! the Anchor Point tool: dragging a handle moves it alone, clicking a smooth anchor makes it a
//! corner and dragging an anchor pulls out new symmetric handles.
//! 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 (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).
//! Each anchor placed snaps to Smart Guides ([`DrawSnap`]): onto the anchors, centres and paths of
//! the other art and of the path being drawn, into line with them, onto the construction guides
//! through the last anchor; with Shift held it slides along its 45° step into line. Hovering shows
//! where the next one would go.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{BezPath, Point};
use crate::direct::hit_handle;
use crate::draw2::AnchorTool;
use crate::guides::{DrawSnap, Leave};
use crate::draw2::{AnchorTool, hit_handle};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
pub struct PenTool {
/// A path is being drawn...
/// The path being drawn (set once the first anchor exists and the engine selected it).
drawing: bool,
/// ...this one, once the engine made it: another path selected meanwhile isn't drawn on.
path: Option<NodeId>,
/// The anchor a press placed, while the drag shapes its handles.
drag: Option<Place>,
drag: Option<(Point, bool)>,
hover: Option<Point>,
/// The incoming handle of the anchor being dragged out, as last previewed.
in_h: Point,
@@ -48,19 +36,6 @@ pub struct PenTool {
handle: Option<(NodeId, usize, usize, Point)>,
/// The Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts.
convert: Option<AnchorTool>,
/// Smart Guides for the anchors placed (and the pointer hovering).
snap: DrawSnap,
}
/// An anchor being placed, as the press decided: where it is, the path it goes on (none: the
/// first anchor of a new path) and whether it closes that path (the press was on its first
/// anchor). The drag only shapes its handles, whatever the path looks like meanwhile: a closing
/// preview leaves the path closed, so no longer one the pen draws on.
#[derive(Clone, Copy)]
struct Place {
at: Point,
path: Option<NodeId>,
closing: bool,
}
/// The open path the pen is extending: the single selected open path.
@@ -88,14 +63,10 @@ fn last_anchor(cx: &ToolContext, id: NodeId) -> Option<(usize, usize)> {
Some((si, ai))
}
/// Alt held over a handle end, an anchor or a segment of a selected path: the Anchor Point tool's
/// gesture, within its tolerance.
/// Alt held over a handle end or an anchor of a selected path: the Anchor Point tool's gesture.
fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
let tol = cx.pick_tol();
m.alt
&& (hit_handle(cx, p, tol).is_some()
|| crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some()
|| crate::draw2::segment_in(cx, editable_paths(cx), p, tol).is_some())
let tol = cx.tol(4.0);
m.alt && (hit_handle(cx, p, tol).is_some() || crate::draw2::anchor_in(cx, editable_paths(cx), p, tol).is_some())
}
/// The selected paths the pen edits (not guides, nor locked or hidden ones).
@@ -123,23 +94,13 @@ impl Tool for PenTool {
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
let (mut p, _) = if matches!(ev.kind, PointerKind::Down) { crate::guides::snap_draw(cx, ev.pos, &exclude) } else { (ev.pos, vec![]) };
let tol = cx.tol(5.0);
// The path a press began is the selected one by the next event.
if self.drawing && self.path.is_none() {
self.path = active_path(cx).map(|a| a.0);
}
let active = self.active(cx);
let active = if self.drawing { active_path(cx) } else { None };
if self.drawing && active.is_none() && ev.kind == PointerKind::Down {
self.stop();
self.drawing = false;
}
// The path being drawn moves its bounds as it grows: only its anchors and segments pull.
let drawn = active.map(|a| a.0);
let from = active.map(|(_, _, last, _)| Leave::segment(cx, last, ev.mods.shift));
let p = match ev.kind {
PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()),
PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()),
_ => ev.pos,
};
match ev.kind {
PointerKind::Move => {
self.hover = Some(p);
@@ -148,10 +109,13 @@ impl Tool for PenTool {
PointerKind::Down => {
self.last = ev.pos;
if let Some((id, first, last, _)) = active {
if ev.mods.shift {
p = last + vectorcraft_geom::constrain_angle(p - last, 45.0);
}
let end = last_anchor(cx, id);
// A one-anchor path doesn't close on itself: its anchor is the last one too.
if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) {
self.drag = Some(Place { at: first, path: Some(id), closing: true });
self.drag = Some((first, true));
return vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))];
}
if p.distance(last) <= tol
@@ -163,7 +127,7 @@ impl Tool for PenTool {
if let Some(acts) = self.alt_convert(cx, ev) {
return acts;
}
self.drag = Some(Place { at: p, path: Some(id), closing: false });
self.drag = Some((p, false));
self.in_h = p;
return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
@@ -178,7 +142,6 @@ impl Tool for PenTool {
&& (p.distance(last) <= tol || p.distance(first) <= tol)
{
self.drawing = true;
self.path = active_path(cx).map(|a| a.0);
if p.distance(first) <= tol && p.distance(last) > tol {
return vec![Action::Exec("path.reverse".into(), json!({}))];
}
@@ -187,8 +150,8 @@ impl Tool for PenTool {
if let Some(act) = auto_add_delete(cx, ev.pos, ev.mods, tol) {
return vec![act];
}
(self.drawing, self.path) = (true, None);
self.drag = Some(Place { at: p, path: None, closing: false });
self.drawing = true;
self.drag = Some((p, false));
vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))]
}
PointerKind::Drag => {
@@ -202,13 +165,13 @@ impl Tool for PenTool {
}
return vec![set_out_handle(id, si, ai, h)];
}
let Some(Place { at: mut a, path, closing }) = self.drag else { return vec![] };
let Some((mut a, closing)) = self.drag else { return vec![] };
// Space held moves the anchor being placed, handles and all.
if ev.mods.space && !closing {
let d = ev.pos - self.last;
a += d;
self.in_h += d;
self.drag = Some(Place { at: a, path, closing });
self.drag = Some((a, closing));
}
self.last = ev.pos;
let mut out_h = ev.pos;
@@ -216,7 +179,7 @@ impl Tool for PenTool {
out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0);
}
let alt = ev.mods.alt;
let Some(id) = path else {
let Some((id, ..)) = active_path(cx).or(active) else {
// First anchor of a new path: re-issue create with handles.
let in_h = a - (out_h - a);
return vec![Action::Preview(
@@ -240,16 +203,15 @@ impl Tool for PenTool {
)]
}
PointerKind::Up => {
self.snap.clear();
if let Some(mut t) = self.convert.take() {
return t.pointer(cx, ev);
}
if self.handle.take().is_some() {
return vec![Action::Commit];
}
let Some(place) = self.drag.take() else { return vec![] };
if place.closing {
self.stop();
let Some((_, closing)) = self.drag.take() else { return vec![] };
if closing {
self.drawing = false;
}
vec![Action::Commit]
}
@@ -259,10 +221,9 @@ impl Tool for PenTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Enter | ToolKey::Escape => {
self.stop();
self.drawing = false;
self.drag = None;
self.handle = None;
self.snap.clear();
// An Alt gesture under way ends as it stands.
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
@@ -270,35 +231,33 @@ impl Tool for PenTool {
}
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.stop();
self.snap.clear();
self.drawing = false;
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(t) = &self.convert {
return t.overlays(cx);
}
let mut o = vec![];
if cx.pen_rubber_band
&& self.drag.is_none()
&& self.handle.is_none()
&& let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover)
{
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
if !cx.pen_rubber_band || !self.drawing || self.drag.is_some() || self.handle.is_some() {
return vec![];
}
o.extend_from_slice(self.snap.guides());
o
let (Some((id, _, last, out)), Some(h)) = (active_path(cx), self.hover) else { return vec![] };
let mut bp = BezPath::new();
bp.move_to(last);
if out.distance(last) > 1e-9 {
bp.quad_to(out, h);
} else {
bp.line_to(h);
}
let c = cx.doc.layer_color(id);
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let tol = cx.tol(5.0);
if let Some((id, first, last, _)) = self.active(cx) {
let active = active_path(cx);
if self.drawing
&& let Some((id, first, last, _)) = active
{
let one = last_anchor(cx, id).is_some_and(|(_, ai)| ai == 0);
if p.distance(first) <= tol && !one {
return Cursor::PenClose;
@@ -316,7 +275,7 @@ impl Tool for PenTool {
Some([_]) => return Cursor::PenAdd,
_ => {}
}
if active_path(cx).is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
if active.is_some_and(|(_, first, last, _)| p.distance(last) <= tol || p.distance(first) <= tol) {
return Cursor::PenContinue;
}
match auto_add_delete(cx, p, m, tol) {
@@ -330,27 +289,13 @@ impl Tool for PenTool {
}
impl PenTool {
/// The open path being drawn: the one the pen made or went on with, while it is the single
/// selected open path.
fn active(&self, cx: &ToolContext) -> Option<(NodeId, Point, Point, Point)> {
active_path(cx).filter(|(id, ..)| self.drawing && self.path.is_none_or(|p| p == *id))
}
/// The path is finished.
fn stop(&mut self) {
(self.drawing, self.path) = (false, None);
}
/// Hand an Alt press on a selected path's handle, anchor or segment to the Anchor Point tool.
/// Hand an Alt press on a selected path's handle or anchor to the Anchor Point tool.
fn alt_convert(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
if !alt_converts(cx, ev.pos, ev.mods) {
return None;
}
let mut t = AnchorTool::new("anchorPoint");
let acts = t.pointer(cx, ev);
if !t.busy() {
return None;
}
self.convert = Some(t);
Some(acts)
}
@@ -522,32 +467,6 @@ mod tests {
assert_eq!(t.cursor(&cx, Point::new(10.0, 300.0), Mods::default()), Cursor::PenClose);
}
/// #501: a press on the first anchor closes the path, however far the drag then goes: the
/// closing preview leaves the path closed (so not one the pen extends), and each move still
/// previews the close with symmetric handles (Alt breaks them), released as a close.
#[test]
fn a_drag_from_the_first_anchor_always_closes() {
let pts = [Point::new(100.0, 300.0), Point::new(200.0, 300.0), Point::new(200.0, 400.0)];
let (mut d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&pts, false));
let p = paint();
let mut t = PenTool { drawing: true, ..PenTool::default() };
let a = t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 301.0));
assert_eq!(a, vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), json!({"id": id.0}))]);
// What the tool sees once the preview applied: the path closed.
d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true;
let c = cx(&d, &s, &p);
let close = |x: f64, y: f64, alt: bool| {
vec![Action::Preview("path.close".into(), json!({"id": id.0, "in": [200.0 - x, 600.0 - y], "independent": alt}))]
};
for (x, y) in [(105.0, 290.0), (130.0, 260.0), (160.0, 240.0)] {
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, x, y)), close(x, y, false), "at ({x}, {y})");
}
let alt = Mods { alt: true, ..Mods::default() };
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 170.0, 250.0).with_mods(alt)), close(170.0, 250.0, true));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 170.0, 250.0)), vec![Action::Commit]);
assert!(!t.busy() && t.active(&c).is_none(), "the path is done");
}
#[test]
fn a_one_anchor_path_does_not_close_on_itself() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false));
@@ -631,31 +550,6 @@ mod tests {
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
}
/// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends
/// there), in line with another object's centre; Shift slides it along its 45° step into line.
#[test]
fn anchors_snap_to_smart_guides() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = PenTool { drawing: true, ..PenTool::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
let o = t.overlays(&cx);
assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}");
assert!(o.iter().any(
|o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0)))
));
let a = click(&mut t, &cx, 152.0, 318.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0})));
let shift = Mods { shift: true, ..Mods::default() };
let a = click(&mut t, &cx, 152.0, 310.0, shift);
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0})));
// The first anchor of a new path lands on the rect's corner.
let mut t = PenTool::default();
let a = click(&mut t, &cx, 102.0, 99.0, Mods::default());
assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]})));
}
#[test]
fn alt_over_a_shown_handle_or_an_anchor_converts() {
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
@@ -693,53 +587,4 @@ mod tests {
let cx1 = cx(&d, &s, &p);
assert_eq!(t.cursor(&cx1, Point::new(250.0, 101.0), alt), Cursor::Pen);
}
/// #494: Alt held over a segment of a selected path reshapes it as the Anchor Point tool does,
/// while drawing too; Alt away from paths still places an anchor.
#[test]
fn alt_over_a_segment_reshapes_it() {
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false));
let p = paint();
let cx = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
let mut t = PenTool { drawing: true, ..PenTool::default() };
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert);
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::Pen);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt));
assert_eq!(a[0], Action::Begin("Reshape".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
"{a:?}"
);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 262.0)), vec![Action::Commit]);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 250.0, 400.0).with_mods(alt));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.appendAnchor"), "{a:?}");
}
/// #494: the Pen goes on drawing the path it drew while that path stays selected (after a Cmd
/// drag with Direct Selection), but never another open path selected meanwhile.
#[test]
fn the_pen_draws_on_the_path_it_began_only() {
let (mut d, a, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
let l = d.layers[0].id;
let b = d.alloc_id();
let line = vectorcraft_geom::shapes::line(Point::new(10.0, 400.0), Point::new(60.0, 400.0));
d.insert(Some(l), 2, vectorcraft_doc::Node::path(b, line, vectorcraft_doc::Appearance::default_art())).unwrap();
let p = paint();
let mut t = PenTool { drawing: true, ..PenTool::default() };
let cx_a = cx(&d, &s, &p);
let appended = |acts: &[Action]| match acts {
[Action::Begin(_), Action::Preview(c, v)] if c == "path.appendAnchor" => v["id"].as_u64(),
_ => None,
};
assert_eq!(appended(&click(&mut t, &cx_a, 100.0, 320.0, Mods::default())), Some(a.0));
// Path B selected instead: the next click starts a new path.
let mut sb = Selection::default();
sb.set([b]);
let cx_b = cx(&d, &sb, &p);
assert_eq!(t.cursor(&cx_b, Point::new(150.0, 350.0), Mods::default()), Cursor::Pen);
let acts = click(&mut t, &cx_b, 150.0, 350.0, Mods::default());
assert!(matches!(&acts[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{acts:?}");
}
}
+23 -69
View File
@@ -2,13 +2,11 @@
//!
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count. The start point and
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use crate::guides::{DrawSnap, Leave, square};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct ShapeTool {
@@ -22,7 +20,7 @@ pub struct ShapeTool {
pub corner_radius: f64,
/// Star inner radius as a fraction of the outer radius.
pub star_ratio: f64,
snap: DrawSnap,
guides: Vec<Overlay>,
}
impl ShapeTool {
@@ -45,7 +43,7 @@ impl ShapeTool {
points: 5,
corner_radius: 12.0,
star_ratio: 0.5,
snap: DrawSnap::default(),
guides: vec![],
}
}
@@ -60,18 +58,7 @@ impl ShapeTool {
}
}
/// How the dragged point keeps to the start: a polygon's or star's radius goes anywhere, a
/// line is a segment (Shift: 45° steps), the rest are boxes (Shift: a square).
fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option<Leave> {
match self.id {
"polygon" | "star" => None,
"lineSegment" => Some(Leave::segment(cx, start, m.shift)),
_ => Some(Leave::diagonal(start, m.shift)),
}
}
/// The command for a drag from `start` to `p` (snapped: a line's end already keeps Shift's
/// angle).
/// The command for the current drag.
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
match self.id {
"polygon" | "star" => {
@@ -89,6 +76,7 @@ impl ShapeTool {
}
"lineSegment" => {
let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) };
let b = if m.shift { a + vectorcraft_geom::constrain_angle(b - a, 45.0) } else { b };
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
}
_ => {
@@ -116,7 +104,11 @@ pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
/// centre.
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
let d = if m.shift { square(p - start) } else { p - start };
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
}
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
}
@@ -129,12 +121,8 @@ impl Tool for ShapeTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
self.snap.hover(cx, ev.pos, &[], None);
vec![]
}
PointerKind::Down => {
let p = self.snap.press(cx, ev.pos, &[], None);
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.start = Some(p);
self.last = ev.pos;
self.began = false;
@@ -142,7 +130,8 @@ impl Tool for ShapeTool {
}
PointerKind::Drag => {
let Some(mut s) = self.start else { return vec![] };
let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref());
let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.guides = g;
let ev = &PointerEvent { pos, ..*ev };
space_moves(&mut s, &mut self.last, ev, self.began);
self.start = Some(s);
@@ -161,7 +150,7 @@ impl Tool for ShapeTool {
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
self.snap.clear();
self.guides.clear();
if self.began {
self.began = false;
vec![Action::Commit]
@@ -207,26 +196,23 @@ impl Tool for ShapeTool {
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
self.snap.clear();
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
// A box's size as drawn (from its centre with Alt, square with Shift).
let (w, h) = match self.id {
"polygon" | "star" | "lineSegment" => ((self.last - s).x.abs(), (self.last - s).y.abs()),
_ => {
let r = drag_rect(s, self.last, self.mods);
(r.width(), r.height())
match self.start {
Some(s) if self.began => {
let d = self.last - s;
let mut o = self.guides.clone();
if cx.measurement_labels {
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
}
};
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
o
}
_ => vec![],
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
@@ -324,36 +310,4 @@ mod tests {
let a = t.key(&cx, ToolKey::Up, Mods::default());
assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7));
}
/// The start and the dragged corner land on another object's anchors (#506), hovering shows
/// it before the press, and the measurement label gives the size drawn.
#[test]
fn corners_snap_to_smart_guides() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ShapeTool::new("rectangle");
let anchor = |o: &[Overlay], at: Point| o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == at));
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 98.0, 102.0));
assert!(anchor(&t.overlays(&cx), Point::new(100.0, 100.0)));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 98.0, 102.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0));
assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 100.0, "y": 100.0, "width": 100.0, "height": 100.0})));
let o = t.overlays(&cx);
assert!(anchor(&o, Point::new(200.0, 200.0)), "{o:?}");
assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(100.0, 100.0))));
// Alt draws from the centre: the label gives the whole size.
let alt = Mods { alt: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0).with_mods(alt));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(200.0, 200.0))));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 198.0, 202.0));
assert!(t.overlays(&cx).is_empty());
// A line with Shift keeps to 45° steps, sliding into line with the rect's right side.
let mut t = ShapeTool::new("lineSegment");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let shift = Mods { shift: true, ..Mods::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 302.0).with_mods(shift));
assert!(matches!(&a[..], [_, Action::Preview(_, v)] if v["x2"] == 200.0 && (v["y2"].as_f64().unwrap() - 300.0).abs() < 1e-9), "{a:?}");
}
}
+12 -24
View File
@@ -3,8 +3,7 @@
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
//! editing ends (`text.discardEmpty`); frames keep their shape. Where new type goes (the click, the
//! frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
//! editing ends (`text.discardEmpty`); frames keep their shape.
//!
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
@@ -28,7 +27,6 @@ use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
use vectorcraft_text::{FontDb, TextLayout, edit};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// Which of the Type tools this is.
@@ -70,12 +68,8 @@ pub struct TypeTool {
goal_x: Option<f64>,
typing: Option<Typing>,
preedit: Option<Preedit>,
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
press: Option<(Point, Point)>,
/// The other corner of an area dragged out (snapped).
press: Option<Point>,
drag: Option<Point>,
/// Smart Guides for where new type goes.
snap: DrawSnap,
/// The press landed in the edited text: dragging selects.
selecting: bool,
/// Click counting for double/triple click (position of the last click, count).
@@ -258,8 +252,7 @@ impl TypeTool {
Some(out)
}
/// The button released after a press at `start` (new type goes at `at`).
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
// Click into existing text: place the caret.
if let Some(out) = self.edit_at(cx, start) {
return out;
@@ -279,10 +272,10 @@ impl TypeTool {
return out;
}
}
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": at.x, "y": at.y, "text": ""}),
None => json!({"x": start.x, "y": start.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
params["placeholder"] = json!(cx.placeholder_text);
@@ -393,7 +386,7 @@ impl Tool for TypeTool {
self.selecting = true;
return out;
}
self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None)));
self.press = Some(ev.pos);
self.drag = None;
vec![]
}
@@ -405,7 +398,7 @@ impl Tool for TypeTool {
self.clicks = (None, 0);
}
} else if self.press.is_some() {
self.drag = Some(self.snap.drag(cx, ev.pos, None));
self.drag = Some(ev.pos);
}
vec![]
}
@@ -414,9 +407,8 @@ impl Tool for TypeTool {
self.selecting = false;
return vec![];
}
self.snap.clear();
let Some((start, at)) = self.press.take() else { return vec![] };
self.on_up(cx, start, at)
let Some(start) = self.press.take() else { return vec![] };
self.on_up(cx, start)
}
PointerKind::DoubleClick => {
if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() {
@@ -425,11 +417,7 @@ impl Tool for TypeTool {
}
vec![]
}
PointerKind::Move => {
// The type being edited is no target: its guides would only point at itself.
self.snap.hover(cx, ev.pos, self.editing.as_slice(), None);
vec![]
}
PointerKind::Move => vec![],
}
}
fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec<Action> {
@@ -712,8 +700,8 @@ impl Tool for TypeTool {
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = self.snap.guides().to_vec();
if let (Some((_, s)), Some(d)) = (self.press, self.drag) {
let mut o = vec![];
if let (Some(s), Some(d)) = (self.press, self.drag) {
o.push(Overlay::Marquee(Rect::from_points(s, d)));
}
// Overflow markers on selected text.
-21
View File
@@ -335,24 +335,3 @@ fn hebrew_typing_and_visual_arrows_keep_logical_text() {
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "שלום");
}
/// Where new type goes snaps to Smart Guides (#506): a click beside a rect lines up with its
/// centre, and hovering shows it first; an area's corners land on its anchors.
#[test]
fn new_type_snaps_to_smart_guides() {
let (d, _) = crate::testutil::doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Move, 151.0, 330.0));
assert!(tool.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 330.0))));
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 330.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 151.0, 330.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 150.0 && v["y"] == 330.0)), "{acts:?}");
let mut tool = TypeTool::new("type");
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.0, 98.0));
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 199.0, 202.0));
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 199.0, 202.0));
let area = json!({"width": 100.0, "height": 100.0});
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
}
+10 -9
View File
@@ -8,14 +8,14 @@
//! the Selection tool. Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
//! snap too.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::guides::{DrawSnap, Leave, Targets};
use crate::guides::Targets;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug)]
@@ -57,13 +57,11 @@ pub struct ArtboardTool {
copy_targets: Option<Targets>,
/// Smart Guides shown while dragging.
guides: Vec<Overlay>,
/// Smart Guides for the corners of a new artboard.
draw: DrawSnap,
}
impl Default for ArtboardTool {
fn default() -> Self {
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![], draw: DrawSnap::default() }
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![] }
}
}
@@ -136,7 +134,6 @@ impl ArtboardTool {
self.targets = None;
self.copy_targets = None;
self.guides.clear();
self.draw.clear();
}
}
@@ -174,7 +171,7 @@ impl Tool for ArtboardTool {
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
} else {
let p = self.draw.press(cx, p, &[], None);
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
@@ -216,7 +213,12 @@ impl Tool for ArtboardTool {
out
}
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
let cur = self.draw.drag(cx, p, Some(&Leave::diagonal(start, m.shift)));
let (mut cur, _) = crate::guides::snap_draw(cx, p, &[]);
if m.shift {
let d = cur - start;
let s = d.x.abs().max(d.y.abs());
cur = start + Vec2::new(s.copysign(d.x), s.copysign(d.y));
}
self.drag = Some(Drag::Create { start, cur });
vec![]
}
@@ -287,7 +289,6 @@ impl Tool for ArtboardTool {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if let Some(Drag::Create { start, cur }) = self.drag {
o.extend_from_slice(self.draw.guides());
let r = Rect::from_points(start, cur);
o.push(Overlay::Marquee(r));
if cx.measurement_labels {
+41 -14
View File
@@ -76,19 +76,31 @@ enum Drag {
start_angle: f64,
start_rot: f64,
},
/// Cmd held: temporary selection tool; restore this tool on release.
TempSelect,
/// A Selection tool move dragged off the canvas: the panels get the art
/// ([`widgets::PanelDrag::Art`]).
Art,
/// ([`widgets::PanelDrag::Art`]); `temp` restores the tool Cmd switched from on release.
Art {
temp: bool,
},
}
fn drag_id() -> egui::Id {
egui::Id::new("canvas-drag")
}
fn temp_tool_id() -> egui::Id {
egui::Id::new("canvas-temp-tool")
}
/// Where the pointer was last seen during a press on the canvas.
fn drag_pos_id() -> egui::Id {
egui::Id::new("canvas-drag-pos")
}
/// Selection, Direct Selection and Group Selection: the tools Cmd switches to for a drag.
pub(crate) fn is_selection_tool(id: &str) -> bool {
matches!(id, "selection" | "directSelection" | "groupSelection")
}
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
/// until a pen reports it). A mouse presses fully (1).
@@ -519,10 +531,19 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
let c = rect.center();
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation }
} else {
// Cmd with another tool drags with the selection tool used last (the session lends it).
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
let mut kind = Drag::Tool;
// Cmd with another tool: drag with the selection tool used last.
if m.command && !is_selection_tool(tool) {
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
let last = app.ui.last_selection_tool.clone();
app.select_tool(if is_selection_tool(&last) { &last } else { "selection" });
kind = Drag::TempSelect;
}
// Cmd only brought the selection tool: its click is a plain one, not Select Behind.
let mods = Mods { cmd: m.command && kind != Drag::TempSelect, ..mods(m, space) };
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods, pressure: pen_pressure(ui, true) };
dispatch(app, &ev, view);
Drag::Tool
kind
};
ui.data_mut(|dd| {
dd.insert_temp(drag_id(), d);
@@ -559,11 +580,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
vm.rotation = vectorcraft_geom::normalize_deg(deg);
}
}
Drag::ZoomBox { .. } | Drag::Art => {}
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
Drag::ZoomBox { .. } | Drag::Art { .. } => {}
Drag::Tool | Drag::TempSelect if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art { temp: d == Drag::TempSelect }));
}
Drag::Tool => {
Drag::Tool | Drag::TempSelect => {
if pointer.delta() != egui::Vec2::ZERO {
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
@@ -599,11 +620,17 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
}
}
}
Drag::Tool => {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
Drag::Tool | Drag::TempSelect | Drag::Art { .. } => {
if !matches!(d, Drag::Art { .. }) {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
if matches!(d, Drag::TempSelect | Drag::Art { temp: true })
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
{
app.restore_tool(&prev);
}
}
Drag::Art => {}
Drag::Pan { .. } | Drag::RotateView { .. } => {}
}
}
@@ -1344,7 +1371,7 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let app = &*app;
let Some(st) = app.session.active() else { return };
let tool = app.session.tool_id();
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
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 look = HandleLook::of(&app.session.prefs);
+9 -36
View File
@@ -108,32 +108,10 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
/// An anchor button: icon, tooltip, command and the convert command's `to`.
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
/// The anchor controls the Control bar and the Properties panel show.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum AnchorControls {
None,
/// Paths selected as a whole with a tool that edits anchors (Direct Selection, the Pen…),
/// which shows all their anchors selected: "Convert:" only, as removing or cutting at every
/// anchor isn't what they're for.
Convert,
/// Anchors direct-selected: "Convert:" and "Anchors:".
All,
}
/// Which anchor controls show for the selection and the active tool.
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
let Some(st) = app.session.active() else { return AnchorControls::None };
if !st.selection.anchors.is_empty() {
return AnchorControls::All;
}
let paths = st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. })));
if paths && vectorcraft_tools::catalog::edits_anchors(app.session.tool_id()) { AnchorControls::Convert } else { AnchorControls::None }
}
/// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]):
/// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a
/// row of its own: inline in the Control bar, one under the other in a panel.
pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) {
/// What the Control bar and the Properties panel offer for direct-selected anchors: "Convert:"
/// corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a row of its
/// own: inline in the Control bar, one under the other in a panel.
pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let mut run = None;
let groups: [(&str, &[AnchorButton]); 2] = [
@@ -153,12 +131,7 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
],
),
];
let shown = match controls {
AnchorControls::None => 0,
AnchorControls::Convert => 1,
AnchorControls::All => groups.len(),
};
for (label, buttons) in groups.into_iter().take(shown) {
for (label, buttons) in groups {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).size(12.0).color(t.text));
for &(icon, tip, id, to) in buttons {
@@ -193,9 +166,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let sel = st.selection.objects.clone();
let units = app.session.general_unit();
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchors = anchor_controls(app);
let anchor_mode = !st.selection.anchors.is_empty();
let label = match &first {
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
Some(_) if sel.len() == 1 && anchor_mode => tl!("Anchor Point"),
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
if im.link.is_some() {
tl!("Linked File")
@@ -207,8 +180,8 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
if anchors != AnchorControls::None {
anchor_buttons(app, ui, anchors);
if anchor_mode {
anchor_buttons(app, ui);
}
ui.add_space(6.0);
// An image or an Image Trace object shows its own controls in place of Fill and Stroke.
-99
View File
@@ -1,99 +0,0 @@
//! Checks the system clipboard for Paste off the UI thread. `has()` can block for seconds when the
//! clipboard owner never answers (an unresponsive X11 client), so it must not run on the thread that
//! draws the window: a worker owns a second clipboard handle, sends its verdict when it changes, and
//! the UI only reads the latest one. Without a worker the check stays in line, as before (the web,
//! and hosts that don't install a factory).
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::time::{Duration, Instant};
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
use crate::sysclip::ClipboardProbeFactory;
/// How often the worker looks at the clipboard while the UI wants to know.
const PROBE_INTERVAL: Duration = Duration::from_millis(250);
/// How long dropping the probe waits for the worker to let go of its clipboard handle.
const STOP_WAIT: Duration = Duration::from_millis(250);
/// The background clipboard check ([`crate::VectorcraftApp`]'s `clipboard_probe`).
pub(crate) struct Probe {
/// Tells the worker whether Paste depends on the system clipboard; dropped to stop it.
wants: Option<Sender<bool>>,
wanted: bool,
verdicts: Receiver<bool>,
/// The worker's last verdict.
last: bool,
}
impl Probe {
/// Start the worker. `None` on wasm, or when the thread can't be spawned: the caller then falls
/// back to the in-line check.
pub(crate) fn start(make: ClipboardProbeFactory, ctx: egui::Context) -> Option<Self> {
if cfg!(target_arch = "wasm32") {
return None;
}
let (wants, wanted_rx) = std::sync::mpsc::channel();
let (tx, verdicts) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("vectorcraft-clip".into())
.spawn(move || {
let mut clipboard = make();
let (mut wanted, mut sent) = (false, None);
loop {
if wanted {
// This thread may wait here for a slow owner; the UI's never does.
let verdict = clipboard.has(&PASTE_ORDER);
if sent != Some(verdict) {
if tx.send(verdict).is_err() {
break;
}
sent = Some(verdict);
ctx.request_repaint();
}
}
// Sleep until the next look (no looks while unwanted) or the UI's next word.
let next =
if wanted { wanted_rx.recv_timeout(PROBE_INTERVAL) } else { wanted_rx.recv().map_err(|_| RecvTimeoutError::Disconnected) };
match next {
Ok(w) => wanted = w,
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
// The handle goes before the app stops waiting (`Drop`): on X11 the last one alive
// hands what the app copied to the clipboard manager, which must finish before exit.
drop(clipboard);
drop(tx);
})
.ok()?;
Some(Self { wants: Some(wants), wanted: false, verdicts, last: false })
}
/// Paste can take from the system clipboard: `wanted` (nothing copied in the app, a document
/// open) and the worker's latest verdict says so. Never blocks. `None` once the worker is gone.
pub(crate) fn pasteable(&mut self, wanted: bool) -> Option<bool> {
if wanted != self.wanted {
self.wants.as_ref()?.send(wanted).ok()?;
self.wanted = wanted;
}
loop {
match self.verdicts.try_recv() {
Ok(verdict) => self.last = verdict,
Err(TryRecvError::Empty) => return Some(wanted && self.last),
Err(TryRecvError::Disconnected) => return None,
}
}
}
}
impl Drop for Probe {
/// Wake the worker to stop and wait (a moment at most: it may be stuck on an owner that never
/// answers) until it has let go of its clipboard handle.
fn drop(&mut self) {
self.wants = None;
let end = Instant::now() + STOP_WAIT;
while self.verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {}
}
}
+85 -156
View File
@@ -8,7 +8,7 @@ use serde_json::json;
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, floating, icons, panels, widgets};
use crate::{VectorcraftApp, icons, panels, widgets};
const ICON_COL: f32 = 38.0;
/// Height of the strip along the top of a dock column that carries its double arrow.
@@ -57,26 +57,18 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
}
}
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
/// column (`column`), the panel floats.
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
/// One icon of the column: a click pops its panel out (or puts it away).
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, label: &str, icon: &str) {
let open = app.ui.open_panel.as_deref() == Some(id);
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
if resp.clicked() {
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let shown = floating::shown_tab(&app.ui);
// Main tabbed group, unless it is collapsed to icons or all its panels float.
let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| {
// Main tabbed group, unless it is collapsed to icons.
if !app.ui.dock_collapsed {
egui::Panel::right("dock")
.resizable(true)
.default_size(DOCK_WIDTH)
@@ -86,31 +78,47 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if collapse_header(ui, false) {
toggle(app, true);
}
if tab_strip(app, ui, active) {
// It floats from this frame on.
return;
// Tab strip.
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
for tab in DockTab::ALL {
let (_, label, _) = tab.info();
let active = app.ui.dock_tab == tab;
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
}
x = r.right();
}
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active {
let menu_id = app.ui.dock_tab.info().0;
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
});
})
.response
.rect
});
});
}
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
// collapsed group's panels head it, under the « that expands them again. Floating panels leave
// it.
let collapsed = app.ui.dock_collapsed && shown.is_some();
// collapsed group's panels head it, under the « that expands them again.
let collapsed = app.ui.dock_collapsed;
let column = egui::Panel::right("icon_column")
.resizable(false)
.exact_size(ICON_COL)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
let bounds = ui.max_rect();
if collapsed && collapse_header(ui, true) {
toggle(app, false);
}
@@ -121,98 +129,41 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if collapsed {
for tab in DockTab::ALL {
let (id, label, icon) = tab.info();
if floating::group_of(&app.ui, id).is_none() {
panel_icon(app, ui, id, label, icon, bounds);
}
panel_icon(app, ui, id, label, icon);
}
}
// A divider above each group with icons left, but the first.
let mut divider = collapsed;
for group in ICON_PANEL_GROUPS {
let mut first = true;
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 || collapsed {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter()
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
if floating::group_of(&app.ui, id).is_some() {
continue;
}
if std::mem::take(&mut first) && std::mem::replace(&mut divider, true) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter()
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
panel_icon(app, ui, id, label, icon, bounds);
panel_icon(app, ui, id, label, icon);
}
}
});
});
});
let column = column.response.rect;
let dock = group.map_or(column, |g| g.union(column));
ui.ctx().data_mut(|d| {
d.insert_temp(column_left_id(), column.left());
d.insert_temp(floating::dock_rect_id(), dock);
});
}
/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a tab dragged out of
/// the strip floats on its own, and the strip right of the tabs floats the whole group. True when
/// the shown panel floats now.
fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
let t = Tokens::get(ui.ctx());
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
let mut x = strip.left();
for tab in DockTab::ALL {
let (id, label, _) = tab.info();
if floating::group_of(&app.ui, id).is_some() {
continue;
}
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
if tab == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
} else if resp.dragged() && out {
tear = Some((vec![id], id, r.left()));
}
x = r.right();
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
if resp.dragged() && out {
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
}
panels::panel_menu(app, ui, active.info().0, menu);
if let Some((ids, id, left)) = tear {
floating::tear(app, ui.ctx(), &ids, id, floating::strip_grab(ui.ctx(), left));
}
// Torn off, or floated from its menu.
floating::group_of(&app.ui, active.info().0).is_some()
let left = column.response.rect.left();
ui.ctx().data_mut(|d| d.insert_temp(column_left_id(), left));
}
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
/// while the dock is collapsed. Dragging its title floats it.
/// while the dock is collapsed.
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some((id, label)) = app.ui.open_panel.as_deref().and_then(|p| crate::state::all_panels().find(|(q, _)| *q == p)) else { return };
let tab = DockTab::from_id(id);
// Expanded, the group shows its panels in the dock itself; a floating panel shows in its group.
if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() {
return;
}
let Some(id) = app.ui.open_panel.clone() else { return };
let tab = DockTab::from_id(&id);
let label = match tab {
Some(tab) if app.ui.dock_collapsed => tab.info().1,
// Expanded, the group shows its panels in the dock itself.
Some(_) => return,
None => match ICON_PANELS.iter().find(|p| p.0 == id) {
Some((_, label, _)) => *label,
None => return,
},
};
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
// Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its
@@ -228,39 +179,52 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let right = column - 6.0;
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
// that keeps the flyout on screen.
let width = panel_width(id);
// The tabbed group's panels keep the width the dock gives them; icon panels are 256 points.
let width = if tab.is_some() { DOCK_WIDTH } else { 256.0 };
let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0);
let mut open = true;
let mut torn = None;
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(width);
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let title = Rect::from_min_size(
let title = egui::Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
if cr.clicked() {
open = false;
}
let menu_r = Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, id, menu_r);
let drag = ui.interact(title, ui.id().with("title"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
if drag.dragged() && ui.input(|i| i.pointer.is_decidedly_dragging()) {
torn = Some(title.left());
}
// Torn off, or floated from its menu: it floats from this frame on.
if torn.is_some() || floating::group_of(&app.ui, id).is_some() {
return;
}
panel_body(app, ui, id, width, tall);
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
panels::panel_menu(app, ui, &id, menu_r);
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(width - 20.0);
match tab {
Some(DockTab::Properties) => {
// At least `tall` when the sections are taller: the flyout's area keeps the
// size its content needs, and egui sizes a new area at most 400 points high,
// so a scroll area that only took the room it was given never grew past it.
egui::ScrollArea::vertical()
.id_salt("props-flyout")
.max_height(tall)
.min_scrolled_height(tall)
.auto_shrink([false, true])
.show(ui, |ui| panels::properties::show(app, ui));
}
Some(DockTab::Layers) => {
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
None => panels::show_icon_panel(app, ui, &id),
}
});
});
});
// User Interface › Auto-Collapse Iconic Panels (#394): a press away from the flyout puts it
@@ -276,46 +240,11 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
open = false;
}
}
if let Some(left) = torn {
floating::tear(app, ctx, &[id], id, floating::strip_grab(ctx, left));
} else if !open {
if !open {
app.ui.open_panel = None;
}
}
/// The width of a popped-out or floating panel: the tabbed group's panels keep the width the dock
/// gives them; icon panels are 256 points.
pub(crate) fn panel_width(id: &str) -> f32 {
if DockTab::from_id(id).is_some() { DOCK_WIDTH } else { 256.0 }
}
/// Panel `id`'s contents in a popped-out or floating panel `width` wide; the tabbed group's panels
/// fill (Layers) or scroll (Properties) `tall` points.
pub(crate) fn panel_body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32, tall: f32) {
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(width - 20.0);
match DockTab::from_id(id) {
Some(DockTab::Properties) => {
// At least `tall` when the sections are taller: the flyout's area keeps the size its
// content needs, and egui sizes a new area at most 400 points high, so a scroll area
// that only took the room it was given never grew past it.
egui::ScrollArea::vertical()
.id_salt("props-flyout")
.max_height(tall)
.min_scrolled_height(tall)
.auto_shrink([false, true])
.show(ui, |ui| panels::properties::show(app, ui));
}
Some(DockTab::Layers) => {
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
None => panels::show_icon_panel(app, ui, id),
}
});
}
#[cfg(test)]
mod tests {
use serde_json::json;
-769
View File
@@ -1,769 +0,0 @@
//! Floating panels: panels dragged out of the dock float inside the window as groups of tabs.
//!
//! A dock tab, an icon of the icon column or a popped-out panel's title dragged out of the dock
//! floats that panel; the strip right of the dock's tabs floats the whole tabbed group. A floating
//! group moves by its title bar (or the strip right of its tabs, or the tab of a lone panel), and a
//! tab dragged out of a group floats on its own. Dropped on the dock, which lights up while the
//! pointer is over it, a group's panels go back to where they live in the dock (the tabbed group or
//! the icon column); dropped on another group's title bar or tabs, they stack with that group. The
//! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its
//! title bar ([`crate::toolbar`]) and docks again on the window's left edge.
//!
//! `window.panel.float` / `window.panel.dock` do the same for agents. The groups and the Tools
//! panel's position are kept with the preferences and in user workspaces, and a position saved on
//! a bigger window is clamped into this one when drawn.
use egui::{CornerRadius, Id, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
use serde_json::{Value, json};
use crate::state::{DockTab, FloatingPanels, UiState, all_panels};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, dock, icons, panels};
/// Height of a floating group's title bar (its grip and ×).
pub const TITLE: f32 = 14.0;
/// Height of a floating group's tab strip.
pub const STRIP: f32 = 26.0;
/// Where `window.panel.float` floats a group given no position; each further group 24 points
/// lower right.
const FLOAT_AT: [f32; 2] = [420.0, 120.0];
/// Where `window.panel.float {panel: "tools"}` floats the Tools panel given no position.
const TOOLS_AT: [f32; 2] = [60.0, 120.0];
/// Points the pointer travels from its press before a drop counts (a click on a title bar that
/// overlaps the dock doesn't dock its group).
const TRAVEL: f32 = 4.0;
/// What a move carries.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Moving {
/// The floating group holding this panel.
Panel(&'static str),
/// The floating Tools panel.
Tools,
}
/// A move in progress: what moves, where the pointer holds it from its top-left corner, and where
/// the press began.
#[derive(Clone, Copy, Debug)]
struct Move {
what: Moving,
grab: Vec2,
from: Pos2,
}
/// Where a moved group or Tools panel lands.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drop {
/// Back in the dock (the Tools panel: at the window's left edge).
Dock,
/// Stacked with the floating group holding this panel.
Stack(&'static str),
}
fn move_id() -> Id {
Id::new("floating-move")
}
/// The dock (tabbed group and icon column) as last drawn: a group dropped there docks.
pub(crate) fn dock_rect_id() -> Id {
Id::new("floating-dock-rect")
}
/// The strip along the window's left edge where the Tools panel docks, as last laid out.
pub(crate) fn tools_zone_id() -> Id {
Id::new("floating-tools-zone")
}
/// The area of the floating group whose first panel is `first`.
pub fn area_id(first: &str) -> Id {
Id::new(("floating-panels", first))
}
/// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel.
fn panel(id: &str) -> Option<(&'static str, &'static str)> {
all_panels().find(|(p, _)| *p == id)
}
/// The floating group holding panel `id`.
pub fn group_of(ui: &UiState, id: &str) -> Option<usize> {
ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id))
}
/// The panels of group `g`, as static ids.
fn ids_of(g: &FloatingPanels) -> Vec<&'static str> {
g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect()
}
/// The dock tabs left in the dock, in tab order.
pub fn docked_tabs(ui: &UiState) -> impl Iterator<Item = DockTab> + '_ {
DockTab::ALL.into_iter().filter(|t| group_of(ui, t.info().0).is_none())
}
/// The dock tab the tabbed group shows: the chosen one, or the first left when it floats.
pub fn shown_tab(ui: &UiState) -> Option<DockTab> {
docked_tabs(ui).find(|t| *t == ui.dock_tab).or_else(|| docked_tabs(ui).next())
}
/// Take `ids` out of the floating groups, dropping the groups left empty.
fn take(ui: &mut UiState, ids: &[&str]) {
ui.floating_panels.retain_mut(|g| {
let active = g.panels.get(g.active).filter(|p| !ids.contains(&p.as_str())).cloned();
g.panels.retain(|p| !ids.contains(&p.as_str()));
g.active = active.and_then(|a| g.panels.iter().position(|p| *p == a)).unwrap_or(0);
!g.panels.is_empty()
});
}
/// Float `ids` (out of the dock or of the group they were in) as a new group with its top-left
/// corner at `pos`, showing `active`.
pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) {
take(ui, ids);
if ui.open_panel.as_deref().is_some_and(|p| ids.contains(&p)) {
ui.open_panel = None;
}
let panels: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
let active = panels.iter().position(|p| p == active).unwrap_or(0);
ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y] });
// The tabbed group shows another of its tabs when the one it showed floats.
if let Some(tab) = shown_tab(ui) {
ui.dock_tab = tab;
}
}
/// Stack `ids` with the floating group holding `onto` (not one of them), showing `active`.
pub fn stack(ui: &mut UiState, ids: &[&str], active: &str, onto: &str) {
take(ui, ids);
if ui.open_panel.as_deref().is_some_and(|p| ids.contains(&p)) {
ui.open_panel = None;
}
if let Some(g) = group_of(ui, onto).and_then(|i| ui.floating_panels.get_mut(i)) {
g.panels.extend(ids.iter().map(|id| id.to_string()));
g.active = g.panels.iter().position(|p| p == active).unwrap_or(g.active);
}
if let Some(tab) = shown_tab(ui) {
ui.dock_tab = tab;
}
}
/// Put `ids` back in the dock: the dock tabs in the tabbed group (showing `active`, or the first of
/// them), the other panels in the icon column.
pub fn dock(ui: &mut UiState, ids: &[&str], active: &str) {
take(ui, ids);
if let Some(tab) = DockTab::from_id(active).or_else(|| ids.iter().find_map(|id| DockTab::from_id(id))) {
ui.dock_tab = tab;
}
ui.dock = true;
}
/// `pos`, a saved top-left corner, moved so that a `size` box there is inside `screen` (its
/// top-left corner first when it doesn't fit). Pulled in by whole points: egui rounds an area's
/// position, which would push a box of fractional size half a point past the edge.
pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 {
let [x, y] = pos;
let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) };
let fit = |v: f32, max: f32| if v > max { max.floor() } else { v };
pos2(fit(x, screen.right() - size.x).max(screen.left()), fit(y, screen.bottom() - size.y).max(screen.top()))
}
/// Start moving `what` with the pointer, held `grab` from its top-left corner.
fn start(ctx: &egui::Context, what: Moving, grab: Vec2) {
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from }));
}
/// Where the pointer holds a group torn off by a press `left` points right of where the group's
/// tab strip will begin (under its title bar, past its 1-point border).
pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 {
let x = ctx.input(|i| i.pointer.press_origin()).map_or(12.0, |p| p.x - left);
vec2(1.0 + x.max(0.0), 1.0 + TITLE + STRIP / 2.0)
}
/// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on
/// moving them with the pointer (a tab or group dragged out of the dock or out of its group).
pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) {
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
float(&mut app.ui, ids, active, at - grab);
start(ctx, Moving::Panel(active), grab);
}
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
/// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it.
pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option<Pos2>) {
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
match floating {
Some(pos) if title.drag_started() => start(ctx, Moving::Tools, at - pos),
None if title.dragged() && !bounds.contains(at) => {
// Held where the press grabbed the title bar.
let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min);
let p = at - grab;
app.ui.toolbar_pos = Some([p.x, p.y]);
start(ctx, Moving::Tools, grab);
}
_ => {}
}
}
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));
let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };
let own = group_of(&app.ui, id);
// Another group's title bar and tabs stack with it.
for (i, g) in app.ui.floating_panels.iter().enumerate() {
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| Some(i) != own) else { continue };
let head = ctx.memory(|m| m.area_rect(area_id(first))).map(|r| Rect::from_min_size(r.min, vec2(r.width(), 1.0 + TITLE + STRIP)));
if let Some(head) = head.filter(|h| h.contains(at)) {
return Some((Drop::Stack(first), head));
}
}
zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3).map(|r| (Drop::Dock, r))
}
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
fn highlight(ctx: &egui::Context, zone: Rect) {
let t = Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
}
/// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel
/// follows the pointer and the zone it would drop on lights up; the release drops it there.
pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
let at = ctx.input(|i| i.pointer.interact_pos());
let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at));
if ctx.input(|i| i.pointer.primary_down()) {
if let Some(p) = at.map(|at| at - m.grab)
&& p.x.is_finite()
&& p.y.is_finite()
{
match m.what {
Moving::Panel(id) => {
if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) {
g.pos = [p.x, p.y];
}
}
Moving::Tools if app.ui.toolbar_pos.is_some() => app.ui.toolbar_pos = Some([p.x, p.y]),
Moving::Tools => {}
}
}
if let Some((_, zone)) = target {
highlight(ctx, zone);
}
return;
}
ctx.data_mut(|d| d.remove::<Move>(move_id()));
let Some((drop, _)) = target else { return };
let Moving::Panel(id) = m.what else {
app.ui.toolbar_pos = None;
return;
};
let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i)) else { return };
let ids = ids_of(g);
let active = ids.get(g.active).copied().unwrap_or(id);
match drop {
Drop::Dock => dock(&mut app.ui, &ids, active),
Drop::Stack(onto) => stack(&mut app.ui, &ids, active, onto),
}
}
/// What a press on a floating group did.
enum Action {
/// Show the group's `k`th tab.
Activate(usize, usize),
/// Its × puts the group's panels back in the dock.
Close(usize),
/// Move the group holding this panel, drawn at this corner.
Move(&'static str, Pos2),
/// Float this panel out of its group; its tab began this far right.
Tear(&'static str, f32),
}
/// The floating groups, each a stack of tabs under a title bar. Hidden with the dock (Tab,
/// Presentation Mode).
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let pointer = ctx.input(|i| i.pointer.interact_pos());
let mut action = None;
for gi in 0..app.ui.floating_panels.len() {
let Some(g) = app.ui.floating_panels.get(gi) else { break };
let ids = ids_of(g);
let Some(&first) = ids.first() else { continue };
let active = ids.get(g.active).copied().unwrap_or(first);
let area = area_id(first);
let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size());
let pos = clamp(g.pos, size, screen);
// The tabbed group's panels fill (Layers) or scroll (Properties) to near the window's bottom,
// leaving room for their margins (a group that only just fitted would creep up a little each
// frame).
let tall = (screen.bottom() - pos.y - TITLE - STRIP - 60.0).clamp(120.0, 560.0);
let lone = ids.len() == 1;
egui::Area::new(area).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
let frame = egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO);
frame.show(ui, |ui| {
ui.spacing_mut().item_spacing = Vec2::ZERO;
let tabs: Vec<_> = ids
.iter()
.map(|id| {
let label = panel(id).map_or(*id, |(_, label)| label);
let color = if *id == active { t.text_strong } else { t.text_dim };
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color)
})
.collect();
let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum();
let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max);
ui.set_width(width);
// Title bar: a grip that moves the group, and the × that docks it.
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip);
let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE));
let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag());
for k in 0..2 {
let y = bar.center().y - 1.5 + k as f32 * 3.0;
ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled));
}
if grip.drag_started() {
action = Some(Action::Move(active, pos));
}
grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move"));
let x = ui.interact(close, ui.id().with("close"), Sense::click());
icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim });
if x.on_hover_text(tl!("Put back in the dock")).clicked() {
action = Some(Action::Close(gi));
}
// Tab strip: a click shows a tab, a drag out of the strip floats it on its own.
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let mut left = strip.left();
for (k, (&id, galley)) in ids.iter().zip(tabs).enumerate() {
let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP));
left = r.right();
let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag());
if id == active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
if resp.clicked() {
action = Some(Action::Activate(gi, k));
} else if lone && resp.drag_started() {
action = Some(Action::Move(id, pos));
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
action = Some(Action::Tear(id, r.left()));
}
}
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
if rest.width() > 0.0 {
let resp = ui.interact(rest, ui.id().with("strip"), Sense::drag());
if resp.drag_started() {
action = Some(Action::Move(active, pos));
}
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move"));
}
panels::panel_menu(app, ui, active, menu);
dock::panel_body(app, ui, active, width, tall);
});
});
}
match action {
Some(Action::Activate(gi, k)) => {
if let Some(g) = app.ui.floating_panels.get_mut(gi) {
g.active = k;
}
}
Some(Action::Close(gi)) => {
if let Some(g) = app.ui.floating_panels.get(gi) {
let ids = ids_of(g);
let active = ids.get(g.active).copied().unwrap_or_default();
dock(&mut app.ui, &ids, active);
}
}
Some(Action::Move(id, pos)) => {
// From where it is drawn (a position saved on a bigger window was clamped into this one).
if let Some(g) = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get_mut(i)) {
g.pos = [pos.x, pos.y];
}
if let Some(at) = pointer {
start(ctx, Moving::Panel(id), at - pos);
}
}
Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)),
None => {}
}
}
/// `window.panel.float` and `window.panel.dock`; `None` for other commands.
pub fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let float = match id {
"window.panel.float" => true,
"window.panel.dock" => false,
_ => return None,
};
Some(float_or_dock(app, float, p))
}
/// The `x`, `y` params: a top-left corner in screen points, if given.
fn position(p: &Value) -> Result<Option<Pos2>, String> {
let coord = |k: &str| match p.get(k) {
None | Some(Value::Null) => Some(None),
Some(v) => v.as_f64().filter(|f| f.abs() < 1e6).map(|f| Some(f as f32)),
};
match (coord("x"), coord("y")) {
(Some(Some(x)), Some(Some(y))) => Ok(Some(pos2(x, y))),
(Some(None), Some(None)) => Ok(None),
_ => Err("x and y must both be numbers".into()),
}
}
fn float_or_dock(app: &mut VectorcraftApp, float_it: bool, p: &Value) -> Result<Value, String> {
let raw = p.get("panel").and_then(Value::as_str).unwrap_or_default().trim();
let group = crate::menus::opt_bool(p, "group")?.unwrap_or(false);
let at = position(p)?;
if raw.eq_ignore_ascii_case("tools") || raw.eq_ignore_ascii_case("toolbar") {
app.ui.toolbar_pos = float_it.then(|| at.map_or(TOOLS_AT, |p| [p.x, p.y]));
app.ui.toolbar |= float_it;
return Ok(json!({ "panel": "tools", "floating": float_it, "pos": app.ui.toolbar_pos }));
}
let Some(id) = crate::menus::normalize_panel(raw) else { return Err(format!("unknown panel `{raw}`")) };
let own = group_of(&app.ui, id);
// The panel, or with `group` its whole group: its floating group, or the tabs left in the dock.
let ids: Vec<&'static str> = match (group, own.and_then(|i| app.ui.floating_panels.get(i))) {
(true, Some(g)) => ids_of(g),
(true, None) if DockTab::from_id(id).is_some() => docked_tabs(&app.ui).map(|t| t.info().0).collect(),
_ => vec![id],
};
app.ui.dock = true;
if !float_it {
dock(&mut app.ui, &ids, id);
return Ok(json!({ "panel": id, "floating": false }));
}
match p.get("onto").and_then(Value::as_str) {
Some(onto) => {
let onto = crate::menus::normalize_panel(onto)
.filter(|o| !ids.contains(o) && group_of(&app.ui, o).is_some_and(|g| Some(g) != own))
.ok_or("onto must name a panel floating in another group")?;
stack(&mut app.ui, &ids, id, onto);
}
// Its own group already: moved there (or left where it is), showing the panel.
None => match own.and_then(|i| app.ui.floating_panels.get_mut(i)).filter(|g| g.panels.len() == ids.len()) {
Some(g) => {
g.active = g.panels.iter().position(|p| p == id).unwrap_or(g.active);
if let Some(at) = at {
g.pos = [at.x, at.y];
}
}
None => {
let n = app.ui.floating_panels.len() as f32;
let at = at.unwrap_or(pos2(FLOAT_AT[0] + 24.0 * n, FLOAT_AT[1] + 24.0 * n));
float(&mut app.ui, &ids, id, at);
}
},
}
let g = group_of(&app.ui, id).and_then(|i| app.ui.floating_panels.get(i));
Ok(json!({ "panel": id, "floating": true, "group": g.map(|g| &g.panels), "pos": g.map(|g| g.pos) }))
}
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton};
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
const SCREEN: Vec2 = vec2(1400.0, 900.0);
/// Frames of the whole window, 1400 × 900, with pointer input one event a frame (as the
/// control channel's `ui.drag` sends it).
struct Harness {
app: VectorcraftApp,
ctx: egui::Context,
time: f64,
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
impl Harness {
fn new() -> Self {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 };
h.settle();
h
}
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
let app = &mut self.app;
self.ctx
.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
}
fn settle(&mut self) {
for _ in 0..4 {
self.frame(vec![]);
}
}
/// Press at `from` and move to `to` in steps, the button still down.
fn hold(&mut self, from: Pos2, to: Pos2) {
self.frame(vec![Event::PointerMoved(from)]);
self.frame(vec![button(from, true)]);
for k in 1..=8 {
self.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))]);
}
}
fn release(&mut self, at: Pos2) {
self.frame(vec![button(at, false)]);
self.settle();
}
fn drag(&mut self, from: Pos2, to: Pos2) {
self.hold(from, to);
self.release(to);
}
fn click(&mut self, at: Pos2) {
self.frame(vec![Event::PointerMoved(at), button(at, true)]);
self.release(at);
}
fn temp_rect(&self, id: Id) -> Rect {
self.ctx.data(|d| d.get_temp::<Rect>(id)).unwrap()
}
/// The floating group whose first panel is `first`, as drawn.
fn group(&self, first: &str) -> Rect {
self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap()
}
/// A point on a floating group's title bar (left of its ×).
fn title(&self, first: &str) -> Pos2 {
let r = self.group(first);
pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0)
}
/// The rects of the widgets that sense a drag and pass `keep`, top to bottom, left to right.
fn widgets(&self, keep: impl Fn(Rect) -> bool) -> Vec<Rect> {
let mut r: Vec<Rect> = self.ctx.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.sense.senses_drag() && keep(w.rect)).map(|w| w.rect).collect()
});
r.sort_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left())));
r
}
/// The dock's tabs, left to right.
fn dock_tabs(&self) -> Vec<Rect> {
let dock = self.temp_rect(dock_rect_id());
self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r))
}
/// The icon column's panel icons, top to bottom.
fn icons(&self) -> Vec<Rect> {
let dock = self.temp_rect(dock_rect_id());
self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r))
}
fn groups(&self) -> Vec<Vec<&str>> {
self.app.ui.floating_panels.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect()
}
}
#[test]
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
let mut h = Harness::new();
let tabs = h.dock_tabs();
assert_eq!(tabs.len(), 3, "Properties, Layers, Libraries: {tabs:?}");
let to = pos2(600.0, 400.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["layers"]], "Layers floats");
assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers"));
assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock");
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
// A click on its title bar moves nothing, even over the dock.
let dock = h.temp_rect(dock_rect_id());
h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap();
h.settle();
h.click(h.title("layers"));
assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it");
// Dragged by its title bar onto the dock, the dock lights up, and the release docks it.
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap();
h.settle();
let over = dock.center();
h.hold(h.title("layers"), over);
assert!(h.group("layers").contains(over), "the group follows the pointer");
let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over);
assert_eq!(target.map(|t| t.0), Some(Drop::Dock), "the dock is the drop zone");
h.release(over);
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown");
}
#[test]
fn the_strip_right_of_the_tabs_floats_the_whole_group_and_its_close_box_docks_it() {
let mut h = Harness::new();
let tabs = h.dock_tabs();
let rest = tabs[2].right_center() + vec2(30.0, 0.0);
h.drag(rest, pos2(500.0, 300.0));
assert_eq!(h.groups(), [["properties", "layers", "libraries"]]);
assert!(h.dock_tabs().is_empty(), "the tabbed group left the dock");
let column = h.temp_rect(dock_rect_id());
assert!(column.width() < 40.0, "only the icon column is left: {column:?}");
// Its × puts them back.
let g = h.group("properties");
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
assert!(h.groups().is_empty());
assert_eq!(h.dock_tabs().len(), 3);
}
#[test]
fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() {
let mut h = Harness::new();
// Color, Color Guide, Swatches… top to bottom.
let icons = h.icons();
let count = icons.len();
h.drag(icons[2].center(), pos2(500.0, 250.0));
assert_eq!(h.groups(), [["swatches"]]);
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
h.drag(h.icons()[0].center(), pos2(500.0, 650.0));
assert_eq!(h.groups(), [["swatches"], ["color"]]);
// Color's title bar dropped on the Swatches group's tabs stacks them.
let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0);
h.hold(h.title("color"), onto);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches")));
h.release(onto);
assert_eq!(h.groups(), [["swatches", "color"]]);
assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it");
// A click shows a tab; a tab dragged out of the strip floats on its own again.
let g = h.group("swatches");
let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r));
assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}");
h.click(tabs[0].center());
assert_eq!(h.app.ui.floating_panels[0].active, 0);
let to = pos2(900.0, 500.0);
h.drag(tabs[1].center(), to);
assert_eq!(h.groups(), [["swatches"], ["color"]]);
assert!(h.group("color").contains(to));
// Window › Color shows it where it floats; Dock Panel puts it back.
assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]}));
assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true));
h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap();
h.settle();
assert_eq!(h.icons().len(), count - 1, "Color is back in the column");
}
#[test]
fn a_popped_out_panel_dragged_by_its_title_floats() {
let mut h = Harness::new();
h.app.run("window.panel", json!({"panel": "swatches"})).unwrap();
h.settle();
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
let to = pos2(500.0, 400.0);
h.drag(flyout.left_top() + vec2(20.0, 13.0), to);
assert_eq!((h.groups(), h.app.ui.open_panel.as_deref()), (vec![vec!["swatches"]], None));
assert!(h.group("swatches").contains(to), "under the pointer: {:?}", h.group("swatches"));
// Its panel menu docks it again.
h.app.run("window.panel.dock", json!({"panel": "swatches"})).unwrap();
assert!(h.groups().is_empty());
}
#[test]
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
let mut h = Harness::new();
let zone = h.temp_rect(tools_zone_id());
let title = zone.left_top() + vec2(30.0, 7.0);
let to = pos2(400.0, 300.0);
h.drag(title, to);
let pos = h.app.ui.toolbar_pos.expect("the Tools panel floats");
let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap();
assert!(float.contains(to) && (pos2(pos[0], pos[1]) - float.min).length() < 1.0, "under the pointer: {float:?}");
// Back on the window's left edge.
let edge = pos2(10.0, 400.0);
h.hold(float.left_top() + vec2(30.0, 7.0), edge);
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Tools, edge).map(|t| t.0), Some(Drop::Dock));
h.release(edge);
assert_eq!(h.app.ui.toolbar_pos, None, "docked");
// A click on the title bar still switches the columns.
let double = h.app.ui.toolbar_double;
h.click(title);
assert_ne!(h.app.ui.toolbar_double, double);
}
#[test]
fn floating_panels_are_kept_in_workspaces_and_preferences_and_clamped_into_the_window() {
let mut h = Harness::new();
let app = &mut h.app;
// Saved on a much bigger screen.
app.run("window.panel.float", json!({"panel": "layers", "x": 5000, "y": 4000})).unwrap();
let r = app.run("window.panel.float", json!({"panel": "stroke", "onto": "Layers"})).unwrap();
assert_eq!(r["group"], json!(["layers", "stroke"]));
app.run("window.panel.float", json!({"panel": "tools", "x": 300, "y": 200})).unwrap();
app.run("window.workspace.new", json!({"name": "Floating"})).unwrap();
let saved = app.ui.floating_panels.clone();
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
assert!(app.ui.floating_panels.is_empty() && app.ui.toolbar_pos.is_none(), "Essentials docks everything");
app.run("window.workspace", json!({"name": "Floating"})).unwrap();
assert_eq!((&app.ui.floating_panels, app.ui.toolbar_pos), (&saved, Some([300.0, 200.0])));
let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
let prefs = prefs.sanitized();
assert_eq!((&prefs.floating_panels, prefs.toolbar_pos), (&saved, Some([300.0, 200.0])));
// Unknown and repeated panels (a hand-edited file) are dropped.
let edited: UiState = serde_json::from_value(json!({
"floating_panels": [{"panels": ["nope", "layers"], "active": 7, "pos": [1, 2]}, {"panels": ["layers"], "pos": [3, 4]}],
}))
.unwrap();
let edited = edited.sanitized();
assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0] }]);
h.settle();
let g = h.group("layers");
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
assert!(screen.contains_rect(g), "clamped into the window: {g:?}");
assert!(h.dock_tabs().len() == 2 && h.app.ui.dock_tab == DockTab::Properties);
}
#[test]
fn float_and_dock_commands_check_their_params() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
assert!(app.run("window.panel.float", json!({"panel": "nope"})).is_err());
assert!(app.run("window.panel.float", json!({"panel": "layers", "x": "left", "y": 2})).is_err());
assert!(app.run("window.panel.float", json!({"panel": "layers", "x": 2})).is_err(), "both x and y");
assert!(app.run("window.panel.float", json!({"panel": "layers", "group": "yes"})).is_err());
assert!(app.run("window.panel.float", json!({"panel": "layers", "onto": "color"})).is_err(), "Color isn't floating");
assert!(app.ui.floating_panels.is_empty());
// The tabbed group's tabs float together.
let r = app.run("window.panel.float", json!({"panel": "layers", "group": true})).unwrap();
assert_eq!(r["group"], json!(["properties", "layers", "libraries"]));
assert_eq!(app.ui.floating_panels[0].active, 1);
assert!(app.run("window.panel.float", json!({"panel": "layers", "onto": "properties"})).is_err(), "not onto its own group");
// Floated out of its group; a lone panel moves.
app.run("window.panel.float", json!({"panel": "layers", "x": 10, "y": 20})).unwrap();
assert_eq!((app.ui.floating_panels.len(), floating_pos(&app, "layers")), (2, [10.0, 20.0]));
app.run("window.panel.float", json!({"panel": "layers", "x": 30, "y": 40})).unwrap();
assert_eq!((app.ui.floating_panels.len(), floating_pos(&app, "layers")), (2, [30.0, 40.0]));
app.run("window.panel.dock", json!({"panel": "properties", "group": true})).unwrap();
assert_eq!(app.ui.floating_panels.len(), 1);
assert_eq!(app.ui.dock_tab, DockTab::Properties);
assert_eq!(app.run("window.panel.dock", json!({"panel": "tools"})).unwrap()["floating"], json!(false));
app.ui.toolbar = false;
app.run("window.panel.float", json!({"panel": "Toolbar"})).unwrap();
assert!(app.ui.toolbar && app.ui.toolbar_pos == Some(TOOLS_AT), "floating shows the Tools panel");
}
fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] {
group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap()
}
}
-2
View File
@@ -611,6 +611,4 @@
Drag to move Přetažením přesunete
Drag to float as a toolbar Přetažením vytvoříte plovoucí panel nástrojů
Put back in the toolbar Vrátit do panelu nástrojů
Float Panel Plovoucí panel
Dock Panel Ukotvit panel
Float Tool Group Plovoucí skupina nástrojů
Can't render this file because it contains an unexpected character in line 2 and column 73.
-9
View File
@@ -3053,12 +3053,6 @@ axis Both Ambos
Drag to move Arrastrar para mover
Drag to float as a toolbar Arrastrar para flotar como barra de herramientas
Put back in the toolbar Devolver a la barra de herramientas
Float Panel Hacer flotar el panel
Dock Panel Anclar panel
Drag to float the panel group Arrastrar para hacer flotar el grupo de paneles
Put back in the dock Devolver al área de paneles
Drag to float the toolbar Arrastrar para hacer flotar la barra de herramientas
Drag to the window's left edge to dock Arrastrar al borde izquierdo de la ventana para anclar
Float Tool Group Hacer flotar el grupo de herramientas
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
@@ -3571,7 +3565,6 @@ axis Both Ambos
@msg Sent to {_1} Se envió a {_1}
@msg {_1} files {_1} archivos
@msg The graphics were lost and have been restarted Se perdieron los gráficos y se reiniciaron
@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} El procesador gráfico probado primero no pudo mostrar la ventana, así que VectorCraft se inició de nuevo con {summary}
@msg Loading VectorCraft… Cargando VectorCraft…
@msg VectorCraft failed to start: {e} VectorCraft no pudo iniciarse: {e}
@msg A browser with WebGPU or WebGL2 is required. Se necesita un navegador con WebGPU o WebGL2.
@@ -3580,5 +3573,3 @@ axis Both Ambos
@msg Reload the page to go on (changes not saved yet may be lost). Vuelve a cargar la página para continuar (los cambios que aún no se guardaron podrían perderse).
@msg tool (a tool id) is required se requiere tool (un id de herramienta)
@msg {tool} isn't in a toolbar group of several tools {tool} no está en un grupo de la barra de herramientas con varias herramientas
@msg onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo
@msg x and y must both be numbers x e y deben ser números
Can't render this file because it contains an unexpected character in line 2 and column 73.
File diff suppressed because it is too large. Load diff
-6
View File
@@ -3041,10 +3041,4 @@ axis Both 両方
Drag to move 移動するにはドラッグ
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
Put back in the toolbar ツールバーに戻す
Float Panel パネルをフローティング
Dock Panel パネルをドッキング
Drag to float the panel group ドラッグしてパネルグループをフローティング
Put back in the dock ドックに戻す
Drag to float the toolbar ドラッグしてツールバーをフローティング
Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング
Float Tool Group ツールグループをフローティング
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -11
View File
@@ -64,7 +64,7 @@ fn plural_czech(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 8] = [
pub static LANGUAGES: [LangInfo; 7] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Japanese: the whole interface (every menu string and `tl!` literal), keeping the product,
// workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
@@ -81,16 +81,6 @@ pub static LANGUAGES: [LangInfo; 8] = [
complete_menus: true,
catalog: OnceLock::new(),
},
// Italian: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `it-*` locale (`it-IT`, `it-CH`, `it-SM` …) resolves here.
LangInfo {
code: "it",
name: "Italiano",
source: include_str!("it.tsv"),
plural: plural_one_other,
complete_menus: true,
catalog: OnceLock::new(),
},
// Brazilian Portuguese: every menu label (`menu_catalogs_translate_every_menu_label`), every
// `tl!` literal and the plural messages; left English on purpose are the `MENU_KEEP_AS_IS`
// strings (product name, language names, workspace names, perspective grid presets).
-2
View File
@@ -2633,6 +2633,4 @@
Drag to move Arraste para mover
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
Put back in the toolbar Devolver à barra de ferramentas
Float Panel Flutuar Painel
Dock Panel Encaixar Painel
Float Tool Group Flutuar Grupo de Ferramentas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7 -38
View File
@@ -15,10 +15,6 @@ fn tags_map_to_languages() {
for tag in ["es", "es_ES.UTF-8", "es-MX", "es_AR", "es-419", "es-US"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("es"), "{tag}");
}
// Italian: Italy, Switzerland and San Marino share the one catalog.
for tag in ["it", "it_IT.UTF-8", "it-CH", "it_SM"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("it"), "{tag}");
}
// Traditional Chinese: by region, by script, and with a region after the script.
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
@@ -395,9 +391,9 @@ fn cs() -> Lang {
}
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
const KEEPS_MENU_NAMES: [&str; 5] = ["cs", "es", "it", "ja", "pt-br"];
const KEEPS_MENU_NAMES: [&str; 4] = ["cs", "es", "ja", "pt-br"];
/// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
/// Menu labels the menu-complete catalogs (Czech, Spanish, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
/// Each language's own name in the Language menu is left alone too.
const MENU_KEEP_AS_IS: &[&str] = &[
@@ -487,7 +483,7 @@ fn toggled_labels() -> Vec<String> {
labels
}
/// Czech, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
/// Czech, Spanish, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
/// and the canvas context menu included (panels and dialogs not yet).
#[test]
fn menu_catalogs_translate_every_menu_label() {
@@ -518,17 +514,12 @@ fn menu_catalogs_translate_every_menu_label() {
assert_eq!(tr(Lang::from_code("ja").expect("ja"), "File"), "ファイル");
assert_eq!(tr(Lang::from_code("pt-br").expect("pt-br"), "File"), "Arquivo");
assert_eq!(tr(es(), "File"), "Archivo");
assert_eq!(tr(it(), "Edit"), "Modifica");
}
fn es() -> Lang {
Lang::from_code("es").expect("es registered")
}
fn it() -> Lang {
Lang::from_code("it").expect("it registered")
}
/// Spanish uses the vector-illustration vocabulary its users know, has two plural forms like
/// English, and reads the same in the menus and in the panels.
#[test]
@@ -550,27 +541,6 @@ fn spanish_reads_as_spanish() {
assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas");
}
/// Italian uses the vector-illustration vocabulary its users know, has two plural forms like
/// English (zero takes the plural), and reads the same in the menus and in the panels.
#[test]
fn italian_reads_as_italian() {
for (en, want) in [
("Artboard Tool", "Strumento Tavola da disegno"),
("Swatches", "Campioni"),
("Pathfinder", "Elaborazione tracciati"),
("Stroke", "Traccia"),
("Fill", "Riempimento"),
("Direct Selection Tool", "Strumento Selezione diretta"),
("Save As…", "Salva con nome…"),
("Undo", "Annulla"),
] {
assert_eq!(tr(it(), en), want);
}
assert_eq!(trn(it(), 1, "{n} Layer", "{n} Layers"), "1 livello");
assert_eq!(trn(it(), 0, "{n} Layer", "{n} Layers"), "0 livelli");
assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli");
}
/// Catalogs written with spaces between words keep a fragment's leading and trailing spaces: the
/// hint bar and a few labels are joined from pieces (" to finish", "Press ").
#[test]
@@ -589,11 +559,11 @@ fn czech_plurals_have_three_forms() {
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2]);
}
/// Czech, Spanish and Italian letters (and the punctuation their text uses) come from each family's own first font, not
/// Czech and Spanish letters (and the punctuation their text uses) come from each family's own first font, not
/// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the
/// face that draws missing glyphs, the first one, as missing.)
#[test]
fn czech_spanish_and_italian_glyphs_are_available_without_system_fonts() {
fn czech_and_spanish_glyphs_are_available_without_system_fonts() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
@@ -604,8 +574,7 @@ fn czech_spanish_and_italian_glyphs_are_available_without_system_fonts() {
let first = first.unwrap();
let mut font = fonts.fonts.font(&family);
let chars = font.characters();
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’".chars()
{
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”".chars() {
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
}
}
@@ -682,7 +651,7 @@ fn complete_languages_translate_every_message() {
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["es", "it"];
const COMPLETE_MESSAGES: &[&str] = &["es"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad"];
-6
View File
@@ -3298,10 +3298,4 @@ axis Both 两者
Drag to move 拖动以移动
Drag to float as a toolbar 拖动使其浮动为工具栏
Put back in the toolbar 放回工具栏
Float Panel 浮动面板
Dock Panel 停放面板
Drag to float the panel group 拖动以浮动面板组
Put back in the dock 放回停放区
Drag to float the toolbar 拖动以浮动工具栏
Drag to the window's left edge to dock 拖到窗口左边缘以停放
Float Tool Group 浮动工具组
Can't render this file because it contains an unexpected character in line 2 and column 73.
-6
View File
@@ -3072,10 +3072,4 @@ axis Both 兩者
Drag to move 拖曳以移動
Drag to float as a toolbar 拖曳使其浮動為工具列
Put back in the toolbar 放回工具列
Float Panel 浮動面板
Dock Panel 停駐面板
Drag to float the panel group 拖曳以浮動面板群組
Put back in the dock 放回停駐區
Drag to float the toolbar 拖曳以浮動工具列
Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐
Float Tool Group 浮動工具組
Can't render this file because it contains an unexpected character in line 6 and column 47.
+21 -30
View File
@@ -17,7 +17,6 @@ pub mod background;
mod brand;
pub mod canvas;
pub mod chrome;
mod clipboard_probe;
pub mod community;
pub mod control;
pub mod credits;
@@ -25,7 +24,6 @@ pub mod cursors;
pub mod dialogs;
pub mod dock;
pub mod find_font;
pub mod floating;
pub mod font_menu;
pub mod graphics;
pub mod i18n;
@@ -130,8 +128,6 @@ mod tests_sysclip;
#[cfg(test)]
mod tests_sysclip_emf;
#[cfg(test)]
mod tests_sysclip_probe;
#[cfg(test)]
mod tests_transparencygrid;
#[cfg(test)]
mod tests_widthtool;
@@ -145,7 +141,7 @@ use vectorcraft_engine::{Session, ViewInfo};
pub use control::{ControlRequest, ControlResponse};
pub use state::{UiState, View};
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
pub use sysclip::SystemClipboard;
/// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the
/// file-type filters.
@@ -208,10 +204,6 @@ pub struct Services {
/// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through
/// egui and `clipboard_read`).
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
/// A second system-clipboard handle, for checking whether Paste has something to take on a
/// background thread (Linux: an X11 clipboard owner that never answers would freeze the UI).
/// Taken on the first frame. Without it the check runs on the UI thread.
pub clipboard_probe: Option<ClipboardProbeFactory>,
/// File → Show in Folder: select a file in the system file manager (desktop).
pub reveal: Option<RevealFn>,
/// Write a file from any thread (desktop): lets Background Save and Export write off the UI
@@ -334,11 +326,8 @@ pub struct VectorcraftApp {
/// (SVG without a `system_clipboard`) while the internal clipboard is empty. It enables the
/// Paste menu items.
pub(crate) system_paste: bool,
/// When `system_paste` was last checked in line (app time, s; at most once per frame).
/// When `system_paste` was last checked (app time, s; at most once per frame).
system_paste_at: f64,
/// The thread that checks the system clipboard for `system_paste` instead
/// ([`Services::clipboard_probe`]).
clipboard_probe: Option<clipboard_probe::Probe>,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
@@ -414,7 +403,6 @@ impl VectorcraftApp {
place: Default::default(),
system_paste: false,
system_paste_at: f64::NEG_INFINITY,
clipboard_probe: None,
paste_chord: Default::default(),
background: Default::default(),
recovery: Default::default(),
@@ -551,8 +539,19 @@ impl VectorcraftApp {
/// Select a tool (also used by the toolbar and shortcuts).
pub fn select_tool(&mut self, id: &str) {
self.change_tool(id, true);
}
/// Go back to tool `id` after a temporary one, without choosing it afresh
/// ([`vectorcraft_engine::Session::switch_tool`]).
pub fn restore_tool(&mut self, id: &str) {
self.change_tool(id, false);
}
fn change_tool(&mut self, id: &str, choose: bool) {
let v = self.view_info();
if let Err(e) = self.session.select_tool(id, v) {
let r = if choose { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) };
if let Err(e) = r {
self.ui.status = e.to_string();
}
if let Some(g) = vectorcraft_tools::catalog::group_of(id)
@@ -560,6 +559,9 @@ impl VectorcraftApp {
{
*slot = id.to_string();
}
if canvas::is_selection_tool(id) {
self.ui.last_selection_tool = id.to_string();
}
toolbar::remember(self, id);
self.ui.flyout = None;
}
@@ -740,20 +742,11 @@ impl VectorcraftApp {
}
self.last_time = now;
self.sync_views();
// Read the system clipboard only when that alone decides whether Paste is enabled. A
// background thread reads it where the host installs one (an unresponsive owner must never
// stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a
// few times a second (opening it locks it against other apps on some systems).
let wanted = self.session.clipboard.is_empty() && self.session.active().is_some();
if let Some(make) = self.services.clipboard_probe.take() {
self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone());
}
if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) {
self.system_paste = pasteable;
} else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) {
self.clipboard_probe = None;
// Read the system clipboard only when that alone decides whether Paste is enabled, and at
// most a few times a second (opening it locks it against other apps on some systems).
if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) {
self.system_paste_at = now;
self.system_paste = wanted && self.system_clipboard_pasteable();
self.system_paste = self.session.clipboard.is_empty() && self.session.active().is_some() && self.system_clipboard_pasteable();
}
background::poll(self);
if !self.background.jobs.is_empty() {
@@ -902,7 +895,6 @@ impl VectorcraftApp {
let t0 = now_ms();
scrub::begin_frame(self, &ctx);
font_menu::end_stale_preview(self, &ctx);
floating::track(self, &ctx);
let t = theme::Tokens::get(&ctx);
if self.ui.screen_mode < 2 {
chrome::app_bar(self, ui);
@@ -927,7 +919,6 @@ impl VectorcraftApp {
canvas::show(self, ui);
});
dock::floating_panel(self, &ctx);
floating::show(self, &ctx);
panels::library_panel::show_window(self, &ctx);
dialogs::show(self, &ctx);
palette::show(self, &ctx);
+4 -29
View File
@@ -225,18 +225,6 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
(
"window.panel.float",
"Float Panel",
"",
"{panel: id or \"tools\", x?, y?, onto?: id, group?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it with the floating group holding that panel instead; `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved. \"tools\" floats the Tools panel. Returns {panel, floating, group, pos}",
),
(
"window.panel.dock",
"Dock Panel",
"",
"{panel: id or \"tools\", group?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; \"tools\" docks the Tools panel at the window's left edge",
),
(
"window.collapseDock",
"Collapse Panels to Icons",
@@ -784,13 +772,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label,
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`).
pub(crate) fn normalize_panel(input: &str) -> Option<&'static str> {
fn normalize_panel(input: &str) -> Option<&'static str> {
let name = input.trim();
crate::state::all_panels().find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)).map(|(id, _)| id)
}
/// An optional `{key?: bool}` param: none when it is omitted or null (the commands then toggle).
pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
@@ -815,10 +803,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
if let Some(r) = crate::panels::character::intercept_text_command(app, id) {
return Some(r);
}
if let Some(r) = crate::shortcut_editor::run_command(app, id, p)
.or_else(|| crate::workspaces::run_command(app, id, p))
.or_else(|| crate::floating::command(app, id, p))
{
if let Some(r) = crate::shortcut_editor::run_command(app, id, p).or_else(|| crate::workspaces::run_command(app, id, p)) {
return Some(r);
}
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
@@ -1074,15 +1059,6 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
// Canonical id (case-insensitive; display labels work too), then the
// dock tab it names, if any.
let canonical = normalize_panel(&raw);
// A floating panel shows its tab in its group.
if let Some(p) = canonical
&& let Some(g) = crate::floating::group_of(&app.ui, p).and_then(|i| app.ui.floating_panels.get_mut(i))
{
g.active = g.panels.iter().position(|q| q == p).unwrap_or(g.active);
let out = json!({ "floating": g.panels });
app.ui.dock = true;
return Some(Ok(out));
}
let tab = canonical.and_then(DockTab::from_id);
match (canonical, tab) {
// A collapsed dock pops the panel out of its icon, like the icon panels.
@@ -1394,8 +1370,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
_ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true,
Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab),
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
}
}
+6 -19
View File
@@ -113,9 +113,8 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
}
}
/// Items of a panel's (≡) menu. Unimplemented Illustrator items are listed disabled. False for a
/// panel without items of its own.
pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool {
/// Items of a panel's (≡) menu. Unimplemented Illustrator items are listed disabled.
pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
match id {
"swatches" => swatches::menu(app, ui),
"color" => color::menu(app, ui),
@@ -153,9 +152,10 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
links::ID => links::menu(app, ui),
asset_export::ID => asset_export::menu(app, ui),
css_properties::ID => css_properties::menu(app, ui),
_ => return false,
_ => {
ui.add_enabled(false, egui::Button::new(tl!("No options")).frame(false));
}
}
true
}
/// The ≡ panel-menu button drawn into `rect` (the right end of a panel's title/tab strip).
@@ -167,20 +167,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
egui::Popup::menu(&resp).show(|ui| {
crate::widgets::menu_scroll(ui, |ui| {
ui.set_min_width(220.0);
if panel_menu_items(app, ui, id) {
ui.separator();
}
// The panel floats out of the dock or goes back in, as dragging its tab does.
let (label, cmd) = if crate::floating::group_of(&app.ui, id).is_some() {
(tl!("Dock Panel"), "window.panel.dock")
} else {
(tl!("Float Panel"), "window.panel.float")
};
if crate::widgets::menu_item(ui, label, true, false)
&& let Err(e) = app.run(cmd, json!({ "panel": id }))
{
app.ui.status = e;
}
panel_menu_items(app, ui, id);
});
});
}
+3 -3
View File
@@ -16,7 +16,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
};
let n_sel = st.selection.len();
let anchors = crate::chrome::anchor_controls(app);
let anchor_mode = !st.selection.anchors.is_empty();
let first = first_selected(app);
let label = match (&first, n_sel) {
(None, _) => tl!("Document").to_string(),
@@ -85,10 +85,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
}
if anchors != crate::chrome::AnchorControls::None {
if anchor_mode {
divider(ui);
section_header(ui, tl!("Anchor Point"));
crate::chrome::anchor_buttons(app, ui, anchors);
crate::chrome::anchor_buttons(app, ui);
}
divider(ui);
section_header(ui, tl!("Quick Actions"));
+4 -22
View File
@@ -6,7 +6,7 @@
use serde_json::{Map, Value, json};
use vectorcraft_engine::Prefs;
use vectorcraft_engine::cmd::prefscmds::{GPU_PREFERENCES, PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, PrefKind};
use vectorcraft_engine::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, PrefKind};
use crate::state::Dialog;
use crate::theme::{self, Brightness, Tokens};
@@ -69,11 +69,6 @@ pub fn restore(app: &mut VectorcraftApp) {
{
p.interface_language = code;
}
// 0.5.0 saved its default, `powerSaving`, which Automatic replaced (#502): any value this
// version doesn't offer reads as the default, as the desktop app reads it at startup.
if !GPU_PREFERENCES.iter().any(|(v, _)| *v == p.gpu_preference) {
p.gpu_preference = Prefs::default().gpu_preference;
}
app.session.apply_prefs(p);
}
@@ -459,8 +454,8 @@ mod tests {
assert_eq!(a.session.prefs.ui_brightness, "light");
}
/// Performance › Graphics Processor (#306, #502): shown with its restart note, applied by OK
/// and saved with the UI state under the key the desktop app reads before the window opens.
/// Performance › Graphics Processor (#306): shown with its restart note, applied by OK and
/// saved with the UI state under the key the desktop app reads before the window opens.
#[test]
fn graphics_processor_preference_shows_and_persists() {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
@@ -482,7 +477,7 @@ mod tests {
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
assert!(shown.iter().any(|t| t.starts_with("Graphics Processor")), "{shown:?}");
assert!(shown.iter().any(|t| t == "Automatic"), "{shown:?}");
assert!(shown.iter().any(|t| t == "Power Saving (integrated)"), "{shown:?}");
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
a.ui.dialog.as_mut().unwrap().fields.insert("gpuPreference".into(), json!("highPerformance"));
confirm(&mut a).unwrap();
@@ -519,19 +514,6 @@ mod tests {
assert!(picas_in_use(d), "enabled with a unit in picas");
}
/// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the
/// choices this version offers are kept (#502).
#[test]
fn a_graphics_processor_this_version_does_not_offer_reads_as_automatic() {
for (saved, read) in [("powerSaving", "automatic"), ("turbo", "automatic"), ("lowPower", "lowPower"), ("highPerformance", "highPerformance")]
{
let mut a = app();
a.ui.engine_prefs = json!({"gpuPreference": saved});
restore(&mut a);
assert_eq!(a.session.prefs.gpu_preference, read, "{saved}");
}
}
#[test]
fn every_category_renders_fields() {
for c in PREF_CATEGORIES {
+1 -1
View File
@@ -246,7 +246,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Enter with a selection tool opens the Move dialog, as a double-click on its button
// does.
let tool = app.session.tool_id();
if app.ui.dialog.is_none() && vectorcraft_tools::catalog::is_selection_tool(tool) {
if app.ui.dialog.is_none() && crate::canvas::is_selection_tool(tool) {
// Nothing selected: it fails and nothing opens (the menu item is disabled then too).
let _ = crate::toolbar::open_options(app, tool);
}
+4 -38
View File
@@ -268,13 +268,6 @@ pub struct UiState {
/// Tool flyouts torn off the toolbar into floating panels.
#[serde(default)]
pub floating_flyouts: Vec<FloatingFlyout>,
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
#[serde(default)]
pub floating_panels: Vec<FloatingPanels>,
/// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:
/// docked at the window's left edge.
#[serde(default)]
pub toolbar_pos: Option<[f32; 2]>,
pub status_bar: bool,
pub dock: bool,
pub view: ViewFlags,
@@ -283,6 +276,9 @@ pub struct UiState {
pub flyout: Option<usize>,
/// Last tool shown for each toolbar group (flyout selection sticks).
pub group_tool: Vec<String>,
/// The selection tool used last (Selection, Direct Selection or Group Selection): a Cmd press
/// with any other tool drags with it.
pub last_selection_tool: String,
pub status: String,
pub palette_open: bool,
pub palette_query: String,
@@ -415,34 +411,6 @@ pub struct TaskBarPlace {
pub offset: Option<(u64, egui::Vec2)>,
}
/// A group of panels dragged out of the dock: it floats as a stack of tabs, one panel shown.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FloatingPanels {
/// Panel ids (`window.panel`), in tab order.
pub panels: Vec<String>,
/// The tab shown.
#[serde(default)]
pub active: usize,
/// Top-left corner in screen points.
pub pos: [f32; 2],
}
impl FloatingPanels {
/// Keep known panels, each in one group, drop the groups left empty and the toolbar position
/// that isn't a number (a hand-edited preferences file or workspace).
pub fn sanitize(groups: &mut Vec<FloatingPanels>, toolbar_pos: &mut Option<[f32; 2]>) {
let mut seen = std::collections::BTreeSet::new();
groups.retain_mut(|g| {
g.panels.retain(|id| all_panels().any(|(p, _)| p == id) && seen.insert(id.clone()));
g.active = g.active.min(g.panels.len().saturating_sub(1));
!g.panels.is_empty()
});
if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) {
*toolbar_pos = None;
}
}
}
impl UiState {
/// Clear transient state after loading saved preferences.
pub fn sanitized(mut self) -> Self {
@@ -461,7 +429,6 @@ impl UiState {
f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
});
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
// the default back.
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
@@ -485,14 +452,13 @@ impl Default for UiState {
task_bar_place: TaskBarPlace::default(),
slot_tool: Default::default(),
floating_flyouts: vec![],
floating_panels: vec![],
toolbar_pos: None,
status_bar: true,
dock: true,
view: ViewFlags::default(),
dialog: None,
flyout: None,
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
last_selection_tool: "selection".into(),
status: String::new(),
palette_open: false,
palette_query: String::new(),
-5
View File
@@ -24,11 +24,6 @@ pub trait SystemClipboard {
fn has(&mut self, mimes: &[&'static str]) -> bool;
}
/// Makes a second system-clipboard handle, to check off the UI thread whether Paste has something
/// to take ([`Services::clipboard_probe`](crate::Services::clipboard_probe)). It is called on that
/// thread and the handle lives and dies there, so the handle needn't be `Send`.
pub type ClipboardProbeFactory = Box<dyn FnOnce() -> Box<dyn SystemClipboard> + Send>;
/// The command (and its params) that loads `f` into the internal clipboard, centred on `center`.
pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static str, Value) {
let b64 = || vectorcraft_format::base64_encode(&f.data);
-23
View File
@@ -205,26 +205,3 @@ fn the_control_bar_converts_connects_and_cuts_selected_anchors() {
assert!(app.session.active().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].closed);
assert_eq!(anchor_count(&app, id), 4, "the coincident ends merge");
}
/// #504: with a tool that edits anchors (Direct Selection, the Pen…), a path selected as a whole
/// shows all its anchors selected, and the Control bar and the Properties panel offer to convert
/// them; removing and cutting wait for anchors direct-selected. The Selection tool shows neither.
#[test]
fn a_whole_path_converts_with_the_tools_that_edit_anchors() {
let mut app = app();
let id = rect(&mut app);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
app.select_tool("selection");
assert!(!has(&control_frame(&mut app, &ctx, vec![]), "Convert:"));
for tool in ["directSelection", "pen"] {
app.select_tool(tool);
let texts = control_frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Convert:") && !has(&texts, "Anchors:"), "{tool}: {texts:?}");
let props = crate::tests_labels::painted_text(&mut app, crate::panels::properties::show);
assert!(props.contains("Convert:") && !props.contains("Anchors:"), "{tool}: {props}");
}
click_button(&mut app, &ctx, "Convert:", 1);
let sp = app.session.active().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone();
assert!(sp.anchors.iter().all(|a| a.has_in() && a.has_out()), "every corner made smooth: {sp:?}");
}
+1 -1
View File
@@ -66,7 +66,7 @@ pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
pub(super) fn copy_rect(app: &mut VectorcraftApp) {
fn copy_rect(app: &mut VectorcraftApp) {
let id = run(app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].clone();
run(app, "paint.setFill", json!({"color": "#ff0000"}));
run(app, "select.set", json!({"ids": [id]}));
-156
View File
@@ -1,156 +0,0 @@
//! The system clipboard is checked for Paste off the UI thread where the host installs a probe
//! (Linux): an X11 clipboard owner that never answers used to block the frame loop for seconds, so
//! the window stopped drawing and could not even be closed. The in-line check (no probe: the web,
//! Windows, macOS) is covered by `tests_clipboard` and `tests_sysclip`.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::clipboard::Flavour;
use crate::tests_sysclip::{copy_rect, run};
use crate::{Services, SystemClipboard, VectorcraftApp, menus};
/// What the clipboard handles saw: the thread each `has()` ran on, and how many handles are alive.
#[derive(Default)]
struct Recorder {
threads: Mutex<Vec<Option<String>>>,
alive: AtomicUsize,
}
impl Recorder {
fn looks(&self) -> usize {
self.threads.lock().unwrap().len()
}
}
/// A system clipboard whose `has()` takes `delay` (an owner slow to answer) and answers `verdict`,
/// or panics without one (a bug in the platform code).
struct Slow {
rec: Arc<Recorder>,
delay: Duration,
verdict: Option<bool>,
}
impl Slow {
fn boxed(rec: &Arc<Recorder>, delay: Duration, verdict: Option<bool>) -> Box<dyn SystemClipboard> {
rec.alive.fetch_add(1, Ordering::SeqCst);
Box::new(Self { rec: Arc::clone(rec), delay, verdict })
}
}
impl Drop for Slow {
fn drop(&mut self) {
self.rec.alive.fetch_sub(1, Ordering::SeqCst);
}
}
impl SystemClipboard for Slow {
fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> {
Ok(())
}
fn holds_ours(&mut self) -> bool {
false
}
fn read(&mut self, _mimes: &[&'static str]) -> Option<Flavour> {
None
}
fn has(&mut self, _mimes: &[&'static str]) -> bool {
self.rec.threads.lock().unwrap().push(std::thread::current().name().map(str::to_owned));
std::thread::sleep(self.delay);
self.verdict.expect("the clipboard failed")
}
}
/// An app whose two clipboard handles record into `rec`: the UI's says `inline`, the probe's
/// `probe`, each after `delay`.
fn app(rec: &Arc<Recorder>, delay: Duration, inline: bool, probe: Option<bool>) -> VectorcraftApp {
let make = {
let rec = Arc::clone(rec);
move || Slow::boxed(&rec, delay, probe)
};
let services =
Services { system_clipboard: Some(Slow::boxed(rec, delay, Some(inline))), clipboard_probe: Some(Box::new(make)), ..Default::default() };
VectorcraftApp::new(Session::new(), services)
}
/// One headless frame of app logic in the test's one context (a new one would restart the fonts),
/// half a second after the last: past the in-line check's throttle, so it would run every frame.
fn frame(app: &mut VectorcraftApp) {
thread_local! {
static CTX: egui::Context = egui::Context::default();
static NOW: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) };
}
let time = NOW.with(|t| {
t.set(t.get() + 0.5);
t.get()
});
let input = egui::RawInput { time: Some(time), ..Default::default() };
CTX.with(|ctx| ctx.run_ui(input, |ui| app.logic(ui.ctx())).textures_delta.clear());
}
/// Frames for `wait`, or until `done` holds; whether it does.
fn frames_until(app: &mut VectorcraftApp, wait: Duration, done: impl Fn(&VectorcraftApp) -> bool) -> bool {
let end = Instant::now() + wait;
loop {
frame(app);
if done(app) || Instant::now() > end {
return done(app);
}
std::thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn a_slow_clipboard_is_read_off_the_ui_thread() {
let rec = Arc::new(Recorder::default());
let mut app = app(&rec, Duration::from_secs(1), false, Some(true));
// The first frames install the UI fonts. Without a document Paste is off whatever the clipboard
// holds: nobody looks.
assert!(!frames_until(&mut app, Duration::from_millis(100), |_| rec.looks() > 0));
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
frame(&mut app);
let t0 = Instant::now();
frame(&mut app);
assert!(t0.elapsed() < Duration::from_millis(300), "the frame waited on the clipboard: {:?}", t0.elapsed());
// The probe's verdict (the UI's own handle would say no) enables Paste once it arrives.
assert!(frames_until(&mut app, Duration::from_secs(3), |app| app.system_paste), "the probe's verdict never arrived");
assert!(menus::enabled(&app, "edit.paste"));
let threads = rec.threads.lock().unwrap().clone();
assert!(threads.iter().all(|n| n.as_deref() == Some("vectorcraft-clip")), "checked on: {threads:?}");
}
#[test]
fn the_probe_rests_after_a_copy_and_stops_with_the_app() {
let rec = Arc::new(Recorder::default());
let mut app = app(&rec, Duration::ZERO, false, Some(true));
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
assert!(frames_until(&mut app, Duration::from_secs(2), |app| app.system_paste));
// Something copied in the app enables Paste alone: the clipboard isn't read any more.
copy_rect(&mut app);
frames_until(&mut app, Duration::from_millis(50), |_| false);
let looks = rec.looks();
frames_until(&mut app, Duration::from_millis(600), |_| false);
assert_eq!(rec.looks(), looks);
assert!(!app.system_paste && menus::enabled(&app, "edit.paste"));
// Quitting waits until the probe's thread has let go of its handle: on X11 the last handle
// alive hands what the app copied to the clipboard manager.
assert_eq!(rec.alive.load(Ordering::SeqCst), 2);
drop(app);
assert_eq!(rec.alive.load(Ordering::SeqCst), 0, "the probe's thread kept its clipboard");
}
#[test]
fn without_its_thread_the_check_runs_in_line_again() {
let rec = Arc::new(Recorder::default());
// The probe's clipboard panics: its thread ends.
let mut app = app(&rec, Duration::ZERO, true, None);
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
assert!(frames_until(&mut app, Duration::from_secs(2), |app| app.system_paste), "no fallback");
assert!(app.clipboard_probe.is_none());
let threads = rec.threads.lock().unwrap().clone();
assert_eq!(threads.first().cloned().flatten().as_deref(), Some("vectorcraft-clip"), "{threads:?}");
}
+136 -156
View File
@@ -89,170 +89,148 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
// The user's choice alone: a window too short for the tools scrolls them.
let cols = if app.ui.toolbar_double { 2 } else { 1 };
let w = if cols == 2 { 76.0 } else { WIDTH };
// Where the panel docks: along the window's left edge, under the bars.
let edge = ui.available_rect_before_wrap();
ui.ctx().data_mut(|d| d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height()))));
let frame =
egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border));
match app.ui.toolbar_pos {
None => {
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(frame).show(ui, |ui| body(app, ui, cols, None));
}
Some(pos) => {
// Floating, kept inside the window, its tools scrolling when they don't fit under it.
let ctx = ui.ctx().clone();
let id = egui::Id::new("toolbar-floating");
let screen = ctx.content_rect();
// Kept on screen by its title bar and first tools: the rest scrolls.
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(w, 160.0), |r| vec2(r.width(), r.height().min(160.0)));
let at = crate::floating::clamp(pos, size, screen);
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(&ctx, |ui| {
flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| {
ui.set_width(w);
ui.set_max_height(screen.bottom() - at.y - 12.0);
body(app, ui, cols, Some(at));
});
egui::Panel::left("toolbar")
.resizable(false)
.exact_size(w)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Dock header with » and a grip strip.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(
ui,
if cols == 2 { "chevrons-left" } else { "chevrons-right" },
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
if hresp.on_hover_text(tl!("Toggle single/double column")).clicked()
// It only fails on bad params, which this never sends; show it all the same.
&& let Err(e) = app.run("window.toolbarColumns", json!({ "double": cols == 1 }))
{
app.ui.status = e;
}
let (grip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::hover());
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
Stroke::new(0.4, t.text_disabled),
);
}
// The tools and the controls under them scroll in a window too short for them.
widgets::strip_scroll(ui, "toolbar", |ui| {
let active = app.session.tool_id();
// The tool button whose long press opened its flyout, until the next press.
let held_id = egui::Id::new("toolbar-held");
if ui.input(|inp| inp.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
// A long name is cut to its first four characters in the single column.
let cat = tl!(cat);
let label = if cols == 1 && cat.chars().count() > 6 {
format!("{}...", cat.chars().take(4).collect::<String>())
} else {
cat.to_string()
};
ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text);
}
// One row = `cols` slots (a category label always starts a new row).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());
let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
if slot.len() > 1 {
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)],
t.icon,
Stroke::NONE,
));
}
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if press.is_some() {
if is_floating(app, slot[0]) {
raise = Some(slot[0]);
} else {
open_flyout = Some((slot.clone(), rect));
}
if press == Some(FlyoutPress::Primary) {
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
}
} else if resp.clicked() && held == Some(resp.id) {
// Releasing the long press that opened the flyout leaves it open.
} else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.double_clicked() {
app.select_tool(shown.id);
app.run("tool.options", json!({ "tool": shown.id })).ok();
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("flyout-anchor"), rect);
d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
});
}
}
});
// Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
// layers in one frame).
floating(app, ui.ctx());
flyout(app, ui.ctx());
}
/// The Tools panel's contents, docked or floating at `floating`.
fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egui::Pos2>) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
let bounds = ui.max_rect();
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Title bar with » and a grip strip: a drag on either floats the panel or moves it.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click_and_drag());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(
ui,
if cols == 2 { "chevrons-left" } else { "chevrons-right" },
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
let (grip, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::drag());
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
Stroke::new(0.4, t.text_disabled),
);
}
crate::floating::drag_tools(app, ui.ctx(), &hresp.union(gresp.clone()), bounds, floating);
if hresp.on_hover_text(tl!("Toggle single/double column")).clicked()
// It only fails on bad params, which this never sends; show it all the same.
&& let Err(e) = app.run("window.toolbarColumns", json!({ "double": cols == 1 }))
{
app.ui.status = e;
}
let grip_tip = if floating.is_some() { tl!("Drag to the window's left edge to dock") } else { tl!("Drag to float the toolbar") };
gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(grip_tip);
// The tools and the controls under them scroll in a window too short for them.
widgets::strip_scroll(ui, "toolbar", |ui| {
let active = app.session.tool_id();
// The tool button whose long press opened its flyout, until the next press.
let held_id = egui::Id::new("toolbar-held");
if ui.input(|inp| inp.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
// A long name is cut to its first four characters in the single column.
let cat = tl!(cat);
let label =
if cols == 1 && cat.chars().count() > 6 { format!("{}...", cat.chars().take(4).collect::<String>()) } else { cat.to_string() };
ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text);
}
// One row = `cols` slots (a category label always starts a new row).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());
let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
if slot.len() > 1 {
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE));
}
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if press.is_some() {
if is_floating(app, slot[0]) {
raise = Some(slot[0]);
} else {
open_flyout = Some((slot.clone(), rect));
}
if press == Some(FlyoutPress::Primary) {
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
}
} else if resp.clicked() && held == Some(resp.id) {
// Releasing the long press that opened the flyout leaves it open.
} else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.double_clicked() {
app.select_tool(shown.id);
app.run("tool.options", json!({ "tool": shown.id })).ok();
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("flyout-anchor"), rect);
d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
});
}
/// How a press on a tool group's button opens its flyout.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FlyoutPress {
@@ -300,7 +278,7 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
"hand" => app.run("view.fitArtboard", json!({})),
"zoom" => app.run("view.actualSize", json!({})),
"rotate" | "scale" | "reflect" | "shear" | "selection" | "directSelection" | "groupSelection" => {
let dialog = if vectorcraft_tools::catalog::is_selection_tool(tool) { "move" } else { tool };
let dialog = if crate::canvas::is_selection_tool(tool) { "move" } else { tool };
let id = format!("object.{dialog}");
if let Some(c) = vectorcraft_engine::find_command(&id) {
(c.enabled)(&app.session)?;
@@ -653,7 +631,9 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
// Kept on screen (a smaller window, or a position saved on a bigger one).
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size());
let pos = crate::floating::clamp(f.pos, size, screen);
let [x, y] = f.pos;
let (x, y) = if x.is_finite() && y.is_finite() { (x, y) } else { (screen.left() + 60.0, screen.top() + 100.0) };
let pos = pos2(x.min(screen.right() - size.x).max(screen.left()), y.min(screen.bottom() - size.y).max(screen.top()));
let mut input = FlyoutInput::default();
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));
+1 -8
View File
@@ -9,15 +9,8 @@ use crate::{icons, scrub};
/// Square icon button; `selected` draws the pressed well.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response {
let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
paint_icon_button(ui, resp, icon, tip, selected)
}
/// [`icon_button`] drawn for a response the caller made (its own id or sense).
pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, selected: bool) -> Response {
let t = Tokens::get(ui.ctx());
let rect = resp.rect;
let size = rect.width();
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let bg = if selected {
t.tool_active
} else if resp.hovered() {
+1 -10
View File
@@ -7,7 +7,7 @@ use std::sync::atomic::Ordering;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::state::{Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState};
use crate::state::{Dialog, DockTab, FloatingFlyout, UiState};
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
@@ -29,10 +29,6 @@ pub struct Workspace {
pub status_bar: bool,
/// Tool groups torn off the toolbar into floating strips (built-in workspaces: none).
pub floating_flyouts: Vec<FloatingFlyout>,
/// Panels dragged out of the dock, where they float (built-in workspaces: none).
pub floating_panels: Vec<FloatingPanels>,
/// Where the Tools panel floats (built-in workspaces: docked).
pub toolbar_pos: Option<[f32; 2]>,
}
impl Default for Workspace {
@@ -127,8 +123,6 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
open_panel: ui.open_panel.clone(),
status_bar: ui.status_bar,
floating_flyouts: ui.floating_flyouts.clone(),
floating_panels: ui.floating_panels.clone(),
toolbar_pos: ui.toolbar_pos,
}
}
@@ -146,9 +140,6 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
ui.open_panel = w.open_panel.clone();
ui.status_bar = w.status_bar;
ui.floating_flyouts = w.floating_flyouts.clone();
ui.floating_panels = w.floating_panels.clone();
ui.toolbar_pos = w.toolbar_pos;
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
ui.flyout = None;
ui.workspace = w.name.clone();
}
+1 -15
View File
@@ -22,7 +22,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `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 |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
@@ -214,20 +214,6 @@ Bar Position (`window.taskBar.reset`), which unpins it and puts it back under th
Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the
selection.
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;
dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the
pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column.
`window.panel.float {panel, x?, y?, onto?, group?}` floats a panel (with `group`, its whole group) with its top-left
corner at `x`, `y` (window points; default cascaded), or stacks it with the floating group holding `onto`;
`window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for
the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating
panel shows its tab. `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the index of the tab shown,
the top-left corner) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the
preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window
is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows).
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,
+7 -21
View File
@@ -36,19 +36,10 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
## Desktop graphics processor
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses.
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI. The desktop app (`apps/vectorcraft/src/main.rs`) therefore asks wgpu for the **power-saving** adapter by default, which is the integrated GPU on hybrid-graphics laptops. Presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some laptops (#306). A machine with one GPU gets that GPU either way.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; then the power preference, keeping the system's order among equals.
- **Preferences › Performance › Graphics Processor** (`gpuPreference`): `automatic` (the default), `lowPower` (Power Saving, the integrated GPU) or `highPerformance` (the discrete GPU). The adapter is chosen when the window opens, so a change applies after a restart.
- **Automatic on Windows and macOS** is power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306).
- **Automatic on Linux and the BSDs** keeps the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502).
- 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`.
- **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking.
- **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference:
- `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order).
- `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log.
- `WGPU_BACKEND`: the backends to use, such as `vulkan`, `dx12`, `metal` or `gl`.
- On Linux with Mesa, `MESA_VK_DEVICE_SELECT=10de:2f04!` (vendor:device as `vulkaninfo --summary` or `lspci -nn` show it) leaves only that GPU to Vulkan, and `DRI_PRIME=1` picks the other GPU.
- **Preferences › Performance › Graphics Processor** (`gpuPreference`: `powerSaving` or `highPerformance`) picks the other one. The adapter is chosen when the window opens, so a change applies after a restart.
- The `WGPU_POWER_PREF` environment variable (`low`, `high` or `none`) overrides the preference.
- Help › About and the control channel's `ui.inspect` (`graphicsAdapter`) show the adapter in use, and the app logs it at startup.
## Logs
@@ -64,17 +55,13 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
| `VECTORCRAFT_CONTROL_PORT` | Same as `--control <port>` |
| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) |
| `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) |
| `WGPU_ADAPTER_NAME` | Graphics adapter by (part of) its name, any case (see [Desktop graphics processor](#desktop-graphics-processor)) |
| `WGPU_BACKEND` | Graphics backends: `vulkan`, `dx12`, `metal`, `gl` |
| `VECTORCRAFT_GPU_SKIP` | Set by the app when it starts again without a graphics adapter that failed (see [Desktop graphics processor](#desktop-graphics-processor)) |
| `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) |
## Linux: Wayland and X11
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EMF/WMF or a bitmap), before the clipboard's other formats (which include the files' paths as text).
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or run the app under XWayland, where drops work (`WAYLAND_DISPLAY= vectorcraft`).
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (`WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
@@ -124,8 +111,7 @@ Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, c
in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Simplified Chinese (`zh-hans`,
complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve to it,
so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complete, in neutral
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), Italian (`it`,
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian Portuguese
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), Czech (`cs`, every menu label) and Brazilian Portuguese
(`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added.
- `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a
@@ -153,10 +139,10 @@ complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Czech (
control channel, MCP and tests read them) and are translated only where the status bar draws them
(`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as
`Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish and Italian cover every
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish covers every
message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in
`COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`
and an `it.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
complete_languages_translate_every_message` lists them all). Other languages show messages in English
until they add `@msg` rows.
- Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab
+11 -53
View File
@@ -402,14 +402,11 @@ their stop opacity and stop where the shading doesn't extend. Text becomes point
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font, and
every export that draws that type — PDF, EPS, EMF/WMF, raster images, SVG with outlined or embedded fonts — says in
its `warnings` that it wrote the fallback font) — `textAs: "outlines"` keeps the glyph outlines the file draws instead
(its embedded fonts, installed or not). Lines wrapped in a frame (each wrapped line ending in a space, sharing a left
edge, font and leading) come back as one area type object, left aligned or justified, where laying it out breaks the
lines where the file does; a stroke written as each glyph's outline stroked is the type object's stroke; glyphs set
apart by gaps wider than a space keep their places. Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
(its embedded fonts, installed or not). Strokes stay live strokes (width, cap, join, miter limit, dash and paint), and
an object written as a fill and then a stroke of the same outline is one path with both. Optional content groups (the
layers of PDF and PDF-compatible `.ai` files) become layers with their name, visibility (the default configuration's,
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them, stacked as the pages mark them
(layers with no art come in empty); art that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
or a view state that is off), print state and lock, nested as sublayers the way the file's layer order nests them; art
that is off comes in as a hidden layer. Art outside them goes to a layer per page (except the opaque white page a `.ai`
paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows:
```json
@@ -1052,20 +1049,7 @@ on top of each other, and an open path becomes one path per piece. Each cut leav
so `path.moveAnchors {dx, dy}` (or a Direct Selection drag) pulls the path apart there; `path.join {}` (Connect
Selected End Points) joins the ends again. `path.convertAnchor {id, subpath?, anchor, to, x?, y?}` and
`path.split {id, subpath?, anchor}` do the same to one anchor (the Anchor Point and Scissors tools); the Pen with Alt
held over a selected path's handle, anchor or segment works as the Anchor Point tool. `path.reshapeSegment {id,
subpath?, segment, t, dx, dy}` (a segment dragged with Direct Selection or the Anchor Point tool) moves the segment's
point at `t` by `dx`, `dy`: a smooth anchor at either end stays smooth, its other handle turning with the moved one.
A `pointer_gesture` with `mods: {cmd: true}` held at the press, with any tool but the selection tools (Command on
macOS, Ctrl elsewhere), drags with the selection tool chosen last (`select_tool`), or Direct Selection with the Pen,
Curvature and anchor tools until one is chosen; the release gives the tool back as it was, so the Pen goes on drawing
the path it had begun while that stays selected. Its reply's `tool` is the tool given back.
```json
{"name":"pointer_gesture","arguments":{"tool":"pen","events":[{"kind":"down","x":100,"y":300},{"kind":"up","x":100,"y":300},{"kind":"down","x":200,"y":300},{"kind":"drag","x":250,"y":300},{"kind":"up","x":250,"y":300}]}}
{"name":"pointer_gesture","arguments":{"mods":{"cmd":true},"events":[{"kind":"down","x":250,"y":300},{"kind":"drag","x":250,"y":260},{"kind":"up","x":250,"y":260}]}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":350},{"kind":"up","x":300,"y":350}]}}
```
held over a selected path's handle or anchor works as the Anchor Point tool.
```json
{"name":"run_command","arguments":{"command":"select.anchors","params":{"id":12,"anchors":[[0,2]]}}}
@@ -1169,9 +1153,8 @@ Effects off a 100 pt wide rectangle with a 10 pt stroke set to `width: 220` gets
The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse:
- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor,
handle or segment for Direct Selection, the Anchor Point, Add and Delete Anchor Point and Scissors tools, and the
Pen with Alt. A handle shorter than that leaves its anchor to be picked when pressed nearer the anchor.
- `selectionTolerance` (1–8 px, 3 by default): how near a click must be to a path to pick it, and to an anchor or
handle for Direct Selection.
- `objectSelectionByPathOnly`: a click inside a filled path or compound path doesn't select it, one on its path does.
- `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl
elsewhere) selects the object under the selected one there, the next such click the one under that, then the
@@ -1185,9 +1168,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
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 (and the Anchor Point tool, and the Pen with Alt)
shows and drags direction handles only while a single anchor is selected. On, the handles of the direct-selected
anchors, or of every anchor of a path selected as a whole, show and drag. `handleStyle` (`solid`, `hollow`, `large`) draws their ends (desktop app).
- `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`,
@@ -1215,8 +1197,8 @@ off, an empty window, and the Home button or `app.home` still shows the screen)
default: on, a click away from a panel popped out of the icon column puts it away).
The Smart Guides preferences (Preferences › Smart Guides) apply to `pointer_gesture` with Smart Guides on (the
default view) and to the mouse; they change what the tools show and how far a target pulls, and only Construction
Guides change where a snapped point lands:
default view) and to the mouse; they change what the tools show and how far a target pulls, never where a snapped
point lands:
- `smartGuideColor` (`#ff3dfc` by default): the colour of the smart guides' lines and labels.
- `alignmentGuides` (on by default): off, no line is drawn along the edge or centre the art lines up with; the art
@@ -1231,11 +1213,6 @@ Guides change where a snapped point lands:
- `snappingTolerance` (0–40 px, 4 by default): how near an anchor, edge, centre or artboard edge pulls a drawn point,
a dragged selection, a bounding-box handle, a ruler guide or an artboard. With Smart Guides off, Snap to Point uses
`snapToPointTolerance` instead.
- `constructionGuides` (on by default) and `constructionAngles` (`90° & 45° Angles` by default; also `90° Angles`,
`45° Angles`, `60° Angles`, `30° Angles`, `90° & 45° & 30° Angles`): a point drawn near a line at one of those
angles through the point it leaves from (the Pen's or Curvature tool's last anchor, a line's start) lands on that
line, where a target's alignment line crosses it if one does nearby. "90°" gives the horizontal and the vertical,
"45°" the diagonals, "60°" and "30°" the multiples of those angles in between. Off, the point only lines up.
With Smart Guides on, a bounding-box handle dragged with the Selection or Free Transform tool (`mods.shift`
proportional, `mods.alt` from the centre) lands on another object's anchor or centre (labelled "anchor" or
@@ -1247,26 +1224,7 @@ snap the pointer as drawing tools do, so dragging a corner onto another object's
{"name":"pointer_gesture","arguments":{"tool":"scale","events":[{"kind":"down","x":100,"y":100},{"kind":"up","x":100,"y":100},{"kind":"down","x":199,"y":198},{"kind":"drag","x":302,"y":262},{"kind":"up","x":302,"y":262}]}}
```
Every point a drawing tool places snaps the same way, through one snapper (#506): the Pen's and Curvature tool's
anchors, the Pencil's first and last point, the shape tools' start and dragged corner or radius (`rectangle`,
`roundedRectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, `arc`, `spiral`, `rectangularGrid`, `polarGrid`), the
Type tool's click and frame, and a new artboard's corners. The point lands on another object's anchor, centre or
path ("anchor", "center", "path"), else lines up with the anchors, edges and centres of the art and the artboards (a
line from the target and "align"); the Pen's lands on the path being drawn too, so a click on its first anchor closes
it exactly. With `mods.shift` the point keeps to its 45° step from the anchor before it (from `constrainAngle`; a box
dragged keeps its corner on a diagonal) and slides along that way into line with the nearest target. A `move` event
before the press shows where it would go, as hovering does with the mouse. The targets are the art in the window (all
of it headless), gathered once per document state; the shape being drawn is never one of them:
```json
{"name":"pointer_gesture","arguments":{"tool":"rectangle","events":[{"kind":"down","x":202,"y":203},{"kind":"drag","x":330,"y":341},{"kind":"up","x":330,"y":341}]}}
{"name":"pointer_gesture","arguments":{"tool":"pen","events":[{"kind":"down","x":300,"y":420},{"kind":"up","x":300,"y":420},{"kind":"down","x":151.5,"y":431},{"kind":"up","x":151.5,"y":431}]}}
```
With a 100 × 100 square at (100, 100), the first starts the rectangle on its corner (200, 200); the second puts the
Pen's second anchor at (150, 431), in line with the square's centre.
Not read yet: Spacing Guides (the tools draw none).
Not read yet: Construction Guides and their Angles, and Spacing Guides (the tools draw neither).
## Type preferences