Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1012361773 | ||
|
|
f2a2671933 | ||
|
|
aafd565b99 | ||
|
|
571881a830 | ||
|
|
347369187f | ||
|
|
d899a63286 | ||
|
|
f95b032c82 | ||
|
|
ce9f3aa7c6 | ||
|
|
ef3fa1689f | ||
|
|
68352dab02 | ||
|
|
51a9b899c1 | ||
|
|
81e7b915af | ||
|
|
fcc9414480 | ||
|
|
377204f90e | ||
|
|
0306d51592 | ||
|
|
ebc6d1af7b | ||
|
|
cdb734aede | ||
|
|
2f743f155f | ||
|
|
4bc0837867 | ||
|
|
2ba0e1f6e5 | ||
|
|
80eb5844c7 | ||
|
|
77159bb08e | ||
|
|
047a3bfc6f | ||
|
|
ab6a668aa9 | ||
|
|
8dc2fd077f | ||
|
|
66fa0a998a | ||
|
|
b045390311 | ||
|
|
caba979b01 | ||
|
|
32f73e9cce | ||
|
|
0aba6ef6e4 | ||
|
|
980372f6d0 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
@@ -11,6 +12,8 @@ on:
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -55,3 +58,47 @@ jobs:
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
@@ -232,6 +232,56 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -277,7 +327,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
Generated
+2
@@ -4220,6 +4220,8 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
|
||||
</p>
|
||||
|
||||
+9
-7
@@ -35,7 +35,7 @@ since 2026-10-01.
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), scripting |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
@@ -58,8 +58,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics,
|
||||
mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 35–48 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
@@ -95,6 +95,7 @@ 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.
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- 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, 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.
|
||||
- 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.
|
||||
@@ -112,7 +113,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.
|
||||
- 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), 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.
|
||||
- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); 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.
|
||||
- **Japanese and Czech interface, vertical type:** VectorCraft ▸ Language switches the menus between English, Japanese and Czech (untranslated labels stay English; Czech covers every menu label, tested) and persists; Japanese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. 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, upright glyphs centred on the column, point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; 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, 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):**
|
||||
@@ -126,6 +127,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **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.
|
||||
- **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, 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).
|
||||
@@ -145,7 +147,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
@@ -181,7 +183,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
|
||||
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
|
||||
| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
@@ -191,7 +193,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
|
||||
@@ -14,11 +14,41 @@
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `println!` / `print!` that end the program quietly when stdout is closed
|
||||
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
|
||||
/// Broken pipe (os error 32)".
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod perf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
||||
/// ripgrep does; any other write error is reported.
|
||||
fn stdout_failed(e: std::io::Error) -> ! {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
@@ -68,11 +98,11 @@ fn main() -> ExitCode {
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{}", usage());
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
@@ -120,7 +150,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
fn commands() -> Result<(), String> {
|
||||
let mut h = Headless::new();
|
||||
let v = h.call("engine.commands", json!({}))?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,7 +176,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +192,7 @@ fn info(args: &[String]) -> Result<(), String> {
|
||||
let artboards: Vec<Value> =
|
||||
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
|
||||
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +282,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
* vectorcraft_geom::Affine::scale(z)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
let opts = vectorcraft_render::RenderOptions::default();
|
||||
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
for threads in [vectorcraft_render::default_threads(), 0] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = threads;
|
||||
@@ -261,7 +291,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
for _ in 0..iters {
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
outln!(
|
||||
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
|
||||
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n)?;
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
outln!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
|
||||
@@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[2]["result"]["isError"], false);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
/// stdout: Broken pipe (os error 32)" and exit status 101.
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 16];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ 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"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -42,12 +42,27 @@ impl eframe::App for App {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.0.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn discard_marked_text() {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new()
|
||||
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
|
||||
{
|
||||
ic.discardMarkedText();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
|
||||
@@ -116,10 +116,21 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
fn typing_in_progress(s: &Session) -> bool {
|
||||
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
|
||||
// the document.
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
s.set_tool_option("endTyping", &Value::Bool(true));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
|
||||
let gid = s.edit(&label, |d, sel| {
|
||||
let gid = super::object::group_nodes(d, &loose)?;
|
||||
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
|
||||
}
|
||||
|
||||
/// The selected objects a Pathfinder effect should group before it applies: it combines the
|
||||
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
|
||||
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
|
||||
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let roots = super::appearance::subject_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
|
||||
Ok((roots.len() >= 2 && !grouped).then_some(roots))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let catalog: Vec<Value> = effects::effect_catalog()
|
||||
.into_iter()
|
||||
@@ -507,6 +529,34 @@ mod tests {
|
||||
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_loose_objects_groups_them_first() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert_eq!(r["grouped"], true);
|
||||
let g = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
|
||||
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
// Live: one united shape, so expanding leaves a single path.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 1);
|
||||
// Grouping and applying are one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(g).is_none());
|
||||
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
|
||||
// A selection holding a group applies to each object as before; so does a single object.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert!(r.get("grouped").is_none());
|
||||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name",
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), as the font menus list them (without the system's hidden families, whose names start with a dot; they still resolve by name); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Refresh Font List",
|
||||
[],
|
||||
None,
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
@@ -96,12 +96,12 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let families = db.families();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
e.1.push(id);
|
||||
}
|
||||
@@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style), (runs, objects))| {
|
||||
let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family));
|
||||
let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style));
|
||||
json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing })
|
||||
.map(|((family, style), (runs, objects, chars))| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
"missingGlyphs": lacking.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(list))
|
||||
@@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ff, fs) = font_param(p, "from", C)?;
|
||||
let (tf, ts) = font_param(p, "to", C)?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) {
|
||||
return Err(bad(C, format!("font family `{tf}` is not available")));
|
||||
}
|
||||
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
|
||||
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
|
||||
};
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let matches = |st: &vectorcraft_doc::CharStyle| {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
@@ -196,7 +208,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.family_list() })),
|
||||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +241,7 @@ fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
||||
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -261,6 +273,39 @@ mod tests {
|
||||
mod open_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_says_how_each_font_resolved() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
for (text, font, style) in
|
||||
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black"), ("Gone", "No Such Font Family", "Regular")]
|
||||
{
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
|
||||
}
|
||||
let list = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let rows: Vec<(String, String, String, String)> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
(
|
||||
f["style"].as_str().unwrap().into(),
|
||||
f["status"].as_str().unwrap().into(),
|
||||
f["resolved"]["family"].as_str().unwrap().into(),
|
||||
f["resolved"]["style"].as_str().unwrap().into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap();
|
||||
assert_eq!(row("Semibold").1, "exact");
|
||||
assert_eq!(row("Black").1, "substitute", "{rows:?}");
|
||||
assert_eq!(row("Black").2, "Source Sans 3");
|
||||
assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY));
|
||||
s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap();
|
||||
let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap();
|
||||
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_documents_have_exact_text_bounds() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let gid = s.edit("Group", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
let gid = group_nodes(d, &ids)?;
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": gid.0 }))
|
||||
}
|
||||
|
||||
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
|
||||
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
|
||||
@@ -315,6 +315,23 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap();
|
||||
let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let wide = bounds(&s);
|
||||
assert!(wide.width() > wide.height());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.orientation.vertical", &json!({})).unwrap();
|
||||
let tall = bounds(&s);
|
||||
assert!(tall.height() > tall.width(), "{tall:?}");
|
||||
assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bounds(&s), wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_options_columns_change_layout() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -14,14 +14,14 @@ fn names(v: &Value) -> Vec<String> {
|
||||
fn the_font_list_names_every_available_family_with_its_styles() {
|
||||
let mut s = Session::new();
|
||||
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
|
||||
assert_eq!(fams, FontDb::global().families());
|
||||
assert_eq!(fams, *FontDb::global().menu_family_list());
|
||||
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
|
||||
assert!(fams.iter().any(|x| x == f), "{f}");
|
||||
}
|
||||
// Every installed family, though nothing asked for system fonts.
|
||||
let installed = FontDb::with_font_dirs(system_font_dirs());
|
||||
installed.load_system_fonts();
|
||||
let missing: Vec<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
|
||||
let missing: Vec<String> = installed.menu_family_list().iter().filter(|f| !fams.contains(f)).cloned().collect();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap();
|
||||
assert_eq!(r["family"], "Source Sans 3");
|
||||
@@ -38,7 +38,7 @@ fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert_eq!(r["families"], json!(FontDb::global().families().len()));
|
||||
assert_eq!(r["families"], json!(FontDb::global().menu_family_list().len()));
|
||||
assert!(r["faces"].is_u64());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.revision > revision, "the canvas redraws");
|
||||
|
||||
@@ -157,6 +157,98 @@ fn undo_during_a_drag_takes_back_only_the_drag() {
|
||||
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_composition_shows_in_place_and_commits_as_one_step() {
|
||||
// Romaji → kana → conversion → commit, as the macOS Japanese IME sends it.
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let created = undo_labels(&s).len();
|
||||
s.tool_text("曲:", v).unwrap();
|
||||
for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] {
|
||||
s.tool_preedit(t, Some(r), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), format!("曲:{t}"), "marked text lays out in place");
|
||||
assert!(s.tool_composing());
|
||||
}
|
||||
// Conversion: the active clause is the whole word.
|
||||
s.tool_preedit("雅楽", Some(0..2), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
|
||||
assert_eq!(s.tool_options()["composing"], json!(true));
|
||||
// macOS clears the marked text, then commits.
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
s.tool_text("雅楽", v).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
|
||||
assert_eq!(s.tool_options()["caret"], json!("曲:雅楽".len()));
|
||||
// Typing goes on after the commit, in the same session.
|
||||
s.tool_text("。", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽。");
|
||||
assert_eq!(undo_labels(&s).len(), created + 1, "one undo step for the session");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = text(&mut s, "雅楽");
|
||||
s.select_tool("type", v).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let (a, b) = vectorcraft_text::caret_position(&lay, "雅楽".len());
|
||||
let p = t.xf * a.midpoint(b);
|
||||
click(&mut s, p.x - 0.1, p.y);
|
||||
assert!(s.tool_wants_text());
|
||||
s.set_tool_option("select", &json!({"start": 6, "end": 6}));
|
||||
let before = undo_labels(&s).len();
|
||||
s.tool_preedit("えんそう", Some(4..4), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽えんそう");
|
||||
// Escape in the IME: the marked text goes, nothing else changes.
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert!(!s.in_interaction(), "a cancelled composition leaves no typing session");
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽");
|
||||
assert_eq!(undo_labels(&s).len(), before);
|
||||
// A stray clear (no composition) never deletes the selection.
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽");
|
||||
// A composition over a selection replaces it.
|
||||
s.tool_preedit("が", Some(1..1), v).unwrap();
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
s.tool_text("我", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "我楽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_away_keeps_marked_text_as_typed_and_undo_mid_composition_is_safe() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_preedit("しょうこ", Some(4..4), v).unwrap();
|
||||
// Keys belong to the IME while it composes.
|
||||
key(&mut s, ToolKey::Backspace, Mods::default());
|
||||
assert_eq!(obj(&s, id).plain_text(), "しょうこ");
|
||||
click(&mut s, 600.0, 500.0);
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id).plain_text(), "しょうこ", "the marked text stays, committed");
|
||||
// The UI holds Undo back while composing; if it comes anyway, the session is committed first.
|
||||
click(&mut s, 50.0, 50.0);
|
||||
let id2 = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_preedit("ひちりき", Some(4..4), v).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id2).plain_text(), "");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id2).plain_text(), "ひちりき");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_selection_replace_and_delete() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -214,6 +214,24 @@ impl Session {
|
||||
self.tool.wants_text()
|
||||
}
|
||||
|
||||
/// IME marked text for the active tool (see `Tool::ime_preedit`; `active_chars` counts
|
||||
/// characters).
|
||||
pub fn tool_preedit(&mut self, text: &str, active_chars: Option<std::ops::Range<usize>>, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.ime_preedit(cx, text, active_chars));
|
||||
self.take_tool_panic()?;
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Is the active tool showing uncommitted IME text?
|
||||
pub fn tool_composing(&self) -> bool {
|
||||
self.tool.composing()
|
||||
}
|
||||
|
||||
/// The caret line (document space) the IME candidate window follows.
|
||||
pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(Point, Point)> {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_caret(cx))
|
||||
}
|
||||
|
||||
pub fn tool_busy(&self) -> bool {
|
||||
self.tool.busy()
|
||||
}
|
||||
|
||||
@@ -206,6 +206,10 @@ impl Families {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
|
||||
_ => name,
|
||||
};
|
||||
// An installed face of that exact PostScript name: its own family and style.
|
||||
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
|
||||
return (family, style, true);
|
||||
}
|
||||
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
|
||||
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
|
||||
let style = match spaced(strip_ps(style)).as_str() {
|
||||
|
||||
@@ -1488,8 +1488,14 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
let mut runs = vec![BezPath::new(); t.runs.len()];
|
||||
let mut all = BezPath::new();
|
||||
// Per run: is its family missing, and the face its glyphs should come from.
|
||||
let faces: Vec<(bool, Option<u32>)> =
|
||||
t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect();
|
||||
let faces: Vec<(bool, Option<u32>)> = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
|
||||
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
|
||||
for g in &layout.glyphs {
|
||||
if let Some(r) = runs.get_mut(g.run) {
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::CharStyle;
|
||||
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OtFeatures {
|
||||
/// Vertical glyph alternates (vert/vrt2), set by the writing direction.
|
||||
/// Vertical glyph alternates (`vert`), set by the writing direction.
|
||||
pub vertical: bool,
|
||||
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
|
||||
pub ligatures: bool,
|
||||
@@ -142,8 +142,9 @@ impl OtFeatures {
|
||||
}
|
||||
}
|
||||
if self.vertical {
|
||||
// Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin
|
||||
// and digits itself (they would lie upside down, and tate-chu-yoko break).
|
||||
v.push(f(b"vert", true));
|
||||
v.push(f(b"vrt2", true));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
+370
-12
@@ -35,6 +35,8 @@ enum FontBytes {
|
||||
|
||||
/// One loaded font face.
|
||||
pub struct FontFace {
|
||||
/// Every name the face answers to (normalized), in every language its name table has.
|
||||
keys: FaceKeys,
|
||||
id: u32,
|
||||
/// Typographic family name (e.g. "Source Sans 3").
|
||||
pub family: String,
|
||||
@@ -153,20 +155,28 @@ struct CatalogFamily {
|
||||
/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts.
|
||||
type Catalog = HashMap<String, CatalogFamily>;
|
||||
|
||||
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
||||
type PostScriptNames = HashMap<String, (String, String)>;
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
catalog: RwLock<Catalog>,
|
||||
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
|
||||
postscript: RwLock<PostScriptNames>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<PathBuf>,
|
||||
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
|
||||
family_cache: Mutex<Option<Arc<[String]>>>,
|
||||
menu_cache: Mutex<Option<MenuFamilies>>,
|
||||
generation: AtomicU64,
|
||||
/// System fallback state: characters no system font covers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -217,6 +227,116 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// Every localized string of `id`, with its language.
|
||||
fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, String)> {
|
||||
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Every localized string of `id`.
|
||||
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
|
||||
localized(font, id).into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
/// The names a face answers to besides its (English) family and style, normalized ([`norm`]):
|
||||
/// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise
|
||||
/// (`ミディアム`), its legacy family names (`Hiragino Sans W3`) with their legacy styles, paired per
|
||||
/// language record (the Mac and Windows records of a face differ), and its PostScript names
|
||||
/// (`HiraginoSans-W3`, as PDF and `.ai` files name fonts).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct FaceKeys {
|
||||
families: Vec<String>,
|
||||
styles: Vec<String>,
|
||||
legacy: Vec<(String, String)>,
|
||||
postscript: Vec<String>,
|
||||
}
|
||||
|
||||
impl FaceKeys {
|
||||
fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self {
|
||||
let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME));
|
||||
let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME));
|
||||
// Without typographic names, the legacy ones are the family's.
|
||||
let family_names: Vec<&str> = if typo_family.is_empty() {
|
||||
legacy_family.iter().map(|(_, f)| f.as_str()).collect()
|
||||
} else {
|
||||
typo_family.iter().map(String::as_str).collect()
|
||||
};
|
||||
let mut families: Vec<String> = std::iter::once(family).chain(family_names).map(norm).collect();
|
||||
// A legacy style only names the face beside its legacy family when the font has a
|
||||
// typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family.
|
||||
let style_names: Vec<&str> = if typo_style.is_empty() {
|
||||
legacy_style.iter().map(|(_, s)| s.as_str()).collect()
|
||||
} else {
|
||||
typo_style.iter().map(String::as_str).collect()
|
||||
};
|
||||
let mut styles: Vec<String> = std::iter::once(style).chain(style_names).map(norm).collect();
|
||||
let mut legacy: Vec<(String, String)> = if typo_family.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
legacy_family
|
||||
.iter()
|
||||
.flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s))))
|
||||
.filter(|(f, s)| !f.is_empty() && !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
let mut postscript: Vec<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
|
||||
for v in [&mut families, &mut styles, &mut postscript] {
|
||||
v.retain(|k| !k.is_empty());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
}
|
||||
legacy.sort();
|
||||
legacy.dedup();
|
||||
FaceKeys { families, styles, legacy, postscript }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a name other than a family's own one stands for ([`FontDb::canonical`]).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Alias {
|
||||
/// The family's own name.
|
||||
family: String,
|
||||
/// The face the name names, for legacy family names and PostScript names.
|
||||
style: Option<String>,
|
||||
/// A legacy family name's legacy style (`Regular` beside `Hiragino Sans W3`).
|
||||
paired: Option<String>,
|
||||
}
|
||||
|
||||
impl Alias {
|
||||
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
|
||||
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
|
||||
let mut v: Vec<(String, Alias)> =
|
||||
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
|
||||
v.extend(
|
||||
keys.legacy
|
||||
.iter()
|
||||
.map(|(f, s)| (f.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: Some(s.clone()) })),
|
||||
);
|
||||
v.extend(keys.postscript.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: None })));
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// How a requested family and style resolved ([`FontDb::resolve`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontMatch {
|
||||
/// The face of that family and style (by any of its names, in any language).
|
||||
Exact,
|
||||
/// The family is there but not the style: its closest style stands in.
|
||||
Style,
|
||||
/// The family is unknown: the fallback family stands in.
|
||||
Missing,
|
||||
}
|
||||
|
||||
impl FontMatch {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
FontMatch::Exact => "exact",
|
||||
FontMatch::Style => "substitute",
|
||||
FontMatch::Missing => "missing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's (family, style) names.
|
||||
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
|
||||
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
|
||||
@@ -239,10 +359,11 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// (family, style) of every face in the font file at `path`, reading only its table directories
|
||||
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long.
|
||||
/// (family, style, PostScript name) of every face in the font file at `path`, reading only its
|
||||
/// table directories and `name` tables: a scan opens hundreds of font files, many of them
|
||||
/// megabytes long.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_face_names(path: &Path) -> Vec<(String, String)> {
|
||||
fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
/// Caps on what a (possibly damaged) file can make the scan read.
|
||||
const MAX_FACES: u32 = 256;
|
||||
@@ -284,7 +405,10 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
|
||||
font.extend_from_slice(&u32::to_be_bytes(v));
|
||||
}
|
||||
font.extend_from_slice(&table);
|
||||
face_names(&skrifa::FontRef::new(&font).ok()?)
|
||||
let f = skrifa::FontRef::new(&font).ok()?;
|
||||
let (family, style) = face_names(&f)?;
|
||||
let keys = FaceKeys::of(&f, &family, &style);
|
||||
Some((family, style, keys))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -301,6 +425,13 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join("Library/Fonts"));
|
||||
}
|
||||
// Fonts macOS downloads on demand (Yu Gothic, Yu Mincho, Tsukushi Maru Gothic…): one
|
||||
// asset folder per font, under a catalog folder whose number changes with macOS.
|
||||
for e in std::fs::read_dir("/System/Library/AssetsV2").into_iter().flatten().flatten() {
|
||||
if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
|
||||
dirs.push(e.path());
|
||||
}
|
||||
}
|
||||
} else if cfg!(windows) {
|
||||
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
|
||||
dirs.push(root.join("Fonts"));
|
||||
@@ -326,7 +457,9 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path:
|
||||
let m = f.metrics(Size::unscaled(), LocationRef::default());
|
||||
let a = f.attributes();
|
||||
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
|
||||
let keys = FaceKeys::of(&f, &family, &style);
|
||||
Some(FontFace {
|
||||
keys,
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
@@ -379,6 +512,9 @@ fn style_italic(style: &str) -> bool {
|
||||
s.contains("italic") || s.contains("oblique") || s == "it"
|
||||
}
|
||||
|
||||
/// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from.
|
||||
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
|
||||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
|
||||
@@ -397,10 +533,13 @@ impl FontDb {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
catalog: RwLock::new(Catalog::new()),
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_dirs,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
family_cache: Mutex::new(None),
|
||||
menu_cache: Mutex::new(None),
|
||||
generation: AtomicU64::new(0),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
|
||||
@@ -468,8 +607,27 @@ impl FontDb {
|
||||
list
|
||||
}
|
||||
|
||||
/// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden
|
||||
/// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its
|
||||
/// own interface, and Mac apps don't list them). Those still resolve by name, for documents and
|
||||
/// fallbacks that name them. Cheap to call every frame.
|
||||
pub fn menu_family_list(&self) -> Arc<[String]> {
|
||||
let all = self.family_list();
|
||||
let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((from, list)) = cache.as_ref()
|
||||
&& Arc::ptr_eq(from, &all)
|
||||
{
|
||||
return list.clone();
|
||||
}
|
||||
let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect();
|
||||
*cache = Some((all, list.clone()));
|
||||
list
|
||||
}
|
||||
|
||||
/// Style names available for `family`: upright styles by weight, then italics.
|
||||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
let (family, _) = self.canonical(family, "");
|
||||
let family = family.as_str();
|
||||
let mut v: Vec<(bool, f32, String)> =
|
||||
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
|
||||
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
||||
@@ -543,6 +701,8 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
let mut catalog = Catalog::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript = PostScriptNames::new();
|
||||
let mut n = 0;
|
||||
let mut stack = self.font_dirs.clone();
|
||||
// Each folder once, however links lead back to it.
|
||||
@@ -562,7 +722,14 @@ impl FontDb {
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for (family, style) in file_face_names(&p) {
|
||||
for (family, style, keys) in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
|
||||
for ps in &keys.postscript {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
@@ -574,10 +741,85 @@ impl FontDb {
|
||||
}
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
|
||||
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
|
||||
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
|
||||
self.changed();
|
||||
n
|
||||
}
|
||||
|
||||
/// The family's own name and the style for a name given in a document: any of a family's
|
||||
/// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its
|
||||
/// legacy style names the W6 face) or a PostScript name (`HiraginoSans-W6`, as PDF and `.ai`
|
||||
/// files name fonts). Case and anything but letters and digits are ignored. A name nothing
|
||||
/// answers to comes back as it is.
|
||||
pub fn canonical(&self, family: &str, style: &str) -> (String, String) {
|
||||
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
|
||||
return (family.to_string(), style.to_string());
|
||||
}
|
||||
let key = norm(family);
|
||||
let mut found: Vec<Alias> =
|
||||
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
|
||||
if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
found.extend(v.iter().cloned());
|
||||
}
|
||||
let ns = norm(style);
|
||||
// A family name keeps the style asked for; a legacy pair names its face; a PostScript name
|
||||
// its face whatever style is asked for.
|
||||
if let Some(a) = found.iter().find(|a| a.style.is_none()) {
|
||||
return (a.family.clone(), style.to_string());
|
||||
}
|
||||
if let Some(a) = found.iter().find(|a| a.paired.as_ref().is_some_and(|p| *p == ns) || a.paired.is_none()) {
|
||||
return (a.family.clone(), a.style.clone().unwrap_or_else(|| style.to_string()));
|
||||
}
|
||||
match found.first() {
|
||||
Some(a) => (a.family.clone(), style.to_string()),
|
||||
None => (family.to_string(), style.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how
|
||||
/// it matched.
|
||||
pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc<FontFace>, FontMatch)> {
|
||||
let (family, style) = self.canonical(family, style);
|
||||
let f = self.face(&family, &style)?;
|
||||
let m = if !f.family.eq_ignore_ascii_case(&family) {
|
||||
FontMatch::Missing
|
||||
} else if norm(&f.style) == norm(&style) || f.keys.styles.contains(&norm(&style)) {
|
||||
FontMatch::Exact
|
||||
} else {
|
||||
FontMatch::Style
|
||||
};
|
||||
Some((f, m))
|
||||
}
|
||||
|
||||
/// The installed face whose PostScript name is `name` (`HiraginoSans-W3`; any case), loaded
|
||||
/// from the system fonts if need be. `None` when no installed font has the name.
|
||||
pub fn find_postscript(&self, name: &str) -> Option<Arc<FontFace>> {
|
||||
let key = norm(name);
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let loaded = |db: &FontDb| db.read_faces().iter().find(|f| f.keys.postscript.contains(&key)).cloned();
|
||||
if let Some(f) = loaded(self) {
|
||||
return Some(f);
|
||||
}
|
||||
self.ensure_catalog();
|
||||
let face = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key).and_then(|v| {
|
||||
v.iter().find(|a| a.paired.is_none() && a.style.is_some()).map(|a| (a.family.clone(), a.style.clone().unwrap_or_default()))
|
||||
});
|
||||
let (family, style) = face?;
|
||||
self.face(&family, &style)?;
|
||||
loaded(self)
|
||||
}
|
||||
|
||||
/// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII
|
||||
/// case). Documents name fonts this way, and a family can hold hyphens
|
||||
/// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split.
|
||||
pub fn by_postscript_name(&self, name: &str) -> Option<(String, String)> {
|
||||
self.ensure_catalog();
|
||||
self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned()
|
||||
}
|
||||
|
||||
/// Load the files of the installed `family`. Returns whether any face was added.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
@@ -599,17 +841,21 @@ impl FontDb {
|
||||
/// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has
|
||||
/// no glyphs.
|
||||
pub fn face(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
let (family, style) = self.canonical(family, style);
|
||||
let (family, style) = (family.as_str(), style.as_str());
|
||||
let found = self.find(family, style);
|
||||
if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) {
|
||||
return found;
|
||||
}
|
||||
// The family, or this style of it, is installed but not loaded yet.
|
||||
// The family, or this style of it, is installed but not loaded yet. Looked for again
|
||||
// whether or not this call loaded it: another thread may have just done so (then this
|
||||
// load adds nothing, but the face is there).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if (found.is_none() || self.is_cataloged(family, style))
|
||||
&& self.load_cataloged(family)
|
||||
&& let Some(f) = self.find(family, style)
|
||||
{
|
||||
return Some(f);
|
||||
if found.is_none() || self.is_cataloged(family, style) {
|
||||
self.load_cataloged(family);
|
||||
if let Some(f) = self.find(family, style) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
found
|
||||
.or_else(|| self.find(FALLBACK_FAMILY, style))
|
||||
@@ -637,7 +883,8 @@ impl FontDb {
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
||||
let (family, _) = self.canonical(family, "");
|
||||
self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(crate) fn is_loaded(&self, family: &str) -> bool {
|
||||
@@ -770,3 +1017,114 @@ impl OutlinePen for FlipPen {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod alias_tests {
|
||||
use super::*;
|
||||
|
||||
/// A one-table font holding a name table from (platform, language, name id, string) records:
|
||||
/// Windows Unicode (UTF-16) or Mac Roman (ASCII here).
|
||||
fn name_font(records: &[(u16, u16, u16, &str)]) -> Vec<u8> {
|
||||
let (mut head, mut strings) = (vec![], vec![]);
|
||||
let n = records.len() as u16;
|
||||
for v in [0, n, 6 + 12 * n] {
|
||||
head.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for &(platform, lang, id, s) in records {
|
||||
let bytes: Vec<u8> = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() };
|
||||
let encoding = if platform == 3 { 1 } else { 0 };
|
||||
for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] {
|
||||
head.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend(bytes);
|
||||
}
|
||||
head.extend(strings);
|
||||
let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
font.extend_from_slice(b"name");
|
||||
for v in [0u32, 28, head.len() as u32] {
|
||||
font.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
font.extend(head);
|
||||
font
|
||||
}
|
||||
|
||||
fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) {
|
||||
let font = name_font(records);
|
||||
let f = skrifa::FontRef::new(&font).unwrap();
|
||||
let (family, style) = face_names(&f).unwrap();
|
||||
let k = FaceKeys::of(&f, &family, &style);
|
||||
(family, style, k)
|
||||
}
|
||||
|
||||
const EN: u16 = 0x0409;
|
||||
const JA: u16 = 0x0411;
|
||||
|
||||
/// Laid out like the name table of Hiragino Sans W3.
|
||||
const HIRAGINO_W3: &[(u16, u16, u16, &str)] = &[
|
||||
(1, 0, 1, "Hiragino Sans"),
|
||||
(1, 0, 2, "W3"),
|
||||
(3, EN, 1, "Hiragino Sans W3"),
|
||||
(3, EN, 2, "Regular"),
|
||||
(3, JA, 1, "ヒラギノ角ゴシック W3"),
|
||||
(3, JA, 2, "Regular"),
|
||||
(3, EN, 6, "HiraginoSans-W3"),
|
||||
(3, EN, 16, "Hiragino Sans"),
|
||||
(3, JA, 16, "ヒラギノ角ゴシック"),
|
||||
(3, EN, 17, "W3"),
|
||||
(3, JA, 17, "W3"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn a_face_answers_to_its_names_in_every_language() {
|
||||
let (family, style, k) = keys(HIRAGINO_W3);
|
||||
assert_eq!((family.as_str(), style.as_str()), ("Hiragino Sans", "W3"));
|
||||
let aliases = Alias::of(&family, &style, &k);
|
||||
let find = |name: &str| aliases.iter().filter(|(n, _)| *n == norm(name)).map(|(_, a)| a.clone()).collect::<Vec<_>>();
|
||||
assert!(find("ヒラギノ 角ゴシック").iter().any(|a| a.family == "Hiragino Sans" && a.style.is_none()), "a family name in Japanese");
|
||||
let legacy = find("Hiragino Sans W3");
|
||||
assert!(legacy.iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.as_deref() == Some("regular")), "{legacy:?}");
|
||||
assert!(!legacy.iter().any(|a| a.paired.as_deref() == Some("w3")), "Mac family + Windows style is no pair");
|
||||
assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_typographic_names_the_legacy_ones_are_the_familys() {
|
||||
let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]);
|
||||
assert_eq!((family.as_str(), style.as_str()), ("Example Serif", "Bold Italic"));
|
||||
assert!(k.families.contains(&norm("例セリフ")) && k.styles.contains(&norm("太字斜体")) && k.legacy.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_says_how_a_font_matched() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap();
|
||||
assert_eq!(m("Source Sans 3", "Semibold"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
|
||||
assert_eq!(m("Source Sans 3", "Black").2, FontMatch::Style);
|
||||
assert_eq!(m("No Such Family", "Regular"), (FALLBACK_FAMILY.into(), "Regular".into(), FontMatch::Missing));
|
||||
// A PostScript name names its face, whatever style is asked for (text opened from a PDF).
|
||||
assert_eq!(m("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
|
||||
let semi = db.find_postscript("sourcesans3-semibold").unwrap();
|
||||
assert_eq!(semi.style, "Semibold");
|
||||
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
|
||||
assert!(db.has_family("SourceSans3-Regular"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod race_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_family_loaded_by_another_thread_meanwhile_is_still_found() {
|
||||
// Two threads ask for the same installed family at once: each must get it, not the
|
||||
// fallback (the second one's load finds every face already added).
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
|
||||
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
|
||||
// Only the catalog knows Inter (the bundled faces are dropped).
|
||||
db.faces.write().unwrap().retain(|f| f.family != "Inter");
|
||||
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(move || db.face("Inter", "Regular").map(|f| f.family.clone()))).collect();
|
||||
for t in threads {
|
||||
assert_eq!(t.join().unwrap().as_deref(), Some("Inter"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
-50
@@ -8,19 +8,22 @@ use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextO
|
||||
use crate::composer::{Breakpoint, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::shape::{SGlyph, cap_x_heights, hyphen_glyph, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, TextLayout};
|
||||
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
struct Ctx<'a> {
|
||||
vertical: bool,
|
||||
on_path: bool,
|
||||
db: &'a FontDb,
|
||||
text: &'a str,
|
||||
runs: Vec<(Range<usize>, &'a CharStyle)>,
|
||||
default: CharStyle,
|
||||
opts: &'a LayoutOptions,
|
||||
/// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned
|
||||
/// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines
|
||||
/// up as columns.
|
||||
vertical: bool,
|
||||
out: TextLayout,
|
||||
}
|
||||
|
||||
@@ -33,6 +36,19 @@ impl Ctx<'_> {
|
||||
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
|
||||
let mut v = Vec::with_capacity(r.len());
|
||||
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v);
|
||||
if self.vertical {
|
||||
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
|
||||
// An upright glyph advances at least one em down the column (the vertical advance of
|
||||
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
|
||||
for g in v.iter_mut().filter(|g| stands_upright(g)) {
|
||||
let natural = g.face.advance(g.gid) * g.sx;
|
||||
let em = g.face.units_per_em() * g.sx;
|
||||
if natural < em && g.adv > 0.0 {
|
||||
g.dx += (em - natural) * 0.5;
|
||||
g.adv += em - natural;
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -40,20 +56,21 @@ impl Ctx<'_> {
|
||||
let src = self.db.outline(&g.face, g.gid);
|
||||
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let mut m = pre * local;
|
||||
let mut origin = origin;
|
||||
let mut angle = angle;
|
||||
if self.vertical {
|
||||
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
if self.on_path {
|
||||
// Vertical path type keeps the baseline path and turns each glyph across it.
|
||||
m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m;
|
||||
} else {
|
||||
let physical = writing * origin;
|
||||
let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f);
|
||||
m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m };
|
||||
origin = physical;
|
||||
angle += std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
if self.vertical && self.on_path {
|
||||
// Vertical path type keeps the baseline path and turns each glyph across it.
|
||||
m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
|
||||
} else if let (true, Some(t)) = (self.vertical, g.tcy) {
|
||||
// Tate-chu-yoko: the block set across the column, centred on its em of it.
|
||||
let em = self.style_at(g.byte).size;
|
||||
let start = origin.x - t.pen;
|
||||
let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em);
|
||||
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
|
||||
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
|
||||
} else if self.vertical && stands_upright(g) {
|
||||
// Turned about the centre of its em box, which the column's centre line runs through.
|
||||
let em = self.style_at(g.byte).size;
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + advance * 0.5, origin.y - EM_CENTER * em)) * m;
|
||||
}
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
|
||||
@@ -116,48 +133,122 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
}
|
||||
}
|
||||
paras.push(s..text.len());
|
||||
let mut vertical_opts = opts.clone();
|
||||
vertical_opts.features.vertical = t.vertical;
|
||||
let opts = &vertical_opts;
|
||||
let vertical = t.vertical;
|
||||
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
|
||||
let mut cx = Ctx {
|
||||
vertical: t.vertical,
|
||||
on_path: is_on_path,
|
||||
db,
|
||||
text: &text,
|
||||
runs,
|
||||
default: CharStyle::default(),
|
||||
opts,
|
||||
out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() },
|
||||
let vopts;
|
||||
let opts = if vertical {
|
||||
vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() };
|
||||
&vopts
|
||||
} else {
|
||||
opts
|
||||
};
|
||||
let mut cx = Ctx { on_path: is_on_path, db, text: &text, runs, default: CharStyle::default(), opts, vertical, out: TextLayout::default() };
|
||||
// Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
|
||||
// one to the left). Point type's anchor is on the first column's centre line. Vertical path
|
||||
// type stays on its path (each glyph turned across it in `emit`).
|
||||
let line_xf = match &t.kind {
|
||||
_ if !vertical => Affine::IDENTITY,
|
||||
TextKind::OnPath { .. } => Affine::IDENTITY,
|
||||
TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN,
|
||||
_ => QUARTER_TURN,
|
||||
};
|
||||
match &t.kind {
|
||||
TextKind::Point => flow(&mut cx, ¶s, &t.para, None),
|
||||
TextKind::Area { frame } => {
|
||||
let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]);
|
||||
let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() };
|
||||
let mut wrap = t.wrap.clone();
|
||||
if t.vertical {
|
||||
for w in &mut wrap {
|
||||
w.path.transform(inverse);
|
||||
}
|
||||
}
|
||||
let regions = Region::cells(&frame.to_bezpath(), opts, &wrap);
|
||||
cx.out.frames = regions.iter().map(|r| r.cell).collect();
|
||||
let to_lines = line_xf.inverse();
|
||||
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
|
||||
t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect()
|
||||
} else {
|
||||
t.wrap.clone()
|
||||
};
|
||||
let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
|
||||
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||
}
|
||||
TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
|
||||
}
|
||||
if cx.out.vertical {
|
||||
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
for frame in &mut cx.out.frames {
|
||||
*frame = writing.transform_rect_bbox(*frame);
|
||||
if vertical && !is_on_path {
|
||||
// Glyph origins, outlines and transforms to text space (lines stay in line space).
|
||||
for g in &mut cx.out.glyphs {
|
||||
g.outline.apply_affine(line_xf);
|
||||
g.xf = line_xf * g.xf;
|
||||
g.origin = line_xf * g.origin;
|
||||
g.angle += std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
cx.out.vertical = true;
|
||||
cx.out.line_xf = line_xf;
|
||||
}
|
||||
finish_bounds(&mut cx.out);
|
||||
cx.out
|
||||
}
|
||||
|
||||
/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across
|
||||
/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`)
|
||||
/// stay on their side.
|
||||
fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
|
||||
let digits: Vec<bool> = g.iter().map(|g| g.ch.is_ascii_digit()).collect();
|
||||
let digit = |i: usize| digits.get(i).copied().unwrap_or(false);
|
||||
let mut i = 0;
|
||||
while i < g.len() {
|
||||
if !digit(i) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut end = i;
|
||||
while digit(end) {
|
||||
end += 1;
|
||||
}
|
||||
if (2..=3).contains(&(end - i)) {
|
||||
let em = size(&g[i]);
|
||||
let width: f64 = g[i..end].iter().map(|g| g.adv).sum();
|
||||
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
|
||||
let mut ink = 0.0;
|
||||
for (k, gl) in g[i..end].iter_mut().enumerate() {
|
||||
let adv = gl.adv;
|
||||
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
|
||||
gl.adv = if k == 0 { em } else { 0.0 };
|
||||
ink += adv;
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
|
||||
/// from 0.12 em below the baseline to 0.88 em above it).
|
||||
const EM_CENTER: f64 = 0.38;
|
||||
|
||||
/// A quarter turn clockwise (y down), exact: line space → text space for vertical type.
|
||||
const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
|
||||
|
||||
/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical
|
||||
/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on
|
||||
/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on
|
||||
/// their side when the font has none (Unicode's Vertical_Orientation Tr).
|
||||
fn stands_upright(g: &SGlyph) -> bool {
|
||||
let c = g.ch as u32;
|
||||
let upright = matches!(c,
|
||||
0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7
|
||||
| 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051
|
||||
| 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117
|
||||
| 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189
|
||||
| 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59
|
||||
| 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF
|
||||
| 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7
|
||||
| 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF);
|
||||
if !upright {
|
||||
return false;
|
||||
}
|
||||
let needs_vertical_form = matches!(c,
|
||||
0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC
|
||||
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF01 | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B | 0xFF1F
|
||||
| 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3);
|
||||
// Without a vertical alternate the shaped glyph is the nominal one.
|
||||
!needs_vertical_form || g.gid != g.face.glyph_for(g.ch)
|
||||
}
|
||||
|
||||
fn finish_bounds(out: &mut TextLayout) {
|
||||
let xf = out.line_xf;
|
||||
let mut b: Option<Rect> = None;
|
||||
let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r)));
|
||||
for g in &out.glyphs {
|
||||
@@ -167,8 +258,7 @@ fn finish_bounds(out: &mut TextLayout) {
|
||||
}
|
||||
if !out.on_path {
|
||||
for l in &out.lines {
|
||||
let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent);
|
||||
add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r });
|
||||
add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)));
|
||||
}
|
||||
}
|
||||
out.bounds = b.unwrap_or_default();
|
||||
@@ -502,7 +592,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
break;
|
||||
}
|
||||
x += gl.adv;
|
||||
if gl.break_after() {
|
||||
if gl.break_after() && kinsoku_allows(g, j) {
|
||||
if gl.is_soft_hyphen() {
|
||||
if x + hyphen_glyph(gl).adv <= width + EPS {
|
||||
last_break = Some((j + 1, true));
|
||||
@@ -524,7 +614,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
we += 1;
|
||||
}
|
||||
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
|
||||
let pts = hyphen_breaks(text, g, word_start, we);
|
||||
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect();
|
||||
for &k in pts.iter().rev() {
|
||||
if k <= j && k > i {
|
||||
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
|
||||
@@ -545,6 +635,30 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
|
||||
(end, hy)
|
||||
}
|
||||
|
||||
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
|
||||
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
|
||||
/// with the one before it).
|
||||
fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool {
|
||||
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch)))
|
||||
}
|
||||
|
||||
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph).
|
||||
fn kinsoku_between(before: char, after: Option<char>) -> bool {
|
||||
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
|
||||
for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] {
|
||||
assert!(!kinsoku_between(a, Some(b)), "{a}{b}");
|
||||
}
|
||||
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
|
||||
assert!(kinsoku_between(a, Some(b)), "{a}{b}");
|
||||
}
|
||||
assert!(kinsoku_between('。', None));
|
||||
}
|
||||
|
||||
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
|
||||
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
|
||||
// Strip leading/trailing punctuation (quotes, commas).
|
||||
@@ -579,19 +693,19 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
|
||||
let end_of_word = gl.is_space() || gl.break_after();
|
||||
if end_of_word {
|
||||
if hyphenate && j > ws {
|
||||
for k in hyphen_breaks(text, g, ws, j) {
|
||||
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
|
||||
if j + 1 < g.len() && g[j + 1].byte != gl.byte {
|
||||
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) {
|
||||
v.push(Breakpoint { end: j + 1, hyphen: hy });
|
||||
}
|
||||
ws = j + 1;
|
||||
}
|
||||
}
|
||||
if hyphenate && g.len() > ws {
|
||||
for k in hyphen_breaks(text, g, ws, g.len()) {
|
||||
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
|
||||
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
@@ -128,6 +128,9 @@ pub struct LineInfo {
|
||||
pub struct TextLayout {
|
||||
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
|
||||
pub vertical: bool,
|
||||
/// Inline/block (line) space → physical text space: identity for horizontal type, a quarter
|
||||
/// turn clockwise for vertical type (point type also centres its first column on the anchor).
|
||||
pub line_xf: Affine,
|
||||
pub glyphs: Vec<PositionedGlyph>,
|
||||
pub lines: Vec<LineInfo>,
|
||||
/// Ink/advance bounds in text space.
|
||||
@@ -144,10 +147,10 @@ pub struct TextLayout {
|
||||
impl TextLayout {
|
||||
/// Convert physical text coordinates to inline/block coordinates.
|
||||
pub fn logical_point(&self, p: Point) -> Point {
|
||||
if self.vertical { Point::new(p.y, -p.x) } else { p }
|
||||
if self.vertical { self.line_xf.inverse() * p } else { p }
|
||||
}
|
||||
pub fn physical_point(&self, p: Point) -> Point {
|
||||
if self.vertical { Point::new(-p.y, p.x) } else { p }
|
||||
if self.vertical { self.line_xf * p } else { p }
|
||||
}
|
||||
/// All glyph outlines combined (e.g. for Create Outlines).
|
||||
pub fn to_bezpath(&self) -> BezPath {
|
||||
@@ -168,7 +171,7 @@ impl TextLayout {
|
||||
/// the layout changed.
|
||||
pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool {
|
||||
let [a, b, c, d, _, _] = to_device.as_coeffs();
|
||||
if self.on_path || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
|
||||
if self.on_path || self.vertical || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let mut moved = false;
|
||||
@@ -381,3 +384,5 @@ mod tests_snap;
|
||||
mod tests_sysfonts;
|
||||
#[cfg(test)]
|
||||
mod tests_typo;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
@@ -40,6 +40,22 @@ pub(crate) struct SGlyph {
|
||||
pub xh: f64,
|
||||
/// First source character of the cluster.
|
||||
pub ch: char,
|
||||
/// Vertical type: part of a tate-chu-yoko block (set across the column, upright).
|
||||
pub tcy: Option<Tcy>,
|
||||
}
|
||||
|
||||
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
|
||||
/// by side across it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Tcy {
|
||||
/// Laid-out distance from the block's start to this glyph's pen position.
|
||||
pub pen: f64,
|
||||
/// Distance from the block's start to the glyph in the block's own (horizontal) setting.
|
||||
pub ink: f64,
|
||||
/// Width of the block's own setting.
|
||||
pub width: f64,
|
||||
/// Horizontal scale that fits the block into one em (1 when it fits as it is).
|
||||
pub squeeze: f64,
|
||||
}
|
||||
|
||||
impl SGlyph {
|
||||
@@ -77,6 +93,46 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||||
h
|
||||
}
|
||||
|
||||
/// Kinsoku (Japanese line breaking, the strict set): a character that can't start a line —
|
||||
/// closing brackets, the Japanese comma and full stop, middle dots, colons, ! and ?, the long
|
||||
/// vowel mark, iteration marks and small kana.
|
||||
pub(crate) fn no_line_start(c: char) -> bool {
|
||||
matches!(c,
|
||||
')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '»' | '’' | '”' | '‐' | '–' | '‼' | '⁇' | '⁈' | '⁉'
|
||||
| '、' | '。' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〟' | '〜' | '゠' | '・' | '⦆'
|
||||
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
|
||||
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
|
||||
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
|
||||
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
|
||||
}
|
||||
|
||||
/// Kinsoku: a character that can't end a line (opening brackets).
|
||||
pub(crate) fn no_line_end(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'(' | '['
|
||||
| '{'
|
||||
| '«'
|
||||
| '‘'
|
||||
| '“'
|
||||
| '〈'
|
||||
| '《'
|
||||
| '「'
|
||||
| '『'
|
||||
| '【'
|
||||
| '〔'
|
||||
| '〖'
|
||||
| '〘'
|
||||
| '〚'
|
||||
| '〝'
|
||||
| '('
|
||||
| '['
|
||||
| '{'
|
||||
| '⦅'
|
||||
| '「'
|
||||
)
|
||||
}
|
||||
|
||||
fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||||
}
|
||||
@@ -255,6 +311,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
|
||||
cap,
|
||||
xh,
|
||||
ch,
|
||||
tcy: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,15 @@ const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
renamed_to(file, FAMILY)
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family`, which is as long as the original name.
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
@@ -132,6 +138,30 @@ fn the_family_list_is_shared_until_fonts_change() {
|
||||
assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_lists_leave_out_hidden_system_families_which_still_resolve() {
|
||||
// macOS names the faces it keeps for its own interface with a leading "." (".SF NS").
|
||||
const HIDDEN: &str = ".Sysfont Sans";
|
||||
const DOTTED: &str = "Sysfont.Sans3";
|
||||
let dir = font_dir("hidden");
|
||||
std::fs::write(dir.join("Hidden.ttf"), renamed_to("SourceSans3-Regular.ttf", HIDDEN)).unwrap();
|
||||
std::fs::write(dir.join("Dotted.ttf"), renamed_to("SourceSans3-Regular.ttf", DOTTED)).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
let listed = db.menu_family_list();
|
||||
assert!(has(&listed, FAMILY) && has(&listed, FALLBACK_FAMILY));
|
||||
assert!(!has(&listed, HIDDEN), "a leading dot hides a family: {listed:?}");
|
||||
assert!(has(&listed, DOTTED), "a dot elsewhere doesn't");
|
||||
assert_eq!(listed.len() + 1, db.family_list().len(), "only the hidden family is left out");
|
||||
assert!(Arc::ptr_eq(&listed, &db.menu_family_list()), "shared until the fonts change");
|
||||
// Documents and fallbacks that name it still find it.
|
||||
assert!(has(&db.families(), HIDDEN) && db.has_family(HIDDEN));
|
||||
assert_eq!(db.find_family(HIDDEN).as_deref(), Some(HIDDEN));
|
||||
assert_eq!(db.face(HIDDEN, "Regular").unwrap().family, HIDDEN);
|
||||
// Loading it changed the fonts: the list is built again, still without it.
|
||||
let again = db.menu_family_list();
|
||||
assert!(!Arc::ptr_eq(&listed, &again) && *listed == *again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_styles_of_a_loaded_family_load_when_asked_for() {
|
||||
let dir = font_dir("styles");
|
||||
@@ -142,3 +172,15 @@ fn installed_styles_of_a_loaded_family_load_when_asked_for() {
|
||||
// A style nobody has still gets the closest one.
|
||||
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold");
|
||||
}
|
||||
|
||||
/// Documents name fonts by PostScript name, and families can hold hyphens: the installed face of
|
||||
/// that exact name gives the family and style, however the name splits.
|
||||
#[test]
|
||||
fn installed_faces_are_found_by_postscript_name() {
|
||||
let dir = font_dir("postscript");
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
// The renamed Source Sans 3 files keep their PostScript names.
|
||||
assert_eq!(db.by_postscript_name("SourceSans3-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
|
||||
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
|
||||
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Vertical type (bundled fonts only: `§` and `×` stand upright like CJK, letters lie on their side).
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{CharStyle, TextKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
fn vertical(text: &str) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = true;
|
||||
t
|
||||
}
|
||||
|
||||
fn ink(l: &TextLayout, i: usize) -> Rect {
|
||||
l.glyphs[i].outline.bounding_box()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_run_down_and_follow_each_other_to_the_left() {
|
||||
let l = layout(FontDb::global(), &vertical("§§\n§"));
|
||||
assert!(l.vertical);
|
||||
let (a, b, c) = (ink(&l, 0), ink(&l, 1), ink(&l, 2));
|
||||
assert!(b.center().y > a.center().y + 15.0, "down the column: {a:?} {b:?}");
|
||||
assert!((a.center().x - b.center().x).abs() < 0.5, "one column");
|
||||
assert!(c.center().x < a.center().x - 15.0, "the next column is to the left: {c:?}");
|
||||
// Point type: the anchor is on the first column's centre line.
|
||||
assert!(a.center().x.abs() < 4.0, "{a:?}");
|
||||
assert!(l.bounds.contains(a.center()) && l.bounds.contains(c.center()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upright_marks_stand_and_letters_lie_on_their_side() {
|
||||
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "§l", CharStyle { size: 20.0, ..CharStyle::default() }));
|
||||
let v = layout(FontDb::global(), &vertical("§l"));
|
||||
let (hs, hl, vs, vl) = (ink(&h, 0), ink(&h, 1), ink(&v, 0), ink(&v, 1));
|
||||
assert!((vs.width() - hs.width()).abs() < 0.01 && (vs.height() - hs.height()).abs() < 0.01, "§ upright: {hs:?} {vs:?}");
|
||||
assert!((vl.width() - hl.height()).abs() < 0.01 && (vl.height() - hl.width()).abs() < 0.01, "l turned: {hl:?} {vl:?}");
|
||||
assert!(vl.width() > vl.height(), "a tall l lies across the column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_and_selection_turn_with_the_columns() {
|
||||
let l = layout(FontDb::global(), &vertical("§§§"));
|
||||
let (top, bottom) = caret_position(&l, "§".len());
|
||||
assert!((top.y - bottom.y).abs() < 1e-6 && (top.x - bottom.x).abs() > 10.0, "a horizontal caret across the column: {top:?} {bottom:?}");
|
||||
let between = (ink(&l, 0).center().y + ink(&l, 1).center().y) / 2.0;
|
||||
assert!((top.y - between).abs() < 3.0, "between the first two marks: {top:?} {between} {:?} {:?}", ink(&l, 0), ink(&l, 1));
|
||||
let q = selection_quads(&l, 0, "§§".len());
|
||||
let r = Rect::from_points(q[0][0], q[0][2]);
|
||||
assert!(r.height() > r.width() && r.contains(ink(&l, 0).center()) && r.contains(ink(&l, 1).center()) && !r.contains(ink(&l, 2).center()));
|
||||
// Clicking a mark finds it.
|
||||
let c = ink(&l, 2).center();
|
||||
assert_eq!(hit_byte(&l, c + Vec2::new(0.0, 6.0)), "§§§".len());
|
||||
assert_eq!(hit_byte(&l, c - Vec2::new(0.0, 6.0)), "§§".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
|
||||
let mut t = vertical(&"§".repeat(12));
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 105.0).to_path(0.1)) };
|
||||
let l = layout(FontDb::global(), &t);
|
||||
let first = ink(&l, 0);
|
||||
assert!(first.x1 <= 100.0 + 1e-6 && first.x1 > 80.0 && first.y0 >= 0.0, "top right: {first:?}");
|
||||
let cols: Vec<f64> = l.glyphs.iter().map(|g| (g.outline.bounding_box().center().x * 10.0).round()).collect();
|
||||
let mut distinct = cols.clone();
|
||||
distinct.dedup();
|
||||
assert!(distinct.len() >= 2 && distinct.windows(2).all(|w| w[1] < w[0]), "columns right to left: {distinct:?}");
|
||||
assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_on_a_path_stays_horizontal() {
|
||||
let mut t = vertical("ab");
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
|
||||
assert!(!layout(FontDb::global(), &t).vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_digits_stand_across_the_column_squeezed_into_one_em() {
|
||||
let l = layout(FontDb::global(), &vertical("§100§"));
|
||||
let block: Vec<Rect> = (1..4).map(|i| ink(&l, i)).collect();
|
||||
assert!(block.windows(2).all(|w| w[1].center().x > w[0].center().x + 2.0 && (w[1].center().y - w[0].center().y).abs() < 1.0), "{block:?}");
|
||||
let span = block[0].union(block[2]);
|
||||
assert!(span.width() <= 20.0 + 1e-6, "squeezed into the 20 pt em: {span:?}");
|
||||
assert!(ink(&l, 4).center().y - ink(&l, 0).center().y < 45.0, "one em of the column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_digits_stand_across_the_column_and_longer_numbers_lie_on_their_side() {
|
||||
let l = layout(FontDb::global(), &vertical("§12§2026"));
|
||||
let (one, two) = (ink(&l, 1), ink(&l, 2));
|
||||
assert!((one.center().y - two.center().y).abs() < 1.0 && two.center().x > one.center().x + 3.0, "12 side by side: {one:?} {two:?}");
|
||||
assert!(one.height() > one.width(), "upright digits are taller than wide: {one:?}");
|
||||
let (before, after) = (ink(&l, 0), ink(&l, 3));
|
||||
assert!(after.center().y - before.center().y < 45.0, "the block takes one em (20 pt) of the column");
|
||||
let year: Vec<Rect> = (4..8).map(|i| ink(&l, i)).collect();
|
||||
assert!(year.windows(2).all(|w| w[1].center().y > w[0].center().y + 5.0), "2026 runs down the column on its side");
|
||||
}
|
||||
|
||||
/// Each line's text, from the layout's line ranges.
|
||||
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
|
||||
let text = t.plain_text();
|
||||
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or("").trim_end().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_ends() {
|
||||
for vertical_type in [false, true] {
|
||||
// Five characters fit a line: without kinsoku "。" would start the second line.
|
||||
let text = "あいうえお。かきくけ「こさしすせそ」";
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 101.0, 400.0).to_path(0.1)) };
|
||||
if vertical_type {
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 101.0).to_path(0.1)) };
|
||||
t.vertical = true;
|
||||
}
|
||||
let l = layout(FontDb::global(), &t);
|
||||
let lines = line_texts(&t, &l);
|
||||
assert!(lines.len() > 2, "{lines:?}");
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i > 0 {
|
||||
let first = line.chars().next().unwrap_or(' ');
|
||||
assert!(!crate::shape::no_line_start(first), "line {i} starts with {first}: {lines:?}");
|
||||
}
|
||||
let last = line.chars().last().unwrap_or(' ');
|
||||
assert!(!crate::shape::no_line_end(last), "line {i} ends with {last}: {lines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,6 +385,21 @@ pub trait Tool: Send {
|
||||
fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// IME composition (marked text) for the tool that wants text: `text` replaces the previous
|
||||
/// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the
|
||||
/// clause being converted, in characters of `text`. The committed result arrives through
|
||||
/// [`Tool::text_input`].
|
||||
fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option<std::ops::Range<usize>>) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is.
|
||||
fn composing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Where the IME candidate window goes: the caret line (top, bottom) in document space.
|
||||
fn ime_caret(&self, _cx: &ToolContext) -> Option<(Point, Point)> {
|
||||
None
|
||||
}
|
||||
fn notify(&mut self, _cx: &ToolContext, _what: &str) {}
|
||||
/// Called when the user switches away (finish pending work).
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
|
||||
@@ -21,6 +21,9 @@ enum State {
|
||||
Moving {
|
||||
start: Point,
|
||||
began: bool,
|
||||
/// Shift-pressed on this selected object: released without a drag, it leaves the
|
||||
/// selection.
|
||||
deselect: Option<NodeId>,
|
||||
},
|
||||
Scaling {
|
||||
handle: Handle,
|
||||
@@ -141,16 +144,19 @@ impl Tool for SelectionTool {
|
||||
Some(h) => {
|
||||
let top = h.top_object(cx.isolation);
|
||||
let mut out = vec![];
|
||||
let mut deselect = None;
|
||||
if m.shift {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||||
// A Shift-click takes a selected object out of the selection when it
|
||||
// is released; a Shift-drag moves the selection, constrained.
|
||||
if cx.selection.contains(top) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
deselect = Some(top);
|
||||
} else {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
|
||||
}
|
||||
} else if !cx.selection.contains(top) {
|
||||
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
|
||||
}
|
||||
self.state = State::Moving { start: p, began: false };
|
||||
self.state = State::Moving { start: p, began: false, deselect };
|
||||
out
|
||||
}
|
||||
None => {
|
||||
@@ -185,7 +191,7 @@ impl Tool for SelectionTool {
|
||||
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
|
||||
self.guides.clear();
|
||||
}
|
||||
self.state = State::Moving { start, began: true };
|
||||
self.state = State::Moving { start, began: true, deselect: None };
|
||||
self.measure = Some((p, cx.offset_label(d.x, d.y)));
|
||||
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||||
out
|
||||
@@ -215,12 +221,16 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, .. }) => {
|
||||
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
match (began, deselect) {
|
||||
(true, _) => vec![Action::Commit],
|
||||
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
|
||||
(false, None) => vec![],
|
||||
}
|
||||
}
|
||||
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
|
||||
self.state = State::Idle;
|
||||
@@ -339,6 +349,48 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_on_a_selected_object_moves_it_constrained() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
// Pressing keeps the selection: nothing is toggled yet.
|
||||
assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "object.transform"
|
||||
&& v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0),
|
||||
"moved right, constrained to horizontal: {a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_an_object_on_release() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let p = paint();
|
||||
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))];
|
||||
// Selected: it leaves the selection when the click is released.
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle);
|
||||
// Not selected: it joins the selection at once, ready to be dragged.
|
||||
let s = Selection::default();
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle);
|
||||
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
+111
-6
@@ -12,7 +12,11 @@
|
||||
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
|
||||
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
|
||||
//! selection from [`Tool::options`]).
|
||||
//!
|
||||
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
|
||||
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -38,6 +42,15 @@ struct Typing {
|
||||
cur: Vec<TextRun>,
|
||||
}
|
||||
|
||||
/// IME marked text inside the typing session, not yet committed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Preedit {
|
||||
/// Byte range of the marked text in the session's runs.
|
||||
range: Range<usize>,
|
||||
/// The clause being converted, in bytes relative to `range.start` (empty: the IME's cursor).
|
||||
active: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TypeTool {
|
||||
vertical: bool,
|
||||
@@ -52,6 +65,7 @@ pub struct TypeTool {
|
||||
/// Horizontal position kept by Up/Down (text space).
|
||||
goal_x: Option<f64>,
|
||||
typing: Option<Typing>,
|
||||
preedit: Option<Preedit>,
|
||||
press: Option<Point>,
|
||||
drag: Option<Point>,
|
||||
/// The press landed in the edited text: dragging selects.
|
||||
@@ -113,8 +127,10 @@ impl TypeTool {
|
||||
(self.caret.min(self.anchor), self.caret.max(self.anchor))
|
||||
}
|
||||
|
||||
/// End the typing session (one undo step).
|
||||
/// End the typing session (one undo step). Marked text left by an IME stays as typed (as
|
||||
/// clicking away from a composition does in macOS text views); the UI interrupts the IME.
|
||||
fn commit(&mut self) -> Vec<Action> {
|
||||
self.preedit = None;
|
||||
if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
@@ -187,6 +203,7 @@ impl TypeTool {
|
||||
self.anchor = caret;
|
||||
self.goal_x = None;
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
|
||||
/// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds).
|
||||
@@ -277,6 +294,14 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
|
||||
/// `None` when `r` runs past the end of `s` or backwards.
|
||||
fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
|
||||
let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) };
|
||||
let (a, b) = (byte(r.start)?, byte(r.end)?);
|
||||
(a <= b).then_some(a..b)
|
||||
}
|
||||
|
||||
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
|
||||
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
|
||||
let mut p = 0;
|
||||
@@ -371,6 +396,15 @@ impl Tool for TypeTool {
|
||||
return vec![];
|
||||
}
|
||||
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
|
||||
// An IME commit replaces its marked text (an empty commit just removes it).
|
||||
let committing = self.preedit.is_some();
|
||||
if committing && s.is_empty() {
|
||||
return self.ime_preedit(cx, "", None);
|
||||
}
|
||||
if let Some(p) = self.preedit.take() {
|
||||
self.anchor = p.range.start;
|
||||
self.caret = p.range.end;
|
||||
}
|
||||
if s.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -387,13 +421,17 @@ impl Tool for TypeTool {
|
||||
self.clicks = (None, 0);
|
||||
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
|
||||
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
|
||||
if s.len() > 1 && s == clip && !cx.paste_plain_text {
|
||||
if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text {
|
||||
let runs = self.clipboard.clone();
|
||||
return self.replace(cx, "", Some(runs));
|
||||
}
|
||||
self.replace(cx, &s, None)
|
||||
}
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
// The IME owns the keys while it composes (the UI doesn't send them; this is a guard).
|
||||
if self.preedit.is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let Some(t) = self.current(cx) else {
|
||||
self.editing = None;
|
||||
return vec![];
|
||||
@@ -485,6 +523,51 @@ impl Tool for TypeTool {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option<Range<usize>>) -> Vec<Action> {
|
||||
if self.editing.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
// A clear with nothing marked (some integrations send one when the IME is enabled) must
|
||||
// not delete the selection.
|
||||
if text.is_empty() && self.preedit.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
// The new marked text replaces the previous one, or the selection when it starts.
|
||||
if let Some(p) = self.preedit.take() {
|
||||
self.anchor = p.range.start;
|
||||
self.caret = p.range.end;
|
||||
}
|
||||
self.clicks = (None, 0);
|
||||
let mut out = self.replace(cx, &text, None);
|
||||
if text.is_empty() {
|
||||
// Cancelled before anything else was typed: no empty undo step.
|
||||
if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) {
|
||||
self.typing = None;
|
||||
out.push(Action::Cancel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let end = self.caret;
|
||||
let start = end.saturating_sub(text.len());
|
||||
self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) });
|
||||
out
|
||||
}
|
||||
fn composing(&self) -> bool {
|
||||
self.preedit.is_some()
|
||||
}
|
||||
fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> {
|
||||
let t = self.current(cx)?;
|
||||
let lay = self.layout(&t);
|
||||
let len = t.plain_text().len();
|
||||
// The candidate window follows the clause being converted, else the caret.
|
||||
let at = match &self.preedit {
|
||||
Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start),
|
||||
None => self.caret.min(self.anchor),
|
||||
};
|
||||
let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len));
|
||||
Some((t.xf * a, t.xf * b))
|
||||
}
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.finish(cx)
|
||||
}
|
||||
@@ -510,13 +593,14 @@ impl Tool for TypeTool {
|
||||
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
|
||||
}
|
||||
}
|
||||
/// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`.
|
||||
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
|
||||
/// `start..end`.
|
||||
fn options(&self) -> Value {
|
||||
match self.editing {
|
||||
Some(id) => {
|
||||
// Offsets may exceed the text after Select All; callers clamp to the text length.
|
||||
let (a, b) = self.sel();
|
||||
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some()})
|
||||
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some(), "composing": self.preedit.is_some()})
|
||||
}
|
||||
None => json!({"editing": null}),
|
||||
}
|
||||
@@ -525,7 +609,10 @@ impl Tool for TypeTool {
|
||||
/// `commitTyping` (the caller commits the engine interaction).
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
match key {
|
||||
"commitTyping" | "endTyping" => self.typing = None,
|
||||
"commitTyping" | "endTyping" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
}
|
||||
"selectAll" if self.editing.is_some() => {
|
||||
self.anchor = 0;
|
||||
self.caret = usize::MAX / 4;
|
||||
@@ -544,6 +631,7 @@ impl Tool for TypeTool {
|
||||
}
|
||||
"stopEditing" => {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
self.editing = None;
|
||||
}
|
||||
_ => {}
|
||||
@@ -578,7 +666,24 @@ impl Tool for TypeTool {
|
||||
o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false });
|
||||
}
|
||||
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
|
||||
if a == b {
|
||||
if let Some(pe) = &self.preedit {
|
||||
// Marked text: a thin underline, a thick one under the clause being converted.
|
||||
let under = |o: &mut Vec<Overlay>, r: Range<usize>, width: f32| {
|
||||
for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) {
|
||||
let (l, rr) = (t.xf * q[3], t.xf * q[2]);
|
||||
o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false });
|
||||
}
|
||||
};
|
||||
under(&mut o, pe.range.clone(), 1.0);
|
||||
match &pe.active {
|
||||
Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5),
|
||||
_ => {
|
||||
let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start);
|
||||
let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len));
|
||||
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
|
||||
}
|
||||
}
|
||||
} else if a == b {
|
||||
let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len));
|
||||
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
|
||||
} else {
|
||||
|
||||
@@ -227,3 +227,68 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
|
||||
assert!(tool.vertical);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_ranges_convert_characters_to_bytes() {
|
||||
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
|
||||
assert_eq!(char_range_to_bytes("ががく", 3..3), Some(9..9));
|
||||
assert_eq!(char_range_to_bytes("a雅b", 1..2), Some(1..4));
|
||||
assert_eq!(char_range_to_bytes("ががく", 2..4), None, "past the end");
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 2..1;
|
||||
assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_text_is_underlined_with_the_converting_clause_thick() {
|
||||
let (mut d, id, mut tool) = editing("曲");
|
||||
let snap = d.clone();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
tool.caret = 3;
|
||||
tool.anchor = 3;
|
||||
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "雅楽演奏", Some(2..4));
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "曲雅楽演奏");
|
||||
assert_eq!(tool.preedit, Some(Preedit { range: 3..15, active: Some(6..12) }));
|
||||
let widths: Vec<f32> = tool
|
||||
.overlays(&cx(&d, &sel, &p))
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Path { width, .. } => Some(*width),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(widths, vec![1.0, 2.5], "a thin underline, then the thick one under 演奏");
|
||||
// The candidate window follows the converting clause.
|
||||
let (top, _) = tool.ime_caret(&cx(&d, &sel, &p)).unwrap();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), t);
|
||||
let (at, _) = vectorcraft_text::caret_position(&lay, 9);
|
||||
assert!((top - t.xf * at).hypot() < 1e-6);
|
||||
// Keys wait for the IME; a commit replaces the marked text.
|
||||
assert!(key(&mut tool, &d, ToolKey::Backspace, Mods::default()).is_empty());
|
||||
let acts = tool.text_input(&cx(&d, &sel, &p), "雅楽演奏会");
|
||||
apply(&mut d, &snap, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "曲雅楽演奏会");
|
||||
assert!(!tool.composing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_follow_the_columns_of_vertical_type() {
|
||||
let (mut d, id) = doc_with_text("§§§\n§§§");
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.vertical = true;
|
||||
}
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.start_editing(id, 0);
|
||||
let s = "§".len();
|
||||
key(&mut tool, &d, ToolKey::Down, Mods::default());
|
||||
assert_eq!(tool.caret, s, "↓ goes down the column to the next character");
|
||||
key(&mut tool, &d, ToolKey::Left, Mods::default());
|
||||
assert_eq!(tool.caret, "§§§\n§".len(), "← goes on to the next column, keeping the place in it");
|
||||
key(&mut tool, &d, ToolKey::Right, Mods::default());
|
||||
assert_eq!(tool.caret, s, "→ comes back");
|
||||
key(&mut tool, &d, ToolKey::Up, Mods::default());
|
||||
assert_eq!(tool.caret, 0);
|
||||
}
|
||||
@@ -158,10 +158,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fit(app, "view.fitArtboard");
|
||||
}
|
||||
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
|
||||
handle_input(app, ui, &resp, rect);
|
||||
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
|
||||
// alone would leave open); the tool doesn't get it.
|
||||
if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
} else {
|
||||
handle_input(app, ui, &resp, rect);
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
context_menu(app, &resp, &xf);
|
||||
let painter = ui.painter_at(rect);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let doc = st.doc.clone();
|
||||
@@ -330,6 +337,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let overlays = app.session.overlays(view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t);
|
||||
}
|
||||
ime_output(app, ui.ctx(), &xf);
|
||||
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
|
||||
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
@@ -821,6 +829,25 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
|
||||
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
|
||||
}
|
||||
|
||||
/// Right-click: the object under the pointer is selected first unless it already is, then the
|
||||
/// context menu lists what applies to the selection ([`crate::menus::context_items`]).
|
||||
fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some(st) = app.session.active()
|
||||
&& let Some(top) = hit_at(app, xf.to_doc(p), xf.zoom).map(|h| h.top_object(st.isolation))
|
||||
&& !st.selection.contains(top)
|
||||
{
|
||||
// A locked or hidden object can't be selected; the menu is then for the selection as is.
|
||||
let _ = app.run("select.set", json!({ "ids": [top.0] }));
|
||||
}
|
||||
let mut clicked = None;
|
||||
resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked));
|
||||
if let Some((id, p)) = clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
|
||||
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
|
||||
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
|
||||
@@ -1144,6 +1171,28 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
}
|
||||
}
|
||||
|
||||
/// While the Type tool edits, let the system IME compose (egui-winit allows it only in frames that
|
||||
/// set `ime`) and keep its candidate window under the caret.
|
||||
fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||||
let ours = app.session.tool_wants_text() && !ctx.egui_wants_keyboard_input() && app.ui.dialog.is_none() && !app.ui.palette_open;
|
||||
let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None };
|
||||
let Some((a, b)) = caret else {
|
||||
// The IME goes away with its marked text: keep that text as typed, like a click away.
|
||||
crate::shortcuts::keep_marked_text(app);
|
||||
return;
|
||||
};
|
||||
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
|
||||
// The tool ended the composition itself: the IME must drop what it still has marked.
|
||||
let interrupt = app.ime_marked.is_some() && !app.session.tool_composing();
|
||||
if interrupt {
|
||||
app.ime_marked = None;
|
||||
app.ime_discard = true;
|
||||
}
|
||||
ctx.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt });
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
|
||||
for o in overlays {
|
||||
match o {
|
||||
@@ -1517,4 +1566,136 @@ mod tests {
|
||||
assert_eq!(*st.doc, *before);
|
||||
assert_eq!(st.history.undo.len(), undo);
|
||||
}
|
||||
|
||||
/// One frame of keyboard handling and canvas, as the app runs them; returns the IME output.
|
||||
fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::output::IMEOutput> {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.ime
|
||||
}
|
||||
|
||||
fn preedit(t: &str, chars: usize) -> egui::Event {
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) })
|
||||
}
|
||||
|
||||
fn plain(app: &VectorcraftApp, id: u64) -> String {
|
||||
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
// Not editing: the IME stays off (single-key tool shortcuts keep working).
|
||||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none());
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||||
assert!(app.session.tool_wants_text());
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
// Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100).
|
||||
let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing");
|
||||
assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect);
|
||||
assert!(!ime.should_interrupt_composition);
|
||||
// gagaku → がが → 雅楽, then commit (the macOS sequence).
|
||||
typing_frame(&mut app, &ctx, vec![preedit("g", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]);
|
||||
typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]);
|
||||
assert_eq!(plain(&app, id), "ががく");
|
||||
// While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z
|
||||
// reaches `menus::invoke`) all wait.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]);
|
||||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||||
assert!(!crate::menus::enabled(&app, "edit.undo"));
|
||||
assert_eq!(plain(&app, id), "ががく");
|
||||
let ime2 =
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })])
|
||||
.unwrap();
|
||||
assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted");
|
||||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]);
|
||||
assert_eq!(plain(&app, id), "雅楽");
|
||||
assert!(!app.session.tool_composing());
|
||||
// After the commit, text and Undo work again.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]);
|
||||
assert_eq!(plain(&app, id), "雅楽!");
|
||||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||||
assert_eq!(plain(&app, id), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(300.0, 250.0)));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(a, false)]);
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
|
||||
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
|
||||
// told to drop its composition.
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||||
let ime = typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||||
assert_eq!(plain(&app, id), "しょうこ");
|
||||
assert!(!app.session.tool_composing());
|
||||
assert!(ime.expect("editing the new text").should_interrupt_composition);
|
||||
assert!(app.ime_marked.is_none());
|
||||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||||
// Interrupted once, not every frame.
|
||||
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
|
||||
assert!(!app.take_ime_discard());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
typing_frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("type");
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||||
let click =
|
||||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]);
|
||||
let backspace =
|
||||
|pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
// The IME empties its marked text and the Backspace comes in the same frame.
|
||||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||||
typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]);
|
||||
assert_eq!(plain(&app, id), "雅", "the committed character stays");
|
||||
// A bare line-break commit ends the composition with the marked text, no newline.
|
||||
typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]);
|
||||
assert!(!app.session.tool_composing());
|
||||
assert_eq!(plain(&app, id), "雅がく");
|
||||
assert!(app.take_ime_discard());
|
||||
// Switching apps mid-composition: the composition goes on when the window comes back.
|
||||
typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]);
|
||||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]);
|
||||
assert!(app.session.tool_composing());
|
||||
assert!(!app.take_ime_discard());
|
||||
assert_eq!(plain(&app, id), "雅がくらくか");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! - `engine.commands`: engine + UI commands with enablement
|
||||
//! - `document.inspect`: document summary; `ui.inspect`: UI state
|
||||
//! - `ui.menu.list`: flattened menu tree
|
||||
//! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened
|
||||
//! - `ui.tool.select {tool}`, `ui.tool.list`
|
||||
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
|
||||
//! drive the active tool through the same path as the mouse
|
||||
@@ -117,6 +118,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
|
||||
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
|
||||
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
|
||||
"ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()),
|
||||
"ui.pointer" => {
|
||||
|
||||
@@ -20,6 +20,9 @@ pub const KIND: &str = "plugin";
|
||||
|
||||
const CMD: &str = "plugin.run";
|
||||
|
||||
/// Object › Plug-ins' header while no filter plug-in is installed.
|
||||
pub const NO_FILTERS: &str = "No filter plug-ins installed";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM };
|
||||
|
||||
@@ -37,7 +40,7 @@ pub fn object_menu() -> Vec<Item> {
|
||||
.map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() })))
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
items.push(Item::Header("No filter plug-ins installed"));
|
||||
items.push(Item::Header(NO_FILTERS));
|
||||
}
|
||||
items.extend([
|
||||
Item::Sep,
|
||||
|
||||
@@ -58,15 +58,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let missing = f["missing"] == true;
|
||||
let label = format!(
|
||||
"{} {}{} ({})",
|
||||
f["family"].as_str().unwrap_or(""),
|
||||
f["style"].as_str().unwrap_or(""),
|
||||
if missing { " — missing" } else { "" },
|
||||
f["runs"]
|
||||
);
|
||||
let text = egui::RichText::new(label).color(if missing { egui::Color32::from_rgb(230, 90, 90) } else { t.text });
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
let text = egui::RichText::new(label).color(color.unwrap_or(t.text));
|
||||
if ui.selectable_label(i == sel, text).clicked() {
|
||||
d.fields.insert("selected".into(), json!(i));
|
||||
}
|
||||
@@ -126,6 +120,22 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Find Font row says after the font's name (`text.fonts` row): a missing family, a style
|
||||
/// standing in for another, characters the font lacks; and the colour to flag it with.
|
||||
fn font_note(f: &Value) -> (String, Option<egui::Color32>) {
|
||||
let lacking = f["missingGlyphs"].as_u64().unwrap_or(0);
|
||||
let glyphs = if lacking > 0 { format!(" — {lacking} characters from another font") } else { String::new() };
|
||||
match f["status"].as_str() {
|
||||
Some("missing") => (format!(" — missing{glyphs}"), Some(egui::Color32::from_rgb(230, 90, 90))),
|
||||
Some("substitute") => {
|
||||
let used = f["resolved"]["style"].as_str().unwrap_or("");
|
||||
(format!(" — substituted by {used}{glyphs}"), Some(egui::Color32::from_rgb(220, 160, 60)))
|
||||
}
|
||||
_ if lacking > 0 => (glyphs, Some(egui::Color32::from_rgb(220, 160, 60))),
|
||||
_ => (String::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -146,4 +156,14 @@ mod tests {
|
||||
change(&mut app, &d, &from, true).unwrap();
|
||||
assert_eq!(fonts(&mut app)[0]["family"], "Source Sans 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_flag_missing_fonts_substituted_styles_and_lacking_characters() {
|
||||
let row = |status: &str, glyphs: u64| json!({"status": status, "resolved": {"style": "W4"}, "missingGlyphs": glyphs});
|
||||
assert_eq!(font_note(&row("exact", 0)), (String::new(), None));
|
||||
assert!(font_note(&row("missing", 0)).0.contains("missing"));
|
||||
assert!(font_note(&row("substitute", 0)).0.contains("substituted by W4"));
|
||||
let (note, color) = font_note(&row("exact", 2));
|
||||
assert!(note.contains("2 characters") && color.is_some());
|
||||
}
|
||||
}
|
||||
+191
-31
@@ -2,21 +2,26 @@
|
||||
//! Untranslated labels fall back to English so coverage can grow incrementally. Every entry is a
|
||||
//! label VectorCraft's menus show (tested), so labels from other apps don't creep in.
|
||||
|
||||
mod czech;
|
||||
use czech::CZECH;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
En,
|
||||
Ja,
|
||||
Cs,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
|
||||
pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::Cs];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
Self::Ja => "日本語",
|
||||
Self::Cs => "Čeština",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +29,18 @@ impl Language {
|
||||
match code {
|
||||
"en" => Some(Self::En),
|
||||
"ja" => Some(Self::Ja),
|
||||
"cs" => Some(Self::Cs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tr(self, text: &str) -> &str {
|
||||
if self == Self::Ja
|
||||
&& let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text)
|
||||
{
|
||||
return japanese;
|
||||
}
|
||||
text
|
||||
let table = match self {
|
||||
Self::En => return text,
|
||||
Self::Ja => JAPANESE,
|
||||
Self::Cs => CZECH,
|
||||
};
|
||||
table.iter().find(|(english, _)| *english == text).map_or(text, |(_, translated)| translated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,55 +85,187 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::menus::Item;
|
||||
|
||||
#[test]
|
||||
fn translations_are_unique_and_preserve_unknown_text() {
|
||||
for (i, (en, ja)) in JAPANESE.iter().enumerate() {
|
||||
assert!(!ja.is_empty());
|
||||
assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other));
|
||||
assert_eq!(Language::En.tr(en), *en);
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("File"), "ファイル");
|
||||
assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
}
|
||||
const TABLES: [(Language, &[(&str, &str)]); 2] = [(Language::Ja, JAPANESE), (Language::Cs, CZECH)];
|
||||
|
||||
#[test]
|
||||
fn every_translated_label_is_a_menu_label() {
|
||||
fn walk(items: &[crate::menus::Item], out: &mut Vec<&'static str>) {
|
||||
/// Menu labels Czech shows as they are: the product name, language names, a format name, the
|
||||
/// built-in workspace names and the perspective grid presets (names, shown untranslated
|
||||
/// wherever else they appear).
|
||||
const KEEP_AS_IS: &[&str] = &[
|
||||
"VectorCraft",
|
||||
"English",
|
||||
"日本語",
|
||||
"Čeština",
|
||||
"OpenType",
|
||||
"Essentials",
|
||||
"Essentials Classic",
|
||||
"Automation",
|
||||
"Layout",
|
||||
"Painting",
|
||||
"Printing and Proofing",
|
||||
"Tracing",
|
||||
"Typography",
|
||||
"Web",
|
||||
"[1P-Normal View]",
|
||||
"[1P-Low View]",
|
||||
"[1P-High View]",
|
||||
"[2P-Normal View]",
|
||||
"[2P-Low View]",
|
||||
"[2P-High View]",
|
||||
"[3P-Normal View]",
|
||||
"[3P-Low View]",
|
||||
];
|
||||
|
||||
/// Every menu title, submenu, item and header label with its command and params (`""`/null
|
||||
/// for the ones that run nothing).
|
||||
fn menu_labels() -> Vec<(&'static str, &'static str, Value)> {
|
||||
fn walk(items: &[Item], out: &mut Vec<(&'static str, &'static str, Value)>) {
|
||||
for i in items {
|
||||
match i {
|
||||
crate::menus::Item::Cmd(l, ..) | crate::menus::Item::Todo(l, _) | crate::menus::Item::Header(l) => out.push(l),
|
||||
crate::menus::Item::Sub(l, children) => {
|
||||
out.push(l);
|
||||
Item::Cmd(l, id, p) => out.push((l, id, p.clone())),
|
||||
Item::Todo(l, _) | Item::Header(l) => out.push((l, "", Value::Null)),
|
||||
Item::Sub(l, children) => {
|
||||
out.push((l, "", Value::Null));
|
||||
walk(children, out);
|
||||
}
|
||||
crate::menus::Item::Sep => {}
|
||||
Item::Sep => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut labels = vec![];
|
||||
for (title, items) in crate::menus::menu_tree() {
|
||||
labels.push(title);
|
||||
labels.push((title, "", Value::Null));
|
||||
walk(&items, &mut labels);
|
||||
}
|
||||
for (en, _) in JAPANESE {
|
||||
assert!(labels.contains(en), "`{en}` isn't a VectorCraft menu label");
|
||||
labels
|
||||
}
|
||||
|
||||
/// The labels toggling items switch to ("Show Guides" once guides are hidden…), from a
|
||||
/// document with every toggle flipped.
|
||||
fn toggled_labels() -> Vec<String> {
|
||||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("object.envelope.makeWithWarp", json!({})).unwrap();
|
||||
let toggles = [
|
||||
"view.outline",
|
||||
"view.edges",
|
||||
"view.cornerWidget",
|
||||
"view.textThreads",
|
||||
"type.hiddenCharacters",
|
||||
"view.gradientAnnotator",
|
||||
"view.artboards",
|
||||
"view.rulers",
|
||||
"view.boundingBox",
|
||||
"view.transparencyGrid",
|
||||
"view.guides",
|
||||
"view.grid",
|
||||
"view.guides.lock",
|
||||
"view.slices.hide",
|
||||
"view.printTiling",
|
||||
"perspective.grid.show",
|
||||
"perspective.grid.rulers",
|
||||
"perspective.grid.lock",
|
||||
"object.envelope.editContents",
|
||||
];
|
||||
let mut labels = vec![];
|
||||
for id in toggles {
|
||||
let before = crate::menus::dynamic_label(&app, id, "");
|
||||
app.run(id, json!({})).unwrap();
|
||||
let after = crate::menus::dynamic_label(&app, id, "");
|
||||
assert_ne!(before, after, "{id} didn't toggle its label");
|
||||
labels.extend([before, after]);
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_are_unique_and_preserve_unknown_text() {
|
||||
for (language, table) in TABLES {
|
||||
for (i, (en, translated)) in table.iter().enumerate() {
|
||||
assert!(!translated.is_empty());
|
||||
assert!(table.iter().take(i).all(|(other, _)| en != other), "`{en}` twice");
|
||||
assert_eq!(en.ends_with('…'), translated.ends_with('…'), "`{en}` and `{translated}` differ in their ellipsis");
|
||||
assert_eq!(Language::En.tr(en), *en);
|
||||
assert_eq!(language.tr(en), *translated);
|
||||
}
|
||||
assert_eq!(language.tr("日本語の文書.pdf"), "日本語の文書.pdf");
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("File"), "ファイル");
|
||||
assert_eq!(Language::Cs.tr("File"), "Soubor");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
for language in Language::ALL {
|
||||
let code = serde_json::to_value(language).unwrap();
|
||||
assert_eq!(Language::parse(code.as_str().unwrap()), Some(language));
|
||||
}
|
||||
assert_eq!(serde_json::to_value(Language::Cs).unwrap(), json!("cs"));
|
||||
assert_eq!(Language::Cs.name(), "Čeština");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_translated_label_is_a_menu_label() {
|
||||
let mut labels: Vec<String> = menu_labels().into_iter().map(|(l, ..)| l.to_string()).collect();
|
||||
// Object › Plug-ins' header leaves the menu while any test has a filter plug-in installed.
|
||||
labels.push(crate::dialogs::plugin::NO_FILTERS.into());
|
||||
labels.extend(toggled_labels());
|
||||
for (language, table) in TABLES {
|
||||
for (en, _) in table {
|
||||
assert!(labels.iter().any(|l| l == en), "{language:?}: `{en}` isn't a VectorCraft menu label");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_translates_every_menu_label() {
|
||||
let translated = |l: &str| CZECH.iter().any(|(en, _)| *en == l);
|
||||
let labels = menu_labels();
|
||||
// Font names and sizes, and installed plug-ins' own names, are not interface text.
|
||||
let interface = |(label, id, p): &(&str, &str, Value)| {
|
||||
let plugin = *id == "plugin.dialog" || p.get("effect").and_then(Value::as_str).is_some_and(|e| e.starts_with("plugin."));
|
||||
*id != "text.setStyle" && !plugin && !KEEP_AS_IS.contains(label)
|
||||
};
|
||||
let mut missing: Vec<String> = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect();
|
||||
missing.extend(toggled_labels());
|
||||
missing.retain(|l| !translated(l));
|
||||
missing.dedup();
|
||||
assert!(missing.is_empty(), "untranslated Czech menu labels: {missing:?}");
|
||||
for keep in KEEP_AS_IS {
|
||||
assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label");
|
||||
assert!(!translated(keep), "`{keep}` is both kept and translated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_command_validates_and_persists_without_a_document() {
|
||||
let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "ja"})).unwrap().unwrap();
|
||||
crate::menus::run_ui_command(&mut app, "app.language", &json!({"lang": "ja"})).unwrap().unwrap();
|
||||
assert_eq!(app.ui.language, Language::Ja);
|
||||
assert_eq!(crate::menus::checked(&app, "app.language", &serde_json::json!({"lang": "ja"})), Some(true));
|
||||
assert!(crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "xx"})).unwrap().is_err());
|
||||
assert_eq!(crate::menus::checked(&app, "app.language", &json!({"lang": "ja"})), Some(true));
|
||||
assert!(crate::menus::run_ui_command(&mut app, "app.language", &json!({"lang": "xx"})).unwrap().is_err());
|
||||
assert_eq!(app.ui.language, Language::Ja);
|
||||
let saved = serde_json::to_string(&app.ui).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, Language::Ja);
|
||||
assert_eq!(crate::menus::run_ui_command(&mut app, "app.language", &json!({"lang": "cs"})).unwrap(), Ok(json!("cs")));
|
||||
assert_eq!(app.ui.language, Language::Cs);
|
||||
assert_eq!(crate::menus::checked(&app, "app.language", &json!({"lang": "cs"})), Some(true));
|
||||
assert_eq!(crate::menus::checked(&app, "app.language", &json!({"lang": "ja"})), Some(false));
|
||||
let restored: crate::state::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::Cs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_language_menu_offers_every_language() {
|
||||
let items: Vec<_> = menu_labels().into_iter().filter(|(_, id, _)| *id == "app.language").collect();
|
||||
assert_eq!(items.len(), Language::ALL.len());
|
||||
for language in Language::ALL {
|
||||
let code = serde_json::to_value(language).unwrap();
|
||||
assert!(items.iter().any(|(l, _, p)| *l == language.name() && p["lang"] == code), "{language:?} isn't in the Language menu");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -150,4 +288,26 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Czech letters (and the punctuation Czech 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() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
let families: Vec<_> = fonts.definitions().families.iter().map(|(f, stack)| (f.clone(), stack.first().cloned())).collect();
|
||||
for (family, first) in families {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–".chars() {
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Czech (Čeština) menu labels, in menu order.
|
||||
//!
|
||||
//! Clean-room: every entry is written from the meaning of VectorCraft's English label, following
|
||||
//! common Czech desktop-software conventions (commands in the infinitive, menus as nouns); no
|
||||
//! other application's Czech localisation was consulted or copied.
|
||||
//!
|
||||
//! Left in English on purpose (see `KEEP_AS_IS` in the tests): the product name, language names,
|
||||
//! OpenType, the built-in workspace names (names the workspace switcher and saved preferences
|
||||
//! show as-is) and the bracketed perspective grid preset names (preset names stored in documents).
|
||||
|
||||
pub(super) const CZECH: &[(&str, &str)] = &[
|
||||
// VectorCraft
|
||||
("About VectorCraft", "O aplikaci VectorCraft"),
|
||||
("Join Our Discord", "Připojit se k našemu Discordu"),
|
||||
("Settings…", "Nastavení…"),
|
||||
("Language", "Jazyk"),
|
||||
("UI Brightness", "Jas rozhraní"),
|
||||
("Dark", "Tmavý"),
|
||||
("Medium Dark", "Středně tmavý"),
|
||||
("Medium Light", "Středně světlý"),
|
||||
("Light", "Světlý"),
|
||||
("Quit VectorCraft", "Ukončit VectorCraft"),
|
||||
// File
|
||||
("File", "Soubor"),
|
||||
("New…", "Nový…"),
|
||||
("New from Template…", "Nový ze šablony…"),
|
||||
("Open…", "Otevřít…"),
|
||||
("Open Recent Files", "Otevřít nedávné soubory"),
|
||||
("Recent File", "Nedávný soubor"),
|
||||
("Clear Recent Files", "Vymazat seznam nedávných souborů"),
|
||||
("Show in Folder", "Zobrazit ve složce"),
|
||||
("Close", "Zavřít"),
|
||||
("Close All", "Zavřít vše"),
|
||||
("Save", "Uložit"),
|
||||
("Save As…", "Uložit jako…"),
|
||||
("Save a Copy…", "Uložit kopii…"),
|
||||
("Save as Template…", "Uložit jako šablonu…"),
|
||||
("Save as PDF…", "Uložit jako PDF…"),
|
||||
("Save for Office Documents…", "Uložit pro kancelářské dokumenty…"),
|
||||
("Revert", "Vrátit k uložené verzi"),
|
||||
("Place…", "Umístit…"),
|
||||
("Export", "Export"),
|
||||
("Export for Screens…", "Exportovat pro obrazovky…"),
|
||||
("Export As…", "Exportovat jako…"),
|
||||
("Export As SVG…", "Exportovat jako SVG…"),
|
||||
("Export As PNG…", "Exportovat jako PNG…"),
|
||||
("Save for Web (Legacy)…", "Uložit pro web (starší)…"),
|
||||
("Export Selection…", "Exportovat výběr…"),
|
||||
("Package…", "Sbalit…"),
|
||||
("Scripts", "Skripty"),
|
||||
("Other Script…", "Jiný skript…"),
|
||||
("Document Setup…", "Nastavení dokumentu…"),
|
||||
("Document Color Mode", "Barevný režim dokumentu"),
|
||||
("CMYK Color", "Barvy CMYK"),
|
||||
("RGB Color", "Barvy RGB"),
|
||||
("File Info…", "Informace o souboru…"),
|
||||
("Print…", "Tisk…"),
|
||||
// Edit
|
||||
("Edit", "Úpravy"),
|
||||
("Undo", "Zpět"),
|
||||
("Redo", "Znovu"),
|
||||
("Cut", "Vyjmout"),
|
||||
("Copy", "Kopírovat"),
|
||||
("Paste", "Vložit"),
|
||||
("Paste in Front", "Vložit dopředu"),
|
||||
("Paste in Back", "Vložit dozadu"),
|
||||
("Paste in Place", "Vložit na místo"),
|
||||
("Paste on All Artboards", "Vložit na všechny pracovní plochy"),
|
||||
("Paste without Formatting", "Vložit bez formátování"),
|
||||
("Clear", "Smazat"),
|
||||
("Find and Replace…", "Najít a nahradit…"),
|
||||
("Find Next", "Najít další"),
|
||||
("Spelling", "Pravopis"),
|
||||
("Auto Spell Check", "Automatická kontrola pravopisu"),
|
||||
("Check Spelling…", "Zkontrolovat pravopis…"),
|
||||
("Edit Custom Dictionary…", "Upravit vlastní slovník…"),
|
||||
("Edit Colors", "Upravit barvy"),
|
||||
("Recolor Artwork…", "Přebarvit kresbu…"),
|
||||
("Recolor with Preset", "Přebarvit podle přednastavení"),
|
||||
("1 Color Job…", "Zakázka s 1 barvou…"),
|
||||
("2 Color Job…", "Zakázka se 2 barvami…"),
|
||||
("3 Color Job…", "Zakázka se 3 barvami…"),
|
||||
("Color Library…", "Knihovna barev…"),
|
||||
("Adjust Color Balance…", "Upravit vyvážení barev…"),
|
||||
("Blend Front to Back", "Prolnout zepředu dozadu"),
|
||||
("Blend Horizontally", "Prolnout vodorovně"),
|
||||
("Blend Vertically", "Prolnout svisle"),
|
||||
("Convert to CMYK", "Převést na CMYK"),
|
||||
("Convert to Grayscale", "Převést na stupně šedi"),
|
||||
("Convert to RGB", "Převést na RGB"),
|
||||
("Invert Colors", "Invertovat barvy"),
|
||||
("Overprint Black…", "Přetisknout černou…"),
|
||||
("Saturate…", "Upravit sytost…"),
|
||||
("Edit Original", "Upravit originál"),
|
||||
("Transparency Flattener Presets…", "Přednastavení sloučení průhlednosti…"),
|
||||
("Print Presets…", "Přednastavení tisku…"),
|
||||
("PDF Presets…", "Přednastavení PDF…"),
|
||||
("Perspective Grid Presets…", "Přednastavení perspektivní mřížky…"),
|
||||
("Color Settings…", "Nastavení barev…"),
|
||||
("Assign Profile…", "Přiřadit profil…"),
|
||||
("Keyboard Shortcuts…", "Klávesové zkratky…"),
|
||||
("Preferences…", "Předvolby…"),
|
||||
// Object
|
||||
("Object", "Objekt"),
|
||||
("Transform", "Transformace"),
|
||||
("Transform Again", "Transformovat znovu"),
|
||||
("Move…", "Přesunout…"),
|
||||
("Rotate…", "Otočit…"),
|
||||
("Reflect…", "Zrcadlit…"),
|
||||
("Scale…", "Změnit velikost…"),
|
||||
("Shear…", "Zkosit…"),
|
||||
("Transform Each…", "Transformovat jednotlivě…"),
|
||||
("Reset Bounding Box", "Obnovit ohraničovací rámeček"),
|
||||
("Arrange", "Uspořádat"),
|
||||
("Bring to Front", "Přenést do popředí"),
|
||||
("Bring Forward", "Přenést blíž"),
|
||||
("Send Backward", "Přesunout dál"),
|
||||
("Send to Back", "Přesunout do pozadí"),
|
||||
("Send to Current Layer", "Přesunout do aktuální vrstvy"),
|
||||
("Align", "Zarovnat"),
|
||||
("Horizontal Align Left", "Vodorovně zarovnat doleva"),
|
||||
("Horizontal Align Center", "Vodorovně zarovnat na střed"),
|
||||
("Horizontal Align Right", "Vodorovně zarovnat doprava"),
|
||||
("Vertical Align Top", "Svisle zarovnat nahoru"),
|
||||
("Vertical Align Center", "Svisle zarovnat na střed"),
|
||||
("Vertical Align Bottom", "Svisle zarovnat dolů"),
|
||||
("Group", "Seskupit"),
|
||||
("Ungroup", "Oddělit"),
|
||||
("Lock", "Zamknout"),
|
||||
("Selection", "Výběr"),
|
||||
("All Artwork Above", "Veškerou kresbu výše"),
|
||||
("Other Layers", "Ostatní vrstvy"),
|
||||
("Unlock All", "Odemknout vše"),
|
||||
("Hide", "Skrýt"),
|
||||
("Show All", "Zobrazit vše"),
|
||||
("Expand…", "Rozložit…"),
|
||||
("Expand Appearance", "Rozložit vzhled"),
|
||||
("Crop Image", "Oříznout obraz"),
|
||||
("Rasterize…", "Rastrovat…"),
|
||||
("Create Gradient Mesh…", "Vytvořit přechodovou síť…"),
|
||||
("Create Object Mosaic…", "Vytvořit mozaiku objektů…"),
|
||||
("Vector Halftone…", "Vektorový polotón…"),
|
||||
("Create Trim Marks", "Vytvořit značky ořezu"),
|
||||
("Flatten Transparency…", "Sloučit průhlednost…"),
|
||||
("Make Pixel Perfect", "Zarovnat na obrazové body"),
|
||||
("Slice", "Řez"),
|
||||
("Make", "Vytvořit"),
|
||||
("Release", "Uvolnit"),
|
||||
("Create from Guides", "Vytvořit z vodítek"),
|
||||
("Create from Selection", "Vytvořit z výběru"),
|
||||
("Duplicate Slice", "Duplikovat řez"),
|
||||
("Combine Slices", "Spojit řezy"),
|
||||
("Divide Slices…", "Rozdělit řezy…"),
|
||||
("Delete All", "Odstranit vše"),
|
||||
("Slice Options…", "Možnosti řezu…"),
|
||||
("Clip to Artboard", "Oříznout na pracovní plochu"),
|
||||
("Path", "Cesta"),
|
||||
("Join", "Spojit"),
|
||||
("Average…", "Zprůměrovat…"),
|
||||
("Outline Stroke", "Převést tah na obrys"),
|
||||
("Offset Path…", "Posunout cestu…"),
|
||||
("Reverse Path Direction", "Obrátit směr cesty"),
|
||||
("Simplify…", "Zjednodušit…"),
|
||||
("Add Anchor Points", "Přidat kotevní body"),
|
||||
("Remove Anchor Points", "Odstranit kotevní body"),
|
||||
("Divide Objects Below", "Rozdělit objekty pod"),
|
||||
("Split Into Grid…", "Rozdělit do mřížky…"),
|
||||
("Clean Up…", "Vyčistit…"),
|
||||
("Shape", "Tvar"),
|
||||
("Convert to Shape", "Převést na tvar"),
|
||||
("Expand Shape", "Rozložit tvar"),
|
||||
("Pattern", "Vzorek"),
|
||||
("Edit Pattern", "Upravit vzorek"),
|
||||
("Tile Edge Color…", "Barva okraje dlaždice…"),
|
||||
("Repeat", "Opakování"),
|
||||
("Radial", "Kruhové"),
|
||||
("Grid", "Mřížkové"),
|
||||
("Mirror", "Zrcadlové"),
|
||||
("Options…", "Možnosti…"),
|
||||
("Blend", "Prolnutí"),
|
||||
("Blend Options…", "Možnosti prolnutí…"),
|
||||
("Expand", "Rozložit"),
|
||||
("Replace Spine", "Nahradit páteř"),
|
||||
("Reverse Spine", "Obrátit páteř"),
|
||||
("Reverse Front to Back", "Obrátit pořadí zepředu dozadu"),
|
||||
("Envelope Distort", "Deformace obálkou"),
|
||||
("Make with Warp…", "Vytvořit pokřivením…"),
|
||||
("Reset with Warp…", "Obnovit pokřivením…"),
|
||||
("Make with Mesh…", "Vytvořit sítí…"),
|
||||
("Reset with Mesh…", "Obnovit sítí…"),
|
||||
("Make with Top Object", "Vytvořit horním objektem"),
|
||||
("Envelope Options…", "Možnosti obálky…"),
|
||||
("Edit Contents", "Upravit obsah"),
|
||||
("Edit Envelope", "Upravit obálku"),
|
||||
("Perspective", "Perspektiva"),
|
||||
("Attach to Active Plane", "Připojit k aktivní rovině"),
|
||||
("Release with Perspective", "Uvolnit s perspektivou"),
|
||||
("Move Plane to Match Object", "Posunout rovinu k objektu"),
|
||||
("Edit Text", "Upravit text"),
|
||||
("Live Paint", "Živá malba"),
|
||||
("Merge", "Sloučit"),
|
||||
("Gap Options…", "Možnosti mezer…"),
|
||||
("Image Trace", "Vektorizace obrazu"),
|
||||
("Make…", "Vytvořit…"),
|
||||
("Make and Expand…", "Vytvořit a rozložit…"),
|
||||
("Text Wrap", "Obtékání textu"),
|
||||
("Text Wrap Options…", "Možnosti obtékání textu…"),
|
||||
("Clipping Mask", "Ořezová maska"),
|
||||
("Edit Clipping Path", "Upravit ořezovou cestu"),
|
||||
("Compound Path", "Složená cesta"),
|
||||
("Artboards", "Pracovní plochy"),
|
||||
("Convert to Artboards", "Převést na pracovní plochy"),
|
||||
("Rearrange All Artboards…", "Přeuspořádat všechny pracovní plochy…"),
|
||||
("Fit to Artwork Bounds", "Přizpůsobit hranicím kresby"),
|
||||
("Fit to Selected Art", "Přizpůsobit vybrané kresbě"),
|
||||
("Graph", "Graf"),
|
||||
("Type…", "Typ…"),
|
||||
("Data…", "Údaje…"),
|
||||
("Design…", "Návrh…"),
|
||||
("Column…", "Sloupec…"),
|
||||
("Marker…", "Značka…"),
|
||||
("Collect for Export", "Shromáždit pro export"),
|
||||
("As Single Asset", "Jako jeden prvek"),
|
||||
("As Multiple Assets", "Jako více prvků"),
|
||||
("Plug-ins", "Zásuvné moduly"),
|
||||
("No filter plug-ins installed", "Nejsou nainstalovány žádné filtry"),
|
||||
("Install Plug-in…", "Nainstalovat zásuvný modul…"),
|
||||
("Reload Plug-ins", "Znovu načíst zásuvné moduly"),
|
||||
// Type
|
||||
("Type", "Text"),
|
||||
("Font", "Písmo"),
|
||||
("Recent Fonts", "Nedávná písma"),
|
||||
("Recent Font", "Nedávné písmo"),
|
||||
("Size", "Velikost"),
|
||||
("Glyphs", "Glyfy"),
|
||||
("Insert Special Character", "Vložit speciální znak"),
|
||||
("Bullet", "Odrážka"),
|
||||
("Copyright Symbol", "Symbol copyrightu"),
|
||||
("Ellipsis", "Výpustka"),
|
||||
("Paragraph Symbol", "Symbol odstavce"),
|
||||
("Registered Trademark Symbol", "Symbol registrované ochranné známky"),
|
||||
("Section Symbol", "Symbol paragrafu"),
|
||||
("Trademark Symbol", "Symbol ochranné známky"),
|
||||
("Em Dash", "Dlouhá pomlčka"),
|
||||
("En Dash", "Krátká pomlčka"),
|
||||
("Discretionary Hyphen", "Volitelný spojovník"),
|
||||
("Nonbreaking Hyphen", "Nezlomitelný spojovník"),
|
||||
("Double Left Quotation Marks", "Levé dvojité uvozovky"),
|
||||
("Double Right Quotation Marks", "Pravé dvojité uvozovky"),
|
||||
("Single Left Quotation Mark", "Levá jednoduchá uvozovka"),
|
||||
("Single Right Quotation Mark", "Pravá jednoduchá uvozovka"),
|
||||
("Insert Whitespace Character", "Vložit znak mezery"),
|
||||
("Em Space", "Čtverčíková mezera"),
|
||||
("En Space", "Půlčtverčíková mezera"),
|
||||
("Hair Space", "Vlasová mezera"),
|
||||
("Sixth Space", "Šestinová mezera"),
|
||||
("Thin Space", "Úzká mezera"),
|
||||
("Nonbreaking Space", "Nezlomitelná mezera"),
|
||||
("Figure Space", "Číslicová mezera"),
|
||||
("Punctuation Space", "Interpunkční mezera"),
|
||||
("Third Space", "Třetinová mezera"),
|
||||
("Quarter Space", "Čtvrtinová mezera"),
|
||||
("Insert Break Character", "Vložit znak zalomení"),
|
||||
("Forced Line Break", "Vynucené zalomení řádku"),
|
||||
("Tab", "Tabulátor"),
|
||||
("Convert To Area Type", "Převést na text v oblasti"),
|
||||
("Convert To Point Type", "Převést na bodový text"),
|
||||
("Area Type Options…", "Možnosti textu v oblasti…"),
|
||||
("Type on a Path", "Text na cestě"),
|
||||
("Rainbow", "Duha"),
|
||||
("Skew", "Zkosení"),
|
||||
("3D Ribbon", "3D stuha"),
|
||||
("Stair Step", "Schody"),
|
||||
("Gravity", "Gravitace"),
|
||||
("Type on a Path Options…", "Možnosti textu na cestě…"),
|
||||
("Threaded Text", "Zřetězený text"),
|
||||
("Create", "Vytvořit"),
|
||||
("Release Selection", "Uvolnit výběr"),
|
||||
("Remove Threading", "Odstranit zřetězení"),
|
||||
("Fit Headline", "Přizpůsobit titulek"),
|
||||
("Find Font…", "Najít písmo…"),
|
||||
("Change Case", "Změnit velikost písmen"),
|
||||
("UPPERCASE", "VELKÁ PÍSMENA"),
|
||||
("lowercase", "malá písmena"),
|
||||
("Title Case", "Velká Počáteční Písmena"),
|
||||
("Sentence case", "Velké první písmeno věty"),
|
||||
("Smart Punctuation…", "Inteligentní interpunkce…"),
|
||||
("Create Outlines", "Převést na křivky"),
|
||||
("Optical Margin Alignment", "Optické zarovnání okrajů"),
|
||||
("Fill with Placeholder Text", "Vyplnit zástupným textem"),
|
||||
("Show Hidden Characters", "Zobrazit skryté znaky"),
|
||||
("Hide Hidden Characters", "Skrýt skryté znaky"),
|
||||
("Type Orientation", "Orientace textu"),
|
||||
("Horizontal", "Vodorovně"),
|
||||
("Vertical", "Svisle"),
|
||||
// Select
|
||||
("Select", "Výběr"),
|
||||
("All", "Vybrat vše"),
|
||||
("All on Active Artboard", "Vše na aktivní pracovní ploše"),
|
||||
("Deselect", "Zrušit výběr"),
|
||||
("Reselect", "Znovu vybrat"),
|
||||
("Inverse", "Invertovat výběr"),
|
||||
("Next Object Above", "Další objekt nad"),
|
||||
("Next Object Below", "Další objekt pod"),
|
||||
("Same", "Stejné"),
|
||||
("Appearance", "Vzhled"),
|
||||
("Appearance Attribute", "Atribut vzhledu"),
|
||||
("Blending Mode", "Režim míchání"),
|
||||
("Fill & Stroke", "Výplň a tah"),
|
||||
("Fill Color", "Barva výplně"),
|
||||
("Opacity", "Krytí"),
|
||||
("Stroke Color", "Barva tahu"),
|
||||
("Stroke Weight", "Tloušťka tahu"),
|
||||
("Graphic Style", "Grafický styl"),
|
||||
("Symbol Instance", "Instance symbolu"),
|
||||
("Font Family", "Rodina písma"),
|
||||
("Font Family & Style", "Rodina a řez písma"),
|
||||
("Font Family, Style & Size", "Rodina, řez a velikost písma"),
|
||||
("Font Size", "Velikost písma"),
|
||||
("Text Fill Color", "Barva výplně textu"),
|
||||
("Text Stroke Color", "Barva tahu textu"),
|
||||
("All on Same Layers", "Vše ve stejných vrstvách"),
|
||||
("Direction Handles", "Směrové úchyty"),
|
||||
("Brush Strokes", "Tahy štětcem"),
|
||||
("Bristle Brush Strokes", "Tahy štětinovým štětcem"),
|
||||
("Clipping Masks", "Ořezové masky"),
|
||||
("Slices", "Řezy"),
|
||||
("Stray Points", "Osamocené body"),
|
||||
("Open Paths", "Otevřené cesty"),
|
||||
("All Text Objects", "Všechny textové objekty"),
|
||||
("Point Text Objects", "Bodové textové objekty"),
|
||||
("Area Text Objects", "Textové objekty v oblasti"),
|
||||
("Start Global Edit", "Zahájit globální úpravy"),
|
||||
("Save Selection…", "Uložit výběr…"),
|
||||
("Edit Selection…", "Upravit výběr…"),
|
||||
// Effect
|
||||
("Effect", "Efekt"),
|
||||
("Apply Last Effect", "Použít poslední efekt"),
|
||||
("Last Effect…", "Poslední efekt…"),
|
||||
("Document Raster Effects Settings…", "Nastavení rastrových efektů dokumentu…"),
|
||||
("Vector Effects", "Vektorové efekty"),
|
||||
("3D and Materials", "3D a materiály"),
|
||||
("Extrude & Bevel…", "Vysunutí a zkosení…"),
|
||||
("Revolve…", "Rotační těleso…"),
|
||||
("Inflate…", "Nafouknutí…"),
|
||||
("Materials…", "Materiály…"),
|
||||
("Color Adjustments", "Úpravy barev"),
|
||||
("Brightness/Contrast…", "Jas/kontrast…"),
|
||||
("Curves…", "Křivky…"),
|
||||
("Hue/Saturation…", "Odstín/sytost…"),
|
||||
("Levels…", "Úrovně…"),
|
||||
("Shift to Color…", "Posun k barvě…"),
|
||||
("Temperature/Tint…", "Teplota/nádech…"),
|
||||
("Rectangle…", "Obdélník…"),
|
||||
("Rounded Rectangle…", "Zaoblený obdélník…"),
|
||||
("Ellipse…", "Elipsa…"),
|
||||
("Crop Marks", "Ořezové značky"),
|
||||
("Distort & Transform", "Deformace a transformace"),
|
||||
("Free Distort…", "Volná deformace…"),
|
||||
("Pucker & Bloat…", "Smrštění a vyboulení…"),
|
||||
("Roughen…", "Zdrsnění…"),
|
||||
("Transform…", "Transformovat…"),
|
||||
("Tweak…", "Náhodné posunutí…"),
|
||||
("Twist…", "Zkroucení…"),
|
||||
("Zig Zag…", "Cikcak…"),
|
||||
("Pathfinder", "Operace s cestami"),
|
||||
("Add", "Sjednotit"),
|
||||
("Intersect", "Protnout"),
|
||||
("Exclude", "Vyloučit"),
|
||||
("Subtract", "Odečíst"),
|
||||
("Minus Back", "Odečíst zadní"),
|
||||
("Divide", "Rozdělit"),
|
||||
("Trim", "Zastřihnout"),
|
||||
("Crop", "Oříznout"),
|
||||
("Outline", "Obrys"),
|
||||
("Stylize", "Stylizace"),
|
||||
("Round Corners…", "Zaoblit rohy…"),
|
||||
("Scribble…", "Čmáranice…"),
|
||||
("Drop Shadow…", "Vržený stín…"),
|
||||
("Inner Glow…", "Vnitřní záře…"),
|
||||
("Outer Glow…", "Vnější záře…"),
|
||||
("Feather…", "Měkké okraje…"),
|
||||
("SVG Filters", "Filtry SVG"),
|
||||
("Apply SVG Filter…", "Použít filtr SVG…"),
|
||||
("Warp", "Pokřivení"),
|
||||
("Arc…", "Oblouk…"),
|
||||
("Arc Lower…", "Dolní oblouk…"),
|
||||
("Arc Upper…", "Horní oblouk…"),
|
||||
("Arch…", "Klenba…"),
|
||||
("Bulge…", "Vydutí…"),
|
||||
("Shell Lower…", "Dolní mušle…"),
|
||||
("Shell Upper…", "Horní mušle…"),
|
||||
("Flag…", "Vlajka…"),
|
||||
("Wave…", "Vlna…"),
|
||||
("Fish…", "Ryba…"),
|
||||
("Rise…", "Stoupání…"),
|
||||
("Fisheye…", "Rybí oko…"),
|
||||
("Squeeze…", "Stlačení…"),
|
||||
("Raster Effects", "Rastrové efekty"),
|
||||
("Blur", "Rozostření"),
|
||||
("Gaussian Blur…", "Gaussovské rozostření…"),
|
||||
// View
|
||||
("View", "Zobrazení"),
|
||||
("Preview", "Náhled"),
|
||||
("Overprint Preview", "Náhled přetisků"),
|
||||
("Pixel Preview", "Náhled obrazových bodů"),
|
||||
("Trim View", "Oříznuté zobrazení"),
|
||||
("Presentation Mode", "Režim prezentace"),
|
||||
("Screen Mode", "Režim obrazovky"),
|
||||
("Normal Screen Mode", "Normální režim obrazovky"),
|
||||
("Full Screen Mode with Menu Bar", "Celá obrazovka s pruhem nabídek"),
|
||||
("Full Screen Mode", "Celá obrazovka"),
|
||||
("Proof Setup", "Nastavení nátisku"),
|
||||
("Working CMYK", "Pracovní CMYK"),
|
||||
("Legacy Macintosh RGB (Gamma 1.8)", "Starší Macintosh RGB (gama 1,8)"),
|
||||
("Internet Standard RGB (sRGB)", "Standardní internetové RGB (sRGB)"),
|
||||
("Monitor RGB", "RGB monitoru"),
|
||||
("Color blindness – Protanopia-type", "Barvoslepost – protanopie"),
|
||||
("Color blindness – Deuteranopia-type", "Barvoslepost – deuteranopie"),
|
||||
("Customize…", "Vlastní…"),
|
||||
("Proof Colors", "Barvy nátisku"),
|
||||
("Zoom In", "Zvětšit"),
|
||||
("Zoom Out", "Zmenšit"),
|
||||
("Fit Artboard in Window", "Přizpůsobit pracovní plochu oknu"),
|
||||
("Fit All in Window", "Přizpůsobit vše oknu"),
|
||||
("Actual Size", "Skutečná velikost"),
|
||||
("Reset Rotate View", "Obnovit otočení zobrazení"),
|
||||
("Hide Edges", "Skrýt okraje"),
|
||||
("Show Edges", "Zobrazit okraje"),
|
||||
("Hide Artboards", "Skrýt pracovní plochy"),
|
||||
("Show Artboards", "Zobrazit pracovní plochy"),
|
||||
("Show Print Tiling", "Zobrazit dlaždice tisku"),
|
||||
("Hide Print Tiling", "Skrýt dlaždice tisku"),
|
||||
("Hide Slices", "Skrýt řezy"),
|
||||
("Show Slices", "Zobrazit řezy"),
|
||||
("Lock Slices", "Zamknout řezy"),
|
||||
("Rulers", "Pravítka"),
|
||||
("Show Rulers", "Zobrazit pravítka"),
|
||||
("Hide Rulers", "Skrýt pravítka"),
|
||||
("Change to Global Rulers", "Přepnout na globální pravítka"),
|
||||
("Show Video Rulers", "Zobrazit video pravítka"),
|
||||
("Hide Bounding Box", "Skrýt ohraničovací rámeček"),
|
||||
("Show Bounding Box", "Zobrazit ohraničovací rámeček"),
|
||||
("Show Transparency Grid", "Zobrazit mřížku průhlednosti"),
|
||||
("Hide Transparency Grid", "Skrýt mřížku průhlednosti"),
|
||||
("Hide Text Threads", "Skrýt zřetězení textu"),
|
||||
("Show Text Threads", "Zobrazit zřetězení textu"),
|
||||
("Hide Gradient Annotator", "Skrýt ovladač přechodu"),
|
||||
("Show Gradient Annotator", "Zobrazit ovladač přechodu"),
|
||||
("Hide Corner Widget", "Skrýt ovladač rohů"),
|
||||
("Show Corner Widget", "Zobrazit ovladač rohů"),
|
||||
("Guides", "Vodítka"),
|
||||
("Hide Guides", "Skrýt vodítka"),
|
||||
("Show Guides", "Zobrazit vodítka"),
|
||||
("Lock Guides", "Zamknout vodítka"),
|
||||
("Unlock Guides", "Odemknout vodítka"),
|
||||
("Make Guides", "Vytvořit vodítka"),
|
||||
("Release Guides", "Uvolnit vodítka"),
|
||||
("Clear Guides", "Vymazat vodítka"),
|
||||
("Smart Guides", "Chytrá vodítka"),
|
||||
("Perspective Grid", "Perspektivní mřížka"),
|
||||
("Show Grid", "Zobrazit mřížku"),
|
||||
("Hide Grid", "Skrýt mřížku"),
|
||||
("Snap to Grid", "Přichytávat k mřížce"),
|
||||
("Lock Grid", "Zamknout mřížku"),
|
||||
("Unlock Grid", "Odemknout mřížku"),
|
||||
("Lock Station Point", "Zamknout bod pozorovatele"),
|
||||
("Define Grid…", "Definovat mřížku…"),
|
||||
("One Point Perspective", "Jednobodová perspektiva"),
|
||||
("User Preset", "Uživatelské přednastavení"),
|
||||
("Two Point Perspective", "Dvoubodová perspektiva"),
|
||||
("Three Point Perspective", "Tříbodová perspektiva"),
|
||||
("Save Grid as Preset…", "Uložit mřížku jako přednastavení…"),
|
||||
("Snap to Pixel", "Přichytávat k obrazovým bodům"),
|
||||
("Snap to Point", "Přichytávat k bodům"),
|
||||
("Snap to Glyph", "Přichytávat ke glyfům"),
|
||||
("New View…", "Nové zobrazení…"),
|
||||
("Edit Views…", "Upravit zobrazení…"),
|
||||
("Saved View 1", "Uložené zobrazení 1"),
|
||||
("Saved View 2", "Uložené zobrazení 2"),
|
||||
("Saved View 3", "Uložené zobrazení 3"),
|
||||
("Saved View 4", "Uložené zobrazení 4"),
|
||||
("Saved View 5", "Uložené zobrazení 5"),
|
||||
("Saved View 6", "Uložené zobrazení 6"),
|
||||
("Saved View 7", "Uložené zobrazení 7"),
|
||||
("Saved View 8", "Uložené zobrazení 8"),
|
||||
("Saved View 9", "Uložené zobrazení 9"),
|
||||
("Saved View 10", "Uložené zobrazení 10"),
|
||||
// Window
|
||||
("Window", "Okno"),
|
||||
("New Window", "Nové okno"),
|
||||
("Cascade", "Do kaskády"),
|
||||
("Tile", "Vedle sebe"),
|
||||
("Float in Window", "Uvolnit do samostatného okna"),
|
||||
("Consolidate All Windows", "Sloučit všechna okna"),
|
||||
("Workspace", "Pracovní prostor"),
|
||||
("Reset Essentials", "Obnovit Essentials"),
|
||||
("New Workspace…", "Nový pracovní prostor…"),
|
||||
("Manage Workspaces…", "Spravovat pracovní prostory…"),
|
||||
("Control", "Ovládací panel"),
|
||||
("Contextual Task Bar", "Kontextový panel úloh"),
|
||||
("Tools", "Nástroje"),
|
||||
("Toolbars", "Panely nástrojů"),
|
||||
("Advanced", "Rozšířený"),
|
||||
("Single / Double Column", "Jeden / dva sloupce"),
|
||||
("Actions", "Akce"),
|
||||
("Asset Export", "Export prvků"),
|
||||
("Attributes", "Atributy"),
|
||||
("Brushes", "Štětce"),
|
||||
("Color", "Barva"),
|
||||
("Color Guide", "Průvodce barvami"),
|
||||
("Color Themes", "Barevné motivy"),
|
||||
("CSS Properties", "Vlastnosti CSS"),
|
||||
("Document Info", "Informace o dokumentu"),
|
||||
("Flattener Preview", "Náhled sloučení"),
|
||||
("Gradient", "Přechod"),
|
||||
("Graphic Styles", "Grafické styly"),
|
||||
("History", "Historie"),
|
||||
("Info", "Informace"),
|
||||
("Layers", "Vrstvy"),
|
||||
("Libraries", "Knihovny"),
|
||||
("Links", "Propojení"),
|
||||
("Magic Wand", "Kouzelná hůlka"),
|
||||
("Navigator", "Navigátor"),
|
||||
("Pattern Options", "Možnosti vzorku"),
|
||||
("Properties", "Vlastnosti"),
|
||||
("Separations Preview", "Náhled separací"),
|
||||
("Stroke", "Tah"),
|
||||
("SVG Interactivity", "Interaktivita SVG"),
|
||||
("Swatches", "Vzorník"),
|
||||
("Symbols", "Symboly"),
|
||||
("Transparency", "Průhlednost"),
|
||||
("Character", "Znak"),
|
||||
("Character Styles", "Styly znaků"),
|
||||
("Paragraph", "Odstavec"),
|
||||
("Paragraph Styles", "Styly odstavců"),
|
||||
("Tabs", "Tabulátory"),
|
||||
("Variables", "Proměnné"),
|
||||
("Brush Libraries", "Knihovny štětců"),
|
||||
("Built-in Libraries", "Vestavěné knihovny"),
|
||||
("User Defined", "Uživatelské"),
|
||||
("Other Library…", "Jiná knihovna…"),
|
||||
("Graphic Style Libraries", "Knihovny grafických stylů"),
|
||||
("Shadows and Glows", "Stíny a záře"),
|
||||
("Outlines and Rules", "Obrysy a linky"),
|
||||
("Hand-Drawn", "Ručně kreslené"),
|
||||
("Gradient Finishes", "Přechodové povrchy"),
|
||||
("Shape Effects", "Efekty tvarů"),
|
||||
("Blends and Transparency", "Prolnutí a průhlednost"),
|
||||
("User Library", "Uživatelská knihovna"),
|
||||
("Save Graphic Style Library…", "Uložit knihovnu grafických stylů…"),
|
||||
("Swatch Libraries", "Knihovny vzorníků"),
|
||||
("Default Swatches", "Výchozí vzorník"),
|
||||
("Web Safe 216", "Webově bezpečné 216"),
|
||||
("Grays and Neutrals", "Šedé a neutrální"),
|
||||
("Earth Tones", "Zemité tóny"),
|
||||
("Skin Tone Ramps", "Škály odstínů pleti"),
|
||||
("Pastels", "Pastelové"),
|
||||
("Brights", "Zářivé"),
|
||||
("Metallic Gradients", "Kovové přechody"),
|
||||
("Perceptual Scales", "Percepční škály"),
|
||||
("Harmony Sets", "Harmonické sady"),
|
||||
("Gradients", "Přechody"),
|
||||
("Spectrum Gradients", "Spektrální přechody"),
|
||||
("Sky Gradients", "Přechody oblohy"),
|
||||
("Neutral Gradients", "Neutrální přechody"),
|
||||
("Duotone Gradients", "Dvoutónové přechody"),
|
||||
("Radial Glows", "Kruhové záře"),
|
||||
("Save Swatch Library…", "Uložit knihovnu vzorníku…"),
|
||||
("Symbol Libraries", "Knihovny symbolů"),
|
||||
// Help
|
||||
("Help", "Nápověda"),
|
||||
("ArtCraft Website", "Web ArtCraft"),
|
||||
("VectorCraft on getartcraft.com", "VectorCraft na getartcraft.com"),
|
||||
("VectorCraft on GitHub", "VectorCraft na GitHubu"),
|
||||
("Search Commands…", "Hledat příkazy…"),
|
||||
("VectorCraft Help…", "Nápověda VectorCraft…"),
|
||||
];
|
||||
@@ -48,6 +48,8 @@ mod tests_background;
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
#[cfg(test)]
|
||||
mod tests_contextmenu;
|
||||
#[cfg(test)]
|
||||
mod tests_cut;
|
||||
#[cfg(test)]
|
||||
mod tests_distortkeys;
|
||||
@@ -301,6 +303,11 @@ pub struct VectorcraftApp {
|
||||
host_modifiers: egui::Modifiers,
|
||||
/// Synthetic input set the modifiers egui holds (see [`Self::raw_input_hook`]).
|
||||
synthetic_modifiers: bool,
|
||||
/// The marked text the system IME last sent (`None` once it commits or clears). When the Type
|
||||
/// tool stops composing on its own (a click, a tool switch), the IME is told to drop it.
|
||||
pub(crate) ime_marked: Option<String>,
|
||||
/// The IME must drop its marked text (see [`Self::take_ime_discard`]).
|
||||
pub(crate) ime_discard: bool,
|
||||
}
|
||||
|
||||
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
|
||||
@@ -360,6 +367,8 @@ impl VectorcraftApp {
|
||||
recovery: Default::default(),
|
||||
host_modifiers: Default::default(),
|
||||
synthetic_modifiers: false,
|
||||
ime_marked: None,
|
||||
ime_discard: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +584,13 @@ impl VectorcraftApp {
|
||||
});
|
||||
}
|
||||
|
||||
/// Did the Type tool end an IME composition on its own this frame? Interrupting the IME
|
||||
/// through egui (`should_interrupt_composition`) doesn't reach the macOS input context, which
|
||||
/// keeps the marked text and types it again into the next composition: the host discards it.
|
||||
pub fn take_ime_discard(&mut self) -> bool {
|
||||
std::mem::take(&mut self.ime_discard)
|
||||
}
|
||||
|
||||
/// Show a transient status message.
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.ui.status = s.into();
|
||||
|
||||
+209
-65
@@ -50,7 +50,7 @@ use Item::Sep;
|
||||
|
||||
/// UI-level commands: (id, label, shortcut, params doc).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("app.language", "Interface Language", "", "{lang: en|ja} — persistent interface language"),
|
||||
("app.language", "Interface Language", "", "{lang: en|ja|cs} — persistent interface language"),
|
||||
("file.open", "Open…", "Cmd+O", "{path?}"),
|
||||
(
|
||||
"file.save",
|
||||
@@ -674,7 +674,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if id == "app.language" {
|
||||
let language = p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse);
|
||||
let Some(language) = language else { return Some(Err("lang must be en or ja".into())) };
|
||||
let Some(language) = language else { return Some(Err("lang must be en, ja or cs".into())) };
|
||||
app.ui.language = language;
|
||||
return Some(Ok(json!(language)));
|
||||
}
|
||||
@@ -1372,7 +1372,21 @@ pub fn shortcut_of(id: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
/// Is a command currently enabled?
|
||||
/// Commands that would act on the text being typed, held back while an IME composes in the Type
|
||||
/// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the
|
||||
/// IME —, the clipboard and the selection.
|
||||
pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool {
|
||||
app.session.tool_composing()
|
||||
&& (matches!(
|
||||
id,
|
||||
"edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse"
|
||||
) || id.starts_with("edit.paste"))
|
||||
}
|
||||
|
||||
pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
if waits_for_ime(app, id) {
|
||||
return false;
|
||||
}
|
||||
if let Some(c) = vectorcraft_engine::find_command(id) {
|
||||
// The system clipboard's contents can be pasted with an empty internal clipboard.
|
||||
return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste");
|
||||
@@ -1450,7 +1464,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Join Our Discord", "help.discord"),
|
||||
Sep,
|
||||
c("Settings…", "edit.preferences"),
|
||||
sub("Language", vec![cp("English", "app.language", json!({"lang": "en"})), cp("日本語", "app.language", json!({"lang": "ja"}))]),
|
||||
sub(
|
||||
"Language",
|
||||
vec![
|
||||
cp("English", "app.language", json!({"lang": "en"})),
|
||||
cp("日本語", "app.language", json!({"lang": "ja"})),
|
||||
cp("Čeština", "app.language", json!({"lang": "cs"})),
|
||||
],
|
||||
),
|
||||
Sep,
|
||||
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
|
||||
Sep,
|
||||
@@ -1577,33 +1598,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
(
|
||||
"Object",
|
||||
vec![
|
||||
sub(
|
||||
"Transform",
|
||||
vec![
|
||||
c("Transform Again", "object.transformAgain"),
|
||||
Sep,
|
||||
c("Move…", "object.move"),
|
||||
c("Rotate…", "object.rotate"),
|
||||
c("Reflect…", "object.reflect"),
|
||||
c("Scale…", "object.scale"),
|
||||
c("Shear…", "object.shear"),
|
||||
Sep,
|
||||
c("Transform Each…", "object.transformEach"),
|
||||
Sep,
|
||||
c("Reset Bounding Box", "object.resetBoundingBox"),
|
||||
],
|
||||
),
|
||||
sub(
|
||||
"Arrange",
|
||||
vec![
|
||||
c("Bring to Front", "object.arrange.bringToFront"),
|
||||
c("Bring Forward", "object.arrange.bringForward"),
|
||||
c("Send Backward", "object.arrange.sendBackward"),
|
||||
c("Send to Back", "object.arrange.sendToBack"),
|
||||
Sep,
|
||||
c("Send to Current Layer", "object.arrange.sendToCurrentLayer"),
|
||||
],
|
||||
),
|
||||
sub("Transform", transform_items()),
|
||||
sub("Arrange", arrange_items()),
|
||||
sub(
|
||||
"Align",
|
||||
vec![
|
||||
@@ -2103,6 +2099,141 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Object → Transform (also in the canvas context menu).
|
||||
fn transform_items() -> Vec<Item> {
|
||||
vec![
|
||||
c("Transform Again", "object.transformAgain"),
|
||||
Sep,
|
||||
c("Move…", "object.move"),
|
||||
c("Rotate…", "object.rotate"),
|
||||
c("Reflect…", "object.reflect"),
|
||||
c("Scale…", "object.scale"),
|
||||
c("Shear…", "object.shear"),
|
||||
Sep,
|
||||
c("Transform Each…", "object.transformEach"),
|
||||
Sep,
|
||||
c("Reset Bounding Box", "object.resetBoundingBox"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Object → Arrange (also in the canvas context menu).
|
||||
fn arrange_items() -> Vec<Item> {
|
||||
vec![
|
||||
c("Bring to Front", "object.arrange.bringToFront"),
|
||||
c("Bring Forward", "object.arrange.bringForward"),
|
||||
c("Send Backward", "object.arrange.sendBackward"),
|
||||
c("Send to Back", "object.arrange.sendToBack"),
|
||||
Sep,
|
||||
c("Send to Current Layer", "object.arrange.sendToCurrentLayer"),
|
||||
]
|
||||
}
|
||||
|
||||
/// The canvas context menu (right-click): what applies to the selection, or to the view when
|
||||
/// nothing is selected. Commands that can't run now are left out rather than greyed out.
|
||||
pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
let Some(st) = app.session.active() else { return vec![] };
|
||||
let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect();
|
||||
let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind));
|
||||
let several = roots.len() >= 2;
|
||||
let mut v = vec![c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep];
|
||||
if st.isolation.is_some() {
|
||||
v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]);
|
||||
}
|
||||
if roots.is_empty() {
|
||||
v.extend([
|
||||
c("Paste", "edit.paste"),
|
||||
Sep,
|
||||
c("Zoom In", "view.zoomIn"),
|
||||
c("Zoom Out", "view.zoomOut"),
|
||||
c("Fit Artboard in Window", "view.fitArtboard"),
|
||||
Sep,
|
||||
c("Show Rulers", "view.rulers"),
|
||||
c("Show Grid", "view.grid"),
|
||||
c("Hide Guides", "view.guides"),
|
||||
c("Lock Guides", "view.guides.lock"),
|
||||
Sep,
|
||||
c("Select All", "select.all"),
|
||||
]);
|
||||
} else {
|
||||
v.extend([c("Cut", "edit.cut"), c("Copy", "edit.copy"), c("Paste", "edit.paste"), Sep]);
|
||||
if let [one] = roots.as_slice()
|
||||
&& one.is_container()
|
||||
{
|
||||
v.push(c("Isolate Selected Group", "object.isolate"));
|
||||
}
|
||||
if several {
|
||||
v.push(c("Group", "object.group"));
|
||||
}
|
||||
if any(|k| matches!(k, NodeKind::Group { clip: false, .. })) {
|
||||
v.push(c("Ungroup", "object.ungroup"));
|
||||
}
|
||||
let paths = any(|k| matches!(k, NodeKind::Path { .. }));
|
||||
if paths {
|
||||
v.extend([c("Join", "path.join"), c("Average…", "path.average")]);
|
||||
}
|
||||
if several {
|
||||
v.push(c("Make Clipping Mask", "object.clippingMask.make"));
|
||||
}
|
||||
if any(|k| matches!(k, NodeKind::Group { clip: true, .. })) {
|
||||
v.push(c("Release Clipping Mask", "object.clippingMask.release"));
|
||||
}
|
||||
if several && paths {
|
||||
v.push(c("Make Compound Path", "object.compoundPath.make"));
|
||||
}
|
||||
if any(|k| matches!(k, NodeKind::Compound { .. })) {
|
||||
v.push(c("Release Compound Path", "object.compoundPath.release"));
|
||||
}
|
||||
v.extend([
|
||||
c("Make Guides", "view.guides.make"),
|
||||
Sep,
|
||||
sub("Transform", transform_items()),
|
||||
sub("Arrange", arrange_items()),
|
||||
sub(
|
||||
"Select",
|
||||
vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")],
|
||||
),
|
||||
Sep,
|
||||
c("Export Selection…", "file.exportSelection"),
|
||||
]);
|
||||
}
|
||||
prune(app, v)
|
||||
}
|
||||
|
||||
/// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus
|
||||
/// left empty, and separators that no longer separate anything.
|
||||
fn prune(app: &VectorcraftApp, items: Vec<Item>) -> Vec<Item> {
|
||||
let mut out: Vec<Item> = Vec::with_capacity(items.len());
|
||||
for it in items {
|
||||
let it = match it {
|
||||
Item::Sub(l, children) => Item::Sub(l, prune(app, children)),
|
||||
it => it,
|
||||
};
|
||||
let keep = match &it {
|
||||
Item::Cmd(_, id, _) => matches!(*id, "edit.undo" | "edit.redo") || enabled(app, id),
|
||||
Item::Sub(_, children) => !children.is_empty(),
|
||||
Item::Sep => out.last().is_some_and(|l| !matches!(l, Item::Sep)),
|
||||
_ => true,
|
||||
};
|
||||
if keep {
|
||||
out.push(it);
|
||||
}
|
||||
}
|
||||
if matches!(out.last(), Some(Item::Sep)) {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The canvas context menu's popup; the item clicked goes in `clicked`, for [`invoke`].
|
||||
pub fn context_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Option<(String, Value)>) {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
// Its toggles already say what they do (Show Rulers, Hide Guides): no check-mark gutter.
|
||||
render_items(app, ui, &context_items(app), false, clicked);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the menu bar.
|
||||
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
|
||||
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
@@ -2133,11 +2264,13 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
ui.set_min_width(230.0);
|
||||
render_items(app, ui, items, clicked);
|
||||
render_items(app, ui, items, true, clicked);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
|
||||
/// `items` as menu buttons; with `checks`, items that can be on or off show a check mark (or the
|
||||
/// room for one).
|
||||
fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: bool, clicked: &mut Option<(String, Value)>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for it in items {
|
||||
match it {
|
||||
@@ -2151,7 +2284,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
|
||||
ui.menu_button(app.ui.language.tr(label), |ui| {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
render_items(app, ui, children, clicked);
|
||||
render_items(app, ui, children, checks, clicked);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2171,7 +2304,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
|
||||
let label = dynamic_label(app, id, label);
|
||||
let label = app.ui.language.tr(&label).to_string();
|
||||
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
|
||||
let chk = checked(app, id, p);
|
||||
let chk = checks.then(|| checked(app, id, p)).flatten();
|
||||
let text = match chk {
|
||||
Some(true) => format!("✓ {label}"),
|
||||
Some(false) => format!(" {label}"),
|
||||
@@ -2232,6 +2365,9 @@ fn menu_dialog(id: &str) -> Option<(&'static str, Value)> {
|
||||
|
||||
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
|
||||
pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
if waits_for_ime(app, id) {
|
||||
return;
|
||||
}
|
||||
if let Some((kind, fields)) = menu_dialog(id)
|
||||
&& p.as_object().is_none_or(|o| o.is_empty())
|
||||
{
|
||||
@@ -2387,42 +2523,50 @@ pub struct MenuEntry {
|
||||
|
||||
/// Flattened menu for `ui.menu.list`.
|
||||
pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
|
||||
fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
|
||||
Item::Cmd(l, id, p) => out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
label: dynamic_label(app, id, l),
|
||||
command: Some(id.to_string()),
|
||||
params: p.clone(),
|
||||
enabled: enabled(app, id),
|
||||
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
|
||||
}),
|
||||
Item::Todo(l, sc) => out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
label: l.to_string(),
|
||||
command: None,
|
||||
params: Value::Null,
|
||||
enabled: false,
|
||||
shortcut: sc.to_string(),
|
||||
}),
|
||||
Item::Sub(l, ch) => {
|
||||
let mut p = path.clone();
|
||||
p.push(l.to_string());
|
||||
walk(app, p, ch, out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for (title, items) in menu_tree() {
|
||||
walk(app, vec![title.to_string()], &items, &mut out);
|
||||
flatten(app, vec![title.to_string()], &items, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Flattened canvas context menu for `ui.contextMenu.list` (paths are its submenus).
|
||||
pub fn context_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
|
||||
let mut out = vec![];
|
||||
flatten(app, vec![], &context_items(app), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {}
|
||||
Item::Cmd(l, id, p) => out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
label: dynamic_label(app, id, l),
|
||||
command: Some(id.to_string()),
|
||||
params: p.clone(),
|
||||
enabled: enabled(app, id),
|
||||
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
|
||||
}),
|
||||
Item::Todo(l, sc) => out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
label: l.to_string(),
|
||||
command: None,
|
||||
params: Value::Null,
|
||||
enabled: false,
|
||||
shortcut: sc.to_string(),
|
||||
}),
|
||||
Item::Sub(l, ch) => {
|
||||
let mut p = path.clone();
|
||||
p.push(l.to_string());
|
||||
flatten(app, p, ch, out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type → Size presets.
|
||||
const TYPE_SIZES: [(&str, u32); 14] = [
|
||||
("6 pt", 6),
|
||||
@@ -2485,7 +2629,7 @@ fn font_items() -> Vec<Item> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Read first: fonts that load meanwhile make the next frame build the list again.
|
||||
let generation = db.generation();
|
||||
let fams = db.family_list();
|
||||
let fams = db.menu_family_list();
|
||||
let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] };
|
||||
if items.0 != generation {
|
||||
let list = fams
|
||||
|
||||
@@ -129,6 +129,49 @@ impl PasteChord {
|
||||
}
|
||||
}
|
||||
|
||||
/// End an IME composition outside the IME (a click away, the IME going away): the
|
||||
/// marked text stays as typed, and the system IME is told to drop it.
|
||||
pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) {
|
||||
let Some(m) = app.ime_marked.take() else { return };
|
||||
app.ime_discard = true;
|
||||
if app.session.tool_composing() {
|
||||
// Errors are already reported by the engine's interaction.
|
||||
let _ = app.session.tool_text(&m, app.view_info());
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed text and IME events, in the order they came, to the Type tool.
|
||||
fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let view = app.view_info();
|
||||
// Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes
|
||||
// on with it when the window comes back (measured with Kotoeri), as the tool does.
|
||||
let events: Vec<egui::Event> =
|
||||
ctx.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::Text(_) | egui::Event::Ime(_))).cloned().collect());
|
||||
for e in events {
|
||||
// Errors are already reported by the engine's interaction (a failed preview keeps it).
|
||||
let _ = match e {
|
||||
// Keys the IME passed through come as text only when nothing is marked.
|
||||
egui::Event::Text(t) if !app.session.tool_composing() => app.session.tool_text(&t, view),
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => {
|
||||
app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty());
|
||||
app.session.tool_preedit(&text, active_range_chars, view)
|
||||
}
|
||||
// A bare line break confirms the composition (Enter): the marked text, not a newline.
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) if t == "\n" || t == "\r" => {
|
||||
keep_marked_text(app);
|
||||
Ok(vec![])
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(t)) => {
|
||||
app.ime_marked = None;
|
||||
app.session.tool_text(&t, view)
|
||||
}
|
||||
// `DeleteSurrounding` isn't sent by egui-winit (desktop); the web host has no IME
|
||||
// path to the canvas yet.
|
||||
_ => Ok(vec![]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// Followed before anything returns, so a key typed in a field isn't taken for a paste.
|
||||
let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events));
|
||||
@@ -144,10 +187,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
let view = app.view_info();
|
||||
// While an IME composes, Enter and Escape are its own (confirm / cancel).
|
||||
let composing = app.session.tool_composing();
|
||||
// Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging).
|
||||
let busy = app.session.tool_busy();
|
||||
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] {
|
||||
if !typing && ctx.input(|i| i.key_pressed(k)) {
|
||||
if !typing && !composing && ctx.input(|i| i.key_pressed(k)) {
|
||||
// A key the tool claims (Esc with a loaded place cursor) is only the tool's.
|
||||
let claimed = app.session.tool_claims_key(tk, view);
|
||||
let r = app.session.tool_key(tk, Mods::default(), view);
|
||||
@@ -168,12 +213,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if typing {
|
||||
return;
|
||||
}
|
||||
// Type tool editing: text and editing keys go to the tool.
|
||||
// Type tool editing: text, IME composition and editing keys go to the tool.
|
||||
if app.session.tool_wants_text() {
|
||||
let texts: Vec<String> =
|
||||
ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect());
|
||||
for t in texts {
|
||||
let _ = app.session.tool_text(&t, view);
|
||||
type_text(app, ctx);
|
||||
// Keys of a frame that began composing are the IME's too (a Backspace that empties the
|
||||
// marked text must not delete the committed character before it).
|
||||
if composing || app.session.tool_composing() {
|
||||
// The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts.
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)))
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Editing keys with modifiers, clipboard and Cmd+A.
|
||||
crate::panels::character::route_type_input(app, ctx);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! The canvas context menu: right-click selects the object under the pointer and lists what
|
||||
//! applies to the selection.
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::canvas::Xf;
|
||||
use crate::menus::{self, Item};
|
||||
use crate::{VectorcraftApp, canvas};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn rect(app: &mut VectorcraftApp, x: f64, y: f64) -> NodeId {
|
||||
NodeId(app.run("shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// One headless frame of the canvas on an 800 × 600 window → the text painted, with its rect.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
fn walk(s: &egui::Shape, v: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => v.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn press(pos: Pos2, button: PointerButton, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Click `button` at `pos` (move, press, release, then a frame to show the result).
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, pos: Pos2, button: PointerButton) -> Vec<(String, Rect)> {
|
||||
frame(app, ctx, vec![Event::PointerMoved(pos)]);
|
||||
frame(app, ctx, vec![press(pos, button, true)]);
|
||||
frame(app, ctx, vec![press(pos, button, false)]);
|
||||
frame(app, ctx, vec![])
|
||||
}
|
||||
|
||||
/// Where document point `p` is on screen (after a first frame laid the canvas out).
|
||||
fn screen(app: &VectorcraftApp, x: f64, y: f64) -> Pos2 {
|
||||
Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(x, y))
|
||||
}
|
||||
|
||||
fn has(texts: &[(String, Rect)], label: &str) -> bool {
|
||||
texts.iter().any(|(t, _)| t.trim_start_matches(['✓', ' ']) == label)
|
||||
}
|
||||
|
||||
fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
|
||||
texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{label}` in {texts:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_selects_the_object_and_lists_its_commands() {
|
||||
let mut app = app();
|
||||
let id = rect(&mut app, 50.0, 50.0);
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!has(&frame(&mut app, &ctx, vec![]), "Arrange"));
|
||||
let p = screen(&app, 100.0, 100.0);
|
||||
let texts = click(&mut app, &ctx, p, PointerButton::Secondary);
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, [id], "the object right-clicked is selected");
|
||||
for label in ["Undo Rectangle", "Cut", "Copy", "Make Guides", "Transform", "Arrange", "Select", "Export Selection…"] {
|
||||
assert!(has(&texts, label), "{label} in {texts:?}");
|
||||
}
|
||||
// Nothing that needs several objects, a group or nothing selected.
|
||||
for label in ["Group", "Ungroup", "Make Clipping Mask", "Release Compound Path", "Select All", "Zoom In"] {
|
||||
assert!(!has(&texts, label), "{label} in {texts:?}");
|
||||
}
|
||||
// Choosing an item runs its command and closes the menu.
|
||||
let texts = click(&mut app, &ctx, at(&texts, "Make Guides"), PointerButton::Primary);
|
||||
let n = app.session.active().unwrap().doc.node(id).cloned().unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Path { guide: true, .. }));
|
||||
assert!(!has(&texts, "Arrange"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_on_empty_canvas_offers_the_view_and_a_dismissing_click_does_nothing() {
|
||||
let mut app = app();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let p = screen(&app, 300.0, 250.0);
|
||||
let texts = click(&mut app, &ctx, p, PointerButton::Secondary);
|
||||
for label in ["Zoom In", "Zoom Out", "Fit Artboard in Window", "Show Rulers", "Select All"] {
|
||||
assert!(has(&texts, label), "{label} in {texts:?}");
|
||||
}
|
||||
assert!(!has(&texts, "Cut") && !has(&texts, "Arrange"));
|
||||
// A click on the canvas away from the menu only closes it: the Rectangle tool draws nothing.
|
||||
let away = pos2(100.0, 500.0);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(away)]);
|
||||
frame(&mut app, &ctx, vec![press(away, PointerButton::Primary, true)]);
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(away + vec2(40.0, 30.0))]);
|
||||
frame(&mut app, &ctx, vec![press(away + vec2(40.0, 30.0), PointerButton::Primary, false)]);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(!has(&texts, "Zoom In"));
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
|
||||
}
|
||||
|
||||
fn labels(items: &[Item]) -> Vec<&'static str> {
|
||||
items
|
||||
.iter()
|
||||
.flat_map(|it| match it {
|
||||
Item::Cmd(l, ..) => vec![*l],
|
||||
Item::Sub(l, ch) => std::iter::once(*l).chain(labels(ch)).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_follows_the_selection() {
|
||||
let mut app = app();
|
||||
let (a, b) = (rect(&mut app, 0.0, 0.0), rect(&mut app, 50.0, 50.0));
|
||||
app.run("select.set", json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let l = labels(&menus::context_items(&app));
|
||||
for label in ["Group", "Join", "Make Clipping Mask", "Make Compound Path", "Bring to Front", "Move…"] {
|
||||
assert!(l.contains(&label), "{label} in {l:?}");
|
||||
}
|
||||
assert!(!l.contains(&"Ungroup") && !l.contains(&"Isolate Selected Group"));
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
let l = labels(&menus::context_items(&app));
|
||||
assert!(l.contains(&"Ungroup") && l.contains(&"Isolate Selected Group"));
|
||||
assert!(!l.contains(&"Group") && !l.contains(&"Join"));
|
||||
// Isolated: a way out.
|
||||
menus::invoke(&mut app, "object.isolate", json!({}));
|
||||
assert!(labels(&menus::context_items(&app)).contains(&"Exit Isolation Mode"));
|
||||
// Agents see the same menu, with submenus as paths.
|
||||
let entries = menus::context_entries(&app);
|
||||
assert!(entries.iter().any(|e| e.command.as_deref() == Some("object.exitIsolation") && e.enabled));
|
||||
app.run("select.set", json!({"ids": [a.0]})).unwrap();
|
||||
let entries = menus::context_entries(&app);
|
||||
let front = entries.iter().find(|e| e.command.as_deref() == Some("object.arrange.bringToFront")).unwrap();
|
||||
assert_eq!(front.path, ["Arrange"]);
|
||||
// No separator leads, trails or doubles up.
|
||||
let items = menus::context_items(&app);
|
||||
assert!(!matches!(items.first(), Some(Item::Sep)) && !matches!(items.last(), Some(Item::Sep)));
|
||||
assert!(items.windows(2).all(|w| !matches!(w, [Item::Sep, Item::Sep])));
|
||||
}
|
||||
@@ -16,10 +16,10 @@ fn type_font_menu_lists_every_available_family() {
|
||||
menus::menu_entries(app).into_iter().filter(|e| e.path == ["Type", "Font"]).map(|e| e.label).collect()
|
||||
};
|
||||
let labels = fonts(&app);
|
||||
assert_eq!(labels, FontDb::global().families());
|
||||
assert_eq!(labels, *FontDb::global().menu_family_list());
|
||||
let installed = FontDb::with_font_dirs(system_font_dirs());
|
||||
installed.load_system_fonts();
|
||||
let missing: Vec<String> = installed.families().into_iter().filter(|f| !labels.contains(f)).collect();
|
||||
let missing: Vec<String> = installed.menu_family_list().iter().filter(|f| !labels.contains(f)).cloned().collect();
|
||||
assert!(missing.is_empty(), "installed but not in Type › Font: {missing:?}");
|
||||
// The menu is built every frame, from a list built once.
|
||||
assert_eq!(fonts(&app), labels);
|
||||
|
||||
@@ -475,7 +475,7 @@ pub fn font_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, cu
|
||||
}
|
||||
let query = search_field(ui, field, "Search").to_lowercase();
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let families = vectorcraft_text::FontDb::global().family_list();
|
||||
let families = vectorcraft_text::FontDb::global().menu_family_list();
|
||||
let (mut chosen, mut first) = (None, None);
|
||||
// The search field stays put over the list as it scrolls.
|
||||
menu_scroll(ui, |ui| {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| `document.inspect` | | layer tree, selection, history, paint defaults |
|
||||
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
|
||||
+5
-3
@@ -67,7 +67,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
|
||||
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
|
||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
||||
@@ -1741,8 +1741,10 @@ tone (mono, or CMYK screens multiplied over each other), clipped to the art's ou
|
||||
Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web).
|
||||
The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
|
||||
by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList`
|
||||
lists every family available, the installed ones included; with `family` it gives that family's styles (upright
|
||||
by weight, then italics) and fails when the family isn't available. `text.rescanFonts` (Character panel menu ›
|
||||
lists every family available, the installed ones included, as the font menus do: without the system's hidden
|
||||
families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names
|
||||
them. With `family` it gives that family's styles (upright by weight, then italics) and fails when the family isn't
|
||||
available. `text.rescanFonts` (Character panel menu ›
|
||||
Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a
|
||||
font that became available redraws in it, without editing the document.
|
||||
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package VectorCraft for FreeBSD:
|
||||
#
|
||||
# $DIST/vectorcraft-<version>-freebsd-x86_64.tar.gz a /usr/local-style tree:
|
||||
# vectorcraft-<version>-freebsd-x86_64/{bin, share/applications, share/icons, share/mime,
|
||||
# share/metainfo, share/doc/vectorcraft}
|
||||
#
|
||||
# Install by copying the tree's contents into /usr/local, e.g.
|
||||
# tar -xzf vectorcraft-*-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
|
||||
#
|
||||
# Usage: packaging/freebsd/package.sh [--skip-build]
|
||||
#
|
||||
# Needs: bash, cargo and the build dependencies listed in .github/workflows/freebsd.yml. FreeBSD's
|
||||
# install(1) has no -D, so directories are created first.
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
LINUX="$ROOT/packaging/linux"
|
||||
APP_ID=ai.storyteller.vectorcraft
|
||||
|
||||
SKIP_BUILD=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# FreeBSD calls x86_64 "amd64"; release names use the Rust/Linux spelling.
|
||||
case "$(uname -m)" in
|
||||
amd64 | x86_64) ARCH=x86_64 ;;
|
||||
arm64 | aarch64) ARCH=aarch64 ;;
|
||||
*) echo "unsupported architecture $(uname -m)" >&2; exit 2 ;;
|
||||
esac
|
||||
BASENAME="vectorcraft-$VERSION-freebsd-$ARCH"
|
||||
|
||||
echo "==> VectorCraft $VERSION for FreeBSD $ARCH"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli)
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
WORK="$CARGO_TARGET_DIR/freebsd-package"
|
||||
STAGE="$WORK/$BASENAME"
|
||||
rm -rf "$WORK"
|
||||
|
||||
# ---- stage a /usr/local-style tree ---------------------------------------------------------------
|
||||
mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/mime/packages" "$STAGE/share/metainfo" \
|
||||
"$STAGE/share/icons" "$STAGE/share/doc/vectorcraft"
|
||||
install -m 755 "$BIN/vectorcraft" "$BIN/vectorcraft-cli" "$STAGE/bin/"
|
||||
strip "$STAGE/bin/vectorcraft" "$STAGE/bin/vectorcraft-cli" 2>/dev/null || true
|
||||
# The desktop entry, MIME type and metainfo are the freedesktop files the Linux packages use.
|
||||
install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop"
|
||||
install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$VECTORCRAFT_BUILD_DATE/g" \
|
||||
"$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml"
|
||||
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/"
|
||||
copy_docs "$STAGE/share/doc/vectorcraft"
|
||||
for f in NOTICE ASSETS.md; do
|
||||
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$STAGE/share/doc/vectorcraft/"; fi
|
||||
done
|
||||
|
||||
# ---- .tar.gz -------------------------------------------------------------------------------------
|
||||
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
|
||||
echo "wrote $DIST/$BASENAME.tar.gz"
|
||||
|
||||
"$STAGE/bin/vectorcraft-cli" --version
|
||||
echo "==> done"
|
||||
ls -lh "$DIST"
|
||||
Reference in new issue
Block a user