Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a18e671817 | ||
|
|
a3fec5486d | ||
|
|
dc947d30c1 | ||
|
|
49567e9594 | ||
|
|
831a5e5c96 | ||
|
|
8e80d6bcc9 | ||
|
|
0bf7422d9c | ||
|
|
caeecf4b26 | ||
|
|
58e3916e03 | ||
|
|
3013bbc94f | ||
|
|
12b56818b6 | ||
|
|
aad734e264 | ||
|
|
256e938fff | ||
|
|
acc708ec83 | ||
|
|
746a271aeb | ||
|
|
85dafc78d7 | ||
|
|
2b56a91ce2 | ||
|
|
ce12b4b0f5 | ||
|
|
b5a7994df0 | ||
|
|
5d49a2f4bf | ||
|
|
048db2d230 | ||
|
|
80307f11da | ||
|
|
038f344deb | ||
|
|
b2c6764472 |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
|
||||
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
|
||||
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
|
||||
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
|
||||
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
|
||||
name: Windows 7 x64 compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 5 * * 1" # weekly
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
|
||||
with:
|
||||
arch: x64
|
||||
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
|
||||
with:
|
||||
toolchain: nightly-2026-10-01
|
||||
components: rust-src
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows7-x64
|
||||
- name: Build, check and test the portable package
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/windows7.ps1 -Test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vectorcraft-windows7-x64-portable
|
||||
path: dist/release/vectorcraft-windows7-x64-portable.zip
|
||||
if-no-files-found: error
|
||||
Generated
+3
@@ -952,6 +952,7 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
@@ -4251,6 +4252,7 @@ name = "vectorcraft"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"clipboard-win",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4259,6 +4261,7 @@ dependencies = [
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
|
||||
@@ -30,6 +30,11 @@ based in part on the work of the Independent JPEG Group.
|
||||
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
|
||||
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
|
||||
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
|
||||
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
|
||||
other build uses the unmodified crate from crates.io.
|
||||
|
||||
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
|
||||
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
|
||||
|
||||
@@ -176,6 +176,8 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
|
||||
|
||||
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
|
||||
|
||||
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
|
||||
|
||||
## Status
|
||||
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
@@ -184,7 +186,9 @@ milestones, and honest time-to-parity estimates.
|
||||
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"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 scores are
|
||||
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).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
|
||||
+11
-9
@@ -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, the font menu, saved selections, MCP and EPS; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## 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; hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); 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 |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -96,20 +96,22 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- 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) and Japanese ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…).
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- 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.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
@@ -125,7 +127,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.
|
||||
- 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), 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. 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.
|
||||
- **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 and Japanese cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. 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 puts a quarter em between Japanese and Latin letters or digits; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); 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 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 puts a quarter em between Japanese and Latin letters or digits; 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, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
@@ -142,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed.
|
||||
- **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, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, 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, 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).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, 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).
|
||||
- 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, and falls back to its palette TIFF preview. 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.
|
||||
- **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.
|
||||
@@ -167,7 +169,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
|
||||
@@ -9,10 +9,17 @@ default-run = "vectorcraft"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["wgpu", "accessibility"]
|
||||
wgpu = ["eframe/wgpu"]
|
||||
accessibility = ["eframe/accesskit"]
|
||||
# Build with --no-default-features --features windows7 and the win7 Rust target.
|
||||
windows7 = ["eframe/glow"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -31,7 +38,10 @@ clipboard-win = { workspace = true }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -7,10 +7,19 @@
|
||||
//! See `vectorcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
|
||||
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
|
||||
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
|
||||
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
|
||||
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
|
||||
compile_error!("the win7 target requires --no-default-features --features windows7");
|
||||
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
mod printing;
|
||||
mod window;
|
||||
|
||||
@@ -30,6 +39,7 @@ impl eframe::App for App {
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0);
|
||||
}
|
||||
open_files(&mut self.0, open_documents::take());
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
@@ -47,9 +57,25 @@ impl eframe::App for App {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
|
||||
/// can't be opened is reported in the status bar and on stderr; the others still open.
|
||||
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
for f in files {
|
||||
if let Err(e) = vectorcraft_ui_egui::io::open_path(app, &f) {
|
||||
eprintln!("vectorcraft: {f}: {e}");
|
||||
app.status(format!("Couldn't open {}: {e}", fileio::file_name(&f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
@@ -248,6 +274,7 @@ fn app_icon() -> egui::IconData {
|
||||
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
|
||||
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
|
||||
/// GPU both preferences pick it.
|
||||
#[cfg(feature = "wgpu")]
|
||||
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
match (env, pref) {
|
||||
@@ -258,6 +285,7 @@ fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreferenc
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
#[cfg(feature = "wgpu")]
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
@@ -283,9 +311,11 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let mut options = eframe::NativeOptions {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
@@ -297,11 +327,21 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
};
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
options
|
||||
};
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
@@ -323,22 +363,25 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = vectorcraft_ui_egui::io::open_path(&mut app, &f) {
|
||||
eprintln!("vectorcraft: {f}: {e}");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::set_ui(&cc.egui_ctx);
|
||||
open_files(&mut app, files);
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -348,7 +391,7 @@ fn main() -> eframe::Result {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
@@ -367,6 +410,35 @@ mod tests {
|
||||
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
.iter()
|
||||
.flat_map(|key| plist.split(key).skip(1))
|
||||
.filter_map(|rest| rest.split("</array>").next())
|
||||
.flat_map(|array| array.split("<string>").skip(1))
|
||||
.filter_map(|s| s.split("</string>").next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354), takes over no other
|
||||
/// app's files, and hands them to the app rather than to AppKit's document machinery.
|
||||
#[test]
|
||||
fn the_macos_bundle_opens_every_readable_format() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let declared = plist_extensions(plist);
|
||||
for e in fileio::OPEN_EXTS {
|
||||
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
|
||||
}
|
||||
for e in &declared {
|
||||
assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open");
|
||||
}
|
||||
assert!(!plist.contains("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
|
||||
let types = plist.matches("<key>CFBundleTypeName</key>").count();
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// The preference the engine saves is the one the app reads back before the window opens.
|
||||
#[test]
|
||||
fn the_saved_engine_preference_is_found() {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
|
||||
//! `open -a VectorCraft file`.
|
||||
//!
|
||||
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
|
||||
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
|
||||
//! which fails with "VectorCraft cannot open files in the … format" because VectorCraft isn't an
|
||||
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
|
||||
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
|
||||
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
|
||||
//! wakes the UI, which opens them with [`take`] like files named on the command line.
|
||||
//!
|
||||
//! Adding a method to another crate's Objective-C class has no safe API, hence the scoped
|
||||
//! `unsafe_code` allowance (winit overrides `sendEvent:` on `NSApplication` the same way).
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::runtime::{AnyObject, Imp, Sel};
|
||||
use objc2::sel;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
|
||||
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
|
||||
|
||||
/// Paths that arrived and haven't been opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
/// Handle open-document events from now on. Call before the event loop runs.
|
||||
pub fn install() {
|
||||
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
|
||||
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
|
||||
// runs on the posting thread (AppKit posts it on the main thread). The notification center
|
||||
// keeps the registration (and the block) for the life of the app.
|
||||
unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(NSApplicationWillFinishLaunchingNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive while the app runs.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
let _ = UI.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The paths that arrived since the last call.
|
||||
pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// `application:openURLs:` on the application delegate's class.
|
||||
fn add_open_urls_method() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
|
||||
log::warn!("vectorcraft: no application delegate; files opened from Finder won't open");
|
||||
return;
|
||||
};
|
||||
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
|
||||
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
|
||||
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
|
||||
// it with the arguments the type encoding declares, which match `open_urls`: no return value
|
||||
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
|
||||
let added = unsafe {
|
||||
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
|
||||
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
|
||||
};
|
||||
if !added.as_bool() {
|
||||
log::warn!("vectorcraft: the application delegate already handles open-document events");
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
|
||||
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
||||
let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
||||
if let Some(ctx) = UI.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2::runtime::NSObject;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Files queue until the UI takes them; URLs that aren't files are left out.
|
||||
#[test]
|
||||
fn opened_files_wait_for_the_ui() {
|
||||
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.svg")).unwrap();
|
||||
let urls = NSArray::from_retained_slice(&[NSURL::from_file_path("/tmp/a b.pdf").unwrap(), web, NSURL::from_file_path("/tmp/c.ai").unwrap()]);
|
||||
let any = NSObject::new();
|
||||
open_urls(&any, sel!(application:openURLs:), &any, &urls);
|
||||
assert_eq!(take(), ["/tmp/a b.pdf", "/tmp/c.ai"]);
|
||||
assert!(take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,9 @@ mod system {
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
@@ -174,7 +177,19 @@ mod system {
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
#[cfg(feature = "windows7")]
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let mut command = {
|
||||
let mut command = Command::new("explorer");
|
||||
command.arg("ms-settings:printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,43 @@ fn direct_selection_moves_one_anchor() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
/// Dragging a curved segment with Direct Selection bends it (its anchors stay), and dragging a
|
||||
/// straight one moves its two anchors, not the whole path; each is one undo step.
|
||||
#[test]
|
||||
fn direct_selection_drags_segments() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64)| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
|
||||
};
|
||||
let arch = &json!({"anchors": [{"x": 100, "y": 200, "out": [100, 100]}, {"x": 300, "y": 200, "in": [300, 100]}]});
|
||||
let arch = NodeId(s.execute("path.create", arch).unwrap()["id"].as_u64().unwrap());
|
||||
let square = &json!({"anchors": [{"x": 150, "y": 400}, {"x": 150, "y": 300}, {"x": 250, "y": 300}, {"x": 250, "y": 400}], "closed": true});
|
||||
let square = NodeId(s.execute("path.create", square).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = steps(&s);
|
||||
// The arch's middle, 40 up: the curve bends, its two anchors stay.
|
||||
drag(&mut s, (200.0, 125.0), (200.0, 85.0));
|
||||
let sp = s.doc().unwrap().doc.node(arch).unwrap().path_data().unwrap().subpaths[0].clone();
|
||||
let (a, b) = (sp.anchors[0], sp.anchors[1]);
|
||||
assert_eq!((a.p, b.p), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(300.0, 200.0)), "the anchors stay");
|
||||
assert!(a.h_out.y < 100.0 && b.h_in.y < 100.0, "the curve bent up: {a:?} {b:?}");
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
// The square's top edge, 40 up: its two anchors move, the bottom two stay.
|
||||
drag(&mut s, (200.0, 300.0), (200.0, 260.0));
|
||||
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((sq.y0, sq.y1, sq.x0, sq.x1), (260.0, 400.0, 150.0, 250.0), "the edge moved, the rest stayed");
|
||||
assert_eq!(steps(&s), before + 2);
|
||||
// Its fill still moves the whole square.
|
||||
drag(&mut s, (200.0, 350.0), (200.0, 330.0));
|
||||
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((sq.y0, sq.y1), (240.0, 380.0), "the whole path moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_create_has_bounds() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -20,7 +20,7 @@ use vectorcraft_doc::{
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{Ocgs, Scan, all_on, hides_forms, scan_page};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, Look, Placement, TextLine, Upright};
|
||||
@@ -112,6 +112,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let mut parts = b.end_page();
|
||||
// A PDF-compatible `.ai` (the editor's private data next to the PDF art).
|
||||
let ai = crate::pages::has_private_data(page);
|
||||
if ai {
|
||||
drop_page_fill(&mut parts, xf.transform_rect_bbox(crate::pages::page_box(page, CropTo::Crop)));
|
||||
}
|
||||
// A file with several artboards writes, on each page, the art of its neighbours that
|
||||
// reaches into the page's box: art lying wholly outside this page is theirs (each page
|
||||
// draws it shifted by its own artboard spacing), so keep it only where it belongs.
|
||||
@@ -122,7 +127,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
parts.retain(|(_, art)| !art.is_empty());
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
|
||||
placeholder &= ai && only_text(&children);
|
||||
let mut right = ab.x1;
|
||||
if opts.crop == CropTo::Bounding
|
||||
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
|
||||
@@ -347,6 +352,28 @@ struct FontInfo {
|
||||
face: Option<Arc<vectorcraft_text::FontFace>>,
|
||||
}
|
||||
|
||||
/// The page a PDF-compatible `.ai` paints under its layers is the editor's page, not art: an opaque
|
||||
/// white rectangle the size of the page (`page`, document space), outside every layer and before
|
||||
/// any of them. Drop it from the page's art (`parts`, as [`Builder::end_page`] gives them).
|
||||
fn drop_page_fill(parts: &mut Vec<(Option<usize>, Vec<Arc<Node>>)>, page: Rect) {
|
||||
let layered = parts.iter().any(|(g, _)| g.is_some());
|
||||
let Some((None, art)) = parts.first_mut() else { return };
|
||||
// Not a spot colour: a white ink is art.
|
||||
let fill = art.first().is_some_and(|n| {
|
||||
layered
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& white_cover(n, page)
|
||||
&& n.appearance.fill().is_some_and(|f| matches!(f.paint, Paint::Solid { swatch: None, .. }))
|
||||
&& n.geometric_bounds().is_some_and(|b| contains(page.inflate(0.5, 0.5), b))
|
||||
});
|
||||
if fill {
|
||||
art.remove(0);
|
||||
if art.is_empty() {
|
||||
parts.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this art text and nothing else (in groups and clips), with some text?
|
||||
fn only_text(nodes: &[Arc<Node>]) -> bool {
|
||||
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
|
||||
|
||||
@@ -42,6 +42,11 @@ fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> {
|
||||
(covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity))
|
||||
}
|
||||
|
||||
/// An opaque white rectangle covering `page`, filled only.
|
||||
pub(crate) fn white_cover(n: &Node, page: Rect) -> bool {
|
||||
cover(n, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
|
||||
}
|
||||
|
||||
/// Does `outer` contain `inner`?
|
||||
pub(crate) fn contains(outer: Rect, inner: Rect) -> bool {
|
||||
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
|
||||
@@ -105,7 +110,7 @@ pub(crate) fn mask_spec(
|
||||
if art.len() > 1
|
||||
&& let Some(last) = art.last()
|
||||
&& last.blend == BlendMode::Difference
|
||||
&& cover(last, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
|
||||
&& white_cover(last, page)
|
||||
{
|
||||
art.pop();
|
||||
invert = !invert;
|
||||
|
||||
@@ -134,3 +134,61 @@ fn an_encrypted_file_keeps_its_hidden_layers() {
|
||||
assert!(d.layers[0].visible && !d.layers[1].visible);
|
||||
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
|
||||
}
|
||||
|
||||
/// A page drawing `before`, white and red squares in "Shapes", then `after`; `ai`: with an
|
||||
/// editor's private data (a PDF-compatible `.ai`).
|
||||
fn page_fill(before: &str, after: &str, ai: bool) -> Document {
|
||||
let (objs, catalog, ids) = groups(1, "");
|
||||
let page = PdfPage {
|
||||
resources: format!(
|
||||
"/Properties << /MC0 {} 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> /ColorSpace << /W [/Separation /White /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 0] /N 1 >>] >>",
|
||||
ids[0]
|
||||
),
|
||||
entries: if ai { "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into() } else { String::new() },
|
||||
..PdfPage::new(200.0, 100.0, &format!("{before} /OC /MC0 BDC 1 1 1 rg 20 20 60 60 re f 1 0 0 rg 120 20 60 60 re f EMC {after}"))
|
||||
};
|
||||
import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None)).unwrap()
|
||||
}
|
||||
|
||||
fn path_count(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
d.walk(|c| n += usize::from(matches!(c.kind, NodeKind::Path { .. })));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_files_white_page_outside_its_layers_is_not_art() {
|
||||
// RGB, grey and CMYK white, the rectangle drawn either way round.
|
||||
for fill in ["1 1 1 rg 0 0 200 100 re f", "1 g 0 100 200 -100 re f", "0 0 0 0 k 0 0 200 100 re f"] {
|
||||
let d = page_fill(fill, "", true);
|
||||
assert_eq!(names(&d), ["Shapes"], "{fill}");
|
||||
assert_eq!(path_count(&d), 2, "{fill}");
|
||||
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0)], "{fill}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_fill_that_is_art_stays() {
|
||||
// A plain PDF's white background is art, as are an .ai's fills that aren't a plain white
|
||||
// page under its layers: grey, smaller or larger than the page, stroked, translucent or a
|
||||
// white ink.
|
||||
let page = "1 1 1 rg 0 0 200 100 re f";
|
||||
let kept = [
|
||||
(page, false),
|
||||
("0.9 g 0 0 200 100 re f", true),
|
||||
("1 g 10 10 180 80 re f", true),
|
||||
("1 g -20 -20 240 140 re f", true),
|
||||
("1 g 0 G 0 0 200 100 re B", true),
|
||||
("/GS0 gs 1 g 0 0 200 100 re f", true),
|
||||
("/W cs 1 scn 0 0 200 100 re f", true),
|
||||
];
|
||||
for (fill, ai) in kept {
|
||||
let d = page_fill(fill, "", ai);
|
||||
assert_eq!(names(&d), ["Page 1", "Shapes"], "{fill}");
|
||||
assert_eq!(path_count(&d), 3, "{fill}");
|
||||
}
|
||||
// Painted over the layers.
|
||||
let d = page_fill("", page, true);
|
||||
assert_eq!(names(&d), ["Shapes", "Page 1"]);
|
||||
assert_eq!(path_count(&d), 3);
|
||||
}
|
||||
+135
-3
@@ -10,11 +10,15 @@
|
||||
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
|
||||
//! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`):
|
||||
//! the text reflows at its size.
|
||||
//!
|
||||
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
|
||||
//! (the fill, or Alt, selects the whole path); dragging the segment bends it if it's curved, else
|
||||
//! moves its anchors.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Rect};
|
||||
|
||||
@@ -35,6 +39,15 @@ enum State {
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
|
||||
Segment {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
Handle {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
@@ -191,6 +204,27 @@ fn anchors_json(v: &[AnchorRef]) -> Value {
|
||||
Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect())
|
||||
}
|
||||
|
||||
/// A segment of a path under the pointer.
|
||||
struct SegmentHit {
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
/// The indexes of the segment's two anchors in its subpath.
|
||||
anchors: [usize; 2],
|
||||
curved: bool,
|
||||
}
|
||||
|
||||
/// The segment of path `id` nearest to `p`, where the hit test found its stroke.
|
||||
fn segment_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<SegmentHit> {
|
||||
let pd = cx.doc.node(id)?.path_data()?;
|
||||
let (si, seg, t, ..) = pd.nearest(p)?;
|
||||
let sp = pd.subpaths.get(si)?;
|
||||
let n = sp.anchors.len();
|
||||
let (i0, i1) = (seg.checked_rem(n)?, (seg + 1).checked_rem(n)?);
|
||||
let (a, b) = (sp.anchors.get(i0)?, sp.anchors.get(i1)?);
|
||||
Some(SegmentHit { si, seg, t, anchors: [i0, i1], curved: a.has_out() || b.has_in() })
|
||||
}
|
||||
|
||||
impl Tool for DirectSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
if self.group { "groupSelection" } else { "directSelection" }
|
||||
@@ -267,7 +301,29 @@ impl Tool for DirectSelectionTool {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
// Clicking a segment/fill selects the whole leaf path (all anchors).
|
||||
// A segment of a path that isn't selected as a whole: its two anchors get
|
||||
// selected, and dragging it reshapes it if it's curved, else moves it. Alt picks
|
||||
// the whole path (and Alt-drag copies it), as with Group Selection.
|
||||
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
|
||||
if !whole
|
||||
&& !ev.mods.shift
|
||||
&& !ev.mods.alt
|
||||
&& matches!(h.kind, HitKind::Stroke | HitKind::Outline)
|
||||
&& let Some(s) = segment_at(cx, h.leaf, p)
|
||||
{
|
||||
let selected = cx.selection.partial(h.leaf).is_some_and(|sel| s.anchors.iter().all(|&ai| sel.contains(&(s.si, ai))));
|
||||
self.state = if s.curved {
|
||||
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
|
||||
} else {
|
||||
State::MoveAnchors { start: p, began: false }
|
||||
};
|
||||
if selected {
|
||||
return vec![];
|
||||
}
|
||||
let anchors: Vec<Value> = s.anchors.iter().map(|ai| json!([s.si, ai])).collect();
|
||||
return vec![Action::Exec("select.anchors".into(), json!({"id": h.leaf.0, "anchors": anchors, "mode": "set"}))];
|
||||
}
|
||||
// Clicking the fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
@@ -293,6 +349,22 @@ impl Tool for DirectSelectionTool {
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Segment { id, si, seg, t, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape".into()));
|
||||
self.state = State::Segment { id, si, seg, t, start, began: true };
|
||||
}
|
||||
let d = p - start;
|
||||
out.push(Action::Preview(
|
||||
"path.reshapeSegment".into(),
|
||||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
@@ -367,7 +439,11 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
(
|
||||
PointerKind::Up,
|
||||
State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. } | State::TypeArea { began, .. },
|
||||
State::MoveAnchors { began, .. }
|
||||
| State::MoveObject { began, .. }
|
||||
| State::Segment { began, .. }
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
@@ -570,4 +646,60 @@ mod tests {
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "add": false}))]);
|
||||
}
|
||||
/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5).
|
||||
fn arch_doc() -> (vectorcraft_doc::Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false);
|
||||
sp.anchors[0].h_out = Point::new(100.0, 250.0);
|
||||
sp.anchors[1].h_in = Point::new(200.0, 250.0);
|
||||
d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() {
|
||||
let (d, arch) = arch_doc();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
// The arch: its two anchors get selected, and the drag bends it.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
|
||||
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": arch.0, "anchors": [[0, 0], [0, 1]], "mode": "set"}))]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5));
|
||||
assert_eq!(a[0], Action::Begin("Reshape".into()));
|
||||
let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") };
|
||||
assert_eq!(
|
||||
(cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()),
|
||||
("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0))
|
||||
);
|
||||
assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}");
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]);
|
||||
// A straight edge of the rectangle: its two anchors get selected and move.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.anchors" && v["anchors"] == json!([[0, 0], [0, 1]])), "{a:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
|
||||
assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0})));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0));
|
||||
// Alt on the stroke picks the whole path, and Alt-drag copies it.
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt));
|
||||
// The fill still selects the whole path, and a path selected as a whole still moves whole.
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
|
||||
assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
let mut whole = Selection::default();
|
||||
whole.set([rect]);
|
||||
let cx = crate::testutil::cx(&d, &whole, &p);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan
|
||||
//! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt
|
||||
//! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click
|
||||
//! or Return opens the dialog too. A clicked or Alt-clicked point snaps to the nearest anchor or
|
||||
//! centre (Snap to Point / Smart Guides), labelled while hovering, so transforms pivot exactly
|
||||
//! on a corner.
|
||||
//! at release makes a copy: Alt-drag). Alt-click (the release of a click made with Alt) sets the
|
||||
//! point and opens the tool's dialog; double-click or Return opens the dialog too. A clicked or
|
||||
//! Alt-clicked point snaps to the nearest anchor or centre (Snap to Point / Smart Guides), labelled
|
||||
//! while hovering, so transforms pivot exactly on a corner.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
@@ -61,6 +61,8 @@ struct Drag {
|
||||
pick: Point,
|
||||
origin: Point,
|
||||
began: bool,
|
||||
/// Alt was down at the press: a click opens the dialog, a drag transforms a copy.
|
||||
alt: bool,
|
||||
bounds: Rect,
|
||||
last: Affine,
|
||||
}
|
||||
@@ -169,13 +171,10 @@ impl Tool for TransformTool {
|
||||
let Some(bounds) = selection_bounds(cx) else { return vec![] };
|
||||
self.hover.clear();
|
||||
let (pick, _) = snap_pick(cx, p);
|
||||
if ev.mods.alt {
|
||||
self.origin = Some((pick, cx.selection.objects.clone()));
|
||||
self.drag = None;
|
||||
return vec![self.dialog(pick)];
|
||||
}
|
||||
// Alt: decided on release. A click sets the point and opens the dialog; a drag
|
||||
// transforms a copy about the reference point as it was.
|
||||
let origin = self.reference_point(cx).unwrap_or(bounds.center());
|
||||
self.drag = Some(Drag { start: p, pick, origin, began: false, bounds, last: Affine::IDENTITY });
|
||||
self.drag = Some(Drag { start: p, pick, origin, began: false, alt: ev.mods.alt, bounds, last: Affine::IDENTITY });
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
@@ -202,9 +201,9 @@ impl Tool for TransformTool {
|
||||
// The Alt state at release decides whether the result is a copy.
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
|
||||
} else {
|
||||
// A click sets the reference point.
|
||||
// A click sets the reference point; with Alt, it opens the dialog too.
|
||||
self.origin = Some((d.pick, cx.selection.objects.clone()));
|
||||
vec![]
|
||||
if d.alt { vec![self.dialog(d.pick)] } else { vec![] }
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
@@ -357,9 +356,10 @@ mod tests {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = TransformTool::new(TransformKind::Reflect);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt()));
|
||||
// The release decides: a click with Alt opens the dialog…
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt())), vec![]);
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt()));
|
||||
assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [120.0, 130.0]}))]);
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt()));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 200.0));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 200.0).with_mods(alt()));
|
||||
@@ -373,6 +373,28 @@ mod tests {
|
||||
assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_transforms_a_copy_and_leaves_the_dialog_shut() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
for kind in [TransformKind::Rotate, TransformKind::Reflect, TransformKind::Scale, TransformKind::Shear] {
|
||||
let mut t = TransformTool::new(kind);
|
||||
// Alt down at the press, then a drag: no dialog, and the preview and the release copy.
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 260.0, 150.0).with_mods(alt())), vec![], "{kind:?}");
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 180.0).with_mods(alt()));
|
||||
assert_eq!(a[0], Action::Begin(kind.label().into()), "{kind:?}");
|
||||
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["copy"] == true), "{kind:?}: {a:?}");
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 180.0).with_mods(alt()));
|
||||
assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true), "{kind:?}: {up:?}");
|
||||
assert_eq!(up[1], Action::Commit);
|
||||
// The reference point is the one it was (the centre), not the press point.
|
||||
assert_eq!(t.reference_point(&cx), Some(Point::new(150.0, 150.0)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_and_click_snap_the_reference_point_to_anchors_and_centres() {
|
||||
let (d, id) = doc_with_rect();
|
||||
@@ -382,7 +404,8 @@ mod tests {
|
||||
let mut c = cx(&d, &s, &p);
|
||||
// Alt-click 3 px from the top-left corner: the reflect pivots exactly on the corner.
|
||||
let mut t = TransformTool::new(TransformKind::Reflect);
|
||||
let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Up, 102.0, 98.0).with_mods(alt()));
|
||||
assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [100.0, 100.0]}))]);
|
||||
// A plain click near the bottom-right corner, then near the centre.
|
||||
let mut t = TransformTool::new(TransformKind::Rotate);
|
||||
@@ -400,7 +423,8 @@ mod tests {
|
||||
// Snapping off: the point stays where it was clicked.
|
||||
c.snap_to_point = false;
|
||||
c.smart_guides = false;
|
||||
let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Up, 102.0, 98.0).with_mods(alt()));
|
||||
assert!(matches!(&a[0], Action::Dialog(_, v) if v["origin"] == json!([102.0, 98.0])));
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -60,13 +60,23 @@ fn plural_czech(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 6] = [
|
||||
pub static LANGUAGES: [LangInfo; 7] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the whole interface (every menu string and `tl!` literal), keeping the product,
|
||||
// workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Czech: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs not yet.
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Spanish: the whole interface in neutral, international Spanish, keeping the same names in
|
||||
// English as Japanese; every `es-*` locale (`es-ES`, `es-MX`, `es-AR`, `es-419` …) resolves here.
|
||||
LangInfo {
|
||||
code: "es",
|
||||
name: "Español",
|
||||
source: include_str!("es.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).
|
||||
|
||||
@@ -11,6 +11,10 @@ fn tags_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("fr_FR"), None);
|
||||
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Lang::from_code("ja"));
|
||||
assert_eq!(lang_from_tag("ja"), Lang::from_code("ja"));
|
||||
// Spanish: every region (and Latin America as a whole) resolves to the one catalog.
|
||||
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}");
|
||||
}
|
||||
// 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()));
|
||||
@@ -387,9 +391,9 @@ fn cs() -> Lang {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
|
||||
const KEEPS_MENU_NAMES: [&str; 3] = ["cs", "ja", "pt-br"];
|
||||
const KEEPS_MENU_NAMES: [&str; 4] = ["cs", "es", "ja", "pt-br"];
|
||||
|
||||
/// Menu labels the menu-complete catalogs (Czech, Japanese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// Menu labels the menu-complete catalogs (Czech, Spanish, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
|
||||
/// Each language's own name in the Language menu is left alone too.
|
||||
const MENU_KEEP_AS_IS: &[&str] = &[
|
||||
@@ -479,8 +483,8 @@ fn toggled_labels() -> Vec<String> {
|
||||
labels
|
||||
}
|
||||
|
||||
/// Czech, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs and the
|
||||
/// canvas context menu included (panels and dialogs not yet).
|
||||
/// Czech, Spanish, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
|
||||
/// and the canvas context menu included (panels and dialogs not yet).
|
||||
#[test]
|
||||
fn menu_catalogs_translate_every_menu_label() {
|
||||
let labels = menu_labels();
|
||||
@@ -509,6 +513,44 @@ fn menu_catalogs_translate_every_menu_label() {
|
||||
assert_eq!(tr(cs(), "File"), "Soubor");
|
||||
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");
|
||||
}
|
||||
|
||||
fn es() -> Lang {
|
||||
Lang::from_code("es").expect("es 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]
|
||||
fn spanish_reads_as_spanish() {
|
||||
for (en, want) in [
|
||||
("Artboard Tool", "Herramienta Mesa de trabajo"),
|
||||
("Swatches", "Muestras"),
|
||||
("Pathfinder", "Buscatrazos"),
|
||||
("Stroke", "Trazo"),
|
||||
("Fill", "Relleno"),
|
||||
("Direct Selection Tool", "Herramienta Selección directa"),
|
||||
("Save As…", "Guardar como…"),
|
||||
("Undo", "Deshacer"),
|
||||
] {
|
||||
assert_eq!(tr(es(), en), want);
|
||||
}
|
||||
assert_eq!(trn(es(), 1, "{n} Layer", "{n} Layers"), "1 capa");
|
||||
assert_eq!(trn(es(), 0, "{n} Layer", "{n} Layers"), "0 capas");
|
||||
assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn spaced_catalogs_keep_the_spaces_around_fragments() {
|
||||
let edge = |s: &str| (s.len() - s.trim_start_matches(' ').len(), s.len() - s.trim_end_matches(' ').len());
|
||||
for l in LANGUAGES.iter().filter(|l| !l.source.is_empty() && !l.source.chars().any(is_cjk)) {
|
||||
let (entries, _) = parse_entries(l.source);
|
||||
let bad: Vec<_> = entries.iter().filter(|(ctx, src, tr)| ctx.is_empty() && edge(src) != edge(tr)).map(|(_, src, _)| src).collect();
|
||||
assert!(bad.is_empty(), "{}: spaces differ around {bad:?}", l.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,11 +559,11 @@ fn czech_plurals_have_three_forms() {
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2]);
|
||||
}
|
||||
|
||||
/// Czech letters (and the punctuation Czech text uses) come from each family's own first font, not
|
||||
/// Czech and Spanish letters (and the punctuation their text uses) come from each family's own first font, not
|
||||
/// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the
|
||||
/// face that draws missing glyphs, the first one, as missing.)
|
||||
#[test]
|
||||
fn czech_glyphs_are_available_without_system_fonts() {
|
||||
fn czech_and_spanish_glyphs_are_available_without_system_fonts() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
@@ -532,7 +574,7 @@ fn czech_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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"app.language",
|
||||
"Interface Language",
|
||||
"",
|
||||
"{lang: auto|<code>} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, zh-hant)",
|
||||
"{lang: auto|<code>} the interface language, persisted as the `interfaceLanguage` preference (`auto` follows the system locale; codes: prefs.list › interfaceLanguage, e.g. en, ja, cs, es, zh-hant)",
|
||||
),
|
||||
("file.open", "Open…", "Cmd+O", "{path?}"),
|
||||
(
|
||||
|
||||
@@ -228,6 +228,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if typing {
|
||||
return;
|
||||
}
|
||||
// Tab is ours when no field has the keyboard (it shows and hides the panels, or goes to the
|
||||
// Type tool): left to egui, it would also move the focus on to a field, and every key after it
|
||||
// would count as typing.
|
||||
if ctx.input(|i| i.key_pressed(Key::Tab)) {
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
// Type tool editing: text, IME composition and editing keys go to the tool.
|
||||
if app.session.tool_wants_text() {
|
||||
type_text(app, ctx);
|
||||
@@ -425,6 +431,48 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// Tab shows and hides the panels, and in the Type tool it types a tab, without moving the
|
||||
/// keyboard focus on to a field: the keys after it (a tool letter, the next letter typed) still
|
||||
/// work.
|
||||
#[test]
|
||||
fn tab_leaves_the_keyboard_to_the_shortcuts_and_the_type_tool() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 850.0));
|
||||
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let press = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
for _ in 0..3 {
|
||||
run(&mut app, vec![]);
|
||||
}
|
||||
assert!(app.ui.dock && app.ui.toolbar);
|
||||
run(&mut app, vec![press(Key::Tab)]);
|
||||
run(&mut app, vec![]);
|
||||
assert!(!app.ui.dock && !app.ui.toolbar, "Tab hid the panels");
|
||||
run(&mut app, vec![press(Key::Tab)]);
|
||||
run(&mut app, vec![]);
|
||||
assert!(app.ui.dock && app.ui.toolbar, "a second Tab shows them again");
|
||||
run(&mut app, vec![press(Key::P), egui::Event::Text("p".into())]);
|
||||
assert_eq!(app.session.tool_id(), "pen", "and a tool letter after it works");
|
||||
// The Type tool: a, Tab, b is one text.
|
||||
app.select_tool("type");
|
||||
let view = app.view_info();
|
||||
for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
|
||||
app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap();
|
||||
}
|
||||
run(&mut app, vec![egui::Event::Text("a".into())]);
|
||||
run(&mut app, vec![press(Key::Tab)]);
|
||||
run(&mut app, vec![egui::Event::Text("b".into())]);
|
||||
let doc = app.session.execute("document.inspect", &json!({})).unwrap();
|
||||
assert_eq!(doc["layers"][0]["children"][0]["name"], "a\tb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_paste_events_use_the_system_clipboard() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -14,19 +14,21 @@ struct Field {
|
||||
time: f64,
|
||||
rect: Cell<Rect>,
|
||||
id: Cell<egui::Id>,
|
||||
/// Whether the window reports keyboard focus (the live app's doesn't always).
|
||||
focused: bool,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
Self { ctx, time: 0.0, rect: Cell::new(Rect::NOTHING), id: Cell::new(egui::Id::NULL) }
|
||||
Self { ctx, time: 0.0, rect: Cell::new(Rect::NOTHING), id: Cell::new(egui::Id::NULL), focused: true }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>, draw: &dyn Fn(&mut egui::Ui) -> Option<f64>) -> Option<f64> {
|
||||
self.time += 0.05;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)));
|
||||
let input = egui::RawInput { events, time: Some(self.time), screen_rect, ..Default::default() };
|
||||
let input = egui::RawInput { events, time: Some(self.time), screen_rect, focused: self.focused, ..Default::default() };
|
||||
let mut got = None;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
self.id.set(ui.id().with("f"));
|
||||
@@ -56,7 +58,11 @@ impl Field {
|
||||
}
|
||||
|
||||
fn enter() -> Event {
|
||||
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
|
||||
key(Key::Enter, Modifiers::NONE)
|
||||
}
|
||||
|
||||
fn key(key: Key, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -125,3 +131,52 @@ fn plain_fields_do_math_with_their_suffix() {
|
||||
f.frame(vec![Event::Text("45*2°".into())], &draw);
|
||||
assert_eq!(f.frame(vec![enter()], &draw), Some(90.0));
|
||||
}
|
||||
|
||||
/// ↑/↓ step a focused numeric field by one of its unit (Shift: ten, Ctrl/Cmd: a tenth) and apply it
|
||||
/// at once, the new value selected so typing replaces it; an unfocused field leaves the arrows to
|
||||
/// the canvas (nudge).
|
||||
#[test]
|
||||
fn arrow_keys_step_a_focused_field() {
|
||||
let mut f = Field::new();
|
||||
let u = Unit::Millimeters;
|
||||
let mm = Cell::new(4.0);
|
||||
let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v)));
|
||||
f.frame(vec![], &draw);
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &draw), None, "unfocused");
|
||||
f.frame(f.click(1), &draw);
|
||||
f.frame(vec![], &draw);
|
||||
// The window needn't report keyboard focus: the field has egui's.
|
||||
f.focused = false;
|
||||
for (k, mods, want) in [
|
||||
(Key::ArrowUp, Modifiers::NONE, 5.0),
|
||||
(Key::ArrowUp, Modifiers::SHIFT, 15.0),
|
||||
(Key::ArrowDown, Modifiers::COMMAND, 14.9),
|
||||
(Key::ArrowDown, Modifiers::NONE, 13.9),
|
||||
] {
|
||||
assert!(f.frame(vec![key(k, mods)], &draw).is_some(), "{k:?} {mods:?}");
|
||||
assert!((mm.get() - want).abs() < 1e-6, "{k:?} {mods:?}: {}", mm.get());
|
||||
}
|
||||
assert_eq!(f.selection(), Some((0, "13.9 mm".len())), "the new value is selected");
|
||||
// Enter after the steps commits nothing more.
|
||||
assert_eq!(f.frame(vec![enter()], &draw), None);
|
||||
}
|
||||
|
||||
/// Plain fields (degrees, percent, counts) step too, at their precision.
|
||||
#[test]
|
||||
fn arrow_keys_step_plain_fields() {
|
||||
let mut f = Field::new();
|
||||
let angle = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 10.0, "°", 2, 80.0);
|
||||
f.frame(vec![], &angle);
|
||||
f.frame(f.click(1), &angle);
|
||||
f.frame(vec![], &angle);
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &angle), Some(11.0));
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowDown, Modifiers::SHIFT)], &angle), Some(1.0));
|
||||
// A count ignores the tenth.
|
||||
let mut f = Field::new();
|
||||
let count = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 3.0, "", 0, 80.0);
|
||||
f.frame(vec![], &count);
|
||||
f.frame(f.click(1), &count);
|
||||
f.frame(vec![], &count);
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::COMMAND)], &count), None);
|
||||
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &count), Some(4.0));
|
||||
}
|
||||
@@ -133,9 +133,42 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
})
|
||||
.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ step in the field's unit.
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
|
||||
let commit = resp.lost_focus() && buf != shown;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if commit { unit.parse(&buf) } else { None }
|
||||
if commit { unit.parse(&buf) } else { stepped.map(|v| unit.to_pt(v)) }
|
||||
}
|
||||
|
||||
/// ↑/↓ in focused numeric field `resp` step the number `buf` shows (`read` parses it, `show`
|
||||
/// formats it) by one (Shift: ten, Ctrl/Cmd: a tenth), applied at once as in Illustrator's panels
|
||||
/// and dialogs. The new text replaces `buf`, all selected so typing replaces it. Returns the new
|
||||
/// number when a step was taken.
|
||||
fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
|
||||
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
|
||||
// window to report keyboard focus.
|
||||
if !ui.memory(|m| m.has_focus(resp.id)) {
|
||||
return None;
|
||||
}
|
||||
use egui::{Key, Modifiers};
|
||||
let mut steps = 0.0;
|
||||
ui.input_mut(|i| {
|
||||
// Most specific first: a plain pattern would also take Shift+↑.
|
||||
for (mods, size) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1), (Modifiers::NONE, 1.0)] {
|
||||
let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64;
|
||||
let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64;
|
||||
steps += size * (up - down);
|
||||
}
|
||||
});
|
||||
if steps == 0.0 {
|
||||
return None;
|
||||
}
|
||||
// Rounded as fields show it (no 12.300000001).
|
||||
let v = ((read(buf)? + steps) * 1000.0).round() / 1000.0;
|
||||
*buf = show(v);
|
||||
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
|
||||
select_all(ui, resp.id, buf);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the
|
||||
@@ -151,11 +184,16 @@ pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) {
|
||||
if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::<bool>(dialog_focus_flag())).is_none() {
|
||||
return;
|
||||
}
|
||||
select_all(ui, id, text);
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
|
||||
/// Select all of `text` in text field `id`.
|
||||
fn select_all(ui: &Ui, id: egui::Id, text: &str) {
|
||||
let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
|
||||
st.cursor.set_char_range(Some(all));
|
||||
st.store(ui.ctx(), id);
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
|
||||
/// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked
|
||||
@@ -164,10 +202,8 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
|
||||
if !(resp.gained_focus() || resp.double_clicked()) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), resp.id) {
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
|
||||
st.cursor.set_char_range(Some(all));
|
||||
st.store(ui.ctx(), resp.id);
|
||||
if egui::TextEdit::load_state(ui.ctx(), resp.id).is_some() {
|
||||
select_all(ui, resp.id, text);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -240,23 +276,26 @@ pub fn mixed_field(
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let id = ui.id().with(id);
|
||||
let shown = value
|
||||
.map(|value| {
|
||||
let s = format!("{:.*}", decimals, value);
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
|
||||
format!("{s}{suffix}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
if resp.lost_focus() && buf != shown {
|
||||
// The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`.
|
||||
let show = |value: f64| {
|
||||
let s = format!("{:.*}", decimals, value);
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
|
||||
format!("{s}{suffix}")
|
||||
};
|
||||
// The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`.
|
||||
let read = |buf: &str| {
|
||||
let suffix = suffix.trim();
|
||||
let bare = if suffix.is_empty() { buf.clone() } else { buf.replace(suffix, "") };
|
||||
let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") };
|
||||
vectorcraft_doc::parse_number(&bare.replace(['%', '°'], ""))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let shown = value.map(show).unwrap_or_default();
|
||||
let (mut buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth).
|
||||
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| {
|
||||
let scale = 10f64.powi(decimals.min(6) as i32);
|
||||
(v * scale).round() / scale
|
||||
});
|
||||
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.filter(|&v| Some(v) != value) }
|
||||
}
|
||||
|
||||
/// Draw a paint preview (swatch chip) into `rect`.
|
||||
|
||||
+2
-1
@@ -76,7 +76,8 @@ always use the English ids and labels, so agents and scripts never see translate
|
||||
Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, complete, in the vocabulary used
|
||||
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), Czech (`cs`, every menu label) and Brazilian Portuguese
|
||||
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
|
||||
(`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
|
||||
|
||||
+1
-1
@@ -366,7 +366,7 @@ their stop opacity and stop where the shading doesn't extend. Text becomes point
|
||||
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font) —
|
||||
`textAs: "outlines"` keeps glyph outlines instead. Optional content groups (the layers of PDF and PDF-compatible `.ai`
|
||||
files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer;
|
||||
art outside them goes to a layer per page. `layers: false` gives one layer per page of only what shows:
|
||||
art outside them goes to a layer per page (except the opaque white page a `.ai` paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines","layers":false}}}
|
||||
|
||||
+7
-1
@@ -67,7 +67,13 @@ The rules are in craftrules `standards/fonts.md`, the build option in
|
||||
|
||||
- `Info.plist` is generated from `Info.plist.in` (bundle id `ai.storyteller.vectorcraft`,
|
||||
`LSMinimumSystemVersion` 11.0, the version and the build commit). The icon is
|
||||
`assets/app-icon/vectorcraft.icns`.
|
||||
`assets/app-icon/vectorcraft.icns`. `cargo xtask bundle` fills in the same template for its
|
||||
development app.
|
||||
- **Document types:** `Info.plist.in` declares every format File › Open reads, so Finder opens
|
||||
them with VectorCraft (double-click, Open With, a drop on the Dock icon). VectorCraft owns its own
|
||||
formats and is an alternate for the others. A test keeps the list in step with the engine's
|
||||
`OPEN_EXTS`. AppKit hands the files to the app as open-document events, which
|
||||
`apps/vectorcraft/src/open_documents.rs` opens like files named on the command line.
|
||||
- **Signing** goes inside-out with the hardened runtime and a secure timestamp: the executable
|
||||
first, then the bundle, with no `--deep` on the final signature. The entitlements
|
||||
(`entitlements.plist`) are deliberately empty.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Windows 7 x64 compatibility build (experimental, unsupported)
|
||||
|
||||
Windows 7 is not a supported platform. This build is experimental and community-maintained,
|
||||
and the VectorCraft maintainers have not verified it on Windows 7.
|
||||
|
||||
This separate build targets **64-bit Windows 7 SP1**. It uses OpenGL instead of wgpu,
|
||||
and omits the native AccessKit accessibility adapter. Printer discovery supports the
|
||||
PowerShell version included with Windows 7, and printer setup opens Control Panel.
|
||||
The normal desktop and web builds retain their existing defaults. A vendor graphics driver providing **OpenGL 3.3 or newer**
|
||||
is required; the Windows built-in generic display driver is insufficient. GPU selection
|
||||
in Preferences and `WGPU_POWER_PREF` do not control this OpenGL build.
|
||||
|
||||
## Build or download
|
||||
|
||||
In your GitHub fork, open **Actions → Windows 7 x64 compatibility → Run workflow** (it
|
||||
also runs weekly, never on pull requests).
|
||||
When it succeeds, download the `vectorcraft-windows7-x64-portable` artifact, extract the
|
||||
contained portable ZIP, then run `vectorcraft.exe` on Windows 7. The CLI is included.
|
||||
No MSI is provided: the existing installer remains for modern Windows.
|
||||
|
||||
To build locally, use a **Windows 10/11 build computer**, with Rust/rustup and Visual Studio
|
||||
2022 C++ build tools (x64 compiler and Windows SDK). Open an **x64 Native Tools Command
|
||||
Prompt for VS 2022**, change to your clone's repository root (the directory containing
|
||||
`Cargo.toml`), and run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File packaging/windows/windows7.ps1
|
||||
```
|
||||
|
||||
Add `-Test` to also run the app and CLI tests on the build computer.
|
||||
|
||||
Output: `dist/release/vectorcraft-windows7-x64-portable.zip`. The script installs the pinned
|
||||
nightly compiler and `rust-src`, rebuilds the standard library for
|
||||
`x86_64-win7-windows-msvc`, and links the C runtime statically. It never installs Rust on
|
||||
the Windows 7 machine. The optional `CRAFT_FONTS_DIR` setting works as in the normal build.
|
||||
Without it, install fonts for the languages you need on the destination machine.
|
||||
|
||||
The Windows 7 target matters: modern Rust's ordinary `x86_64-pc-windows-msvc` target
|
||||
requires Windows 10, including standard-library and random-number APIs. Selecting OpenGL
|
||||
alone or changing an installer version check cannot fix that.
|
||||
|
||||
References: [Rust's Windows 7 target](https://doc.rust-lang.org/rustc/platform-support/win7-windows-msvc.html)
|
||||
and [Windows baseline change](https://blog.rust-lang.org/2024/02/26/Windows-7/).
|
||||
|
||||
The packaging script checks x64 PE headers, the subsystem version, and known incompatible
|
||||
imports (including `GetSystemTimePreciseAsFileTime`) before creating the ZIP. This is a
|
||||
regression check, not an exhaustive audit of every Windows API.
|
||||
|
||||
The small vendored `windows-link` patch routes `CoTaskMemFree` to its documented
|
||||
`ole32.dll` export on the Windows 7 target; the newer binding otherwise imports
|
||||
`combase.dll`, which is absent on Windows 7. Only this script uses it: it passes
|
||||
`--config "patch.crates-io.windows-link.path='vendor/windows-link'"` to Cargo, so every other
|
||||
build, including the normal Windows one, keeps the checksum-verified crates.io crate. Cargo
|
||||
records the patch in `Cargo.lock`, so the script resolves once, fails if anything other than
|
||||
windows-link's source changed, builds with `--locked`, and then restores `Cargo.lock`.
|
||||
|
||||
## Runtime validation
|
||||
|
||||
The contributor reports that a user ran the portable x64 compatibility build on Windows 7 SP1
|
||||
with an NVIDIA graphics card. Their original build failed at startup with
|
||||
`GetSystemTimePreciseAsFileTime` missing from `kernel32.dll`; the compatibility
|
||||
build resolved that failure. The tested build was based on upstream commit
|
||||
`1f7873273b67007b0aefc63313e0d34a39f45453`. The exact GPU/driver version and completion
|
||||
of every checklist item below were not recorded, and the maintainers have not reproduced it,
|
||||
so the build remains experimental and unsupported.
|
||||
|
||||
## Runtime acceptance checklist
|
||||
|
||||
A successful build on the Windows 2022 CI runner does **not** prove Windows 7 runtime
|
||||
compatibility. Before declaring support, test on a Windows 7 SP1 x64 machine and record
|
||||
its GPU, driver version, Windows updates and any missing-DLL/entry-point error:
|
||||
|
||||
- Start `vectorcraft.exe`; verify a visible canvas, resizing, minimize/restore and exit.
|
||||
- Draw a shape, save a `.vectorcraft` document, close and reopen it.
|
||||
- Open `examples/dusk-poster.vectorcraft`; export PNG and PDF and inspect both.
|
||||
- Exercise native Open/Save dialogs, clipboard text/images and drag-and-drop.
|
||||
- Change a preference, exit and verify it survives restarting.
|
||||
- Run `vectorcraft-cli.exe --version` and a headless export from a Command Prompt in the
|
||||
extracted application folder.
|
||||
|
||||
This build is experimental until that checklist passes. New dependency versions can add
|
||||
newer Windows imports, so retain Cargo.lock and repeat runtime QA after dependency updates.
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- Template: packaging/macos/package.sh substitutes @VERSION@ (full, e.g. 0.2.0-rc.1),
|
||||
@SHORT_VERSION@ (numeric X.Y.Z) and @BUILD_SHA@. -->
|
||||
<!-- Template: packaging/macos/package.sh (release) and `cargo xtask bundle` (development)
|
||||
substitute @VERSION@ (full, e.g. 0.2.0-rc.1), @SHORT_VERSION@ (numeric X.Y.Z) and @BUILD_SHA@. -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
@@ -45,6 +45,73 @@
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © the VectorCraft authors. MIT OR Apache-2.0.</string>
|
||||
|
||||
<!-- Every format File › Open reads (OPEN_EXTS in crates/engine/src/cmd/fileio/mod.rs; a test in
|
||||
apps/vectorcraft checks this list), so Finder offers VectorCraft for them (Open With,
|
||||
double-click, Dock drop). VectorCraft owns its own formats; for the others it is an
|
||||
alternate, so it doesn't take over another app's files. The app isn't NSDocument-based (no
|
||||
NSDocumentClass): it opens these files itself (apps/vectorcraft/src/open_documents.rs). -->
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>VectorCraft Document</string>
|
||||
<key>LSItemContentTypes</key><array><string>ai.storyteller.vectorcraft.document</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Owner</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>VectorCraft Template</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>vctemplate</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Owner</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>SVG</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>svg</string><string>svgz</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>PDF</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>pdf</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>PDF-compatible .ai</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>ai</string><string>ait</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>EPS</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>eps</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>DXF</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>dxf</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>EMF and WMF</string>
|
||||
<key>CFBundleTypeExtensions</key><array><string>emf</string><string>wmf</string></array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key><string>Image</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>png</string><string>jpg</string><string>jpeg</string><string>gif</string>
|
||||
<string>webp</string><string>tif</string><string>tiff</string><string>bmp</string>
|
||||
</array>
|
||||
<key>CFBundleTypeRole</key><string>Editor</string>
|
||||
<key>LSHandlerRank</key><string>Alternate</string>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- VectorCraft's own format (.drawcraft is its name from before the rename). -->
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<# Experimental, unsupported Windows 7 SP1 x64 build (docs/windows7.md).
|
||||
Build on Windows 10/11 with the VS 2022 x64 C++ tools and Windows SDK.
|
||||
From the repository root: powershell -File packaging/windows/windows7.ps1 [-Test]
|
||||
The resulting portable ZIP targets Windows 7 SP1 x64 with an OpenGL 3.3 driver.
|
||||
The compiler runs on the build host, not on Windows 7. -Test also runs the app and CLI tests
|
||||
on the build host. #>
|
||||
param([string] $Toolchain = 'nightly-2026-10-01', [switch] $Test)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$Target = 'x86_64-win7-windows-msvc'
|
||||
function Invoke-Checked([scriptblock] $Command) {
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
# Cargo.lock without windows-link's registry source and checksum lines (and with LF line endings),
|
||||
# so the lock files with and without the patch below compare equal.
|
||||
function Get-LockWithoutWindowsLinkSource([string] $Text) {
|
||||
($Text -replace "`r`n", "`n") -replace '(?m)^(name = "windows-link"\nversion = "[^"]*"\n)source = "[^"]*"\nchecksum = "[^"]*"\n', '$1'
|
||||
}
|
||||
Push-Location $Root
|
||||
$FlagName = 'CARGO_TARGET_X86_64_WIN7_WINDOWS_MSVC_RUSTFLAGS'
|
||||
$OldFlags = [Environment]::GetEnvironmentVariable($FlagName)
|
||||
$OldWinres = $env:VECTORCRAFT_REQUIRE_WINRES
|
||||
$Lock = Join-Path $Root 'Cargo.lock'
|
||||
$LockBytes = [IO.File]::ReadAllBytes($Lock)
|
||||
try {
|
||||
Invoke-Checked { rustup toolchain install $Toolchain --profile minimal --component rust-src }
|
||||
# windows-sys imports CoTaskMemFree from combase.dll, which Windows 7 lacks. Only this build uses
|
||||
# the patched copy in vendor/windows-link (see its VECTORCRAFT-PATCH.md); every other build keeps
|
||||
# the crates.io crate. Cargo records the patch in Cargo.lock, so resolve once, check that nothing
|
||||
# but windows-link's source moved, then build --locked. The finally block restores Cargo.lock.
|
||||
$Patch = @('--config', "patch.crates-io.windows-link.path='vendor/windows-link'")
|
||||
Invoke-Checked { cargo "+$Toolchain" metadata --format-version 1 @Patch | Out-Null }
|
||||
$Before = Get-LockWithoutWindowsLinkSource ([Text.Encoding]::UTF8.GetString($LockBytes))
|
||||
$After = Get-LockWithoutWindowsLinkSource ([IO.File]::ReadAllText($Lock))
|
||||
if ($Before -cne $After) { throw 'Cargo.lock is out of date: applying the windows-link patch changed other entries' }
|
||||
$CargoArgs = @('--locked') + $Patch + @('--target', $Target, '--no-default-features', '--features', 'vectorcraft/windows7')
|
||||
$Tree = cargo "+$Toolchain" tree @CargoArgs -p vectorcraft -e normal --prefix none
|
||||
if ($LASTEXITCODE -ne 0) { throw 'dependency inspection failed' }
|
||||
if ($Tree -match '^(wgpu|wgpu-core|wgpu-hal|accesskit_windows) v') { throw 'unsupported Windows backend in compatibility build' }
|
||||
if (-not ($Tree -match '^windows-link v[^ ]+ \(.*vendor[\\/]windows-link\)')) { throw 'the patched windows-link is not in use' }
|
||||
# The ordinary pc-windows-msvc standard library requires Windows 10. Rebuild std for win7.
|
||||
[Environment]::SetEnvironmentVariable($FlagName, '-C target-feature=+crt-static')
|
||||
$env:VECTORCRAFT_REQUIRE_WINRES = '1'
|
||||
$CargoArgs += @('-p', 'vectorcraft', '-p', 'vectorcraft-cli')
|
||||
Invoke-Checked { cargo "+$Toolchain" build -Z build-std=std,panic_unwind @CargoArgs --release }
|
||||
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { 'target' }
|
||||
$Bin = Join-Path $TargetDir "$Target\release"
|
||||
foreach ($Name in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
|
||||
$Exe = Join-Path $Bin $Name
|
||||
$Bytes = [IO.File]::ReadAllBytes($Exe)
|
||||
$Pe = [BitConverter]::ToInt32($Bytes, 0x3c)
|
||||
if ([BitConverter]::ToUInt16($Bytes, $Pe + 4) -ne 0x8664) { throw "$Name is not x64" }
|
||||
$Major = [BitConverter]::ToUInt16($Bytes, $Pe + 0x48)
|
||||
$Minor = [BitConverter]::ToUInt16($Bytes, $Pe + 0x4a)
|
||||
if ($Major -gt 6 -or ($Major -eq 6 -and $Minor -gt 1)) { throw "$Name requires subsystem $Major.$Minor" }
|
||||
$Imports = & dumpbin /nologo /imports $Exe
|
||||
if ($LASTEXITCODE -ne 0) { throw "Could not inspect $Name imports" }
|
||||
# Regression gate for known loader failures; actual Win7 runtime QA remains necessary.
|
||||
if ($Imports -match '(?i)\b(GetSystemTimePreciseAsFileTime|ProcessPrng|WaitOnAddress|WakeByAddressSingle|WakeByAddressAll)\b|combase\.dll|bcryptprimitives\.dll|d3d12\.dll|api-ms-win-core-path-') {
|
||||
throw "$Name directly imports a known post-Windows-7 API"
|
||||
}
|
||||
}
|
||||
Invoke-Checked { & (Join-Path $Bin 'vectorcraft-cli.exe') --version }
|
||||
$Stage = Join-Path $TargetDir ('windows7-portable-' + [Guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Force $Stage | Out-Null
|
||||
Copy-Item (Join-Path $Bin 'vectorcraft.exe'), (Join-Path $Bin 'vectorcraft-cli.exe') $Stage
|
||||
Copy-Item LICENSE-MIT, LICENSE-APACHE, NOTICE $Stage
|
||||
Copy-Item vendor/windows-link/license-mit (Join-Path $Stage 'LICENSE-windows-link-MIT')
|
||||
Copy-Item vendor/windows-link/license-apache-2.0 (Join-Path $Stage 'LICENSE-windows-link-Apache-2.0')
|
||||
Copy-Item docs/windows7.md (Join-Path $Stage 'WINDOWS7.md')
|
||||
if ($env:CRAFT_FONTS_DIR) {
|
||||
foreach ($ofl in Get-ChildItem (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt')) {
|
||||
Copy-Item $ofl.FullName (Join-Path $Stage "OFL-$($ofl.Directory.Name).txt")
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force dist/release | Out-Null
|
||||
$Zip = Join-Path $Root 'dist/release/vectorcraft-windows7-x64-portable.zip'
|
||||
Compress-Archive -Path (Join-Path $Stage '*') -DestinationPath $Zip -Force
|
||||
Remove-Item -Recurse -Force $Stage
|
||||
Get-Item $Zip
|
||||
if ($Test) {
|
||||
# Exercises the Windows code paths on the build host only; it doesn't prove Windows 7 runtime support.
|
||||
Invoke-Checked { cargo "+$Toolchain" test -Z build-std=std,panic_unwind,test @CargoArgs }
|
||||
}
|
||||
} finally {
|
||||
[IO.File]::WriteAllBytes($Lock, $LockBytes)
|
||||
[Environment]::SetEnvironmentVariable($FlagName, $OldFlags)
|
||||
$env:VECTORCRAFT_REQUIRE_WINRES = $OldWinres
|
||||
Pop-Location
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2021"
|
||||
rust-version = "1.71"
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "Linking for Windows"
|
||||
readme = "readme.md"
|
||||
categories = ["os::windows-apis"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/microsoft/windows-rs"
|
||||
|
||||
[lib]
|
||||
name = "windows_link"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints.rust]
|
||||
missing_unsafe_on_extern = "warn"
|
||||
|
||||
[lints.rust.unexpected_cfgs]
|
||||
level = "warn"
|
||||
priority = 0
|
||||
check-cfg = ["cfg(windows_raw_dylib, windows_slim_errors)"]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Windows 7 x64 import compatibility
|
||||
|
||||
Upstream: Microsoft windows-link 0.2.1, from crates.io, MIT OR Apache-2.0.
|
||||
Source: https://crates.io/crates/windows-link/0.2.1
|
||||
The original license texts are retained here.
|
||||
|
||||
Only the non-x86 win7-target macro differs: CoTaskMemFree is imported from
|
||||
ole32.dll rather than combase.dll. windows-sys 0.61.2 otherwise causes native
|
||||
file dialogs to introduce a DLL absent on Windows 7. Ordinary targets retain
|
||||
upstream behavior. This is a binding correction, not a replacement allocator.
|
||||
|
||||
Microsoft documents Ole32.dll and Windows 2000 as the API's minimum:
|
||||
https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-cotaskmemfree
|
||||
|
||||
Only the experimental Windows 7 build uses this copy: packaging/windows/windows7.ps1
|
||||
passes `--config "patch.crates-io.windows-link.path='vendor/windows-link'"`. The
|
||||
workspace has no [patch], so every other build uses the crates.io crate, verified
|
||||
by the checksum in Cargo.lock.
|
||||
|
||||
The Windows 7 packaging check rejects combase.dll to guard this fix. Remove this
|
||||
patch when upstream bindings provide the same compatibility and recheck imports.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
## Linking for Windows
|
||||
|
||||
The [windows-link](https://crates.io/crates/windows-link) crate provides the `link` macro that simplifies linking. The `link` macro is much the same as the one provided by [windows-targets](https://crates.io/crates/windows-targets) but uses `raw-dylib` and thus does not require import lib files.
|
||||
|
||||
* [Getting started](https://kennykerr.ca/rust-getting-started/)
|
||||
* [Samples](https://github.com/microsoft/windows-rs/tree/master/crates/samples)
|
||||
* [Releases](https://github.com/microsoft/windows-rs/releases)
|
||||
|
||||
Start by adding the following to your Cargo.toml file:
|
||||
|
||||
```toml
|
||||
[dependencies.windows-link]
|
||||
version = "0.2"
|
||||
```
|
||||
|
||||
Use the `link` macro to define the external functions you wish to call:
|
||||
|
||||
```rust
|
||||
windows_link::link!("kernel32.dll" "system" fn SetLastError(code: u32));
|
||||
windows_link::link!("kernel32.dll" "system" fn GetLastError() -> u32);
|
||||
|
||||
unsafe {
|
||||
SetLastError(1234);
|
||||
assert_eq!(GetLastError(), 1234);
|
||||
}
|
||||
```
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
#![doc = include_str!("../readme.md")]
|
||||
#![no_std]
|
||||
|
||||
/// Defines an external function to import.
|
||||
#[cfg(all(windows, target_arch = "x86"))]
|
||||
#[macro_export]
|
||||
macro_rules! link {
|
||||
($library:literal $abi:literal $($link_name:literal)? fn $($function:tt)*) => (
|
||||
#[link(name = $library, kind = "raw-dylib", modifiers = "+verbatim", import_name_type = "undecorated")]
|
||||
extern $abi {
|
||||
$(#[link_name=$link_name])?
|
||||
pub fn $($function)*;
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Defines an external function to import.
|
||||
#[cfg(all(windows, not(target_arch = "x86"), not(target_vendor = "win7")))]
|
||||
#[macro_export]
|
||||
macro_rules! link {
|
||||
($library:literal $abi:literal $($link_name:literal)? fn $($function:tt)*) => (
|
||||
#[link(name = $library, kind = "raw-dylib", modifiers = "+verbatim")]
|
||||
extern $abi {
|
||||
$(#[link_name=$link_name])?
|
||||
pub fn $($function)*;
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Defines an external function to import.
|
||||
#[cfg(not(windows))]
|
||||
#[macro_export]
|
||||
macro_rules! link {
|
||||
($library:literal $abi:literal $($link_name:literal)? fn $($function:tt)*) => (
|
||||
extern $abi {
|
||||
pub fn $($function)*;
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// VectorCraft compatibility patch: windows-sys 0.61.2 binds CoTaskMemFree to
|
||||
// combase.dll (Windows 8+). The same API is exported by ole32.dll on Windows 7.
|
||||
// Redirect only this known API; leave every other binding unchanged.
|
||||
#[cfg(all(windows, not(target_arch = "x86"), target_vendor = "win7"))]
|
||||
#[macro_export]
|
||||
macro_rules! link {
|
||||
("combase.dll" $abi:literal fn CoTaskMemFree $($signature:tt)*) => (
|
||||
$crate::link!("ole32.dll" $abi fn CoTaskMemFree $($signature)*);
|
||||
);
|
||||
($library:literal $abi:literal $($link_name:literal)? fn $($function:tt)*) => (
|
||||
#[link(name = $library, kind = "raw-dylib", modifiers = "+verbatim")]
|
||||
extern $abi {
|
||||
$(#[link_name=$link_name])?
|
||||
pub fn $($function)*;
|
||||
}
|
||||
)
|
||||
}
|
||||
+16
-29
@@ -21,35 +21,20 @@ pub fn run(root: &Path) -> Result<(), String> {
|
||||
std::fs::copy(target.join("release/vectorcraft"), app.join("MacOS/VectorCraft")).map_err(|e| format!("copy app: {e}"))?;
|
||||
std::fs::copy(target.join("release/vectorcraft-cli"), app.join("MacOS/vectorcraft-cli")).map_err(|e| format!("copy cli: {e}"))?;
|
||||
std::fs::copy(root.join("assets/app-icon/vectorcraft.icns"), app.join("Resources/VectorCraft.icns")).map_err(|e| format!("copy icon: {e}"))?;
|
||||
std::fs::write(app.join("Info.plist"), info_plist(env!("CARGO_PKG_VERSION"))).map_err(|e| e.to_string())?;
|
||||
let sha = std::env::var("VECTORCRAFT_BUILD_SHA").unwrap_or_else(|_| "unknown".into());
|
||||
std::fs::write(app.join("Info.plist"), info_plist(env!("CARGO_PKG_VERSION"), &sha)).map_err(|e| e.to_string())?;
|
||||
println!("built {}", root.join("dist/VectorCraft.app").display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The development bundle's `Info.plist`.
|
||||
fn info_plist(version: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleName</key><string>VectorCraft</string>
|
||||
<key>CFBundleDisplayName</key><string>VectorCraft</string>
|
||||
<key>CFBundleIdentifier</key><string>ai.storyteller.vectorcraft</string>
|
||||
<key>CFBundleExecutable</key><string>VectorCraft</string>
|
||||
<key>CFBundleIconFile</key><string>VectorCraft</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>{version}</string>
|
||||
<key>CFBundleVersion</key><string>{version}</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
||||
<key>CFBundleDocumentTypes</key><array>
|
||||
<dict><key>CFBundleTypeName</key><string>VectorCraft Document</string><key>CFBundleTypeExtensions</key><array><string>vectorcraft</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
|
||||
<dict><key>CFBundleTypeName</key><string>SVG</string><key>CFBundleTypeExtensions</key><array><string>svg</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
|
||||
<dict><key>CFBundleTypeName</key><string>PDF Document</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
|
||||
</array>
|
||||
</dict></plist>
|
||||
"#
|
||||
)
|
||||
/// The release `Info.plist` template, which `packaging/macos/package.sh` fills in the same way: the
|
||||
/// development bundle declares the same document types (Finder opens them with the app).
|
||||
const INFO_PLIST: &str = include_str!("../../packaging/macos/Info.plist.in");
|
||||
|
||||
/// The bundle's `Info.plist`: [`INFO_PLIST`] for `version` and the build commit `sha`.
|
||||
fn info_plist(version: &str, sha: &str) -> String {
|
||||
let short = version.split('-').next().unwrap_or(version);
|
||||
INFO_PLIST.replace("@VERSION@", version).replace("@SHORT_VERSION@", short).replace("@BUILD_SHA@", sha)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -88,9 +73,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_bundle_plist_names_types_neutrally() {
|
||||
let p = info_plist("1.2.3");
|
||||
assert!(p.contains("<key>CFBundleShortVersionString</key><string>1.2.3</string>"));
|
||||
assert!(p.contains("<string>VectorCraft Document</string>") && p.contains("<string>PDF Document</string>"));
|
||||
fn dev_bundle_plist_is_the_release_one() {
|
||||
let p = info_plist("1.2.3-rc.1", "abc123");
|
||||
assert!(p.contains("<string>1.2.3</string>") && p.contains("<string>1.2.3-rc.1</string>"), "{p}");
|
||||
assert!(p.contains("<string>abc123</string>"));
|
||||
assert!(!p.contains('@'), "every placeholder filled");
|
||||
assert!(p.contains("<key>CFBundleDocumentTypes</key>") && p.contains("<string>pdf</string>"), "Finder opens files with it");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user