Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 ab65474044 macOS: the menus live in the system menu bar, settings in the VectorCraft menu
On macOS the menus are in the system menu bar, laid out as a Mac app's (Illustrator on the Mac as
the reference), and the app settings sit in the VectorCraft menu: About, Settings ▸ (one item per
Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide
VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own
quit, so unsaved documents are asked about); they leave Edit and Help. Window gets Minimize ⌃⌘M,
Zoom and Bring All to Front; Help is the system's Help menu. Windows, Linux and the web keep the
in-window menu bar unchanged; --in-window-menus or VECTORCRAFT_IN_WINDOW_MENUS keeps it on macOS
too, and so does a menu bar AppKit refuses to build.

One menu model: the in-window menus and the native bar draw the same rows (menus::entry), and the
platform-free native_menu module lays them out, decides when to update (a cheap state hash; labels,
enabled and checked in place, a rebuild only when the structure changes) and hands key equivalents
back to egui as the key presses they were, so shortcuts, user overrides and focused text fields
behave as in the window and nothing fires twice. The muda backend (pinned =0.21.1, macOS only, no
unsafe) is apps/vectorcraft/src/mac_menu.rs, mirroring PhotoCraft's. ui.inspect reports
nativeMenuBar. Translations for the new labels in ja, es, zh-hans, zh-hant, cs and pt-br.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:45:31 +07:00
BFlat 8c4c665ee2 Merge pull request #540 from storytold/fix/eps-sources-restore
Fix #498, part of #505: EPS image sources read in turn, restore undoes dictionary changes (PLRM)
2026-10-08 23:44:50 +07:00
BFlat 4c2c606e25 Merge pull request #535 from storytold/pr/eps-spec-only-tests
EPS tests: interpreter programs written from the PostScript Language Reference
2026-10-08 23:44:33 +07:00
bflatasticandClaude Opus 5.5 b43b15a6e8 Part of #505: restore puts dictionaries back as they were at the save
The PostScript Language Reference (3rd ed., §3.7.3 "Save and Restore")
has `restore` reset composite objects in local VM to their values at the
`save`. The reader only restored the graphics state, so dictionary
entries changed between `save` and `restore` stayed changed: a program
that counts its open saves in a dictionary and refuses to go past a depth
stopped after that many saves in sequence, and the file fell back to its
preview. EPS files with many images, each drawn between `save` and
`restore`, are a likely case of #505; its reporter hasn't sent a file, so
this is part of it.

- While a `save` is open, `def`, `store`, `put`, `undef` and dictionary
  `copy` keep each entry's old value in a journal (counted against the
  memory budget); `restore` undoes it, newest change first, down to its
  save. A save restored already, itself or through an outer one, is no
  longer valid (`invalidrestore`, §3.7.3).
- `cachestatus` reports a device's cache sizes (chapter 8) instead of
  zeros: its last value, the most bytes one cached glyph may take, is what
  programs divide by.
- Tests (programs written from the reference): 100 saves counted in a
  dictionary under a depth limit, each dictionary change undone, saves
  restored twice, `cachestatus`'s glyph limit. Mutation-checked: without
  the journal or with a zero `cachestatus` the test fails. The import fuzz
  vocabulary has saves and restores around dictionary changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:43:51 +07:00
bflatasticandClaude Opus 5.5 1d265951df Fix #498: EPS images with several data sources read them in turn
An image can take one data source per colour component
(MultipleDataSources, or `colorimage` with multi true). The PostScript
Language Reference (3rd ed., §4.10 "Images" and `image`/`colorimage` in
chapter 8) has the operator read the sources in turn, the same amount
from each. The reader drained each source on its own instead: when the
sources are procedures reading one file a row at a time, the first plane
took the rows of every component and the image came out in sheared
horizontal bands, as in #498.

- Data sources are read in turn by one reader, `read_sources`: a procedure
  once a round, a file or string a row a round, until each plane is full or
  its source ends. Single sources, procedure filters and masked images use
  it too; a string source resumes where it left off.
- Tests (`tests_images.rs`, programs written from the reference):
  procedures reading one file in the dictionary and `colorimage` operand
  forms (fails without the fix), separate string sources, rows padded to a
  byte at 1, 2, 4, 12 and 16 bits with an odd width (one and several
  sources), Flate PNG predictors with every row filter at an odd width.
  The import fuzz vocabulary has several sources reading one file and
  sources of uneven lengths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:43:49 +07:00
bflatasticandClaude Opus 5.5 eb255ef379 EPS tests: interpreter programs written from the PostScript Language Reference
Two EPS import tests become programs written from the PostScript Language
Reference (3rd ed.), with their own names and structure, covering the
interpreter features the importer relies on:
- `level_2_and_3_probes_read_as_vectors`: a resource category made from
  Generic, resources defined and found in it (§3.9), local and global VM
  (`currentglobal`, `setglobal`, `gcheck`, §3.7.2), `internaldict` probed
  through `stopped`, a halftone dictionary set and restored, `cachestatus`,
  `rootfont` and `currentcolorrendering`, drawing with its own procedures.
- `executable_strings_and_integer_keys`: shading dictionaries closed by an
  executable string (`(>>) cvx`, or a Level 1 builder with `counttomark`),
  procedures kept under integer keys run with `load` through `stopped`,
  `cvx`/`cvlit`/`xcheck` on strings.
Each was mutation-checked: making any of findresource, defineresource,
currentglobal, setglobal, gcheck, internaldict, sethalftone,
currenthalftone, currentcolorrendering, cachestatus, rootfont, stopped,
cvx, load, counttomark, cvlit or xcheck unknown fails a test, and so do
strings that `cvx` leaves literal, `load` refusing integer keys and
`gcheck` answering true.

The ROADMAP's EPS paragraph, file-formats row and last-updated line state
the PostScript features the reader supports; the legacy `.ai` comments in
the import module and the interpreter cite that format's published
specification (PRONOM fmt/423) for the `%AI` header comments and `u`/`U`
group operators the reader keys on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:43:48 +07:00
BFlat 8b036df705 Merge pull request #515 from callmeBalloch/i18n-italian
Fix #514: Italian interface (it)
2026-10-08 23:14:48 +07:00
bflatasticandClaude Opus 5.5 7c5799aec6 i18n: Italian rows for strings added since the PR's base, consistency and fit fixes (review of #515)
- Italian rows for the strings main gained after the PR's base: the
  floating panels of #513 (Float Panel, Dock Panel, the drag hints, two
  window.float messages) and the GPU fallback status message of #519, so
  the completeness tests pass on current main.
- File > Document Setup... reads "Imposta documento..." like the dialog
  title and the Properties button, instead of "Impostazione documento...".
- The Properties panel's quick action "Arrange: Bring to Front" reads
  "Porta in primo piano": "Disponi: Porta in primo piano" was clipped at
  both ends of its fixed-width button.
- OpenType figure style "Proportional Oldstyle" reads "Proporzionali stile
  antico", matching "Tabulari stile antico".
- Blank line between the Italian test and the next item; ROADMAP's
  "Last updated" lists the Italian interface (#514).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:04:36 +07:00
callmeBallochandClaude Opus 5.5 4ba8a02107 Contributors: add callmeBalloch (Riccardo Balloch)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iayep93edeimiAE8oSfaT
2026-10-08 23:04:33 +07:00
callmeBallochandClaude Opus 5.5 7c7ae55625 i18n: Italian interface (it)
Adds an Italian catalog (crates/ui-egui/src/i18n/it.tsv, 3,549 rows in
es.tsv's order) covering every menu string, every tl! literal, the
plurals and every status/error message, at the same scope as Spanish.
Standard Italian in sentence case, written clean-room from the meaning
of the English labels; the product, workspace, perspective preset and
language names stay in English as in es and ja.

Registered as "Italiano" with the one/other plural rule and
complete_menus; every it-* locale resolves to it. Tests: it joins
KEEPS_MENU_NAMES and COMPLETE_MESSAGES, italian_reads_as_italian checks
vocabulary and plurals, tags_map_to_languages the locale tags, and the
bundled-font glyph test covers Italian accents and guillemets. README,
ROADMAP and docs/development.md list the language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iayep93edeimiAE8oSfaT
2026-10-08 23:04:31 +07:00
BFlat 89ea46c805 Merge pull request #531 from storytold/fix/issue-508
Fix #508: PDF/.ai import keeps type strokes, wrapped lines and layer order
2026-10-08 22:54:27 +07:00
BFlat ffd31fd9cf Merge pull request #534 from storytold/pr/linux-clipboard-probe
Linux: check the system clipboard for Paste off the UI thread (#510)
2026-10-08 22:52:37 +07:00
36 changed files with 5603 additions and 542 deletions

No files matched your search

Generated
+3 -4
View File
@@ -2283,9 +2283,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.21.0"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2294,7 +2294,6 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.61.2",
@@ -2777,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -1
View File
@@ -194,7 +194,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 and Spanish (and Czech and Brazilian Portuguese in the menus).
speaks English, Japanese, Traditional and Simplified Chinese, Spanish and Italian (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.
+7 -6
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 import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), 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 into flLine 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); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); 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) |
| **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) |
@@ -101,8 +101,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- 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 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).
- 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).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
- 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).
@@ -137,7 +138,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- 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 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.
- **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.
- **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):**
@@ -155,7 +156,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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).
- 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 a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). 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. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. 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).
- **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.
@@ -214,7 +215,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| 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 | 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 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference 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 |
+4 -2
View File
@@ -37,9 +37,11 @@ winit = { version = "0.30", default-features = false }
clipboard-win = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
+367
View File
@@ -0,0 +1,367 @@
//! The macOS menu bar, through muda (Tauri's menu crate: AppKit menus behind a safe API, no GTK
//! with default features off). The menus, their Mac layout and how their keys reach VectorCraft
//! are in `vectorcraft_ui_egui::native_menu`; this file only builds and updates the `NSMenu`s.
//!
//! - Item ids are the items' places (`vc12`, depth first): one command can run from several items
//! (each language of VectorCraft › Language is `app.language` with its own code).
//! - A structure change (a recent file added, the language or a shortcut changed) rebuilds the
//! menu; labels, enabled and checked are updated in place.
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. A key
//! equivalent goes back to egui as the key press that was made (the event's modifiers, not the
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so VectorCraft's key rules still apply.
//! - Hide and Hide Others are items of ours calling `NSApplication`: AppKit's own always take ⌘H
//! (View › Hide Edges) and ⌥⌘H.
use std::sync::mpsc::{Receiver, channel};
use egui::{Key, KeyboardShortcut};
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, Node, Standard};
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
/// One native item, in the order of [`MenuBar::items`].
struct Entry {
handle: Handle,
item: native_menu::Item,
/// Its shortcut, when it is a key equivalent.
chord: Option<KeyboardShortcut>,
}
/// How an item was chosen, read from AppKit's current event while it calls the handler.
struct Chosen {
id: String,
/// A key press (not a mouse click), and the modifiers held: ⌘, ⌃, ⌥, ⇧.
key: bool,
mods: [bool; 4],
}
pub struct MacMenu {
menu: Option<Menu>,
entries: Vec<Entry>,
rx: Receiver<Chosen>,
structure: u64,
}
impl MacMenu {
fn new(ctx: &egui::Context) -> MacMenu {
let (tx, rx) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
// AppKit calls this on the main thread, from the event that chose the item: a key
// equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
let event = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent());
let key = event.as_ref().is_some_and(|ev| ev.r#type() == NSEventType::KeyDown);
let flags = event.as_ref().map_or(NSEventModifierFlags::empty(), |ev| ev.modifierFlags());
let mods = [NSEventModifierFlags::Command, NSEventModifierFlags::Control, NSEventModifierFlags::Option, NSEventModifierFlags::Shift]
.map(|m| flags.contains(m));
// The receiver only goes away with the app.
let _ = tx.send(Chosen { id: e.id.0, key, mods });
// Wake egui, so the item runs now rather than on the next mouse move.
repaint.request_repaint();
}));
MacMenu { menu: None, entries: Vec::new(), rx, structure: 0 }
}
/// Build the menus of `bar` and make them the app's, in place of the ones before.
fn build(&mut self, bar: &MenuBar) -> muda::Result<()> {
let mut entries = Vec::new();
let menu = Menu::new();
let mut special = Vec::new();
for m in &bar.menus {
let sub = Submenu::new(escape(&m.title), true);
append(&sub, &m.children, &mut entries)?;
menu.append(&sub)?;
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
special.push((m.role, sub));
}
}
if let Some(old) = self.menu.take() {
old.remove_for_nsapp();
}
menu.init_for_nsapp();
// Only once the menu is the app's: muda looks the submenus up in the installed main menu.
for (role, sub) in special {
match role {
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
_ => sub.set_as_help_menu_for_nsapp(),
}
}
self.menu = Some(menu);
self.entries = entries;
self.structure = native_menu::structure_key(bar);
Ok(())
}
}
fn append(sub: &Submenu, nodes: &[Node], entries: &mut Vec<Entry>) -> muda::Result<()> {
for n in nodes {
match n {
Node::Separator => sub.append(&PredefinedMenuItem::separator())?,
Node::Header(label) => sub.append(&MenuItem::new(escape(label), false, None))?,
Node::Submenu { label, children } => {
let s = Submenu::new(escape(label), !children.is_empty());
append(&s, children, entries)?;
sub.append(&s)?;
}
Node::Standard(item) => {
let text = Some(vectorcraft_ui_egui::i18n::t(item.label()));
let p = match item {
Standard::Services => PredefinedMenuItem::services(text),
Standard::ShowAll => PredefinedMenuItem::show_all(text),
Standard::Zoom => PredefinedMenuItem::maximize(text),
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(text),
};
sub.append(&p)?;
}
Node::Item(it) => {
let id = format!("vc{}", entries.len());
let chord = it.shortcut.and_then(vectorcraft_ui_egui::shortcuts::parse).filter(native_menu::native_ok);
let accel = chord.as_ref().and_then(accelerator);
let handle = match it.checked {
Some(c) => {
let h = CheckMenuItem::with_id(id, escape(&it.label), it.enabled, c, accel);
sub.append(&h)?;
Handle::Check(h)
}
None => {
let h = MenuItem::with_id(id, escape(&it.label), it.enabled, accel);
sub.append(&h)?;
Handle::Plain(h)
}
};
entries.push(Entry { handle, item: it.clone(), chord });
}
}
}
Ok(())
}
impl Backend for MacMenu {
fn sync(&mut self, bar: &MenuBar) {
let items = bar.items();
if self.menu.is_none() || native_menu::structure_key(bar) != self.structure || items.len() != self.entries.len() {
if let Err(e) = self.build(bar) {
// The menus as they were stay up (a failed build changed nothing).
log::warn!("couldn't rebuild the macOS menu bar: {e}");
}
return;
}
for (e, it) in self.entries.iter_mut().zip(items) {
if e.item.label != it.label {
let text = escape(&it.label);
match &e.handle {
Handle::Plain(h) => h.set_text(text),
Handle::Check(h) => h.set_text(text),
}
}
if e.item.enabled != it.enabled {
match &e.handle {
Handle::Plain(h) => h.set_enabled(it.enabled),
Handle::Check(h) => h.set_enabled(it.enabled),
}
}
// Compare with AppKit's own state: muda toggles a check item when it's clicked.
if let (Handle::Check(h), Some(c)) = (&e.handle, it.checked)
&& h.is_checked() != c
{
h.set_checked(c);
}
if e.item != *it {
e.item = it.clone();
}
}
}
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(chosen) = self.rx.try_recv() {
// Ids that aren't ours are AppKit's own items (Services, Zoom…), already handled.
let Some(e) = chosen.id.strip_prefix("vc").and_then(|n| n.parse::<usize>().ok()).and_then(|i| self.entries.get(i)) else { continue };
if let Some(command @ (native_menu::HIDE | native_menu::HIDE_OTHERS)) = e.item.command {
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
if command == native_menu::HIDE { app.hide(None) } else { app.hideOtherApplications(None) }
}
continue;
}
// A key-down chose it: its key equivalent, unless it was ↩ or Space inside the open
// menu (no ⌘ or ⌃ while the item's shortcut has one). The key is the item's (AppKit
// matched on it); the modifiers are the ones held.
let [cmd, ctrl, alt, shift] = chosen.mods;
let key = e
.chord
.filter(|c| {
chosen.key && e.item.command != Some(native_menu::MINIMIZE) && (cmd || ctrl || !(c.modifiers.command || c.modifiers.ctrl))
})
.map(|c| KeyboardShortcut::new(egui::Modifiers { alt, ctrl, shift, mac_cmd: cmd, command: cmd }, c.logical_key));
out.push(match key {
Some(k) => Event::Key(k),
None => Event::Click(e.item.clone()),
});
}
out
}
}
/// Install the menu bar (in eframe's creator closure, so winit's default menu doesn't stay up).
/// `None` keeps the menus in the window: AppKit refused them (logged), never a crash.
pub fn install(ctx: &egui::Context, app: &VectorcraftApp) -> Option<native_menu::NativeMenu> {
vectorcraft_ui_egui::i18n::set_current(app.ui_language());
let bar = native_menu::layout(app).bar;
let built = vectorcraft_engine::guard::catch_panic(|| {
let mut menu = MacMenu::new(ctx);
menu.build(&bar).map(|()| menu)
});
match built {
Ok(Ok(menu)) => Some(native_menu::NativeMenu::new(Box::new(menu))),
Ok(Err(e)) => {
log::warn!("couldn't install the macOS menu bar ({e}); the menus stay in the window");
None
}
Err(e) => {
log::warn!("couldn't install the macOS menu bar ({e}); the menus stay in the window");
None
}
}
}
/// `&` marks a mnemonic in muda labels: "Fill & Stroke" keeps its ampersand.
fn escape(s: &str) -> String {
s.replace('&', "&&")
}
/// A key equivalent for AppKit. `Cmd` is ⌘ (`META`) and `Ctrl` is ⌃: never folded together.
fn accelerator(c: &KeyboardShortcut) -> Option<Accelerator> {
let m = c.modifiers;
let mut mods = Modifiers::empty();
for (on, flag) in [(m.command || m.mac_cmd, Modifiers::META), (m.ctrl, Modifiers::CONTROL), (m.alt, Modifiers::ALT), (m.shift, Modifiers::SHIFT)]
{
if on {
mods |= flag;
}
}
Some(Accelerator::new(mods, code(c.logical_key)?))
}
/// The key code AppKit matches for an egui key.
fn code(key: Key) -> Option<Code> {
Some(match key {
Key::A => Code::KeyA,
Key::B => Code::KeyB,
Key::C => Code::KeyC,
Key::D => Code::KeyD,
Key::E => Code::KeyE,
Key::F => Code::KeyF,
Key::G => Code::KeyG,
Key::H => Code::KeyH,
Key::I => Code::KeyI,
Key::J => Code::KeyJ,
Key::K => Code::KeyK,
Key::L => Code::KeyL,
Key::M => Code::KeyM,
Key::N => Code::KeyN,
Key::O => Code::KeyO,
Key::P => Code::KeyP,
Key::Q => Code::KeyQ,
Key::R => Code::KeyR,
Key::S => Code::KeyS,
Key::T => Code::KeyT,
Key::U => Code::KeyU,
Key::V => Code::KeyV,
Key::W => Code::KeyW,
Key::X => Code::KeyX,
Key::Y => Code::KeyY,
Key::Z => Code::KeyZ,
Key::Num0 => Code::Digit0,
Key::Num1 => Code::Digit1,
Key::Num2 => Code::Digit2,
Key::Num3 => Code::Digit3,
Key::Num4 => Code::Digit4,
Key::Num5 => Code::Digit5,
Key::Num6 => Code::Digit6,
Key::Num7 => Code::Digit7,
Key::Num8 => Code::Digit8,
Key::Num9 => Code::Digit9,
Key::F1 => Code::F1,
Key::F2 => Code::F2,
Key::F3 => Code::F3,
Key::F4 => Code::F4,
Key::F5 => Code::F5,
Key::F6 => Code::F6,
Key::F7 => Code::F7,
Key::F8 => Code::F8,
Key::F9 => Code::F9,
Key::F10 => Code::F10,
Key::F11 => Code::F11,
Key::F12 => Code::F12,
Key::F13 => Code::F13,
Key::F14 => Code::F14,
Key::F15 => Code::F15,
Key::F16 => Code::F16,
Key::F17 => Code::F17,
Key::F18 => Code::F18,
Key::F19 => Code::F19,
Key::F20 => Code::F20,
Key::F21 => Code::F21,
Key::F22 => Code::F22,
Key::F23 => Code::F23,
Key::F24 => Code::F24,
Key::OpenBracket => Code::BracketLeft,
Key::CloseBracket => Code::BracketRight,
Key::Backslash => Code::Backslash,
Key::Slash => Code::Slash,
// `+` is Shift+`=` on the keyboards AppKit matches by key: Zoom In shows ⌘=.
Key::Equals | Key::Plus => Code::Equal,
Key::Minus => Code::Minus,
Key::Quote => Code::Quote,
Key::Semicolon => Code::Semicolon,
Key::Comma => Code::Comma,
Key::Period => Code::Period,
Key::Backtick => Code::Backquote,
Key::Backspace => Code::Backspace,
Key::Delete => Code::Delete,
Key::Enter => Code::Enter,
Key::Tab => Code::Tab,
Key::Space => Code::Space,
Key::Escape => Code::Escape,
Key::ArrowLeft => Code::ArrowLeft,
Key::ArrowRight => Code::ArrowRight,
Key::ArrowUp => Code::ArrowUp,
Key::ArrowDown => Code::ArrowDown,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Every shortcut the Mac menu shows as a key equivalent has one AppKit can match.
#[test]
fn every_menu_shortcut_maps_to_a_key_equivalent() {
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let bar = native_menu::layout(&app).bar;
let mut native = 0;
for it in bar.items() {
let Some(c) = it.shortcut.and_then(vectorcraft_ui_egui::shortcuts::parse).filter(native_menu::native_ok) else { continue };
assert!(accelerator(&c).is_some(), "{:?} ({:?}) has no key equivalent", it.shortcut, it.command);
native += 1;
}
assert!(native > 70, "{native} key equivalents");
}
#[test]
fn control_and_command_stay_distinct() {
let parse = |s| vectorcraft_ui_egui::shortcuts::parse(s).and_then(|c| accelerator(&c));
assert_ne!(parse("Cmd+M"), parse("Ctrl+Cmd+M"));
assert!(parse("Cmd+M").is_some());
}
}
+18 -21
View File
@@ -1,6 +1,9 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port>] [files…]`
//! Usage: `vectorcraft [--control <port>] [--in-window-menus] [files…]`
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@@ -20,7 +23,7 @@ mod control_server;
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
mod mac_menu;
#[cfg(target_os = "macos")]
mod open_documents;
mod printing;
@@ -37,8 +40,6 @@ struct App {
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
graphics_lost: bool,
#[cfg(target_os = "macos")]
menu: Option<native_menu::NativeMenu>,
}
impl eframe::App for App {
@@ -59,15 +60,7 @@ impl eframe::App for App {
return;
}
#[cfg(target_os = "macos")]
{
if self.menu.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
}
if let Some(m) = &mut self.menu {
m.poll(&mut self.app, ctx);
}
open_files(&mut self.app, open_documents::take());
}
open_files(&mut self.app, open_documents::take());
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.app.ui.status == "quit" {
@@ -319,10 +312,12 @@ fn main() -> std::process::ExitCode {
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
@@ -434,15 +429,17 @@ fn main() -> std::process::ExitCode {
app = app.with_control(rx);
}
#[cfg(target_os = "macos")]
open_documents::set_ui(&cc.egui_ctx);
{
open_documents::set_ui(&cc.egui_ctx);
// The macOS menu bar, installed now so winit's default menu doesn't stay up.
if !in_window_menus {
app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app);
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
open_files(&mut app, files);
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
#[cfg(target_os = "macos")]
menu: None,
}))
Ok(Box::new(App { app, graphics_loss, graphics_lost: false }))
}),
)
});
-183
View File
@@ -1,183 +0,0 @@
//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt
//! when saved views or recent files come and go.
use std::collections::HashMap;
use std::str::FromStr;
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::i18n::{self, Lang, t};
use vectorcraft_ui_egui::menus::{self, Item};
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
pub struct NativeMenu {
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
/// Shortcut-override / workspace-list generation the menu was built for.
generation: u64,
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
slots: usize,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
/// The language the labels were built in (a change rebuilds the menu).
language: Lang,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
/// don't steal keys from text fields; a focused field still gets Cmd+A/X/C/V, see [`NativeMenu::poll`]).
fn accel(sc: &str) -> Option<Accelerator> {
if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt") || sc.starts_with('F')) {
return None;
}
Accelerator::from_str(&sc.replace("Cmd", "CMD").replace("Alt", "ALT").replace("Shift", "SHIFT").replace("Ctrl", "CTRL")).ok()
}
impl NativeMenu {
pub fn install(app: &mut VectorcraftApp) -> Self {
// Accelerators come from `shortcut_of`, which honours the user's overrides.
vectorcraft_ui_egui::shortcut_editor::sync(&app.ui);
let generation = vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed);
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(t(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
menu.init_for_nsapp();
app.native_menu = true;
app.native_shortcuts = items
.values()
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self {
_menu: menu,
items,
last_refresh: 0.0,
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: i18n::current(),
}
}
/// Dispatch chosen items (a focused text field takes Select All and the clipboard) and refresh
/// state.
pub fn poll(&mut self, app: &mut VectorcraftApp, ctx: &egui::Context) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
if let Some((cmd, params, _, _)) = self.items.get(ev.id.as_ref()) {
let p = if params.is_null() { serde_json::json!({}) } else { params.clone() };
menus::invoke_from_system_menu(app, ctx, cmd, p);
}
}
let now = vectorcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
// changed: rebuild so accelerators, lists and labels are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| i18n::current() != self.language
{
*self = NativeMenu::install(app);
return;
}
for (run, rp, h, cmd) in self.items.values() {
let null = Value::Null;
let params = if run == cmd { rp } else { &null };
let en = menus::enabled(app, cmd);
match h {
Handle::Plain(i) => {
i.set_enabled(en);
if matches!(
cmd.as_str(),
"edit.undo"
| "edit.redo"
| "view.outline"
| "view.rulers"
| "view.guides"
| "view.grid"
| "view.edges"
| "view.artboards"
| "view.boundingBox"
| "view.transparencyGrid"
| "window.workspace.reset"
) || cmd.starts_with("file.openRecent")
|| cmd.starts_with("type.recentFont")
|| cmd.starts_with("view.goto")
|| matches!(
cmd.as_str(),
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::display_label(app, cmd, ""));
}
}
Handle::Check(c) => {
c.set_enabled(en);
c.set_checked(menus::checked(app, cmd, params).unwrap_or(false));
}
}
}
}
}
fn build(
app: &VectorcraftApp,
parent: &Submenu,
entries: &[Item],
items: &mut HashMap<String, (String, Value, Handle, String)>,
counter: &mut usize,
) {
for e in entries {
match e {
Item::Sep => {
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(t(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(t(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
// Unused saved-view and recent-file slots are left out, as in the in-window menus.
Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {}
Item::Cmd(label, cmd, params) => {
*counter += 1;
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
let (run, run_params) = menus::click_target(label, cmd, params);
let _ = &run_params;
items.insert(id, (run, run_params, handle, cmd.to_string()));
}
}
}
}
+4
View File
@@ -21,3 +21,7 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.callmeBalloch]
real_name = "Riccardo Balloch"
sync_github_name = false
+9 -2
View File
@@ -878,7 +878,8 @@ proptest! {
}
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
/// as Illustrator's too, its groups (`u` … `U`) unbalanced, deep and among clips.
/// as files in the legacy Illustrator format too (the creator line is what turns on its
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
@@ -952,7 +953,13 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"/T3 << /FontType 3 /FontMatrix [0.1 0 0 0.1 0 0] /FontBBox [0 0 9 9] /Encoding [/a /b] /BuildChar { pop pop 9 0 setcharwidth 0 0 5 5 rectfill } >> definefont setfont",
"/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).
// Several data sources reading one file in turn, and sources of uneven 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)] >>",
// Local VM 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",
// Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`).
"(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
])
.prop_map(str::to_string),
+43 -29
View File
@@ -635,33 +635,51 @@ 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 several data sources
/// (PLRM 3rd ed., §4.10 "Images" and `image`/`colorimage` in chapter 8: the sources are read
/// in turn, the same amount from each): 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 that read one file 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 +867,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 in turn, a row at a time each (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;
+68 -23
View File
@@ -49,6 +49,12 @@ 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 local VM does (PLRM 3rd ed.,
/// §3.7.3 "Save and Restore": composite objects get the values they had at the `save`).
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`).
@@ -56,8 +62,8 @@ pub(crate) struct Interp<'a> {
pub g: GState,
pub saved: Vec<GState>,
pub out: Out,
/// The program is an Illustrator file's: its `u` … `U` (written at the top level, whatever
/// its prolog defines them as) are groups.
/// The program is in the legacy Illustrator format: its group operators `u` … `U` (at the top
/// level, whatever its prolog defines them as) are groups (see the module docs of `import`).
pub illustrator: bool,
}
@@ -72,6 +78,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 +156,8 @@ impl<'a> Interp<'a> {
ops: 0,
allocated: 0,
seed: 1,
saves: vec![],
journal: vec![],
fault: None,
glyph_width: None,
g,
@@ -157,7 +172,7 @@ impl<'a> Interp<'a> {
loop {
let Some(o) = self.lex.next()? else { return Ok(()) };
let o = self.scanned(o, self.lex.immediate)?;
// An Illustrator group begins before its `u` runs (`Some(true)`) and ends after its `U`
// A group begins before its `u` runs (`Some(true)`) and ends after its `U`
// has (`Some(false)`).
let group = match &o {
Obj::Exec(name) if self.illustrator => match &**name {
@@ -190,6 +205,7 @@ impl<'a> Interp<'a> {
}
self.dicts.clear();
self.stack.clear();
self.journal.clear();
}
// ---------- the operand stack ----------
@@ -879,7 +895,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 +984,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 +997,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 +1017,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 +1051,24 @@ 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) is no longer valid: §3.7.3,
// `invalidrestore`.
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 +1113,12 @@ impl<'a> Interp<'a> {
self.push(Obj::Int(1 << 20))?;
}
}
// An empty font cache of a device's sizes, in `cachestatus`'s order (PLRM 3rd ed.,
// chapter 8): bytes used and most, fonts used and most, glyphs used and most, and the
// most bytes one cached glyph may take (positive on a real device: 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 +1176,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 +1254,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 +1348,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 {
+9 -4
View File
@@ -11,9 +11,11 @@
//! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures.
//! - `preview`: the previews a file carries, for when its PostScript can't be read.
//!
//! Clipped art comes in as clipping groups. In an Illustrator file (Illustrator 3–8, written with
//! the prolog that defines its operators), the groups it writes (`u` … `U`, nested) come in as
//! groups.
//! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
//! as groups. The format's published specification documents its header comments (`%AI…`,
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
@@ -39,6 +41,8 @@ mod tests_fontnames;
mod tests_generators;
#[cfg(test)]
mod tests_illustrator;
#[cfg(test)]
mod tests_images;
use std::sync::Arc;
@@ -73,7 +77,8 @@ struct Dsc {
/// `llx lly urx ury` in PostScript's default space.
bbox: Option<[f64; 4]>,
pages: Option<u32>,
/// Written by Illustrator (or in its format): `%AI…` comments, or Illustrator the creator.
/// In the legacy Illustrator format: its header has `%AI…` comments or names the app as the
/// creator (see the module docs for the specification).
illustrator: bool,
}
+87 -119
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 {
@@ -592,105 +592,52 @@ showpage"##;
assert!(near(bounds(&meshes[1]), Rect::new(150.0, 90.0, 190.0, 140.0)), "{:?}", bounds(&meshes[1]));
}
/// Illustrator's EPS (CS and later, through 2026): procsets that define helpers with
/// `bdf`/`ndf`/`ddf`, probe the level and version (`{version cvr} stopped`), switch VM with
/// `currentglobal`/`setglobal`, save and restore the whole graphics state through
/// `currentcolorrendering`, `rootfont`, the colour transfers, half-tone and flatness, set up
/// categories and keep fonts by VM (`gcheck`), while the art is drawn with procedures of their own.
/// A procset written as the PostScript Language Reference (3rd ed.) describes the Level 2 and 3
/// facilities programs probe before drawing: a resource category made from `Generic` and
/// resources defined and found in it (§3.9), local and global VM (`currentglobal`, `setglobal`,
/// `gcheck`, §3.7.2), `internaldict` (§8.2), a halftone dictionary (§7.4), the font cache
/// (`cachestatus`), `rootfont` and the colour rendering dictionary (§7.1), each guarded or
/// probed through `stopped` (§3.10), saving the state they change and drawing with procedures of
/// their own.
#[test]
fn illustrator_procsets_read_as_vectors() {
fn level_2_and_3_probes_read_as_vectors() {
let program = r##"%%BeginProlog
%%BeginResource: procset Adobe_AGM_Utils 1.0 0
systemdict /setpacking known { currentpacking true setpacking } if
userdict /Adobe_AGM_Utils 75 dict dup begin put
/bdf { bind def } bind def
/nd { null def } bdf
/xdf { exch def } bdf
/ddf { put } bdf
/xddf { 3 -1 roll put } bdf
/ndf { exch dup where { pop pop pop } { xdf } ifelse } def
/gx { get exec } bdf
/ps_level /languagelevel where { pop systemdict /languagelevel gx } { 1 } ifelse def
/level2 ps_level 2 ge def
/level3 ps_level 3 ge def
/ps_version { version cvr } stopped { -1 } if def
/set_gvm { currentglobal exch setglobal } bdf
/reset_gvm { setglobal } bdf
/makereadonlyarray { /packedarray where { pop packedarray } { array astore readonly } ifelse } bdf
/GSTATE 22 dict def
/get_gstate {
GSTATE begin
/clr_spc currentcolorspace def
mark currentcolor counttomark array astore /clr_comps xdf pop
/fnt rootfont def
/lw currentlinewidth def /lc currentlinecap def /lj currentlinejoin def /ml currentmiterlimit def
currentdash /dsh_o xdf /dsh_a xdf
/sa currentstrokeadjust def
/crd currentcolorrendering def
/op currentoverprint def
/bg currentblackgeneration cvlit def
/ucr currentundercolorremoval cvlit def
currentcolortransfer cvlit /gy_x xdf cvlit /b_x xdf cvlit /g_x xdf cvlit /r_x xdf
/ht currenthalftone def
/flt currentflat def
end
} def
/set_gstate {
GSTATE begin
clr_spc setcolorspace clr_comps aload pop setcolor
fnt setfont lw setlinewidth lc setlinecap lj setlinejoin ml setmiterlimit dsh_a dsh_o setdash
sa setstrokeadjust crd setcolorrendering op setoverprint
bg cvx setblackgeneration ucr cvx setundercolorremoval
r_x cvx g_x cvx b_x cvx gy_x cvx setcolortransfer
ht /HalftoneType get dup 9 eq exch 100 eq or { ht sethalftone } if
flt setflat
end
} def
/ct_tst { 1183615869 internaldict pop } stopped pop
currentdict readonly pop
/ExampleKit 40 dict def ExampleKit begin
/defp { bind def } bind def
% A category of our own, implemented by a copy of Generic's dictionary (§3.9.2).
/Generic /Category findresource dup length dict copy /ExampleShapes exch /Category defineresource pop
/Card << /W 80 /H 50 >> /ExampleShapes defineresource pop
% VM: a dictionary made in global VM, then the VM mode put back.
/wasglobal currentglobal def true setglobal /Shared 4 dict def wasglobal setglobal
/vm Shared gcheck { (global) } { (local) } ifelse def
% Probes that fail on some interpreters, inside stopped.
/hasinternal { 1183615869 internaldict } stopped { false } { pop true } ifelse def
/screen << /HalftoneType 1 /Frequency 85 /Angle 15 /SpotFunction { 180 mul cos exch 180 mul cos add 2 div } >> def
/oldscreen currenthalftone def
/rendering currentcolorrendering def
/cache [ cachestatus ] def
/frame { /h exch def /w exch def moveto w 0 rlineto 0 h rlineto w neg 0 rlineto closepath } defp
end
systemdict /setpacking known { setpacking } if
%%EndResource
%%BeginResource: procset Adobe_AGM_Core 2.0 0
userdict /Adobe_AGM_Core 200 dict dup begin put
/AGMCORE_str256 256 string def
/AGMCORE_deviceDPI 72 0 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt def
/AGMCORE_distilling /product where { pop systemdict /setdistillerparams known product (Adobe PostScript Parser) ne and } { false } ifelse def
/AGMCORE_producing_seps currentpagedevice /Separations 2 copy known { get } { pop pop false } ifelse def
/AGMCORE_page_size currentpagedevice /PageSize get def
/Generic /Category findresource dup length dict copy /Category defineresource pop
/AGMCORE_mo { moveto } def /AGMCORE_li { lineto } def /AGMCORE_cv { curveto } def /AGMCORE_cp { closepath } def
/AGMCORE_rgb { setrgbcolor } def /AGMCORE_cmyk { setcmykcolor } def
/doc_setup { Adobe_AGM_Utils begin get_gstate end } def
/page_setup { Adobe_AGM_Utils begin set_gstate end } def
end
%%EndResource
%%BeginResource: procset Adobe_CoolType_Core 2.31 0
userdict /CT_Core 20 dict dup begin put
/GVMFonts 10 dict def /LVMFonts 10 dict def
/VMDictPut { dup gcheck { GVMFonts } { LVMFonts } ifelse 3 1 roll put } bind def
/copyfont { currentglobal exch dup gcheck setglobal dup length dict copy exch setglobal } bind def
end
%%EndResource
%%EndProlog
%%BeginSetup
Adobe_AGM_Core /doc_setup get exec
CT_Core begin /Body /Helvetica findfont copyfont VMDictPut end
ExampleKit begin
screen sethalftone
/Helvetica 12 selectfont
/face rootfont /FontName get def
%%EndSetup
%%Page: 1 1
%%BeginPageSetup
Adobe_AGM_Core /page_setup get exec
%%EndPageSetup
Adobe_AGM_Core begin
/mo { AGMCORE_mo } def /li { AGMCORE_li } def /cv { AGMCORE_cv } def /cp { AGMCORE_cp } def
0 0.94 0.94 0.12 AGMCORE_cmyk
10 140 mo 90 140 li 90 90 li 10 90 li cp fill
0.2 0.4 0.8 AGMCORE_rgb
100 20 mo 150 80 180 0 190 60 cv 2 setlinewidth stroke
0 setgray CT_Core /LVMFonts get /Body get 12 scalefont setfont 10 30 moveto (Art) show
gsave
0 0.94 0.94 0.12 setcmykcolor
10 90 /Card /ExampleShapes findresource dup /W get exch /H get frame fill
0.2 0.4 0.8 setrgbcolor 2 setlinewidth 100 20 moveto 150 80 180 0 190 60 curveto stroke
grestore
% Each probe answered as the reference says: draw only when it did.
vm length 5 eq hasinternal and rendering type /dicttype eq and cache length 7 eq and face /Helvetica eq and
{ 0 setgray 10 30 moveto (Art) show } if
oldscreen sethalftone
end
showpage"##;
let r = open("Adobe Illustrator(R) 30.0", program);
let r = open("Example Generator 1.0", program);
let d = &r.document;
let rect = all(d).into_iter().find(|n| fill(n).and_then(Paint::color) == Some(Color::cmyk(0.0, 0.94, 0.94, 0.12))).unwrap();
assert!(near(bounds(&rect), Rect::new(10.0, 10.0, 90.0, 60.0)), "{:?}", bounds(&rect));
@@ -950,44 +897,65 @@ fn hostile_patterns_glyphs_and_shadings_end() {
assert_eq!(objects(&r.document).len(), 1);
}
/// Illustrator 8 EPS (what stock art often comes as, #474): its gradient procset builds shading
/// dictionaries with `bd` … `ed`, where `ed` is an executable string (`(>>) cvx`, or a Level 1
/// dictionary builder), and its `discard` runs procedures it `load`s by integer keys.
/// Executable strings and keys other than names (#474): `cvx` makes a string executable, and
/// running it runs its text (PLRM 3rd ed. §3.3.1, `cvx`, `exec`); `load` takes any key (§8.2).
/// A program closes its shading dictionaries with an executable string (`>>` at Level 2 and up,
/// else a dictionary built with `counttomark`), and runs procedures it keeps under integer keys
/// through `stopped`.
#[test]
fn illustrator_8_gradients_and_executable_strings() {
fn executable_strings_and_integer_keys() {
let program = r##"%%BeginProlog
userdict /AGM_Gradient 20 dict dup begin put
/AGM_Gradient_private 201 dict def
/initialize {
AGM_Gradient begin AGM_Gradient_private begin
/bd systemdict /mark get def
/ed /languagelevel where { pop languagelevel 2 ge } { false } ifelse
{ (>>) } { (counttomark 2 idiv dup dict begin {def} repeat pop currentdict end) } ifelse cvx def
/ed1 (counttomark 2 idiv dup dict begin {def} repeat pop currentdict end) cvx def
end end
} def
userdict /discardDict 4 dict dup begin put
0 { 0 1 0 setrgbcolor } def
2 { 0 0 1 setrgbcolor } def
/ShadeKit 12 dict def ShadeKit begin
% A Level 1 way to make a dictionary of the pairs above a mark.
/pairs (counttomark 2 idiv dict begin { counttomark 0 eq { exit } if def } loop pop currentdict end) cvx def
/close /languagelevel where { pop languagelevel 2 ge } { false } ifelse { (>>) } { (pairs) } ifelse cvx def
/open /mark load def
/actions 3 dict def actions begin 0 { 0 1 0 setrgbcolor } def 1 { 1 0 0 setrgbcolor } def end
/act { actions 1 index known { actions begin load end stopped pop } { pop } ifelse } bind def
end
/gt38? false def
/discard { discardDict begin /endString exch def gt38? { 2 add } if load stopped pop end } bind def
%%EndProlog
initialize
AGM_Gradient begin AGM_Gradient_private begin
bd /ShadingType 3 /ColorSpace /DeviceRGB
/Function bd /FunctionType 2 /Domain [0 1] /C0 [1 1 0] /C1 [0 0.5 0] /N 1 ed
/Extend [true true] /Coords [50 50 0 50 50 40] ed
ShadeKit begin
open /ShadingType 3 /ColorSpace /DeviceRGB
/Function open /FunctionType 2 /Domain [0 1] /C0 [1 1 0] /C1 [0 0.5 0] /N 1 close
/Extend [true true] /Coords [50 50 0 50 50 40] close
gsave 10 10 80 80 rectclip shfill grestore
bd /Width 3 ed1 /Width get 3 eq { 0 (%AI5_EndPalette) discard 10 10 20 20 rectfill } if
end end
mark /Width 3 pairs /Width get 3 eq { 0 act 10 10 20 20 rectfill } if
2 act
end
(x) cvx xcheck (x) cvx cvlit xcheck not and { 50 50 10 10 rectfill } if
showpage"##;
let r = open("Adobe Illustrator(R) 8.0", program);
let r = open("Example Generator 1.0", program);
let d = &r.document;
let Some(Paint::Gradient(g)) = all(d).into_iter().find_map(|n| fill(&n).cloned()) else { panic!("no gradient") };
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Radial);
// `discard` ran the procedure under key 0 (green), not the one under 2.
// `act` ran the procedure under key 0 (green); there is none under key 2.
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 local VM back as it was at the `save` (PLRM 3rd ed., §3.7.3 "Save and
/// Restore"): dictionaries get back the entries they had, whatever changed them. A program that
/// keeps a count of the saves it has open in a dictionary, and refuses to go past a depth, reads
/// to its end only if each `restore` puts the count back (part of #505: files with many images,
/// each drawn between `save` and `restore`).
#[test]
fn restore_puts_dictionaries_back() {
let r = read(
"/Depth 2 dict def Depth /open 0 put \
/enter { save Depth /open get 1 add dup 8 gt { rangecheck } if Depth exch /open exch put } def \
1 1 100 { pop enter 0 0 1 1 rectfill restore } for Depth /open get 0 eq { 10 10 5 5 rectfill } if",
);
clean(&r);
assert_eq!(objects(&r.document).len(), 101);
// `def`, `store`, `put`, `undef` and dictionary `copy` are undone, newest first.
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",
);
// A save restored (itself, or through an outer one) is no longer valid: `invalidrestore`.
super::tests::check("save dup restore { restore } stopped");
super::tests::check("save save exch restore { restore } stopped");
// `cachestatus` gives a device's cache sizes; its last, the most bytes one cached glyph may
// take, is positive (chapter 8), and programs divide by it.
super::tests::check("cachestatus 7 1 roll 6 { pop } repeat 0 gt");
}
@@ -20,6 +20,9 @@ fn ai(header: &str, ugroup: &str, body: &str) -> Vec<u8> {
.into_bytes()
}
/// A legacy Illustrator format header: the reader takes `u` … `U` as groups only in files whose
/// header says they are in that format (`%AI…` comments or the creator, which the format's
/// published specification documents; see the `import` module docs).
const AI8: &str = "%%Creator: Adobe Illustrator(R) 8.0\n%AI5_FileFormat 4.0\n";
/// A triangle filled at `x`.
+130
View File
@@ -0,0 +1,130 @@
//! Image data read as the PostScript Language Reference (3rd ed.) defines it: several data sources
//! read in turn (§4.10 "Images", `image` and `colorimage` in chapter 8), rows padded to a whole
//! byte at every bit depth and odd widths (§4.10), and Flate predictors undone row by row (§3.13,
//! `FlateDecode` parameters) (#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:
/// the image operator calls them in turn, so each 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);
}
+2 -1
View File
@@ -34,7 +34,8 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("app.home", json!({})).ok();
}
ui.add_space(2.0);
let menus_end = if app.native_menu {
// With the macOS menu bar the menus are at the top of the screen instead.
let menus_end = if app.services.native_menu.is_some() {
let full = ui.max_rect();
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
ui.cursor().min.x
+2
View File
@@ -91,6 +91,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"activeDocument": app.session.active_index(),
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
"graphicsAdapter": app.graphics_adapter,
// The menus are in the macOS menu bar rather than the window.
"nativeMenuBar": app.services.native_menu.is_some(),
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
})
@@ -209,11 +209,6 @@ pub fn slot_preset(app: &VectorcraftApp, id: &str) -> Option<String> {
app.session.prefs.perspective_presets.iter().filter(|p| p.kind == kind).nth(n.checked_sub(1)?).map(|p| p.name.clone())
}
/// How many saved presets the slots list (the native menu is rebuilt when this changes).
pub fn listed_slots(app: &VectorcraftApp) -> usize {
(1..=3u8).map(|k| app.session.prefs.perspective_presets.iter().filter(|p| p.kind == k).count().min(SLOTS)).sum()
}
/// View → Perspective Grid → One/Two/Three Point Perspective (`kind` 1–3): the built-in views of
/// the type, then the saved presets' slots (`slots`, the type's ids).
pub fn menu(kind: u8, slots: [&'static str; SLOTS]) -> Vec<Item> {
@@ -141,7 +141,11 @@ fn the_presets_manager_creates_edits_and_deletes_presets() {
assert_eq!(app.session.prefs.perspective_presets[0].opacity, 90.0);
// Its type's menu lists it in a slot, which applies it.
assert_eq!(crate::menus::dynamic_label(&app, "ui.perspectiveUserPreset3.1", ""), "Gorge");
assert!(crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset3.2") && crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset2.1"));
assert!(
["ui.perspectiveUserPreset3.2", "ui.perspectiveUserPreset2.1"]
.iter()
.all(|id| crate::menus::shown_state(&app, id, &serde_json::Value::Null).is_none())
);
app.run("ui.perspectiveUserPreset3.1", json!({})).unwrap();
assert_eq!(grid(&app).name, "Gorge");
// Delete selects the one above it.
+21
View File
@@ -614,3 +614,24 @@
Float Panel Plovoucí panel
Dock Panel Ukotvit panel
Float Tool Group Plovoucí skupina nástrojů
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Služby
Hide VectorCraft Skrýt VectorCraft
Hide Others Skrýt ostatní
Minimize Minimalizovat
Zoom Zvětšit
Bring All to Front Převést vše do popředí
Settings Nastavení
General Obecné
Selection & Anchor Display Výběr a zobrazení kotevních bodů
Units Jednotky
Guides & Grid Vodítka a mřížka
Hyphenation Dělení slov
Performance & Storage Výkon a úložiště
User Interface Uživatelské rozhraní
Performance Výkon
File Handling Práce se soubory
Clipboard Handling Práce se schránkou
Appearance of Black Vzhled černé
Devices Zařízení
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3582,3 +3582,10 @@ axis Both Ambos
@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
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servicios
Hide VectorCraft Ocultar VectorCraft
Zoom Zoom
Bring All to Front Traer todo al frente
Settings Ajustes
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
+8
View File
@@ -3048,3 +3048,11 @@ axis Both 両方
Drag to float the toolbar ドラッグしてツールバーをフローティング
Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング
Float Tool Group ツールグループをフローティング
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services サービス
Hide VectorCraft VectorCraft を隠す
Zoom 拡大/縮小
Bring All to Front すべてを手前に移動
Settings 環境設定
theme Appearance 外観
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11 -1
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; 7] = [
pub static LANGUAGES: [LangInfo; 8] = [
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,6 +81,16 @@ pub static LANGUAGES: [LangInfo; 7] = [
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).
+8
View File
@@ -2636,3 +2636,11 @@
Float Panel Flutuar Painel
Dock Panel Encaixar Painel
Float Tool Group Flutuar Grupo de Ferramentas
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Serviços
Hide VectorCraft Ocultar o VectorCraft
Hide Others Ocultar Outros
Zoom Zoom
Bring All to Front Trazer Todas para a Frente
Settings Configurações
Can't render this file because it contains an unexpected character in line 6 and column 47.
+41 -7
View File
@@ -15,6 +15,10 @@ 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()));
@@ -391,9 +395,9 @@ fn cs() -> Lang {
}
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
const KEEPS_MENU_NAMES: [&str; 4] = ["cs", "es", "ja", "pt-br"];
const KEEPS_MENU_NAMES: [&str; 5] = ["cs", "es", "it", "ja", "pt-br"];
/// 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
/// 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
/// 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] = &[
@@ -483,7 +487,7 @@ fn toggled_labels() -> Vec<String> {
labels
}
/// Czech, Spanish, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
/// Czech, Spanish, Italian, 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() {
@@ -499,6 +503,9 @@ fn menu_catalogs_translate_every_menu_label() {
let mut all: Vec<String> = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect();
all.extend(toggled_labels());
all.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string()));
// The macOS menu bar's own labels, and its Settings submenu's Preferences pages.
all.extend(crate::native_menu::MAC_LABELS.iter().map(|l| l.to_string()));
all.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|l| l.to_string()));
all.sort();
all.dedup();
for code in KEEPS_MENU_NAMES {
@@ -514,12 +521,17 @@ 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]
@@ -541,6 +553,27 @@ 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]
@@ -559,11 +592,11 @@ fn czech_plurals_have_three_forms() {
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2]);
}
/// Czech and Spanish letters (and the punctuation their text uses) come from each family's own first font, not
/// Czech, Spanish and Italian 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_and_spanish_glyphs_are_available_without_system_fonts() {
fn czech_spanish_and_italian_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(), |_| {});
@@ -574,7 +607,8 @@ fn czech_and_spanish_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}");
}
}
@@ -651,7 +685,7 @@ fn complete_languages_translate_every_message() {
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["es"];
const COMPLETE_MESSAGES: &[&str] = &["es", "it"];
/// 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
@@ -3305,3 +3305,9 @@ axis Both 两者
Drag to float the toolbar 拖动以浮动工具栏
Drag to the window's left edge to dock 拖到窗口左边缘以停放
Float Tool Group 浮动工具组
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services 服务
Hide VectorCraft 隐藏 VectorCraft
Bring All to Front 前置全部窗口
Settings 设置
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3079,3 +3079,10 @@ axis Both 兩者
Drag to float the toolbar 拖曳以浮動工具列
Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐
Float Tool Group 浮動工具組
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services 服務
Hide VectorCraft 隱藏 VectorCraft
Zoom 縮放
Bring All to Front 將全部移至最前
Settings 設定
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19 -6
View File
@@ -33,6 +33,7 @@ pub mod icon_data;
pub mod icons;
pub mod io;
pub mod menus;
pub mod native_menu;
pub mod palette;
pub mod panels;
pub mod place;
@@ -78,6 +79,8 @@ mod tests_home;
#[cfg(test)]
mod tests_labels;
#[cfg(test)]
mod tests_nativemenu;
#[cfg(test)]
mod tests_nativeoptions;
#[cfg(test)]
mod tests_numfields;
@@ -228,6 +231,8 @@ pub struct Services {
/// File → Print: the system's printers and print queue (desktop), the browser's print dialog
/// (web). Without it Print saves the job as a PDF.
pub print: Option<Box<dyn print::PrintService>>,
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
pub native_menu: Option<native_menu::NativeMenu>,
}
/// Cached canvas raster.
@@ -288,12 +293,11 @@ pub struct VectorcraftApp {
pub perf: Perf,
/// macOS: draw our own title strip under the traffic lights.
pub integrated_titlebar: bool,
/// The host installed a native menu bar (macOS): don't draw in-window menus.
pub native_menu: bool,
/// Last applied effect (Effect → Apply Last Effect).
pub last_effect: Option<(String, serde_json::Value)>,
/// Commands whose shortcuts the native menu handles (skip them in egui to avoid double firing).
pub native_shortcuts: std::collections::HashSet<String>,
/// Commands run through [`Self::run`] so far: the native menu bar reads its rows again when it
/// moves ([`native_menu::sync`]).
run_count: u64,
control_rx: Option<Receiver<ControlRequest>>,
/// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame.
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
@@ -389,9 +393,8 @@ impl VectorcraftApp {
},
perf: Perf::default(),
integrated_titlebar: false,
native_menu: false,
last_effect: None,
native_shortcuts: Default::default(),
run_count: 0,
clipboard_out: None,
clipboard_published: None,
clipboard_in: None,
@@ -477,6 +480,7 @@ impl VectorcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
self.run_count = self.run_count.wrapping_add(1);
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
@@ -793,6 +797,7 @@ impl VectorcraftApp {
ctx.copy_text(t);
}
self.drain_inbox();
native_menu::run(self, ctx);
if self.fonts_ready {
shortcuts::handle(self, ctx);
}
@@ -838,6 +843,13 @@ impl VectorcraftApp {
/// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own
/// for the frames it spans (a drag's moves included); the keyboard's come back after.
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
// The native menu's key equivalents become the input their keys make, ahead of what came
// after them (see `native_menu`).
let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default();
if !keys.is_empty() {
let events: Vec<egui::Event> = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect();
raw.events.splice(0..0, events);
}
for e in &raw.events {
match e {
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
@@ -936,6 +948,7 @@ impl VectorcraftApp {
}
self.ui_fonts.frame(&ctx);
scrub::end_frame(self, &ctx);
native_menu::sync(self, &ctx);
self.perf.frame_ms = now_ms() - t0;
let _ = json!(null);
}
+149 -101
View File
@@ -1353,14 +1353,20 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
});
}
let v = &app.ui.view;
// An item that can be on or off is `Some` whatever the document and selection (off with none):
// the macOS menu bar can't change an item's kind without rebuilding.
let selected_text = |onpath: bool| {
let st = app.session.active()?;
st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) if !onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => {
Some(t)
}
_ => None,
})
};
Some(match id {
"type.orientation.vertical" | "type.orientation.horizontal" => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t),
_ => None,
})?;
t.vertical == (id == "type.orientation.vertical")
selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical"))
}
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
@@ -1368,12 +1374,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"view.snapToPixel" => v.snap_to_pixel,
// Type on a Path effect of the selected path type.
"type.pathOptions" if p.get("effect").is_some() => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) if matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => Some(t),
_ => None,
})?;
p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())
selected_text(true).is_some_and(|t| p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id()))
}
"view.smartGuides" => v.smart_guides,
"view.grid" => v.grid,
@@ -1407,8 +1408,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"view.proofColors" => vectorcraft_render::proof::view().proof_colors,
"view.overprintPreview" => vectorcraft_render::proof::view().overprint,
"view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()),
"perspective.grid.snap" => perspective_grid(app)?.snap,
"perspective.grid.lockStation" => perspective_grid(app)?.lock_station,
"perspective.grid.snap" => perspective_grid(app).is_some_and(|g| g.snap),
"perspective.grid.lockStation" => perspective_grid(app).is_some_and(|g| g.lock_station),
"file.documentColorMode" | "object.convertDocumentColorMode" => {
let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk);
p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" })
@@ -1611,28 +1612,81 @@ fn recent_slot<'a>(app: &'a VectorcraftApp, id: &str) -> Option<&'a String> {
io::recent_files(app).get(n.checked_sub(1)?)
}
/// A menu slot hidden while it is empty ([`hidden_when_disabled`]) rather than shown disabled (the
/// native menu leaves it out).
pub fn hidden_slot(app: &VectorcraftApp, id: &str) -> bool {
hidden_when_disabled(id) && !enabled(app, id)
/// How the menus show item `id` (params `p`) now: (enabled, checked: `Some` for an item that is on
/// or off), or `None` while they leave it out: an empty slot ([`hidden_when_disabled`]) or an item
/// another stands in for ([`swapped_out`]).
pub fn shown_state(app: &VectorcraftApp, id: &str, p: &Value) -> Option<(bool, Option<bool>)> {
let en = enabled(app, id);
if (!en && hidden_when_disabled(id)) || swapped_out(app, id) {
return None;
}
Some((en, checked(app, id, p)))
}
/// How many saved-view, recent-file and User Defined library slots are listed: a menu that can't
/// hide items (the native one) is rebuilt when this changes.
pub fn listed_slots(app: &VectorcraftApp) -> usize {
use vectorcraft_engine::cmd::{stylelib, swatchlib};
let user = |libs: Vec<swatchlib::LibraryInfo>| libs.iter().filter(|l| l.category == "user").count().min(10);
let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10));
views
+ app.session.active().map_or(0, |d| d.doc.saved_selections.len().min(SAVED_SELECTION_IDS.len()))
+ io::recent_files(app).len().min(RECENT_IDS.len())
+ user(swatchlib::libraries(&app.session))
+ user(stylelib::libraries(&app.session))
+ crate::dialogs::perspective_presets::listed_slots(app)
/// One row of a menu as it shows now. The in-window menus draw these and the macOS menu bar
/// ([`crate::native_menu`]) is built from them, so the two can't drift.
pub enum Entry<'a> {
Sep,
/// A section header (a disabled label), in the UI language.
Header(&'static str),
/// A submenu: its label in the UI language, and its items.
Sub(&'static str, &'a [Item]),
Item(Row<'a>),
}
/// A menu item as it shows now ([`entry`]).
pub struct Row<'a> {
/// The command and its params; `None` for an item not implemented yet (always disabled).
pub command: Option<(&'static str, &'a Value)>,
/// The tree's own English label, which [`click_target`] reads.
pub source: &'static str,
/// The label in the UI language, as it reads now (Undo *Move*, Show/Hide…, a recent file).
pub label: std::borrow::Cow<'static, str>,
/// The shortcut as the registry writes it (`Cmd+Shift+Z`), the user's overrides applied.
pub shortcut: Option<&'static str>,
pub enabled: bool,
/// `Some` for an item that is on or off.
pub checked: Option<bool>,
}
impl Row<'_> {
/// What choosing it runs ([`click_target`]); `None` for an item not implemented yet.
pub fn target(&self) -> Option<(String, Value)> {
self.command.map(|(id, p)| click_target(self.source, id, p))
}
}
/// Item `it` as the menus show it now, or `None` while they leave it out ([`shown_state`]).
pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option<Entry<'a>> {
use crate::i18n::t;
Some(match it {
Item::Sep => Entry::Sep,
Item::Header(h) => Entry::Header(t(h)),
Item::Sub(label, children) => Entry::Sub(t(label), children),
Item::Todo(label, sc) => Entry::Item(Row {
command: None,
source: label,
label: t(label).into(),
shortcut: Some(*sc).filter(|s| !s.is_empty()),
enabled: false,
checked: None,
}),
Item::Cmd(label, id, p) => {
let (enabled, checked) = shown_state(app, id, p)?;
Entry::Item(Row {
command: Some((id, p)),
source: label,
label: display_label(app, id, label).into(),
shortcut: item_shortcut(id, p),
enabled,
checked,
})
}
})
}
/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins
/// list them, so a menu built once (the native one) is rebuilt.
/// list them (the command palette's cache follows it).
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
}
@@ -2657,66 +2711,64 @@ fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &
/// room for one).
fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) {
let t = Tokens::get(ui.ctx());
for it in items {
match it {
Item::Sep => {
for e in items.iter().filter_map(|it| entry(app, it)) {
match e {
Entry::Sep => {
ui.separator();
}
Item::Header(h) => {
ui.label(egui::RichText::new(tl!(h)).size(11.0).color(t.text_dim));
Entry::Header(h) => {
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
}
Item::Sub(label, children) => {
ui.menu_button(tl!(label), |ui| {
Entry::Sub(label, children) => {
ui.menu_button(label, |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
render_items(app, ui, children, checks, clicked);
});
});
}
Item::Todo(label, sc) => {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(tl!(label)).shortcut_text(pretty_shortcut(sc)));
})
.response
.on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan"));
}
Item::Cmd(label, id, p) => {
let en = enabled(app, id);
// Unused saved-view and recent-file slots are hidden (only the real ones are listed).
if (!en && hidden_when_disabled(id)) || swapped_out(app, id) {
continue;
}
let label = display_label(app, id, label);
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
let chk = checks.then(|| checked(app, id, p)).flatten();
let text = match chk {
Some(true) => format!("✓ {label}"),
Some(false) => format!(" {label}"),
None => label,
};
// Type → Font: each family's sample after its name (Enable in-menu font previews).
let sampled = p.get("font").and_then(Value::as_str).filter(|_| *id == "text.setStyle" && app.session.prefs.font_preview);
let r = match sampled {
Some(family) => {
let slot = ui.id().with(("font-sample", family));
let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE));
let out = ui.add_enabled_ui(en, |ui| button.atom_ui(ui)).inner;
if let Some(rect) = out.rect(slot) {
crate::font_menu::menu_item_sample(ui, rect, family);
}
out.response
}
None => ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc)),
};
if r.clicked() {
*clicked = Some(click_target(label_of(it), id, p));
ui.close();
}
}
Entry::Item(row) => render_row(app, ui, &row, checks, clicked),
}
}
}
/// One menu item: its label (with a check mark, or the room for one, under `checks`), its shortcut,
/// and Type › Font's samples.
fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, clicked: &mut Option<(String, Value)>) {
let sc = row.shortcut.map(pretty_shortcut).unwrap_or_default();
let Some((id, p)) = row.command else {
ui.add_enabled_ui(false, |ui| {
ui.add(egui::Button::new(row.label.as_ref()).shortcut_text(sc));
})
.response
.on_disabled_hover_text(tl!("Coming soon — tracked in the parity plan"));
return;
};
let text = match row.checked.filter(|_| checks) {
Some(true) => format!("✓ {}", row.label),
Some(false) => format!(" {}", row.label),
None => row.label.to_string(),
};
// Type → Font: each family's sample after its name (Enable in-menu font previews).
let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview);
let r = match sampled {
Some(family) => {
let slot = ui.id().with(("font-sample", family));
let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE));
let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner;
if let Some(rect) = out.rect(slot) {
crate::font_menu::menu_item_sample(ui, rect, family);
}
out.response
}
None => ui.add_enabled(row.enabled, egui::Button::new(text).shortcut_text(sc)),
};
if r.clicked() {
*clicked = row.target();
ui.close();
}
}
/// What the Home screen remembers when it opens (`app.home`): the active document and the
/// document count. When either changes, the Home screen gives way to the document.
pub(crate) fn home_key(app: &VectorcraftApp) -> (Option<u64>, usize) {
@@ -2730,13 +2782,6 @@ pub(crate) fn home_showing(app: &VectorcraftApp) -> bool {
app.ui.home.is_some() || (app.session.active().is_none() && app.session.prefs.show_home_screen)
}
fn label_of(it: &Item) -> &'static str {
match it {
Item::Cmd(l, _, _) => l,
_ => "",
}
}
/// What a menu click runs: items whose label ends with "…" and that carry default params open a
/// generic parameter dialog (fields = the defaults) instead of running immediately.
pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) {
@@ -2790,10 +2835,10 @@ fn field_event(app: &mut VectorcraftApp, id: &str) -> Option<egui::Event> {
})
}
/// Invoke an item of the system menu bar (macOS), chosen by a click or by its key equivalent: the
/// system takes those keys before the window sees them. While a text field has the keyboard,
/// Select All, Cut, Copy and Paste act on the field's text, as their keys do in the window;
/// everything else (and those commands with no field focused) goes to [`invoke`].
/// Invoke an item clicked in the system menu bar (macOS; its key equivalents come back to egui as
/// key presses, see [`crate::native_menu`]). While a text field has the keyboard, Select All, Cut,
/// Copy and Paste act on the field's text, as their keys do; everything else (and those commands
/// with no field focused) goes to [`invoke`].
pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) {
if ctx.text_edit_focused()
&& let Some(e) = field_event(app, id)
@@ -3000,15 +3045,17 @@ pub fn context_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
Item::Cmd(l, id, p) => out.push(MenuEntry {
path: path.clone(),
label: dynamic_label(app, id, l),
command: Some(id.to_string()),
params: p.clone(),
enabled: enabled(app, id),
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
}),
Item::Cmd(l, id, p) => {
let Some((enabled, _)) = shown_state(app, id, p) else { continue };
out.push(MenuEntry {
path: path.clone(),
label: dynamic_label(app, id, l),
command: Some(id.to_string()),
params: p.clone(),
enabled,
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
})
}
Item::Todo(l, sc) => out.push(MenuEntry {
path: path.clone(),
label: l.to_string(),
@@ -3285,6 +3332,7 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
out.insert(title.to_string());
walk(&items, title, &mut out);
}
out.extend(crate::native_menu::MAC_LABELS.iter().map(|s| s.to_string()));
out.extend(DYNAMIC_LABELS.iter().map(|s| s.to_string()));
out.extend(CONTEXT_LABELS.iter().map(|s| s.to_string()));
out.extend(UI_COMMANDS.iter().map(|c| c.1.to_string()));
@@ -3549,11 +3597,11 @@ mod tests {
let r = app.run("select.recall2", json!({})).unwrap();
assert!(r["count"].as_u64().unwrap() >= 1);
assert_eq!(app.session.active().unwrap().selection.len(), 2);
// The native menu is rebuilt when the number of slots changes.
let listed = listed_slots(&app);
// A deleted selection's slot leaves the menus.
app.run("select.editSaved", json!({"name": "Ellipse", "delete": true})).unwrap();
assert_eq!(listed_slots(&app), listed - 1);
assert!(!enabled(&app, "select.recall2"));
assert!(shown_state(&app, "select.recall2", &Value::Null).is_none());
assert!(shown_state(&app, "select.recall1", &Value::Null).is_some());
}
#[test]
+528
View File
@@ -0,0 +1,528 @@
//! The macOS menu bar: the menus of the in-window bar ([`crate::menus::menu_tree`], each item as
//! [`crate::menus::entry`] shows it), laid out the way a Mac app's are and handed to a platform
//! [`Backend`] (the desktop app's, through muda).
//!
//! Pure and platform-free, so it is tested everywhere and builds for the web; only the backend
//! touches AppKit. What the layout does ([`mac_layout`], Illustrator on the Mac as the reference):
//! - an **app menu** named VectorCraft: About; Settings, a submenu with one item per Preferences
//! page (General… ⌘K first); Language; Appearance (the UI brightness); Services; Hide VectorCraft
//! ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All; and Quit ⌘Q, still the app's own
//! `app.quit`, so documents with unsaved changes are asked about. These leave the other menus
//! (Edit › Preferences…, Help › About), and Join Our Discord stays under Help only;
//! - **Window** gets Minimize ⌃⌘M, Zoom and Bring All to Front, and **Help** the system's search
//! field;
//! - a system key already bound to a command keeps the command's: the system item goes without.
//!
//! **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key,
//! which would skip VectorCraft's key rules (a focused text field takes ⌘A, ⌘C, ⌘V and ⌘Z; dialogs
//! and the Type tool's input method have the keyboard). So the backend reports a key equivalent as
//! [`Event::Key`], and [`key_events`] feeds it to egui as the key press it was (the clipboard
//! events egui's window integration makes of ⌘X, ⌘C and ⌘V): the shortcut runs through
//! [`crate::shortcuts::handle`] exactly as without a native menu, user overrides included. A click
//! is [`Event::Click`] and runs like an in-window menu click.
//!
//! **Updates are cheap.** The rows are read again only when something they show may have changed
//! ([`sync`]); labels, enabled and checked are then updated in place, and the menus are rebuilt
//! only when their structure changes ([`structure_key`]: a recent file added, the language or a
//! shortcut changed).
use std::hash::{DefaultHasher, Hash, Hasher};
use egui::{Key, KeyboardShortcut};
use serde_json::{Value, json};
use crate::VectorcraftApp;
use crate::i18n::{Lang, tr, tr_ctx};
use crate::menus::{self, Entry};
pub const APP_NAME: &str = "VectorCraft";
/// Hide VectorCraft: an item of ours (AppKit's always takes ⌘H, which is View › Hide Edges).
pub const HIDE: &str = "app.hide";
/// Hide Others: an item of ours, so a command bound to ⌥⌘H keeps it.
pub const HIDE_OTHERS: &str = "app.hideOthers";
/// Minimize: an item of ours with ⌃⌘M (AppKit's always takes ⌘M).
pub const MINIMIZE: &str = "window.minimize";
/// The labels the Mac layout adds to the menus (translated like every menu string:
/// [`crate::menus::menu_strings`]).
pub const MAC_LABELS: &[&str] =
&["Settings", "Appearance", "Services", "Hide VectorCraft", "Hide Others", "Show All", "Minimize", "Zoom", "Bring All to Front"];
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MenuBar {
pub menus: Vec<Menu>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Menu {
/// In the UI language (the app menu is the app's name).
pub title: String,
pub role: MenuRole,
pub children: Vec<Node>,
}
/// What a top-level menu is for: macOS treats the app, Window and Help menus specially.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum MenuRole {
#[default]
Normal,
App,
Window,
Help,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Node {
Item(Item),
Separator,
/// A section header (a disabled label).
Header(String),
Submenu {
label: String,
children: Vec<Node>,
},
/// An item AppKit provides and runs itself.
Standard(Standard),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Item {
/// The command id, one of [`HIDE`], [`HIDE_OTHERS`], [`MINIMIZE`], or `None` for an item not
/// implemented yet.
pub command: Option<&'static str>,
pub params: Value,
/// The menu tree's English label ([`crate::menus::click_target`] reads it).
pub source: &'static str,
/// In the UI language, as it reads now.
pub label: String,
/// As the registry writes it (`Cmd+Shift+Z`): `Cmd` is ⌘ and `Ctrl` the Control key.
pub shortcut: Option<&'static str>,
pub enabled: bool,
/// `Some` for an item that is on or off.
pub checked: Option<bool>,
}
/// Items AppKit provides and runs: none has a key equivalent, so none can take a command's.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Standard {
Services,
ShowAll,
Zoom,
BringAllToFront,
}
impl Standard {
/// The English label ([`MAC_LABELS`]).
pub fn label(self) -> &'static str {
match self {
Standard::Services => "Services",
Standard::ShowAll => "Show All",
Standard::Zoom => "Zoom",
Standard::BringAllToFront => "Bring All to Front",
}
}
}
impl Item {
/// An item of the layout's own (Hide, Minimize…): always enabled, `shortcut` unless a command
/// has it.
fn system(command: &'static str, source: &'static str, shortcut: Option<&'static str>, lang: Lang) -> Item {
Item { command: Some(command), params: Value::Null, source, label: tr(lang, source).into(), shortcut, enabled: true, checked: None }
}
}
impl MenuBar {
/// Every item, depth first.
pub fn items(&self) -> Vec<&Item> {
fn walk<'a>(nodes: &'a [Node], out: &mut Vec<&'a Item>) {
for n in nodes {
match n {
Node::Item(it) => out.push(it),
Node::Submenu { children, .. } => walk(children, out),
Node::Separator | Node::Header(_) | Node::Standard(_) => {}
}
}
}
let mut out = Vec::new();
for m in &self.menus {
walk(&m.children, &mut out);
}
out
}
pub fn menu(&self, role: MenuRole) -> Option<&Menu> {
self.menus.iter().find(|m| m.role == role)
}
}
/// The menus as the in-window bar shows them now, as a tree: `tree`'s items through
/// [`crate::menus::entry`] (labels in the UI language, live state, empty slots left out).
pub fn from_tree(app: &VectorcraftApp, tree: &[(&'static str, Vec<menus::Item>)]) -> MenuBar {
fn level(app: &VectorcraftApp, items: &[menus::Item]) -> Vec<Node> {
let nodes = items
.iter()
.filter_map(|it| menus::entry(app, it))
.map(|e| match e {
Entry::Sep => Node::Separator,
Entry::Header(h) => Node::Header(h.to_string()),
Entry::Sub(label, children) => Node::Submenu { label: label.to_string(), children: level(app, children) },
Entry::Item(row) => Node::Item(Item {
command: row.command.map(|(id, _)| id),
params: row.command.map_or(Value::Null, |(_, p)| p.clone()),
source: row.source,
label: row.label.into_owned(),
shortcut: row.shortcut,
enabled: row.enabled,
checked: row.checked,
}),
})
.collect();
tidy(nodes)
}
let menus = tree
.iter()
.enumerate()
.map(|(i, (title, items))| {
// The first menu is the in-window bar's application menu.
let role = match *title {
_ if i == 0 => MenuRole::App,
"Window" => MenuRole::Window,
"Help" => MenuRole::Help,
_ => MenuRole::Normal,
};
Menu { title: crate::i18n::t(title).to_string(), role, children: level(app, items) }
})
.collect();
MenuBar { menus }
}
/// Drop separators at the start or end of a level and collapse runs of them, at every depth.
fn tidy(nodes: Vec<Node>) -> Vec<Node> {
let mut out: Vec<Node> = Vec::with_capacity(nodes.len());
for n in nodes {
let n = match n {
Node::Submenu { label, children } => Node::Submenu { label, children: tidy(children) },
other => other,
};
if matches!(n, Node::Separator) && matches!(out.last(), None | Some(Node::Separator)) {
continue;
}
out.push(n);
}
while matches!(out.last(), Some(Node::Separator)) {
out.pop();
}
out
}
/// A hash of everything a native menu can only change by rebuilding: menus, items (command,
/// params, kind: plain or check, shortcut), submenu and header labels. Item labels, enabled and
/// checked are updated in place.
pub fn structure_key(bar: &MenuBar) -> u64 {
fn walk(nodes: &[Node], h: &mut DefaultHasher) {
for n in nodes {
match n {
Node::Item(it) => ('i', it.command, it.params.to_string(), it.checked.is_some(), it.shortcut).hash(h),
Node::Separator => '-'.hash(h),
Node::Header(label) => ('h', label).hash(h),
Node::Submenu { label, children } => {
('[', label).hash(h);
walk(children, h);
']'.hash(h);
}
Node::Standard(s) => ('s', s).hash(h),
}
}
}
let mut h = DefaultHasher::new();
for m in &bar.menus {
(&m.title, m.role).hash(&mut h);
walk(&m.children, &mut h);
}
h.finish()
}
// ----------------------------------------------------------------------------- shortcuts
/// Does a shortcut make a key equivalent in the native menu? Only with ⌘ or ⌃, or a function key:
/// bare keys and ⌥/⇧ chords are typing (⌥ types accented letters), which AppKit would otherwise
/// take from a text field.
pub fn native_ok(sc: &KeyboardShortcut) -> bool {
sc.modifiers.command || sc.modifiers.mac_cmd || sc.modifiers.ctrl || is_function_key(sc.logical_key)
}
pub fn is_function_key(key: Key) -> bool {
key.name().strip_prefix('F').and_then(|n| n.parse::<u8>().ok()).is_some_and(|n| (1..=24).contains(&n))
}
/// The input egui's window integration would have made of a press of `sc` (key and modifiers
/// held): the key press and its release, but for ⌘X, ⌘C and ⌘V the Cut, Copy and Paste events
/// (Paste with the clipboard's text, from `paste_text`). A paste's release is left to the window's
/// own key-up: one without a press or a Paste event is how a paste of a picture or a PDF is told
/// apart ([`crate::shortcuts`]).
pub fn key_events(sc: KeyboardShortcut, paste_text: impl FnOnce() -> Option<String>) -> Vec<egui::Event> {
let key = |pressed| egui::Event::Key { key: sc.logical_key, physical_key: None, pressed, repeat: false, modifiers: sc.modifiers };
if sc.modifiers.command {
match sc.logical_key {
Key::X => return vec![egui::Event::Cut, key(false)],
Key::C => return vec![egui::Event::Copy, key(false)],
Key::V => return paste_text().map(|t| t.replace("\r\n", "\n")).filter(|t| !t.is_empty()).map(egui::Event::Paste).into_iter().collect(),
_ => {}
}
}
vec![key(true), key(false)]
}
// ----------------------------------------------------------------------------- macOS layout
/// A system key equivalent VectorCraft already uses for a command.
#[derive(Clone, Debug, PartialEq)]
pub struct Clash {
pub shortcut: &'static str,
/// What macOS uses it for.
pub system: &'static str,
/// The command bound to it.
pub command: &'static str,
}
pub struct Layout {
pub bar: MenuBar,
/// System keys left to commands (logged once).
pub clashes: Vec<Clash>,
}
/// The Mac menu bar of `bar` (the in-window menus, [`from_tree`]): see the module docs.
pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
let mut bar = bar.clone();
let mut clashes = Vec::new();
let bound = crate::shortcuts::all_shortcuts();
// A system item's key, unless a command has it.
let mut key = |sc: &'static str, system: &'static str| {
let chord = crate::shortcuts::parse(sc)?;
match bound.iter().find(|(b, ..)| *b == chord) {
Some((_, command, _)) => {
clashes.push(Clash { shortcut: sc, system, command });
None
}
None => Some(sc),
}
};
let hide = Item::system(HIDE, "Hide VectorCraft", key("Ctrl+Cmd+H", "Hide"), lang);
let hide_others = Item::system(HIDE_OTHERS, "Hide Others", key("Cmd+Alt+H", "Hide Others"), lang);
let minimize = Item::system(MINIMIZE, "Minimize", key("Ctrl+Cmd+M", "Minimize"), lang);
// What the application menu takes from the others (every copy: About is in Help too, Settings
// in Edit as Preferences…).
let about = take_items(&mut bar, "help.about").into_iter().next();
let settings = take_items(&mut bar, "edit.preferences").into_iter().next();
let quit = take_items(&mut bar, "app.quit").into_iter().next();
let language = take_submenu(&mut bar, "app.language");
let appearance = take_submenu(&mut bar, "window.brightness");
// Its other items (Join Our Discord) stay where the in-window bar also has them: Help.
bar.menus.retain(|m| m.role != MenuRole::App);
let mut app = Vec::new();
app.extend(about.map(Node::Item));
app.push(Node::Separator);
app.extend(settings.map(|s| Node::Submenu { label: tr(lang, "Settings").into(), children: settings_pages(&s, lang) }));
app.extend(language);
app.extend(appearance.map(|n| match n {
// The theme, as Mac apps call it.
Node::Submenu { children, .. } => Node::Submenu { label: tr_ctx(lang, "theme", "Appearance").into(), children },
other => other,
}));
app.extend([Node::Separator, Node::Standard(Standard::Services), Node::Separator]);
app.extend([Node::Item(hide), Node::Item(hide_others), Node::Standard(Standard::ShowAll), Node::Separator]);
app.extend(quit.map(Node::Item));
bar.menus.insert(0, Menu { title: APP_NAME.into(), role: MenuRole::App, children: app });
if let Some(window) = bar.menus.iter_mut().find(|m| m.role == MenuRole::Window) {
let mut children = vec![Node::Item(minimize), Node::Standard(Standard::Zoom), Node::Separator];
children.append(&mut window.children);
children.extend([Node::Separator, Node::Standard(Standard::BringAllToFront)]);
window.children = children;
}
for m in &mut bar.menus {
m.children = tidy(std::mem::take(&mut m.children));
}
// A menu the moves emptied goes away (Help stays: it has the search field).
bar.menus.retain(|m| !m.children.is_empty() || m.role == MenuRole::Help);
Layout { bar, clashes }
}
/// Settings ▸: one item per page of the Preferences dialog (its own list), each opening it there;
/// the first (General…) has Preferences' shortcut (⌘K), as in Illustrator.
fn settings_pages(preferences: &Item, lang: Lang) -> Vec<Node> {
vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES
.iter()
.enumerate()
.map(|(i, page)| {
Node::Item(Item {
command: preferences.command,
params: json!({ "category": page }),
// The page's name, not a "…" label: it runs, rather than asking for its params.
source: page,
label: format!("{}…", tr(lang, page)),
shortcut: preferences.shortcut.filter(|_| i == 0),
enabled: preferences.enabled,
checked: None,
})
})
.collect()
}
/// Remove every item running `command`, at any depth; returns them in order.
fn take_items(bar: &mut MenuBar, command: &str) -> Vec<Item> {
fn walk(nodes: &mut Vec<Node>, command: &str, out: &mut Vec<Item>) {
let mut kept = Vec::with_capacity(nodes.len());
for n in std::mem::take(nodes) {
match n {
Node::Item(it) if it.command == Some(command) => out.push(it),
Node::Submenu { label, mut children } => {
walk(&mut children, command, out);
kept.push(Node::Submenu { label, children });
}
other => kept.push(other),
}
}
*nodes = kept;
}
let mut out = Vec::new();
for m in &mut bar.menus {
walk(&mut m.children, command, &mut out);
}
out
}
/// Remove the first submenu that lists `command`, and any other item running it.
fn take_submenu(bar: &mut MenuBar, command: &str) -> Option<Node> {
fn find(nodes: &mut Vec<Node>, command: &str) -> Option<Node> {
let lists = |n: &Node| matches!(n, Node::Submenu { children, .. } if children.iter().any(|c| matches!(c, Node::Item(it) if it.command == Some(command))));
if let Some(i) = nodes.iter().position(lists) {
return Some(nodes.remove(i));
}
nodes.iter_mut().find_map(|n| match n {
Node::Submenu { children, .. } => find(children, command),
_ => None,
})
}
let sub = bar.menus.iter_mut().find_map(|m| find(&mut m.children, command));
take_items(bar, command);
sub
}
/// VectorCraft's menus as a Mac menu bar, as they show now.
pub fn layout(app: &VectorcraftApp) -> Layout {
mac_layout(&from_tree(app, &menus::menu_tree()), crate::i18n::current())
}
// ----------------------------------------------------------------------------- the app's side
/// What the native menu reports.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
/// An item chosen with the mouse (or the keyboard inside an open menu).
Click(Item),
/// A key equivalent pressed: the item's key with the modifiers held, to be handled as the key
/// press it was.
Key(KeyboardShortcut),
}
/// A platform's native menu bar (the desktop app implements it for macOS with muda).
pub trait Backend {
/// Bring the menu in step with `bar`: update labels, enabled and checked in place, or rebuild
/// when its structure changed.
fn sync(&mut self, bar: &MenuBar);
/// What was chosen since the last call. Items the platform runs itself (Services, Zoom, Hide…)
/// are handled there and not reported.
fn drain(&mut self) -> Vec<Event>;
}
/// The native menu bar, while VectorCraft uses one (the in-window menus are hidden then).
pub struct NativeMenu {
backend: Box<dyn Backend>,
/// Hash of the state the menus were last read in ([`state_hash`]).
state: Option<u64>,
clicks: Vec<Item>,
/// Something was chosen (AppKit may have toggled a check mark itself): read the menus again.
dirty: bool,
logged: bool,
}
impl NativeMenu {
pub fn new(backend: Box<dyn Backend>) -> NativeMenu {
NativeMenu { backend, state: None, clicks: Vec::new(), dirty: true, logged: false }
}
/// The key equivalents pressed since the last frame, for [`key_events`]; clicks wait for
/// [`run`].
pub fn take_keys(&mut self) -> Vec<KeyboardShortcut> {
let mut keys = Vec::new();
for e in self.backend.drain() {
match e {
Event::Key(k) => keys.push(k),
Event::Click(it) => self.clicks.push(it),
}
self.dirty = true;
}
keys
}
}
/// Run the items clicked in the native menu since the last frame, like in-window menu clicks.
pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(menu) = app.services.native_menu.as_mut() else { return };
for it in std::mem::take(&mut menu.clicks) {
match it.command {
Some(MINIMIZE) => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)),
Some(command) => {
let (id, p) = menus::click_target(it.source, command, &it.params);
menus::invoke_from_system_menu(app, ctx, &id, p);
}
None => {}
}
}
}
/// Bring the native menu up to date when anything it shows may have changed: a command ran, the
/// document, its history or selection changed, the user clicked or pressed a key. Reading the rows
/// asks every command whether it is enabled, so frames that only animate or scroll skip it.
pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(menu) = app.services.native_menu.as_ref() else { return };
let input = ctx
.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. })));
let state = state_hash(app, input.then_some(app.frame));
if menu.state == Some(state) && !menu.dirty {
return;
}
let layout = layout(app);
let Some(menu) = app.services.native_menu.as_mut() else { return };
if !menu.logged {
for c in &layout.clashes {
log::info!("menu: {} is {} on macOS and {} in VectorCraft, which keeps it", c.shortcut, c.system, c.command);
}
menu.logged = true;
}
menu.backend.sync(&layout.bar);
menu.state = Some(state);
menu.dirty = false;
}
/// What the menus' rows depend on, cheaply (no allocation): commands run, the active document,
/// its revision (edits, selection and view changes), history and clipboard, the language,
/// shortcuts and plug-ins. `input` forces a change on frames with a click or a key press, which
/// covers what the hash doesn't list (panels shown, a tool picked).
fn state_hash(app: &VectorcraftApp, input: Option<u64>) -> u64 {
let mut h = DefaultHasher::new();
input.hash(&mut h);
app.run_count.hash(&mut h);
let s = &app.session;
(s.journal.len(), s.active_index(), s.clipboard.is_empty(), s.tool_composing()).hash(&mut h);
if let Some(d) = s.active() {
(d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h);
}
(app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h);
(crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h);
crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h);
h.finish()
}
+7 -21
View File
@@ -82,10 +82,9 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
/// Consume a press of `sc`. A `=` chord also takes [`Key::Plus`], which is how `+` arrives from
/// the numpad and from layouts where it has its own key (Shift+`=` arrives as either; extra Shift
/// is ignored). `native`: the system menu already handles the chord as written, so only that
/// alias is left to match here.
pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: bool) -> bool {
(!native && i.consume_shortcut(sc))
/// is ignored).
pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut) -> bool {
i.consume_shortcut(sc)
|| (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus))
|| (sc.modifiers.shift && shifted(sc.logical_key).is_some_and(|k| i.consume_key(sc.modifiers, k)))
}
@@ -281,14 +280,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
crate::panels::character::route_type_input(app, ctx);
// Enter was already delivered above as ToolKey::Enter (newline).
let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc, false)));
let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc)));
if let Some((_, id, p)) = fire {
crate::menus::invoke(app, id, p);
}
return;
}
// Clipboard keys arrive as events, not key presses (except where the native menu has them);
// the chord held says which paste.
// Clipboard keys arrive as events, not key presses; the chord held says which paste.
let mut clip = vec![];
ctx.input_mut(|i| {
let held = i.modifiers;
@@ -299,17 +297,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
egui::Event::Paste(t) => (paste_command(held), Some(t.clone())),
_ => return true,
};
if app.native_shortcuts.contains(id) {
return true;
}
clip.push((id, text));
false
})
});
// Only the system clipboard service reads what isn't text (the native menu has its own Paste).
// Only the system clipboard service reads what isn't text.
if let Some(id) = textless_paste.map(paste_command)
&& app.services.system_clipboard.is_some()
&& !app.native_shortcuts.contains(id)
{
clip.push((id, None));
}
@@ -389,8 +383,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
if plain && (sc.logical_key.name().len() == 1 || matches!(sc.logical_key, Key::Slash | Key::Comma | Key::Period)) {
continue;
}
let native = app.native_shortcuts.contains(id);
if ctx.input_mut(|i| consume(i, &sc, native)) {
if ctx.input_mut(|i| consume(i, &sc)) {
fire = Some((id, p));
break;
}
@@ -669,13 +662,6 @@ mod tests {
let before = zoom(&mut app);
frame(&mut app, vec![press(Key::Minus, Modifiers::COMMAND)]);
assert!(zoom(&mut app) < before, "Cmd+- zooms out");
// The system menu handles `Cmd+=` itself; the `+` key is still matched here.
app.native_shortcuts.insert("view.zoomIn".into());
let before = zoom(&mut app);
frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND)]);
assert_eq!(zoom(&mut app), before, "left to the system menu");
frame(&mut app, vec![press(Key::Plus, Modifiers::COMMAND)]);
assert!(zoom(&mut app) > before);
}
#[test]
+422
View File
@@ -0,0 +1,422 @@
//! The macOS menu bar's model ([`crate::native_menu`]), tested on every platform with a fake
//! backend: the Mac layout of the in-window menus, the keys it hands back to egui, the clicks it
//! runs and when it reads the menus again.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use egui::{Event, Key, KeyboardShortcut, Modifiers, PointerButton, Pos2, Rect, vec2};
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES;
use crate::i18n::Lang;
use crate::native_menu::{self, Backend, MenuBar, MenuRole, NativeMenu, Node, Standard};
use crate::{VectorcraftApp, menus};
fn app(doc: bool) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
if doc {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 37, "height": 40})).unwrap();
}
app
}
fn bar(app: &VectorcraftApp) -> MenuBar {
native_menu::mac_layout(&native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
}
/// A level's nodes as short names: an item's command (`---` a separator, `[label]` a submenu,
/// `<Standard>` an AppKit item).
fn ids(nodes: &[Node]) -> Vec<String> {
nodes
.iter()
.map(|n| match n {
Node::Item(it) => it.command.unwrap_or("").to_string(),
Node::Separator => "---".into(),
Node::Header(h) => format!("#{h}"),
Node::Submenu { label, .. } => format!("[{label}]"),
Node::Standard(s) => format!("<{s:?}>"),
})
.collect()
}
fn menu<'a>(bar: &'a MenuBar, title: &str) -> &'a [Node] {
&bar.menus.iter().find(|m| m.title == title).unwrap_or_else(|| panic!("no {title} menu")).children
}
fn submenu<'a>(nodes: &'a [Node], name: &str) -> &'a [Node] {
nodes
.iter()
.find_map(|n| match n {
Node::Submenu { label, children } if label == name => Some(children.as_slice()),
_ => None,
})
.unwrap_or_else(|| panic!("no {name} submenu in {:?}", ids(nodes)))
}
fn items(nodes: &[Node]) -> Vec<&native_menu::Item> {
nodes.iter().filter_map(|n| if let Node::Item(it) = n { Some(it) } else { None }).collect()
}
fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item {
bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item"))
}
#[test]
fn vectorcraft_gets_a_mac_app_menu() {
let bar = bar(&app(true));
let titles: Vec<&str> = bar.menus.iter().map(|m| m.title.as_str()).collect();
assert_eq!(titles, ["VectorCraft", "File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"]);
let app_menu = &bar.menus[0];
assert_eq!(app_menu.role, MenuRole::App);
assert_eq!(
ids(&app_menu.children),
[
"help.about",
"---",
"[Settings]",
"[Language]",
"[Appearance]",
"---",
"<Services>",
"---",
native_menu::HIDE,
native_menu::HIDE_OTHERS,
"<ShowAll>",
"---",
"app.quit"
]
);
let shown = |command: &str| {
let it = find(&bar, command);
(it.label.as_str(), it.shortcut)
};
assert_eq!(shown("help.about"), ("About VectorCraft", None));
assert_eq!(shown("app.quit"), ("Quit VectorCraft", Some("Cmd+Q")), "Quit is the app's own, so unsaved documents are asked about");
// ⌘H stays View › Hide Edges (Illustrator's); Hide takes ⌃⌘H.
assert_eq!(shown(native_menu::HIDE), ("Hide VectorCraft", Some("Ctrl+Cmd+H")));
assert_eq!(shown(native_menu::HIDE_OTHERS), ("Hide Others", Some("Cmd+Alt+H")));
assert_eq!(find(&bar, "view.edges").shortcut, Some("Cmd+H"));
assert_eq!(bar.menu(MenuRole::Help).map(|m| m.title.as_str()), Some("Help"));
}
/// Settings ▸ lists the Preferences dialog's own pages, General… with ⌘K, and each opens the
/// dialog on its page.
#[test]
fn settings_lists_every_preferences_page() {
let mut app = app(true);
let bar = bar(&app);
let pages = items(submenu(&bar.menus[0].children, "Settings"));
assert_eq!(pages.len(), PREF_CATEGORIES.len());
assert_eq!((pages[0].label.as_str(), pages[0].shortcut), ("General…", Some("Cmd+K")));
assert!(pages[1..].iter().all(|it| it.shortcut.is_none()));
for (it, page) in pages.iter().zip(PREF_CATEGORIES) {
assert_eq!((it.command, &it.params, it.label.clone()), (Some("edit.preferences"), &json!({"category": page}), format!("{page}…")));
}
let units = pages.iter().find(|it| it.source == "Units").unwrap();
let (id, p) = menus::click_target(units.source, units.command.unwrap(), &units.params);
assert_eq!(id, "edit.preferences", "a page opens the dialog, not a parameter form");
menus::invoke(&mut app, &id, p);
let dialog = app.ui.dialog.as_ref().expect("Preferences open");
assert_eq!((dialog.kind.as_str(), dialog.fields.get("__category")), ("preferences", Some(&json!("Units"))));
}
/// Language ▸: Automatic, then each language in its own name (in any UI language), the
/// preference checked; Appearance ▸: the UI brightness choices, the current one checked.
#[test]
fn language_and_appearance_are_in_the_app_menu() {
let mut app = app(false);
app.session.prefs.interface_language = "ja".into();
let bar = bar(&app);
let app_menu = &bar.menus[0].children;
assert_eq!(ids(submenu(app_menu, "Language"))[..2], ["app.language", "---"], "Automatic, then the languages");
let langs: Vec<(String, Option<bool>)> = items(submenu(app_menu, "Language")).iter().map(|it| (it.label.clone(), it.checked)).collect();
let mut want = vec![("Automatic".to_string(), Some(false))];
want.extend(Lang::all().map(|l| (l.name().to_string(), Some(l.code() == "ja"))));
assert_eq!(langs, want);
// In every UI language the languages keep their own names.
for ui in Lang::all() {
for l in Lang::all() {
assert_eq!(crate::i18n::tr_id(ui, "app.language", l.name()), l.name(), "{} in {}", l.name(), ui.code());
}
}
let appearance: Vec<(&'static str, Option<bool>)> = items(submenu(app_menu, "Appearance")).iter().map(|it| (it.source, it.checked)).collect();
assert_eq!(appearance.len(), crate::theme::Brightness::ALL.len());
assert_eq!(appearance.iter().filter(|(_, c)| *c == Some(true)).count(), 1, "{appearance:?}");
assert!(appearance.iter().all(|(_, c)| c.is_some()));
assert_eq!(crate::i18n::tr_ctx(Lang::from_code("ja").unwrap(), "theme", "Appearance"), "外観", "the theme, not the Appearance panel");
}
/// Everything the in-window bar lists is in the Mac menu bar, once: what moved to the app menu
/// (About, Settings, Quit, Language, UI Brightness) leaves its other places, and Join Our Discord
/// stays under Help.
#[test]
fn every_in_window_item_is_in_the_mac_menu_bar_once() {
let app = app(true);
let count = |bar: &MenuBar| {
let mut n: HashMap<(String, String), usize> = HashMap::new();
for it in bar.items() {
if let Some(c) = it.command {
// Settings ▸ opens Preferences on each page: one item per page instead of one.
let params = if c == "edit.preferences" { String::new() } else { it.params.to_string() };
*n.entry((c.to_string(), params)).or_default() += 1;
}
}
n
};
let in_window = native_menu::from_tree(&app, &menus::menu_tree());
let mac = bar(&app);
let (a, b) = (count(&in_window), count(&mac));
// The in-window application menu's commands, its submenus' included.
let moved: Vec<&str> = MenuBar { menus: vec![in_window.menus[0].clone()] }.items().iter().filter_map(|it| it.command).collect();
for (key, n) in &a {
let m = b.get(key).copied().unwrap_or(0);
let want = match key.0.as_str() {
"edit.preferences" => PREF_CATEGORIES.len(),
c if moved.contains(&c) => 1,
_ => *n,
};
assert_eq!(m, want, "{key:?} is listed {m} times on the Mac, {n} in the window");
}
let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE];
for key in b.keys() {
assert!(a.contains_key(key) || system.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
}
let help = ids(menu(&mac, "Help"));
assert!(help.contains(&"help.discord".to_string()) && !help.contains(&"help.about".to_string()), "{help:?}");
let edit = ids(menu(&mac, "Edit"));
assert!(!edit.contains(&"edit.preferences".to_string()) && edit.contains(&"edit.keyboardShortcuts".to_string()), "{edit:?}");
assert!(ids(menu(&mac, "Window")).contains(&"[Workspace]".to_string()));
}
#[test]
fn the_window_menu_has_the_system_items() {
let bar = bar(&app(true));
let window = ids(&bar.menu(MenuRole::Window).unwrap().children);
assert_eq!(window[..3], [native_menu::MINIMIZE.to_string(), "<Zoom>".into(), "---".into()]);
assert_eq!(window[window.len() - 2..], ["---".to_string(), "<BringAllToFront>".into()]);
assert_eq!(window[3], "window.newWindow", "then VectorCraft's own items");
assert_eq!(find(&bar, native_menu::MINIMIZE).shortcut, Some("Ctrl+Cmd+M"));
assert!(!window.iter().any(|i| i == "window.brightness"), "the theme is only in the app menu");
assert_eq!(Standard::Zoom.label(), "Zoom");
}
/// A system key a command already has stays the command's: the system item goes without. The
/// user's shortcuts show in the menus.
#[test]
fn a_command_keeps_a_system_key_it_is_bound_to() {
let mut app = app(true);
app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into());
crate::shortcut_editor::sync(&app.ui);
let layout = native_menu::mac_layout(&native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
app.ui.shortcut_overrides.clear();
crate::shortcut_editor::sync(&app.ui);
assert_eq!(find(&layout.bar, native_menu::HIDE).shortcut, None);
assert_eq!(find(&layout.bar, "view.grid").shortcut, Some("Ctrl+Cmd+H"), "the user's override shows in the menu");
assert_eq!(layout.clashes.iter().map(|c| (c.shortcut, c.command)).collect::<Vec<_>>(), [("Ctrl+Cmd+H", "view.grid")]);
}
/// A native menu can't turn a plain item into a check item in place: opening a document or
/// selecting must not change the menus' structure, or every change rebuilds them.
#[test]
fn documents_and_selections_change_state_not_structure() {
let empty = native_menu::structure_key(&bar(&app(false)));
let mut a = app(true);
assert_eq!(native_menu::structure_key(&bar(&a)), empty, "a document with a selected rectangle");
a.run("select.none", json!({})).unwrap();
a.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap();
assert!(!a.session.active().unwrap().selection.objects.is_empty());
assert_eq!(native_menu::structure_key(&bar(&a)), empty, "type selected");
// A recent file is a new item: the menus are rebuilt.
crate::io::note_recent(&mut a, "/art/a.svg");
assert_ne!(native_menu::structure_key(&bar(&a)), empty);
}
#[test]
fn key_equivalents_are_command_and_function_keys_only() {
let ok = |s| crate::shortcuts::parse(s).is_some_and(|k| native_menu::native_ok(&k));
assert!(ok("Cmd+B") && ok("F7") && ok("Ctrl+Cmd+M") && ok("Shift+F7"));
assert!(!ok("B") && !ok("Alt+F") && !ok("Shift+X"));
}
/// What a key equivalent becomes: the press and release egui's window integration sends, or for
/// ⌘X, ⌘C and ⌘V its clipboard events (a paste's release comes from the window).
#[test]
fn key_equivalents_become_the_input_their_keys_make() {
let cmd = |key| KeyboardShortcut::new(Modifiers::COMMAND, key);
let key = |key, pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND };
assert_eq!(native_menu::key_events(cmd(Key::A), || None), [key(Key::A, true), key(Key::A, false)]);
assert_eq!(native_menu::key_events(cmd(Key::C), || None), [Event::Copy, key(Key::C, false)]);
assert_eq!(native_menu::key_events(cmd(Key::X), || None), [Event::Cut, key(Key::X, false)]);
assert_eq!(native_menu::key_events(cmd(Key::V), || Some("a\r\nb".into())), [Event::Paste("a\nb".into())]);
assert_eq!(native_menu::key_events(cmd(Key::V), || None), [], "a picture: the release tells the paste");
}
/// The fake backend: the menus the app synced, and events to report.
#[derive(Clone, Default)]
struct Fake(Rc<RefCell<(Vec<MenuBar>, Vec<native_menu::Event>)>>);
impl Backend for Fake {
fn sync(&mut self, bar: &MenuBar) {
self.0.borrow_mut().0.push(bar.clone());
}
fn drain(&mut self) -> Vec<native_menu::Event> {
std::mem::take(&mut self.0.borrow_mut().1)
}
}
impl Fake {
fn synced(&self) -> usize {
self.0.borrow().0.len()
}
fn last(&self) -> MenuBar {
self.0.borrow().0.last().cloned().unwrap()
}
fn report(&self, e: native_menu::Event) {
self.0.borrow_mut().1.push(e);
}
}
/// The app with the fake native menu (or none), driven a whole frame at a time like the desktop
/// app.
struct Mac {
app: VectorcraftApp,
fake: Fake,
ctx: egui::Context,
time: f64,
}
impl Mac {
fn new(native: bool) -> Self {
let mut app = app(true);
let fake = Fake::default();
if native {
app.services.native_menu = Some(NativeMenu::new(Box::new(fake.clone())));
}
let mut m = Mac { app, fake, ctx: egui::Context::default(), time: 0.0 };
for _ in 0..3 {
m.frame(vec![]);
}
m
}
fn frame(&mut self, events: Vec<Event>) -> egui::FullOutput {
self.time += 0.1;
let mut raw = egui::RawInput {
events,
time: Some(self.time),
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0))),
..Default::default()
};
self.app.raw_input_hook(&mut raw);
let app = &mut self.app;
let mut out = self.ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out
}
/// AppKit chose an item by its key equivalent `sc` (⌘ held).
fn key_equivalent(&mut self, sc: &str) -> egui::FullOutput {
let mut k = crate::shortcuts::parse(sc).unwrap();
k.modifiers.mac_cmd = k.modifiers.command;
self.fake.report(native_menu::Event::Key(k));
self.frame(vec![])
}
fn selected(&self) -> usize {
self.app.session.active().unwrap().selection.objects.len()
}
}
/// A key equivalent runs through the app's own key handling, once: ⌘A selects every object, ⌘Z
/// undoes one step; and frames where nothing changed don't read the menus again.
#[test]
fn key_equivalents_run_through_the_normal_shortcut_path_once() {
let mut m = Mac::new(true);
m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap();
m.app.run("select.none", json!({})).unwrap();
m.frame(vec![]);
m.key_equivalent("Cmd+A");
assert_eq!(m.selected(), 2, "⌘A selected every object");
let undo = |m: &Mac| m.app.session.active().unwrap().history.undo.len();
let before = undo(&m);
m.key_equivalent("Cmd+Z");
assert_eq!(undo(&m), before - 1, "⌘Z undid one step");
let synced = m.fake.synced();
assert!(synced >= 1, "the menus were built");
m.frame(vec![]);
m.frame(vec![]);
assert_eq!(m.fake.synced(), synced, "nothing changed: the menus weren't read again");
assert_eq!(crate::control::inspect(&m.app, &m.ctx)["nativeMenuBar"], json!(true));
}
/// A click runs its command like an in-window click, and the menus follow: View › Show Rulers
/// reads Hide Rulers afterwards; the app menu's Appearance items run with their params.
#[test]
fn clicks_run_their_command_and_the_menus_follow() {
let mut m = Mac::new(true);
let rulers = find(&m.fake.last(), "view.rulers").clone();
let before = m.app.ui.view.rulers;
m.fake.report(native_menu::Event::Click(rulers.clone()));
m.frame(vec![]);
assert_eq!(m.app.ui.view.rulers, !before);
assert_ne!(find(&m.fake.last(), "view.rulers").label, rulers.label, "the label flipped");
let other = m.fake.last().items().into_iter().find(|it| it.command == Some("window.brightness") && it.checked == Some(false)).cloned().unwrap();
m.fake.report(native_menu::Event::Click(other.clone()));
m.frame(vec![]);
assert_eq!(Some(m.app.ui.brightness.id()), other.params.get("brightness").and_then(Value::as_str));
let now = m.fake.last().items().into_iter().find(|it| it.params == other.params).cloned().unwrap();
assert_eq!(now.checked, Some(true), "the checkmark moved");
}
/// With a text field focused, the menu's ⌘A and ⌘C act on the field, as the keys do without the
/// native menu.
#[test]
fn a_focused_field_keeps_select_all_and_copy() {
let mut m = Mac::new(true);
let (id, rect) = m
.ctx
.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.id, w.rect)).collect::<Vec<_>>())
.into_iter()
.find(|&(id, _)| m.ctx.data(|d| d.get_temp::<String>(id)).is_some_and(|t| t == "37 pt") && egui::TextEdit::load_state(&m.ctx, id).is_some())
.expect("the W field");
let click = |pressed| Event::PointerButton { pos: rect.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
m.frame(vec![Event::PointerMoved(rect.center()), click(true)]);
m.frame(vec![click(false)]);
m.frame(vec![Event::Key { key: Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(m.ctx.text_edit_focused());
m.key_equivalent("Cmd+A");
let r = egui::TextEdit::load_state(&m.ctx, id).and_then(|s| s.cursor.char_range()).unwrap();
let (a, b): (usize, usize) = (r.primary.index.into(), r.secondary.index.into());
assert_eq!((a.min(b), a.max(b)), (0, 5), "⌘A selected the field's text");
assert_eq!(m.selected(), 1, "not the art");
let out = m.key_equivalent("Cmd+C");
let copied: Vec<_> =
out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.as_str()) } else { None }).collect();
assert_eq!(copied, ["37 pt"]);
assert!(m.app.session.clipboard.is_empty(), "no art copied");
}
/// With the macOS menu bar the app bar draws no menu titles: each shows once fewer.
#[test]
fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() {
let texts = |native: bool| {
let mut m = Mac::new(native);
let out = m.frame(vec![]);
out.shapes
.iter()
.filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None })
.collect::<Vec<_>>()
};
let (in_window, native) = (texts(false), texts(true));
for (title, _) in menus::menu_tree().iter().skip(1) {
let n = |t: &[String]| t.iter().filter(|s| s == title).count();
assert_eq!(n(&native) + 1, n(&in_window), "{title}");
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ fn recent_files_show_as_many_as_the_preference_keeps() {
assert!(menus::enabled(&app, "file.openRecent30"));
app.session.prefs.recent_files_count = 0;
assert_eq!((io::recent_files(&app).len(), listed(&app)), (0, 0), "0 hides the list");
assert_eq!(menus::listed_slots(&app), 0, "the native menu rebuilds without them");
assert!(menus::shown_state(&app, "file.openRecent1", &json!(null)).is_none(), "the slots leave the menus");
assert!(app.run("file.openRecent1", json!({})).is_err());
app.session.prefs.recent_files_count = 5;
assert_eq!(io::recent_files(&app)[0], "/art/34.svg", "hiding them didn't forget them");
+1 -1
View File
@@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does |
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running |
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) |
| `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}` | |
+14 -3
View File
@@ -62,6 +62,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
| Variable | Effect |
|---|---|
| `VECTORCRAFT_CONTROL_PORT` | Same as `--control <port>` |
| `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) |
| `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)) |
@@ -69,6 +70,15 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
| `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)) |
## macOS: the menu bar
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search box and the workspace switcher next to the traffic lights). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus.
- **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type).
- Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`.
## 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:
@@ -124,7 +134,8 @@ 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), 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), Italian (`it`,
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve 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
@@ -152,10 +163,10 @@ international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or
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 covers every
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish and Italian cover 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`
row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
and an `it.tsv` 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