Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 b832599a47 Fix #550: an SVG of chosen artboards holds only their art
Exporting one artboard of many to SVG wrote the whole document's art into
that artboard's file, the other artboards' art outside its viewBox: an
editor opening it showed every artboard's art. A file for an artboard
named in the export (Use Artboards with a range or All, Export for
Screens) now holds the objects over that artboard only, at any depth of
layers, as DXF's crop leaves them; objects reaching into it stay whole
and a clipping layer keeps all of its objects. An SVG with no artboard
named (Save As) still holds the whole document.

Part of #546: Export As with a range writes the chosen artboard alone in
SVG, DXF, PDF and PNG, each file with that artboard's art (tested from
the dialogs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:38:38 +07:00
BFlat af7a5239be Merge pull request #556: Release: VectorCraft v0.7.0
Release: VectorCraft v0.7.0
2026-10-09 01:18:16 +07:00
bflatasticandClaude Opus 5.5 c3279f1bcf Release: VectorCraft v0.7.0
For users, since 0.6.0:
- Linux: no more startup crash on desktops whose compositor runs on another
  GPU (#502, #551); the window renders on the desktop's GPU and retries
  the next one instead of crashing.
- Windows: rendering through DirectX 12 first, fixing the black flicker on
  Intel GPUs (#545).
- Italian interface (#514).
- macOS: the menus live in the system menu bar.
- Panels, panel groups and the Tools panel undock into floating panels (#495).
- Live Corners on any path's corners: stars, polygons, pen paths (#511).
- Drawing tools snap to Smart Guides as the Selection tool does (#506).
- Pen: Cmd/Ctrl edits the path being drawn, Alt reshapes segments, drags
  out of the first anchor always close (#494, #501); Convert buttons for
  whole paths (#504).
- Right-click a ruler to change the document's units; the Control bar's
  Transform link opens the Transform panel.
- Object > Image Trace > Make traces with the Default preset (#543).
- Edit menu Select All, Cut, Copy and Paste act on a focused field (#516).
- Japanese UI: new type starts top-to-top with em box centre alignment (#432).
- PDF/.ai import keeps type strokes, wrapped lines and layer order (#508);
  EPS import fixes (#498, #505).
- macOS 27: only file URLs are opened (#433); Linux: the Paste menu no
  longer freezes on an unresponsive clipboard owner (#510).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:16:08 +07:00
BFlat 4f07bd93b0 Merge pull request #553: land #533, #548, #549, #552
Land #533, #548, #549, #552 with review fixes
2026-10-09 01:08:30 +07:00
bflatastic 086d6287be Merge #552: Fix #543: Object › Image Trace › Make traces with the Default preset
# Conflicts:
#	ROADMAP.md
2026-10-09 01:03:27 +07:00
bflatasticandClaude Opus 5.5 0e6a47e72d Control bar: Italian for the Transform link's tooltip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 01:03:03 +07:00
bflatastic 03288f34dc Merge #549: Fix #545: Windows renders through DX12 before Vulkan
# Conflicts:
#	ROADMAP.md
2026-10-09 00:58:47 +07:00
bflatastic aa4fa48751 Merge #548: Part of #516: the in-window menus' Select All, Cut, Copy and Paste act on a focused field
# Conflicts:
#	ROADMAP.md
2026-10-09 00:58:46 +07:00
bflatastic 165db95dd4 Merge #533: Control bar: Transform link opens the Transform panel; reference point locator redrawn 2026-10-09 00:58:40 +07:00
bflatasticandClaude Opus 5.5 ac043129c8 Fix #545: Windows renders through DX12 before Vulkan
Windows lists each GPU under both backends, and Vulkan came first. On an
Intel GPU its driver made the whole window flicker black while the
pointer moved; the same machine was fine with WGPU_BACKEND=dx12 or gl.
adapter_order now ranks a GPU's DX12 adapter before its Vulkan one, after
the power preference, so the GPU chosen doesn't change, only how it is
driven. Vulkan stays the fallback (a failed DX12 start restarts on it)
and WGPU_BACKEND=vulkan still picks it. Elsewhere there is no DX12
adapter, so nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:56:49 +07:00
bflatasticandClaude Opus 5.5 8e753d746e Part of #516: the in-window menus' Select All, Cut, Copy and Paste act on a focused field
The clipboard keys already went to a focused text field, but on Windows,
Linux and the web the press that opens a menu takes the keyboard from the
field, so Edit > Copy (and Cut, Paste, Select > All) acted on the art.
The menu bar now keeps the field that had the keyboard, and its
selection, while the menus are open; those four commands go to it as the
events their keys send, the frame after the click that chose them, and
give it the keyboard back. Everything else, and those commands with no
field, run as before. The macOS menu bar shares the same helpers.

Tests: Copy, Cut and Paste events in the W field and the Color panel's
hex field edit the text, never the art; Select > All, Edit > Copy and
Edit > Paste clicked in the in-window menus act on the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:51:02 +07:00
David PanuschandClaude Opus 5.5 b5759662c1 Control bar: Transform link opens the Transform panel; reference point locator redrawn
The Control bar's underlined Transform link opens the Transform panel as a
popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons
and angle presets, flips, shape properties, Scale Corners, Scale Strokes &
Effects), as the Stroke link does the Stroke panel. X/Y/W/H stay inline while
the bar has room.

The reference point locator is now nine squares with the outer eight joined
around the box; the old outline was inset off the squares' centres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:38:18 +02:00
23 changed files with 360 additions and 84 deletions

No files matched your search

Generated
+25 -25
View File
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4320,7 +4320,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4332,7 +4332,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4348,7 +4348,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4364,7 +4364,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4376,7 +4376,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4393,7 +4393,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4407,7 +4407,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4439,7 +4439,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4457,7 +4457,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"proptest",
@@ -4471,7 +4471,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4483,7 +4483,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"log",
@@ -4502,7 +4502,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4516,7 +4516,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4528,7 +4528,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"aes",
"flate2",
@@ -4557,7 +4557,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
@@ -4571,7 +4571,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"gif",
@@ -4596,7 +4596,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4620,7 +4620,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4637,7 +4637,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4670,7 +4670,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4683,7 +4683,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"egui",
"egui_extras",
@@ -4712,7 +4712,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -5815,7 +5815,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+5 -4
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 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
_Last updated: 2026-10-09 (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); 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) |
| **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 Windows renders through DX12 before Vulkan (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), 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) |
@@ -112,8 +112,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). Not yet: Select All across threaded frames (one frame's text is selected).
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
@@ -159,7 +160,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- 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.
- **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 on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), 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.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
+28 -9
View File
@@ -4,9 +4,9 @@
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, and the power
//! preference ([`power_preference`]) among the rest. eframe is handed [`selector`], which takes
//! the first of that order.
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the power preference
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
//! eframe is handed [`selector`], which takes the first of that order.
//!
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
@@ -89,15 +89,17 @@ fn key(backend: Backend, vendor: u32, device: u32) -> String {
/// The order to try `candidates` in (their indices): only those that present to the window and
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
/// software; native backends before OpenGL; then by `power`, keeping the system's order among
/// equals (all of it for [`PowerPreference::None`]).
/// software; native backends before OpenGL; then by `power`; then DX12 before Vulkan, keeping the
/// system's order among equals (all of it for [`PowerPreference::None`]). Windows lists each GPU
/// under both, and Intel's Vulkan driver made the whole window flicker black where DX12 and OpenGL
/// didn't (#545); elsewhere there is no DX12 adapter, so the order is unchanged.
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec<usize> {
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
// Stable, so equals keep the system's order.
order.sort_by_key(|(_, c)| {
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power))
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power), c.backend == Backend::Vulkan)
});
order.into_iter().map(|(i, _)| i).collect()
}
@@ -361,7 +363,7 @@ mod tests {
}
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
/// integrated GPU first, as wgpu's own choice did (#306).
/// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545).
#[test]
fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() {
let c = vec![
@@ -372,8 +374,25 @@ mod tests {
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
];
let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
assert_eq!(order, [1, 3, 0, 2, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [0, 2, 1, 3, 4]);
assert_eq!(order, [3, 1, 2, 0, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [2, 0, 3, 1, 4]);
}
/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black.
/// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME`
/// or a restart leaving DX12 out still reach it.
#[test]
fn a_windows_gpu_renders_through_dx12_before_vulkan() {
let c = vec![
gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu),
gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other),
];
for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] {
assert_eq!(adapter_order(&c, power, None, &[]), [1, 0, 3, 2], "{power:?}");
}
assert_eq!(adapter_order(&c, PowerPreference::LowPower, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
}
#[test]
+20
View File
@@ -87,6 +87,26 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
Some(out)
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping
/// path among them. An artboard's file then holds its own art, not a whole document of artboards.
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
fn kept(n: &Arc<Node>, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
let mut layer = Node::clone(n);
let children = layer.children_mut()?;
*children = children.iter().filter_map(|c| kept(c, rect)).collect();
return Some(Arc::new(layer));
}
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
n.visual_bounds().is_none_or(over).then(|| n.clone())
}
let mut d = doc.clone();
d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect();
d
}
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
/// synthetic artboard, and for callers that pick the artboard themselves).
pub(super) fn without_artboards(p: &Value) -> Value {
+13 -8
View File
@@ -19,7 +19,7 @@ pub const OPTIONS: &[FormatOption] = &[
name: "useArtboards",
ty: "boolean",
default: "true",
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)",
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each; a named artboard's file holds only the art over it)",
},
FormatOption {
name: "styling",
@@ -125,8 +125,10 @@ pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
Ok(m)
}
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers,
/// and whether `p` named those artboards (else the first stands for the whole document, as Save
/// writes it).
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>, bool), String> {
let mut m = options_map(p)?;
// Fonts: unless chosen, Document Setup → Type → Export decides (appearance = outlines).
if !m.contains_key("outlineText") && doc.setup.export_text == vectorcraft_doc::ExportText::Appearance {
@@ -138,21 +140,24 @@ fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>)
opts.check().map_err(|e| format!("SVG options: {e}"))?;
let n = doc.artboards.len();
if n == 0 || boards.use_artboards == Some(false) {
return Ok((opts, vec![None]));
return Ok((opts, vec![None], false));
}
let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]);
Ok((opts, picked.into_iter().map(Some).collect()))
let named = boards.pick.resolve(n)?;
let chosen = named.is_some();
Ok((opts, named.unwrap_or_else(|| vec![0]).into_iter().map(Some).collect(), chosen))
}
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
/// link to and the writer's warnings.
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
let (opts, boards) = plan(doc, p)?;
let (opts, boards, chosen) = plan(doc, p)?;
// Preserve editing embeds the native document (once, shared by every file).
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
let mut enc = Encoded::default();
for artboard in boards {
let mut out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
// A chosen artboard's file holds the art over it (#550).
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
let bytes = out.take_bytes();
for l in out.linked {
if !enc.linked.iter().any(|e| e.name == l.name) {
@@ -49,3 +49,28 @@ fn profile_and_embedded_fonts_reach_the_writer() {
assert!(s.execute("document.serialize", &json!({"format": "svg", "svg": {"profile": "svg2"}})).is_err(), "unknown profiles are refused");
assert!(s.execute("document.serialize", &json!({"format": "svg", "encoding": "ebcdic"})).is_err(), "unknown encodings are refused");
}
/// #550: an SVG of chosen artboards holds the art over each, not every artboard's art; with no
/// artboard named (as Save writes it) the whole document's art is there.
#[test]
fn a_chosen_artboard_s_svg_holds_only_its_own_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3})).unwrap();
let x = |s: &Session, b: usize| s.doc().unwrap().doc.artboards[b].rect.x0;
let (first, third) = (x(&s, 0), x(&s, 2));
s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
// A rectangle has straight sides only; the ellipse is curves.
let shapes = |svg: &str| (svg.matches("<path").count() + svg.matches("<rect").count(), svg.contains(" C") || svg.contains("<ellipse"));
let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string();
let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap());
assert_eq!(shapes(&third_only), (1, true), "{third_only}");
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
let files: Vec<String> = all["files"].as_array().unwrap().iter().map(text).collect();
assert_eq!(files.iter().map(|f| shapes(f)).collect::<Vec<_>>(), [(1, false), (0, false), (1, true)]);
// Export for Screens names one artboard at a time the same way.
let screen = text(&s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap());
assert_eq!(shapes(&screen), (1, false));
let whole = text(&s.execute("document.serialize", &json!({"format": "svg"})).unwrap());
assert_eq!(shapes(&whole).0, 2, "nothing named: the whole document's art");
}
+9 -2
View File
@@ -174,6 +174,9 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
}
}
/// The room the Control bar's inline X/Y/W/H fields need; with less, only the Transform link shows.
const INLINE_TRANSFORM_WIDTH: f32 = 440.0;
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -280,9 +283,13 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.separator();
// Transform fields.
// The Transform link opens the whole Transform panel (reference point, rotate,
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room.
crate::panels::transform::link(app, ui);
// The bounding box, rotated with rotated objects: its centre and its own sides.
if let Some(b) = app.selection_box() {
if let Some(b) = app.selection_box()
&& ui.available_width() >= INLINE_TRANSFORM_WIDTH
{
let c = b.center();
let link = app.session.prefs.constrain_proportions;
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] {
+1 -1
View File
@@ -360,7 +360,7 @@ mod tests {
let f = &mut app.ui.dialog.as_mut().unwrap().fields;
f.insert("decimals".into(), json!(1));
f.insert("allArtboards".into(), json!(false));
f.insert("range".into(), json!("2"));
f.insert("range".into(), json!("1"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let svg = text(&written, 0);
@@ -240,3 +240,29 @@ fn pdf_artboards_continue_in_the_save_pdf_dialog() {
let pdf = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap();
assert_eq!(pdf.artboards.len(), 2, "one page per artboard in the range");
}
/// #546, #550: Export As with Use Artboards and a range writes the chosen artboard alone in every
/// format, each file holding that artboard's art (the ellipse is on artboard 1).
#[test]
fn export_as_writes_only_the_chosen_artboard_in_every_format() {
for (format, ellipse) in [("svg", "<path"), ("dxf", "HATCH"), ("pdf", ""), ("png", "")] {
for (range, on) in [("1", true), ("3", false)] {
let (mut app, written) = app(3);
app.run("file.exportAs", json!({"format": format})).unwrap();
set(&mut app, "useArtboards", json!(true));
set(&mut app, "all", json!(false));
set(&mut app, "range", json!(range));
confirm(&mut app).unwrap();
// The format's own options, then the file.
while app.ui.dialog.is_some() {
frame(&mut app);
confirm(&mut app).unwrap();
}
assert_eq!(names(&written), [format!("/out/Untitled-1.{format}")], "{format} {range}");
if !ellipse.is_empty() {
let text = String::from_utf8_lossy(&written.borrow()[0].1).into_owned();
assert_eq!(text.contains(ellipse), on, "{format} {range}: {text}");
}
}
}
}
+1
View File
@@ -2544,6 +2544,7 @@
Character: Carácter:
Character options Opciones de carácter
Stroke options Opciones de trazo
Transform options Opciones de transformación
Stroke Weight Grosor de trazo
Stroke: Trazo:
Stroke: click for swatches, Shift-click for the color mixer Trazo: clic para muestras, Shift+clic para el mezclador de colores
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2544,6 +2544,7 @@
Character: Carattere:
Character options Opzioni carattere
Stroke options Opzioni traccia
Transform options Opzioni di trasformazione
Stroke Weight Spessore traccia
Stroke: Traccia:
Stroke: click for swatches, Shift-click for the color mixer Traccia: fai clic per i campioni, Shift+clic per il miscelatore colori
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2350,6 +2350,7 @@
Character: 文字:
Character options 文字オプション
Stroke options 線オプション
Transform options 変形オプション
Stroke: 線:
Style スタイル
Style Name: スタイル名:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2246,6 +2246,7 @@
Character: Caractere:
Character options Opções de caractere
Stroke options Opções de traçado
Transform options Opções de transformação
Stroke: Traçado:
Style Estilo
Style Name: Nome do Estilo:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2482,6 +2482,7 @@
Character: 字符:
Character options 字符选项
Stroke options 描边选项
Transform options 变换选项
Stroke Weight 描边粗细
Stroke: 描边:
Stroke: click for swatches, Shift-click for the color mixer 描边:单击开启色板,按住 Shift 单击开启混色器
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2564,6 +2564,7 @@
Character: 字元:
Character options 字元選項
Stroke options 筆畫選項
Transform options 變形選項
Stroke Weight 筆畫寬度
Stroke: 筆畫:
Stroke: click for swatches, Shift-click for the color mixer 筆畫:按一下開啟色票,按住 Shift 按一下開啟混色器
Can't render this file because it contains an unexpected character in line 6 and column 47.
+51 -23
View File
@@ -2664,9 +2664,24 @@ pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Op
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
// The press that opens a menu takes the keyboard from a focused text field: the field and its
// selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on
// its text (#516), the frame after the click that chose them (that click takes the keyboard
// from it again).
type Field = (egui::Id, egui::text_edit::TextEditState);
let (field_key, chosen_key) = (egui::Id::new("menu-bar-field"), egui::Id::new("menu-bar-field-event"));
if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) {
ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key));
state.store(&ctx, field);
to_field(&ctx, field, e);
}
if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) {
ctx.data_mut(|d| d.insert_temp::<Field>(field_key, field));
}
let mut clicked: Option<(String, Value)> = None;
let tree = menu_tree();
let end = egui::MenuBar::new()
let (end, open) = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
@@ -2677,12 +2692,21 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
};
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
})
.inner;
let field = ctx.data(|d| d.get_temp::<Field>(field_key));
if !open && focused_text_field(&ctx).is_none() {
ctx.data_mut(|d| d.remove::<Field>(field_key));
}
if let Some((id, p)) = clicked {
invoke(app, &id, p);
match field.and_then(|f| Some((f, field_event(app, &id)?))) {
Some(chosen) => {
ctx.data_mut(|d| d.insert_temp(chosen_key, chosen));
ctx.request_repaint();
}
None => invoke(app, &id, p),
}
}
end
}
@@ -2695,29 +2719,25 @@ fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::P
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu
/// is open.
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
return false;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving)
&& let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p))
&& i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
true
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
@@ -2862,13 +2882,21 @@ fn field_event(app: &mut VectorcraftApp, id: &str) -> Option<egui::Event> {
/// 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)
{
ctx.input_mut(|i| i.events.push(e));
return;
match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) {
Some((field, e)) => to_field(ctx, field, e),
None => invoke(app, id, p),
}
invoke(app, id, p);
}
/// The text field that has the keyboard, if any.
fn focused_text_field(ctx: &egui::Context) -> Option<egui::Id> {
ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())
}
/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame.
fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
ctx.memory_mut(|m| m.request_focus(field));
ctx.input_mut(|i| i.events.push(e));
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
+16 -1
View File
@@ -13,6 +13,8 @@ use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0];
/// The shear angle's presets: steep enough to be useful, short of the degenerate ±90°.
pub const SHEAR_PRESETS: [f64; 9] = [-60.0, -45.0, -30.0, -15.0, 0.0, 15.0, 30.0, 45.0, 60.0];
/// Proportional size: the other dimension when one changes with the link on.
pub fn constrained(w: f64, h: f64, new_w: Option<f64>, new_h: Option<f64>) -> (f64, f64) {
@@ -151,8 +153,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(4.0);
let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
crate::scrub::note_label(ui, icon.rect);
if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0)
// Shear is relative: the field rests at 0° and a new value shears by it.
if let Some(a) = widgets::spin_plain(ui, "xfp-shear", 0.0, "°", 2, 96.0, 15.0, -89.0, &SHEAR_PRESETS)
&& a != 0.0
&& a.abs() < 90.0
{
app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin})).ok();
}
@@ -206,6 +210,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let resp = ui.link(egui::RichText::new(tl!("Transform")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Transform options"));
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(300.0);
show(app, ui);
});
}
/// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the
/// `constrainProportions` preference, which the size fields pass on as `proportional`.
pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) {
+28
View File
@@ -242,3 +242,31 @@ fn the_window_menu_panel_keys() {
app.ui.shortcut_overrides.clear();
shortcut_editor::sync(&app.ui);
}
/// Where `shapes` paint the text `s`.
fn text_rect(shapes: &[Shape], s: &str) -> Option<Rect> {
shapes.iter().find_map(|sh| match sh {
Shape::Text(t) if t.galley.text() == s => Some(t.visual_bounding_rect()),
Shape::Vec(v) => text_rect(v, s),
_ => None,
})
}
#[test]
fn the_transform_link_opens_the_transform_panel() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
let link = text_rect(&shapes, "Transform").expect("the Control bar shows the Transform link");
assert!(text_rect(&shapes, "Scale Corners").is_none());
click(&mut app, &ctx, link.center(), Modifiers::NONE, control_bar);
let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
for s in ["Scale Corners", "Scale Strokes & Effects", "0°"] {
assert!(text_rect(&shapes, s).is_some(), "the popover shows {s}");
}
// A click outside closes it.
click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar);
assert!(text_rect(&frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar), "Scale Corners").is_none());
}
+91
View File
@@ -171,3 +171,94 @@ fn cmd_a_on_the_canvas_selects_all_objects() {
a.menu("select.all");
assert_eq!(a.selected(), 2, "so does the menu's Select All");
}
/// The copied texts of a frame's output.
fn copied(out: &egui::FullOutput) -> Vec<String> {
out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }).collect()
}
/// #516: Ctrl/Cmd+C, X and V in a focused field (the window's own clipboard keys, which egui sends
/// as Copy, Cut and Paste events) edit the field's text, never the art.
#[test]
fn the_clipboard_keys_in_a_focused_field_edit_its_text() {
let mut a = App::new();
a.app.run("window.panel", json!({"panel": "color"})).unwrap();
for _ in 0..3 {
a.frame(vec![]);
}
for text in ["37 pt", "FFFFFF"] {
let (id, rect) = a.field(text);
a.frame(vec![Event::PointerMoved(rect.center()), click(rect.center(), true)]);
a.frame(vec![click(rect.center(), false)]);
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
assert!(a.ctx.memory(|m| m.has_focus(id)), "{text}: the field has the keyboard");
let out = a.frame(vec![Event::Copy]);
assert_eq!(copied(&out), [text], "Copy copies the field's text");
assert!(a.app.session.clipboard.is_empty(), "{text}: no art copied");
let out = a.frame(vec![Event::Cut]);
assert_eq!(copied(&out), [text], "Cut cuts the field's text");
a.frame(vec![Event::Paste("12".into())]);
assert_eq!(a.ctx.data(|d| d.get_temp::<String>(id)).as_deref(), Some("12"), "Paste pastes into the field");
assert_eq!(a.selected(), 1, "{text}: the art stays");
a.frame(vec![press(Key::Escape, Modifiers::NONE)]);
}
}
/// The centre of the text `label` painted last (in the topmost layer) in a frame's output; with
/// `title`, the first one painted (the menu bar's).
fn text_at(out: &egui::FullOutput, label: &str, title: bool) -> Pos2 {
let mut shapes: Box<dyn Iterator<Item = _>> = if title { Box::new(out.shapes.iter()) } else { Box::new(out.shapes.iter().rev()) };
shapes
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text().trim() == label => Some(Rect::from_min_size(t.pos, t.galley.size()).center()),
_ => None,
})
.unwrap_or_else(|| panic!("no {label:?} painted"))
}
/// A click on `at` → the frame after it.
fn click_at(a: &mut App, at: Pos2) -> egui::FullOutput {
a.frame(vec![Event::PointerMoved(at), click(at, true)]);
a.frame(vec![click(at, false)]);
a.frame(vec![])
}
/// The in-window menus' Select › All, Edit › Copy and Edit › Paste act on the text field that had the keyboard
/// when the menu was opened (the click on the menu took it), and give it the keyboard back.
#[test]
fn the_in_window_edit_menu_acts_on_the_field_it_was_opened_from() {
let mut a = App::new();
// Text on the system clipboard.
a.app.services.clipboard_read = Some(Box::new(|| Some("42 pt".into())));
let (id, rect) = a.field("37 pt");
a.frame(vec![Event::PointerMoved(rect.center()), click(rect.center(), true)]);
a.frame(vec![click(rect.center(), false)]);
let out = a.frame(vec![press(Key::End, Modifiers::NONE)]);
let (edit, select) = (text_at(&out, "Edit", true), text_at(&out, "Select", true));
let menu = |a: &mut App, title: Pos2, item: &str| {
let out = click_at(a, title);
assert!(!a.ctx.text_edit_focused(), "the menu took the keyboard");
click_at(a, text_at(&out, item, false))
};
menu(&mut a, select, "All");
assert!(a.ctx.memory(|m| m.has_focus(id)), "the field has the keyboard back");
assert_eq!(a.field_selection(id), Some((0, 5)), "Select All selected the field's text");
assert_eq!(a.selected(), 1, "not the art");
a.frame(vec![press(Key::End, Modifiers::NONE)]);
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
a.frame(vec![]);
let out = menu(&mut a, edit, "Copy");
assert_eq!(copied(&out), ["37 pt"], "Copy copied the field's text");
assert!(a.app.session.clipboard.is_empty(), "no art copied");
// Art on the app's clipboard (Paste is enabled): the field still takes the text.
a.app.run("edit.copy", json!({})).unwrap();
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
menu(&mut a, edit, "Paste");
assert_eq!(a.ctx.data(|d| d.get_temp::<String>(id)).as_deref(), Some("42 pt"), "Paste pasted into the field");
assert_eq!(a.selected(), 1, "nothing pasted on the canvas");
// With no field focused, the menu's Select All selects the art.
a.frame(vec![press(Key::Escape, Modifiers::NONE)]);
a.frame(vec![]);
menu(&mut a, select, "All");
assert_eq!(a.selected(), 2);
}
+13 -8
View File
@@ -806,18 +806,23 @@ pub fn opacity_blend(
edit
}
/// The 3×3 reference point locator. Returns a new index when clicked.
/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around
/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked.
pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let size = 22.0;
// 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through
// the squares' centres stay crisp.
let (sq, step) = (5.0, 9.0);
let size = 2.0 * step + sq;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let o = rect.min.round();
let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step);
let line = Stroke::new(1.0, t.text_dim);
ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle);
let mut out = None;
let step = size / 2.0 - 2.5;
ui.painter().rect_stroke(rect.shrink(4.5), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Middle);
for i in 0..9 {
let c = Pos2::new(rect.left() + 3.0 + (i % 3) as f32 * step + 1.5, rect.top() + 3.0 + (i / 3) as f32 * step + 1.5);
let r = Rect::from_center_size(c, Vec2::splat(5.0));
let resp = ui.interact(r.expand(1.5), ui.id().with(("refpt", i)), Sense::click());
let r = Rect::from_center_size(centre(i), Vec2::splat(sq));
let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click());
if resp.clicked() {
out = Some(i);
}
@@ -825,7 +830,7 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
ui.painter().rect_filled(r, 0.0, t.text);
} else {
ui.painter().rect_filled(r, 0.0, t.panel);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside);
}
}
out
+1 -1
View File
@@ -38,7 +38,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; then the power preference, keeping the system's order among equals.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; then the power preference; then, on Windows, where each GPU is listed under both, a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. `WGPU_BACKEND=vulkan` still picks Vulkan.
- **Preferences › Performance › Graphics Processor** (`gpuPreference`): `automatic` (the default), `lowPower` (Power Saving, the integrated GPU) or `highPerformance` (the discrete GPU). The adapter is chosen when the window opens, so a change applies after a restart.
- **Automatic on Windows and macOS** is power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306).
- **Automatic on Linux and the BSDs** keeps the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502).
+1 -1
View File
@@ -251,7 +251,7 @@ colour group (one undo step).
SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}`: `styling` (`presentation`,
`style`, `entities`, `css`), `outlineText`, `images` (`embed`, or `link`: embedded images are written next to the
SVG, or returned as `linked`), `objectIds` (`layerNames`, `minimal`, `unique`), `decimals` (1–7), `minify`,
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`), `preserveEditing` (the SVG
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`, each holding only the art over its artboard), `preserveEditing` (the SVG
reopens as the full document), `metadata` and `fewerTspans` (one `<tspan>` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats`
lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian after a byte order mark) or `latin1`
(ISO 8859-1, other characters as `&#x…;` references); `document.serialize` still answers `text`, plus `dataBase64`