Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 64891c1a2e Fix #234: EPS files from the reference app open
EPS files the reference app saves (its procsets in the prolog, a binary header with a TIFF
preview) now open as editable art instead of failing with "undefinedresource in Generic". When
their PostScript still can't be read, the palette preview they carry is placed with the usual
warning instead of the file being refused.

Root cause: five interpreter gaps and one preview format. The Generic and Category resource
categories didn't exist, so categories couldn't be made from Generic (now Category holds the
standard categories, defineresource in Category makes new ones, undefineresource is added);
clipsave/cliprestore were unknown (now a clip stack in the graphics state); systemdict's
languagelevel was the operator, not the integer 3; getinterval copied arrays and strings
instead of sharing their storage, so astore into a subarray was lost (arrays and strings are
now views into shared storage, and readstring/readline return the part they fill); and
resourceforall was unknown (now enumerates matching names with * ? \ templates). The preview
is an 8-bit palette TIFF with an alpha sample, which the image crate can't decode: a small
bounds-checked reader handles that layout (either byte order, any strips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:34:27 +07:00
BFlat 83faf568b0 Merge pull request #244 from storytold/fix/issue-231
Fix #231: docs/releasing.md describes the release pipeline
2026-10-07 09:19:52 +07:00
bflatasticandClaude Opus 5.5 ee1e95d1c7 Fix #231: docs/releasing.md describes the release pipeline
release.yml's comments sent readers to docs/releasing.md, which never
existed: the workflow was ported from a sibling app without its document.
docs/releasing.md now covers how a release is cut (version bump with
cargo xtask version, the release branch, the draft, publishing, test runs
with a version override), what each job builds and how (macOS universal
signing and notarization, Windows x64/x86/arm64 with the PE checks and the
two signing options, Linux packages and the glibc 2.35 floor behind the
ubuntu-22.04 runners, FreeBSD in a VM, the web build, craft-fonts), the
draft release job and the signing secrets. Every detail is checked against
this repository's workflow and packaging scripts. The README links it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:10 +07:00
BFlat e960baa131 Merge pull request #243 from storytold/fix/i18n-review-followups
i18n: names are never translated; palette cache; locale probe off the UI thread
2026-10-07 08:43:42 +07:00
bflatasticandClaude Opus 5.5 0e5e093dd9 i18n: one label_or_name for lists mixing labels and names; normal style rows
panels::label_or_name and dialogs::shown_names applied the same rule; it
now lives once as i18n::label_or_name (explicit language, testable) and
both delegate to it. zh-hant gains the two normal text style names the
Character and Paragraph Styles panels translate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:37:43 +07:00
bflatasticandClaude Opus 5.5 6572636d62 i18n: panels show user, file and system names as they are
Panels passed names to the translating widgets, so a name that happened to
be a catalog key was translated (a font style "Regular", a workspace called
"Layers"). Font styles, pattern names, recent URLs, ICC profiles, saved
width profiles, user and loaded libraries, plug-in effects and their
parameters, Document Info's artboard/style/font/image rows, text-object
contents in Layers, user text styles in brackets, the Artboard tool's
canvas label and the app-bar workspace switcher now show names verbatim;
built-in entries (Working CMYK, (closest), built-in libraries and effects,
[Normal ... Style]) still translate. Formatted tips around a name are
translated as templates only (panels::label_or_name, library_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00
bflatasticandClaude Opus 5.5 cf7a43d786 i18n: dialogs show user, file and system names as they are
Lists and headings that mix our labels with names no longer send the
names through the catalog, so a preset, printer or plug-in called
"Regular" or "Black" keeps its name in every language.

- dialogs::shown_names / mixed_dropdown: translate only a list's built-in
  entries and show the rest with widgets::dropdown_names (same indices).
- Artboard lists (Save for Office, Save for Web, save options) and
  plug-in choices: dropdown_names.
- Mixed lists translate only their built-in entries: Recolor's Limit to
  Library, Save for Web, Print, Save PDF and Export for Screens presets,
  printers (Default Printer, PDF File), colour profiles and flattener
  presets (profile_pick, flatten::preset_dropdown), perspective presets,
  New Document's preset cards and Size, and the shortcut set.
- Plug-in dialogs and plug-in effects are headed by the plug-in's name as
  it is; plug-in parameter labels are not translated.
- The confirm dialog shows its message and detail as given, and the
  shared frame no longer translates headings again: each heading is
  translated by its spec. File > Revert translates its template before
  filling in the document's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00
bflatasticandClaude Opus 5.5 f4181890bd i18n review follow-ups: names are never translated, palette cache, locale probe off the UI thread
Follow-ups from the review of #205.

- Names that are user, file or system data are shown as they are: menu
  items for recent files, saved views, recent fonts and Type > Font,
  user swatch and graphic style libraries, saved perspective presets,
  custom workspaces (and Reset <name>) and plug-ins no longer go through
  the catalog (menus::shows_a_name). widgets::dropdown_names,
  menu_item_name and dim_name are the non-translating forms of the shared
  widgets, for the panels and dialogs that list such names.
- The command palette builds its entries (shown label, search text) once
  per language and plug-in set instead of translating ~700 labels every
  frame while a query is typed.
- The locale probe (reg.exe / defaults) runs on a background thread at
  startup instead of on the first frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:20:35 +07:00
BFlat 116ff3eee7 Merge pull request #205 from WildfootW/claude/laughing-pasteur-5d7kc7
i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
2026-10-07 07:49:14 +07:00
WildfootWandClaude Fable 5.1 902c3ae8c1 i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
Strings in code stay English and are the lookup keys; `crates/ui-egui/src/i18n`
maps them to display text at render time from one `*.tsv` catalog per language
(`LANGUAGES` registry, `tl!`/`t`/`tr`/`tr_ctx`/`tr_id`/`tn`/`fmt`). Command ids,
menu paths, `ui.menu.list`, the control channel, the CLI, MCP, status messages
and undo labels never see translated text, so agents, scripts and the existing
tests are unaffected (under `cargo test` the UI is always English).

Wired: in-window menus and the macOS native menu (rebuilt on a language change),
dynamic labels (Undo X, Reset X, Last Effect: X as templates), the command
palette (matches English and translated text), dock tabs, panel tooltips, the
toolbar (category names now cut by characters, not bytes), the dialog frame, the
shared widgets, the Preferences dialog, the shortcut editor, the hint and status
bars, the Home screen, and the literals of every panel and dialog.

Preference `interfaceLanguage` (User Interface › Language): `auto` or a code.
`auto` follows `VECTORCRAFT_LOCALE`, `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages and, on Windows (which sets no `LANG`), the user
locale from `HKCU\Control Panel\International`. `zh-TW`/`zh-HK`/`zh-MO`/
`zh-Hant-*` resolve to `zh-hant`; Simplified locales stay English until a
`zh-hans` catalog exists.

`zh-hant.tsv` (~2,850 rows) is clean-room, in the vocabulary used in Taiwan;
terms shared with PhotoCraft reuse its wording, vector terms use everyday
vector-drawing vocabulary. Tests: catalog well-formedness (placeholders,
ellipses, command ids, no Simplified characters), locale resolution, and two
coverage tests that fail when a menu string, panel/tool/preference label,
dialog button, hint-bar fragment or `tl!` literal has no row in a language
marked `complete_menus`. `VECTORCRAFT_I18N_DUMP=file cargo test -p
vectorcraft-ui-egui dump_source_strings` lists the strings for translators.

Also: two clippy findings under rustc 1.97 fixed in passing.

Integrated with main (squashed onto it, 42 commits on): the Czech interface
(#213) moves from its Rust table to `i18n/cs.tsv` on the registry (all 556
rows, Czech plural rule, its menu-coverage and glyph tests kept); Find Font's
resolution notes and the canvas context menu's labels are translated and
covered by the catalog tests (`menus::CONTEXT_LABELS`); the glyph test checks
CJK text only when the craft-fonts build input is embedded (#220 moved the
Japanese fonts out of this repository); a language saved by an older version
carries over to `interfaceLanguage`; Windows' locale lookup runs reg.exe by its
full path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:37:05 +07:00
BFlat 2507ed9dd9 Merge pull request #216 from fahrim/mcp-protocol-surface
MCP: prompts, resource templates, completions and logging
2026-10-07 07:35:21 +07:00
Fahri Meral e80b7e383b MCP: prompts, resource templates, completions and logging
The server answered tools/* and two fixed resources and nothing else, and
resources/templates/list returned an empty array. This adds the rest of the surface a
client can actually use, against 2025-06-18.

- prompts/list and prompts/get: five workflows (poster, icon-set, recolor,
  trace-and-style, export-set) with templated arguments. A missing required argument
  is -32602 and names itself.
- resources/templates/list, and resources/read for the templates: object/{id},
  command/{id}, effect/{id}, swatch/{name}. Reading one thing at a time instead of
  vectorcraft://document/json, which on a real document is thousands of lines an agent
  pays for again on every change.
- completion/complete for prompt arguments and template variables, ranked prefix
  first, capped at 100 with total and hasMore. The values are read live, so a suggested
  format, effect id, command id, trace preset, swatch or object id is one the engine
  has.
- logging/setLevel and notifications/message. Nothing installed a log::Log before, so
  the engine's log::debug! calls went nowhere; this is the sink, silent until asked.
  Installing it is vectorcraft-cli's job rather than the library's, so an embedder
  that already has a logger keeps it.

docs/mcp.md gains a Protocol section with the capability table and a Not implemented
section: subscriptions, progress, cancellation, sampling, elicitation and Streamable
HTTP all need the transport to interleave reads, which this one does not — handle_line
answers a line with a reply, so it neither reads an incoming cancellation nor can write
progress before the reply it belongs to.

62 tests (50 unit, 12 property/fuzz). protocol_props.rs gained five: prompts survive
junk arguments, completion/complete never panics and always answers at most 100
string values with a consistent hasMore, every template reads or fails cleanly, and
prompts/completions/templates are swept with junk params while asserting the document
comes back byte-identical.

ROADMAP: one bullet under Automation in Shipped so far and the changelog line. The
parity table and the gap list are untouched — MCP is agent automation, so feature
parity does not move.
2026-10-07 07:25:02 +07:00
BFlat 597b651bb6 Merge pull request #233 from theironchef/fix/canvas-edge-click
Canvas: a click just outside the canvas doesn't reach the tools
2026-10-07 07:24:21 +07:00
BFlat 945f8db359 Merge pull request #237 from theironchef/fix/flyout-panel-width
Panels: a flyout wider than 256 points grows towards the canvas
2026-10-07 07:24:13 +07:00
BFlat e50f02e638 Merge pull request #239 from theironchef/fix/dialog-width
Dialogs: the button row is as wide as the fields, not the window
2026-10-07 07:24:06 +07:00
BFlat aeaece9728 Merge pull request #240 from storytold/fix/font-test-installed-fonts
Tests: the font resolution test no longer depends on the installed fonts
2026-10-07 07:24:01 +07:00
bflatasticandClaude Opus 5.5 c6c2c7d0fe Tests: the font resolution test no longer depends on the installed fonts
list_says_how_each_font_resolved expected Source Sans 3 Black to be a
substitute, which holds only while the bundled family is the only Source
Sans 3: on a machine with the full family installed (it ships with many
font packs), Black resolves exactly and the test failed. It now asks for
a style no Source Sans 3 has, so the substitute path is tested anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:17:36 +07:00
theironchefandClaude Opus 5.5 61cd4ed6e6 Dialogs: the button row is as wide as the fields, not the window
The OK/Cancel row was laid out right to left at the full available
width, and a dialog without a maximum width may use the whole window:
the row, and the window with it, spanned the window (Clean Up, Blend
Options, every generic parameter dialog). The row now spans the fields
above it, at least the dialog's minimum width, so a dialog is sized to
its content.

Fixes #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:38:09 -07:00
theironchefandClaude Opus 5.5 0e1a5b3e45 Panels: a flyout wider than 256 points grows towards the canvas
The icon panel flyout was placed by its left edge, 262 points left of
the icon column, at a width of 256: a panel whose content is wider
(Separations Preview, Align, Transform, Stroke, Attributes, Gradient)
grew to the right, over the panel icons. It is now pinned by its right
edge, 6 points left of the column, so a wider panel grows towards the
canvas; panels of the usual width sit where they did.

Fixes #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:22:27 -07:00
Brandon Thomas b05e575eb8 Merge pull request #235 from storytold/docs/freebsd-platform
README: FreeBSD in the platform line and badge
2026-10-06 18:24:43 -04:00
theironchefandClaude Opus 5.5 10fafed6c4 Canvas: a click just outside the canvas doesn't reach the tools
egui counts a press a few pixels outside the canvas as hovering it (its
interaction radius), but the canvas only takes pointer positions inside
its rect, so such a press fell back to the canvas's centre: a click
just below the document tabs or beside the panels selected, typed or
drew in the middle of the view. Presses, and middle-button pans, now
start only on the canvas itself.

Fixes #232.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:10:50 -07:00
163 changed files with 9872 additions and 2885 deletions

No files matched your search

+2 -1
View File
@@ -197,7 +197,8 @@ the UI can be swapped without touching the engine.
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
explains how VectorCraft avoids crashing, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
## The Crafting Apps
+6 -4
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7 and the MCP prompts, resource templates, completions and logging; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -89,11 +89,13 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- 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.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, Czech covers every menu label and Japanese the main menus, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference.
- 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).
@@ -113,7 +115,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 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.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech every menu label (tested) and Japanese the main menus; untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. 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):**
@@ -131,7 +133,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, 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).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview.
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
@@ -189,7 +191,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
+3
View File
@@ -142,6 +142,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
}
};
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
// The binary owns the logger, not the library: installing one here keeps an embedder that
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
vectorcraft_mcp::logging::install();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
+1
View File
@@ -246,6 +246,7 @@ fn app_icon() -> egui::IconData {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
fn main() -> eframe::Result {
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut args = std::env::args().skip(1);
+14 -12
View File
@@ -10,6 +10,7 @@ use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::i18n::{self, Lang, t};
use vectorcraft_ui_egui::menus::{self, Item};
enum Handle {
@@ -18,7 +19,6 @@ enum Handle {
}
pub struct NativeMenu {
language: vectorcraft_ui_egui::i18n::Language,
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
@@ -28,6 +28,8 @@ pub struct NativeMenu {
slots: usize,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
/// The language the labels were built in (a change rebuilds the menu).
language: Lang,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
@@ -48,7 +50,7 @@ impl NativeMenu {
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(app.ui.language.tr(title), true);
let sub = Submenu::new(t(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
@@ -66,7 +68,7 @@ impl NativeMenu {
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: app.ui.language,
language: i18n::current(),
}
}
@@ -83,12 +85,12 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
// accelerators and lists are current.
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
// changed: rebuild so accelerators, lists and labels are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| app.ui.language != self.language
|| i18n::current() != self.language
{
*self = NativeMenu::install(app);
return;
@@ -121,7 +123,7 @@ impl NativeMenu {
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, "")));
i.set_text(menus::display_label(app, cmd, ""));
}
}
Handle::Check(c) => {
@@ -146,13 +148,13 @@ fn build(
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None));
let _ = parent.append(&MenuItem::new(t(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc)));
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(app.ui.language.tr(label), true);
let sub = Submenu::new(t(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
@@ -163,11 +165,11 @@ fn build(
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc);
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc);
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
+5 -3
View File
@@ -277,8 +277,10 @@ mod open_tests {
fn list_says_how_each_font_resolved() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// "Black Wide" is a style no Source Sans 3 has, bundled or installed, so it is always a
// substitute (the installed family can hold a real Black).
for (text, font, style) in
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black"), ("Gone", "No Such Font Family", "Regular")]
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black Wide"), ("Gone", "No Such Font Family", "Regular")]
{
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
}
@@ -298,8 +300,8 @@ mod open_tests {
.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("Black Wide").1, "substitute", "{rows:?}");
assert_eq!(row("Black Wide").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();
+7
View File
@@ -260,6 +260,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
// `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown.
p!("interfaceLanguage", "User Interface", "Language", "Language", text),
// Performance
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
@@ -390,6 +392,11 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
Err(format!("`{key}` must be a #rrggbb colour"))
}
}
PrefKind::Text if key == "interfaceLanguage" => {
let s = v.as_str().map(str::trim).unwrap_or("");
let ok = !s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
if ok { Ok(json!(s.to_ascii_lowercase())) } else { Err(format!("`{key}` must be `auto` or a language code such as `en` or `zh-hant`")) }
}
PrefKind::Text => match v {
Value::String(s) => Ok(json!(s)),
_ => Err(format!("`{key}` must be a string")),
+4
View File
@@ -391,6 +391,9 @@ pub struct Prefs {
pub large_tabs: bool,
pub ui_scaling: f64,
pub scale_cursor_with_ui: bool,
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
pub interface_language: String,
// Performance
pub gpu_performance: bool,
pub animated_zoom: bool,
@@ -592,6 +595,7 @@ impl Default for Prefs {
large_tabs: false,
ui_scaling: 1.0,
scale_cursor_with_ui: false,
interface_language: s("auto"),
gpu_performance: true,
animated_zoom: true,
history_states: 500,
+3
View File
@@ -910,6 +910,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall",
"[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox",
"clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate",
"clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus",
"resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string",
"(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource",
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
+17 -15
View File
@@ -12,7 +12,7 @@ use vectorcraft_geom::Affine;
use super::graphics::{Space, process};
use super::interp::{Interp, matrix_of};
use super::lex::{find, hex_decode};
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err};
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
use crate::ps;
/// Most bytes one filter or image reads.
@@ -223,20 +223,15 @@ impl Interp<'_> {
ReadHexString | ReadString => {
let s = self.pop_str()?;
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
let n = s.borrow().len();
let n = s.len();
let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? };
let full = got.len() == n;
self.alloc(got.len())?;
if let Some(d) = s.borrow_mut().get_mut(..got.len()) {
d.copy_from_slice(&got);
}
self.push(Obj::string(got))?;
self.push(Obj::Bool(full))?;
self.fill(s, &got)?;
self.push(Obj::Bool(got.len() == n))?;
}
ReadLine => {
let s = self.pop_str()?;
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
let n = s.borrow().len();
let n = s.len();
let mut line = vec![];
let mut ended = false;
while line.len() < n {
@@ -252,8 +247,7 @@ impl Interp<'_> {
Some(b) => line.push(*b),
}
}
self.alloc(line.len())?;
self.push(Obj::string(line))?;
self.fill(s, &line)?;
self.push(Obj::Bool(ended))?;
}
FlushFile | CloseFile => {
@@ -307,12 +301,20 @@ impl Interp<'_> {
Ok(out)
}
/// Read bytes `got` into string `s` and push the part of `s` they fill.
fn fill(&mut self, s: Shared<u8>, got: &[u8]) -> Res {
if !s.write(0, got) {
return ps_err("rangecheck", "");
}
self.push_interval(Obj::Str(s), 0, got.len())
}
/// `filter`: a file that decodes its source. Over the program's own data it decodes when
/// first read (the data follows the operator that reads it); over other data, at once.
fn filter(&mut self) -> Res {
let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?;
let sub = if &*name == "SubFileDecode" {
let eod = self.pop_str()?.borrow().clone();
let eod = self.pop_str()?.to_vec();
let count = self.pop_count()?;
(count, eod)
} else {
@@ -687,8 +689,8 @@ struct Pixels {
fn source(o: &Obj) -> Res<Source> {
Ok(match o {
Obj::File(f) => Source::File(f.clone()),
Obj::Str(s) => Source::Str(s.borrow().clone()),
p @ Obj::Array { .. } => Source::Proc(Obj::Array { items: p.items().cloned().unwrap_or_default(), exec: true }),
Obj::Str(s) => Source::Str(s.to_vec()),
Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }),
_ => return ps_err("typecheck", "DataSource"),
})
}
+21 -5
View File
@@ -106,6 +106,8 @@ pub(crate) struct GState {
pub cur: Option<Point>,
pub start: Option<Point>,
pub clips: Vec<Arc<Clip>>,
/// The clips `clipsave` saved, for `cliprestore`.
pub saved_clips: Vec<Rc<[Arc<Clip>]>>,
pub overprint: bool,
pub font: Option<DictRef>,
}
@@ -128,6 +130,7 @@ impl Default for GState {
cur: None,
start: None,
clips: vec![],
saved_clips: vec![],
overprint: false,
font: None,
}
@@ -489,7 +492,7 @@ impl Interp<'_> {
let (family, items) = match o {
Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]),
Obj::Array { items, .. } => {
let v = items.borrow().clone();
let v = items.to_vec();
let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?;
(n, v)
}
@@ -702,7 +705,7 @@ impl Interp<'_> {
let d = match f {
Obj::Dict(d) => d.clone(),
Obj::Array { items, .. } => {
let fs = items.borrow().clone();
let fs = items.to_vec();
let mut out = vec![];
for g in &fs {
out.extend(self.eval(g, t, depth + 1)?.first().copied());
@@ -730,7 +733,7 @@ impl Interp<'_> {
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
}
Some(3.0) => {
let fs = get("Functions").and_then(|o| o.items().map(|i| i.borrow().clone())).unwrap_or_default();
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
let bounds = nums(get("Bounds"));
let encode = nums(get("Encode"));
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
@@ -1095,6 +1098,19 @@ impl Interp<'_> {
self.g.start = self.g.cur;
}
InitClip => self.g.clips.clear(),
ClipSave => {
if self.g.saved_clips.len() >= MAX_GSAVE {
return Err(PsError::Limit("too many saved clips"));
}
self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?;
let clips = self.g.clips.as_slice().into();
self.g.saved_clips.push(clips);
}
ClipRestore => {
if let Some(clips) = self.g.saved_clips.pop() {
self.g.clips = clips.to_vec();
}
}
Clip | EoClip => {
let bp = self.g.path.clone();
self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero });
@@ -1118,7 +1134,7 @@ impl Interp<'_> {
}
RectStroke => {
let m = match self.stack.last() {
Some(Obj::Array { items, .. }) if items.borrow().len() == 6 => Some(self.pop_matrix()?),
Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?),
_ => None,
};
let mut bp = self.rects()?;
@@ -1157,7 +1173,7 @@ fn linear_points(f: &Obj) -> Option<Vec<f64>> {
match num("FunctionType")? {
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
3.0 => {
let fs = d.get(&Key::name("Functions"))?.items()?.borrow().clone();
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
return None;
}
+139 -96
View File
@@ -11,7 +11,7 @@ use vectorcraft_geom::Affine;
use super::graphics::{GState, Out};
use super::lex::Lexer;
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
/// Most operations one file may run.
const MAX_OPS: u64 = 50_000_000;
@@ -61,6 +61,12 @@ fn encoding() -> Obj {
Obj::array(vec![Obj::name(".notdef"); 256])
}
/// The resource categories there are to begin with (instances of `Category`).
const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Form Pattern ProcSet ColorSpace Halftone \r
ColorRendering IdiomSet InkParams TrapParams OutputDevice ControlLanguage Localization PDL HWOptions \r
Filter ColorSpaceFamily Emulator IODevice ColorRenderingType FMapType FontType FormType HalftoneType \r
ImageType PatternType FunctionType ShadingType";
impl<'a> Interp<'a> {
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
let system = new_dict();
@@ -72,7 +78,15 @@ impl<'a> Interp<'a> {
for name in ["StandardEncoding", "ISOLatin1Encoding"] {
d.insert(Key::name(name), encoding());
}
// An integer, as interpreters have it (`systemdict /languagelevel get 2 ge`): run, the
// name pushes it all the same.
d.insert(Key::name("languagelevel"), Obj::Int(3));
}
// Each category's implementation dictionary (`/Generic /Category findresource`).
let categories: Dict =
CATEGORIES.split_whitespace().map(|c| (Key::name(c), Obj::dict(Dict::from([(Key::name("Category"), Obj::name(c))])))).collect();
let resources = new_dict();
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
let fonts = new_dict();
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone()));
@@ -87,7 +101,7 @@ impl<'a> Interp<'a> {
status: new_dict(),
error: new_dict(),
fonts,
resources: new_dict(),
resources,
depth: 0,
ops: 0,
allocated: 0,
@@ -175,7 +189,7 @@ impl<'a> Interp<'a> {
}
}
pub fn pop_str(&mut self) -> Res<Rc<RefCell<Vec<u8>>>> {
pub fn pop_str(&mut self) -> Res<Shared<u8>> {
match self.pop()? {
Obj::Str(s) => Ok(s),
_ => ps_err("typecheck", "a string"),
@@ -183,7 +197,7 @@ impl<'a> Interp<'a> {
}
/// An array or procedure.
pub fn pop_array(&mut self) -> Res<Rc<RefCell<Vec<Obj>>>> {
pub fn pop_array(&mut self) -> Res<Shared<Obj>> {
match self.pop()? {
Obj::Array { items, .. } => Ok(items),
_ => ps_err("typecheck", "an array"),
@@ -197,15 +211,9 @@ impl<'a> Interp<'a> {
}
/// Store `m` into the matrix operand `items` and push it back.
pub fn put_matrix(&mut self, items: Rc<RefCell<Vec<Obj>>>, m: Affine) -> Res {
{
let mut v = items.borrow_mut();
if v.len() != 6 {
return ps_err("rangecheck", "a matrix");
}
for (slot, c) in v.iter_mut().zip(m.as_coeffs()) {
*slot = Obj::Real(c);
}
pub fn put_matrix(&mut self, items: Shared<Obj>, m: Affine) -> Res {
if items.len() != 6 || !items.write(0, &m.as_coeffs().map(Obj::Real)) {
return ps_err("rangecheck", "a matrix");
}
self.push(Obj::Array { items, exec: false })
}
@@ -276,24 +284,24 @@ impl<'a> Interp<'a> {
Ok(())
}
fn run_proc(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
fn run_proc(&mut self, items: &Shared<Obj>) -> Res {
self.enter()?;
let r = self.run_items(items);
self.depth -= 1;
r
}
fn run_items(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
fn run_items(&mut self, items: &Shared<Obj>) -> Res {
let mut i = 0;
loop {
let Some(o) = items.borrow().get(i).cloned() else { return Ok(()) };
let Some(o) = items.get(i) else { return Ok(()) };
i += 1;
self.exec_token(o)?;
}
}
/// Run `proc` as a loop body: `Ok(false)` when it ran `exit`.
fn body(&mut self, proc: &Obj) -> Res<bool> {
pub fn body(&mut self, proc: &Obj) -> Res<bool> {
self.tick()?;
match self.call(proc.clone()) {
Ok(()) => Ok(true),
@@ -303,7 +311,7 @@ impl<'a> Interp<'a> {
}
/// Run a procedure operand, executable or not (`exec`, `if`, loops).
fn pop_proc(&mut self) -> Res<Obj> {
pub fn pop_proc(&mut self) -> Res<Obj> {
match self.pop()? {
Obj::Array { items, .. } => Ok(Obj::Array { items, exec: true }),
o @ (Obj::Op(_) | Obj::Exec(_)) => Ok(o),
@@ -480,7 +488,7 @@ impl<'a> Interp<'a> {
Gt | Ge | Lt | Le => {
let (b, a) = (self.pop()?, self.pop()?);
let ord = match (&a, &b) {
(Obj::Str(x), Obj::Str(y)) => x.borrow().as_slice().partial_cmp(y.borrow().as_slice()),
(Obj::Str(x), Obj::Str(y)) => x.borrow().partial_cmp(&*y.borrow()),
_ => a.as_num().zip(b.as_num()).and_then(|(x, y)| x.partial_cmp(&y)),
}
.ok_or(PsError::Ps("typecheck", String::new()))?;
@@ -646,14 +654,10 @@ impl<'a> Interp<'a> {
} else {
show_text(&self.pop()?)
};
let n = text.len();
if n > s.borrow().len() {
if !s.write(0, text.as_bytes()) {
return ps_err("rangecheck", "");
}
if let Some(d) = s.borrow_mut().get_mut(..n) {
d.copy_from_slice(text.as_bytes());
}
self.push_interval(Obj::Str(s), 0, n)?;
self.push_interval(Obj::Str(s), 0, text.len())?;
}
ReadOnly | ExecuteOnly | NoAccess | SetPacking | SetGlobal | SetObjectFormat => {
if matches!(op, SetPacking | SetGlobal | SetObjectFormat) {
@@ -683,8 +687,8 @@ impl<'a> Interp<'a> {
}
Length => {
let n = match self.pop()? {
Obj::Array { items, .. } => items.borrow().len(),
Obj::Str(s) => s.borrow().len(),
Obj::Array { items, .. } => items.len(),
Obj::Str(s) => s.len(),
Obj::Dict(d) => d.borrow().len(),
Obj::Name(n) | Obj::Exec(n) => n.len(),
_ => return ps_err("typecheck", ""),
@@ -699,14 +703,8 @@ impl<'a> Interp<'a> {
Get => {
let k = self.pop()?;
let o = match self.pop()? {
Obj::Array { items, .. } => {
let i = index(&k)?;
items.borrow().get(i).cloned().ok_or(PsError::Ps("rangecheck", String::new()))?
}
Obj::Str(s) => {
let i = index(&k)?;
Obj::Int(i64::from(*s.borrow().get(i).ok_or(PsError::Ps("rangecheck", String::new()))?))
}
Obj::Array { items, .. } => items.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?,
Obj::Str(s) => Obj::Int(i64::from(s.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?)),
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let v = d.borrow().get(&key).cloned();
@@ -718,21 +716,21 @@ impl<'a> Interp<'a> {
}
Put => {
let (v, k, c) = (self.pop()?, self.pop()?, self.pop()?);
match c {
Obj::Array { items, .. } => {
let i = index(&k)?;
*items.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = v;
}
let written = match c {
Obj::Array { items, .. } => items.write(index(&k)?, std::slice::from_ref(&v)),
Obj::Str(s) => {
let i = index(&k)?;
let b = v.as_num().ok_or(PsError::Ps("typecheck", String::new()))?;
*s.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = b as i64 as u8;
s.write(index(&k)?, &[b as i64 as u8])
}
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
insert(&d, key, v)?;
true
}
_ => return ps_err("typecheck", ""),
};
if !written {
return ps_err("rangecheck", "");
}
}
GetInterval => {
@@ -744,41 +742,35 @@ impl<'a> Interp<'a> {
PutInterval => {
let src = self.pop()?;
let at = self.pop_count()?;
match (self.pop()?, src) {
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => {
let from = from.borrow().clone();
let mut v = items.borrow_mut();
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
d.clone_from_slice(&from);
}
(Obj::Str(s), Obj::Str(from)) => {
let from = from.borrow().clone();
let mut v = s.borrow_mut();
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
d.copy_from_slice(&from);
}
let written = match (self.pop()?, src) {
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()),
(Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()),
_ => return ps_err("typecheck", ""),
};
if !written {
return ps_err("rangecheck", "");
}
}
Aload => {
let items = self.pop_array()?;
let all = items.borrow().clone();
for o in all {
for o in items.to_vec() {
self.push(o)?;
}
self.push(Obj::Array { items, exec: false })?;
}
Astore => {
let items = self.pop_array()?;
let n = items.borrow().len();
let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?;
*items.borrow_mut() = self.stack.split_off(at);
let at = self.stack.len().checked_sub(items.len()).ok_or(PsError::Ps("stackunderflow", String::new()))?;
if !items.write(0, self.stack.get(at..).unwrap_or_default()) {
return ps_err("rangecheck", "");
}
self.stack.truncate(at);
self.push(Obj::Array { items, exec: false })?;
}
Search | AnchorSearch => {
let seek = self.pop_str()?.borrow().clone();
let seek = self.pop_str()?.to_vec();
let s = self.pop_str()?;
let text = s.borrow().clone();
let text = s.to_vec();
let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) };
match at {
Some(i) => {
@@ -888,7 +880,6 @@ impl<'a> Interp<'a> {
self.push(Obj::Int(v))?;
}
}
LanguageLevel => self.push(Obj::Int(3))?,
Version => self.push(Obj::string(b"3010".to_vec()))?,
Product => self.push(Obj::string(b"VectorCraft".to_vec()))?,
RealTime | UserTime => self.push(Obj::Int(0))?,
@@ -922,19 +913,18 @@ impl<'a> Interp<'a> {
}
}
Obj::Array { items, exec } => {
let from = self.pop_array()?.borrow().clone();
let n = from.len();
{
let mut v = items.borrow_mut();
v.get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.clone_from_slice(&from);
let from = self.pop_array()?.to_vec();
if !items.write(0, &from) {
return ps_err("rangecheck", "copy");
}
self.push_interval(Obj::Array { items, exec }, 0, n)?;
self.push_interval(Obj::Array { items, exec }, 0, from.len())?;
}
Obj::Str(s) => {
let from = self.pop_str()?.borrow().clone();
let n = from.len();
s.borrow_mut().get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.copy_from_slice(&from);
self.push_interval(Obj::Str(s), 0, n)?;
let from = self.pop_str()?.to_vec();
if !s.write(0, &from) {
return ps_err("rangecheck", "copy");
}
self.push_interval(Obj::Str(s), 0, from.len())?;
}
Obj::Dict(d) => {
let from = self.pop_dict()?.borrow().clone();
@@ -946,16 +936,12 @@ impl<'a> Interp<'a> {
Ok(())
}
/// `n` items of an array or string from `at` (a copy: shared storage isn't modelled).
fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
let end = at.checked_add(n).ok_or(PsError::Ps("rangecheck", String::new()))?;
self.alloc(n * if matches!(o, Obj::Str(_)) { 1 } else { std::mem::size_of::<Obj>() })?;
/// `n` items of an array or string from `at`, sharing its storage.
pub fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
let range = || PsError::Ps("rangecheck", String::new());
let r = match o {
Obj::Array { items, exec } => {
let v = items.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec();
Obj::Array { items: Rc::new(RefCell::new(v)), exec }
}
Obj::Str(s) => Obj::string(s.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec()),
Obj::Array { items, exec } => Obj::Array { items: items.sub(at, n).ok_or_else(range)?, exec },
Obj::Str(s) => Obj::Str(s.sub(at, n).ok_or_else(range)?),
_ => return ps_err("typecheck", "getinterval"),
};
self.push(r)
@@ -966,7 +952,7 @@ impl<'a> Interp<'a> {
match self.pop()? {
Obj::Array { items, .. } => {
let mut i = 0;
while let Some(o) = items.borrow().get(i).cloned() {
while let Some(o) = items.get(i) {
i += 1;
self.push(o)?;
if !self.body(&p)? {
@@ -975,8 +961,7 @@ impl<'a> Interp<'a> {
}
}
Obj::Str(s) => {
let bytes = s.borrow().clone();
for b in bytes {
for b in s.to_vec() {
self.push(Obj::Int(i64::from(b)))?;
if !self.body(&p)? {
break;
@@ -1000,22 +985,19 @@ impl<'a> Interp<'a> {
/// Replace the names in a procedure that stand for operators by the operators, down through
/// nested procedures.
fn bind(&mut self, items: &Rc<RefCell<Vec<Obj>>>, depth: u32) {
fn bind(&mut self, items: &Shared<Obj>, depth: u32) {
if depth > 64 {
return;
}
let n = items.borrow().len();
for i in 0..n {
let o = items.borrow().get(i).cloned();
match o {
for i in 0..items.len() {
match items.get(i) {
Some(Obj::Exec(name)) => {
if let Some(Obj::Op(op)) = self.lookup(&name)
&& let Some(slot) = items.borrow_mut().get_mut(i)
{
*slot = Obj::Op(op);
if let Some(o @ Obj::Op(_)) = self.lookup(&name) {
// `i` is in range and nothing is reading the procedure: the write can't fail.
items.write(i, &[o]);
}
}
Some(Obj::Array { items: inner, exec: true }) if !Rc::ptr_eq(&inner, items) => self.bind(&inner, depth + 1),
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1),
_ => {}
}
}
@@ -1032,6 +1014,67 @@ impl<'a> Interp<'a> {
_ => ps_err("typecheck", ""),
})
}
/// The instances of `category`: `FontDirectory` for fonts.
pub fn instances(&mut self, category: &Obj) -> Res<DictRef> {
if category.text().as_deref() == Some("Font") { Ok(self.fonts.clone()) } else { self.category(category) }
}
/// `template proc scratch category resourceforall`: `proc` run on each instance name that
/// matches `template`, copied into `scratch` (in name order).
pub fn resource_for_all(&mut self) -> Res {
let category = self.pop()?;
let scratch = self.pop_str()?;
let p = self.pop_proc()?;
let template = self.pop_str()?.to_vec();
let mut names: Vec<Vec<u8>> = self.instances(&category)?.borrow().keys().map(|k| show_text(&k.obj()).into_bytes()).collect();
names.sort();
for name in names {
if !self.matches(&template, &name)? {
continue;
}
if !scratch.write(0, &name) {
return ps_err("rangecheck", "resourceforall");
}
self.push_interval(Obj::Str(scratch.clone()), 0, name.len())?;
if !self.body(&p)? {
break;
}
}
Ok(())
}
/// Does `name` match a `resourceforall` template: `*` any run of bytes, `?` any one, `\` the
/// next one as it is? Each step counts as an operation.
fn matches(&mut self, template: &[u8], name: &[u8]) -> Res<bool> {
let (mut t, mut n) = (0, 0);
// Where the last `*` was followed (template, name): a mismatch after it retries from there.
let mut star: Option<(usize, usize)> = None;
loop {
self.tick()?;
if n == name.len() {
return Ok(template.get(t..).unwrap_or_default().iter().all(|b| *b == b'*'));
}
let (want, width) = match template.get(t) {
Some(b'*') => {
t += 1;
star = Some((t, n));
continue;
}
Some(b'?') => (name.get(n).copied(), 1),
Some(b'\\') => (template.get(t + 1).copied(), 2),
c => (c.copied(), 1),
};
if want.is_some() && want == name.get(n).copied() {
(t, n) = (t + width, n + 1);
continue;
}
// The last `*` takes one more byte, else there is no match.
let Some((st, sn)) = star else { return Ok(false) };
star = Some((st, sn + 1));
(t, n) = (st, sn + 1);
}
}
}
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
@@ -1057,7 +1100,7 @@ fn equal(a: &Obj, b: &Obj) -> bool {
match (a, b) {
(Obj::Null, Obj::Null) | (Obj::Mark, Obj::Mark) => true,
(Obj::Bool(x), Obj::Bool(y)) => x == y,
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => Rc::ptr_eq(x, y),
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => x.same(y),
(Obj::Dict(x), Obj::Dict(y)) => Rc::ptr_eq(x, y),
(Obj::Op(x), Obj::Op(y)) => x == y,
_ => match (a.as_num(), b.as_num()) {
+4 -1
View File
@@ -192,7 +192,10 @@ fn finish(mut out: Out) -> Imported {
/// A document of `frame`'s size showing TIFF preview `tiff` stretched over it.
fn preview(tiff: &[u8], frame: Rect) -> Option<Document> {
let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).ok()?.to_rgba8();
let img = match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) {
Ok(img) => img.to_rgba8(),
Err(_) => crate::tiff::palette_rgba(tiff)?,
};
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return None;
+77 -11
View File
@@ -1,6 +1,6 @@
//! The PostScript objects the interpreter works with, its operators and its errors.
use std::cell::RefCell;
use std::cell::{Ref, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
@@ -31,10 +31,10 @@ pub(crate) enum Obj {
Name(Rc<str>),
/// An executable name: looked up and run.
Exec(Rc<str>),
Str(Rc<RefCell<Vec<u8>>>),
Str(Shared<u8>),
/// An array; executable ones are procedures.
Array {
items: Rc<RefCell<Vec<Obj>>>,
items: Shared<Obj>,
exec: bool,
},
Dict(DictRef),
@@ -53,15 +53,15 @@ impl Obj {
}
pub fn string(bytes: Vec<u8>) -> Self {
Self::Str(Rc::new(RefCell::new(bytes)))
Self::Str(Shared::new(bytes))
}
pub fn array(items: Vec<Obj>) -> Self {
Self::Array { items: Rc::new(RefCell::new(items)), exec: false }
Self::Array { items: Shared::new(items), exec: false }
}
pub fn proc(items: Vec<Obj>) -> Self {
Self::Array { items: Rc::new(RefCell::new(items)), exec: true }
Self::Array { items: Shared::new(items), exec: true }
}
pub fn dict(d: Dict) -> Self {
@@ -98,7 +98,7 @@ impl Obj {
}
/// The items of an array or procedure.
pub fn items(&self) -> Option<&Rc<RefCell<Vec<Obj>>>> {
pub fn items(&self) -> Option<&Shared<Obj>> {
match self {
Self::Array { items, .. } => Some(items),
_ => None,
@@ -125,6 +125,71 @@ impl Obj {
}
}
/// The values of a string or an array: a run of storage that other strings or arrays may share
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
#[derive(Clone, Debug)]
pub(crate) struct Shared<T> {
buf: Rc<RefCell<Vec<T>>>,
at: usize,
len: usize,
}
impl<T: Clone> Shared<T> {
pub fn new(v: Vec<T>) -> Self {
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
}
pub fn len(&self) -> usize {
self.len
}
/// The values. Only [`Self::write`] borrows the storage mutably, for the copy alone, so this
/// never meets it.
pub fn borrow(&self) -> Ref<'_, [T]> {
let (at, end) = (self.at, self.at.saturating_add(self.len));
Ref::map(self.buf.borrow(), |v| v.get(at..end).unwrap_or(&[]))
}
pub fn get(&self, i: usize) -> Option<T> {
self.borrow().get(i).cloned()
}
pub fn to_vec(&self) -> Vec<T> {
self.borrow().to_vec()
}
/// `n` values from `at`, sharing the storage (`getinterval`); `None` past the end.
pub fn sub(&self, at: usize, n: usize) -> Option<Self> {
(at.checked_add(n)? <= self.len).then(|| Self { buf: self.buf.clone(), at: self.at + at, len: n })
}
/// Write `vals` from `at`: false when they don't fit (or `vals` is borrowed from the storage).
pub fn write(&self, at: usize, vals: &[T]) -> bool {
if at.saturating_add(vals.len()) > self.len {
return false;
}
let start = self.at + at;
let Ok(mut v) = self.buf.try_borrow_mut() else { return false };
match v.get_mut(start..start + vals.len()) {
Some(d) => {
d.clone_from_slice(vals);
true
}
None => false,
}
}
/// The same object (`eq` on arrays): the same values of the same storage.
pub fn same(&self, o: &Self) -> bool {
Rc::ptr_eq(&self.buf, &o.buf) && self.at == o.at && self.len == o.len
}
/// Do both see the same storage?
pub fn shares(&self, o: &Self) -> bool {
Rc::ptr_eq(&self.buf, &o.buf)
}
}
impl Key {
pub fn name(s: &str) -> Self {
Self::Name(Rc::from(s))
@@ -209,7 +274,7 @@ ops! {
ErrorDict = "errordict", DollarError = "$error", Bind = "bind",
// Virtual memory and the environment.
Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus",
LanguageLevel = "languagelevel", Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
Print = "print", EqPrint = "=", EqEqPrint = "==", Pstack = "pstack", Stack = "stack", Flush = "flush",
// Graphics state.
Gsave = "gsave", Grestore = "grestore", GrestoreAll = "grestoreall", InitGraphics = "initgraphics",
@@ -242,8 +307,8 @@ ops! {
RLineTo = "rlineto", CurveTo = "curveto", RCurveTo = "rcurveto", Arc = "arc", Arcn = "arcn", Arct = "arct",
Arcto = "arcto", ClosePath = "closepath", FlattenPath = "flattenpath", ReversePath = "reversepath",
PathBBox = "pathbbox", ClipPath = "clippath", InitClip = "initclip", Clip = "clip", EoClip = "eoclip",
RectClip = "rectclip", Fill = "fill", EoFill = "eofill", Stroke = "stroke", RectFill = "rectfill",
RectStroke = "rectstroke", ShFill = "shfill",
RectClip = "rectclip", ClipSave = "clipsave", ClipRestore = "cliprestore", Fill = "fill", EoFill = "eofill",
Stroke = "stroke", RectFill = "rectfill", RectStroke = "rectstroke", ShFill = "shfill",
// Images.
Image = "image", ImageMask = "imagemask", ColorImage = "colorimage",
// Files.
@@ -252,7 +317,8 @@ ops! {
// Fonts and type.
FindFont = "findfont", ScaleFont = "scalefont", MakeFont = "makefont", SetFont = "setfont", SelectFont = "selectfont",
CurrentFont = "currentfont", DefineFont = "definefont", UndefineFont = "undefinefont", FindResource = "findresource",
DefineResource = "defineresource", ResourceStatus = "resourcestatus", Show = "show", AShow = "ashow",
DefineResource = "defineresource", UndefineResource = "undefineresource", ResourceStatus = "resourcestatus",
ResourceForAll = "resourceforall", Show = "show", AShow = "ashow",
WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow",
KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth",
+231
View File
@@ -452,3 +452,234 @@ fn device_settings_are_accepted_and_change_nothing() {
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// Check that `body` draws one square, blue: it sets blue when what it checks holds, else red.
fn blue(body: &str) {
let r = read(&format!("{body} 10 10 80 80 rectfill"));
let o = objects(&r.document);
assert_eq!(o.len(), 1, "{body}: {:?}", r.warnings);
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))), "{body}");
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
}
/// Check that PostScript `cond` leaves `true`.
fn check(cond: &str) {
blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse"));
}
#[test]
fn the_generic_category_defines_new_categories() {
// #234: the reference app's prolog.
blue("/Generic /Category findresource pop 0 0 1 setrgbcolor");
blue(
"[/CSA /Gradient /Procedure] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall 0 0 1 setrgbcolor",
);
let define = "[/CSA /Gradient] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall \
/G1 << /Kind 7 >> /Gradient defineresource pop";
check(&format!("{define} /G1 /Gradient findresource /Kind get 7 eq"));
check(&format!("{define} /Gradient /Category resourcestatus {{ pop pop true }} {{ false }} ifelse"));
check(&format!("{define} /G1 /Gradient resourcestatus {{ pop pop true }} {{ false }} ifelse"));
check(&format!("{define} /G1 /Gradient undefineresource /G1 /Gradient resourcestatus not"));
check(&format!("{define} {{ /G2 /Gradient findresource }} stopped"));
// Unknown categories aren't instances of Category; a category is a dictionary.
check("/Nonsense /Category resourcestatus not");
check("{ /Nonsense /Category findresource } stopped");
check("{ /X 5 /Category defineresource } stopped");
}
#[test]
fn clipsave_and_cliprestore_bring_the_clip_back() {
// #234.
blue("clipsave 0 0 50 50 rectclip cliprestore 0 0 1 setrgbcolor");
// The fill after cliprestore covers the whole square, unclipped.
let r = read("clipsave 0 0 50 50 rectclip 1 0 0 setrgbcolor 0 0 10 10 rectfill cliprestore 0 0 1 setrgbcolor 10 10 80 80 rectfill");
let o = objects(&r.document);
assert_eq!(o.len(), 2, "{:?}", r.warnings);
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
assert!(matches!(o[1].kind, NodeKind::Path { clipping: false, .. }), "{:?}", o[1].kind);
assert!(near(bounds(&o[1]), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(&o[1]));
// Nested, leaving the rest of the graphics state alone.
let r = read(
"0 0 60 60 rectclip clipsave 0 0 20 20 rectclip clipsave 0 0 5 5 rectclip cliprestore 0 1 0 setrgbcolor cliprestore \
10 10 80 80 rectfill",
);
let NodeKind::Group { children, clip: true } = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
assert!(near(bounds(&children[0]), Rect::new(0.0, 40.0, 60.0, 100.0)), "{:?}", bounds(&children[0]));
assert_eq!(fill(&children[1]), Some(&Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
// grestore undoes the clipsaves after its gsave; cliprestore with nothing saved does nothing.
let o = objects(&read("cliprestore gsave clipsave 0 0 50 50 rectclip grestore cliprestore 10 10 80 80 rectfill").document);
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
}
#[test]
fn languagelevel_is_an_integer_and_runs() {
// #234.
blue(
"/Level2? systemdict /languagelevel known dup { pop systemdict /languagelevel get 2 ge } if def \
Level2? { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse",
);
check("languagelevel 3 eq");
check("{ languagelevel } bind exec 3 eq");
}
#[test]
fn intervals_share_their_arrays_and_strings() {
// #234: astore into a subarray stores into the array.
blue("/a 4 array def 1 2 a 0 2 getinterval astore pop a 0 get 1 eq { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse");
// put, putinterval and copy into an interval; an interval of an interval.
check("/a [0 0 0 0 0] def a 1 3 getinterval 1 2 getinterval 0 7 put a 2 get 7 eq");
check("/a [0 0 0 0] def a 2 2 getinterval 0 [8 9] putinterval a 3 get 9 eq");
check("/a [0 0 0 0] def [5 6] a 1 2 getinterval copy pop a 2 get 6 eq");
check("/s (abcd) def s 1 2 getinterval 0 (XY) putinterval s (aXYd) eq");
check("/s (abcd) def s 2 1 getinterval 0 90 put s (abZd) eq");
check("/s 8 string def 42 s 2 6 getinterval cvs pop s 2 2 getinterval (42) eq");
// The same interval is the same object; another one is not.
check("/a 4 array def a 0 2 getinterval a 0 2 getinterval eq");
check("/a 4 array def a 0 2 getinterval a 0 3 getinterval ne a 0 2 getinterval a 1 2 getinterval ne and");
// Reads and writes stay inside the interval.
check("/a [1 2 3 4] def { a 1 2 getinterval 2 get } stopped { a 1 2 getinterval 2 0 put } stopped and");
check("/a [1 2 3 4] def a 1 2 getinterval length 2 eq a 1 2 getinterval aload pop 3 eq exch 2 eq and and");
check("/n 0 def [1 2 3 4] 1 2 getinterval { n add /n exch def } forall n 5 eq");
// A procedure from an interval runs only its part.
check("{ 1 2 3 } 1 2 getinterval cvx exec add 5 eq");
// The prolog's stack save: the operands into part of one array, and back.
check(
"/stk 10 array def /lev 3 array def 1 2 3 count dup lev exch 0 exch put stk exch 0 exch getinterval astore pop \
clear stk 0 lev 0 get getinterval aload pop add add 6 eq",
);
}
#[test]
fn resourceforall_enumerates_matching_names() {
// #234.
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
check(&format!("{define} {} 4 eq", count("*")));
check(&format!("{define} {} 2 eq", count("Fo?")));
check(&format!("{define} {} 3 eq", count("F*")));
check(&format!("{define} {} 1 eq", count("F\\\\*x")));
check(&format!("{define} {} 0 eq", count("Q*")));
check(&format!("{define} {} 1 eq", count("*a*")));
// Each name is copied into the scratch string; exit stops.
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
// A scratch string too small for the names, as the prolog probes it.
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
}
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
fn palette_tiff(w: u16, h: u16, per: u16, big: bool) -> Vec<u8> {
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
// Shorts in an entry, left-justified.
let shorts = |v: &[u16]| -> [u8; 4] {
let mut f = [0; 4];
for (i, s) in v.iter().enumerate() {
f[2 * i..2 * i + 2].copy_from_slice(&u16b(*s));
}
f
};
let row = u32::from(w) * 2;
let mut out = vec![];
out.extend(if big { *b"MM\0*" } else { *b"II*\0" });
out.extend([0; 4]);
let data_at = out.len() as u32;
for y in 0..h {
for x in 0..w {
out.extend([((x + y) % 3) as u8, (x * 12) as u8]);
}
}
let map_at = out.len() as u32;
for (c, full) in [0xFFFF, 0x8000, 0xFFFF].into_iter().enumerate() {
for i in 0..256 {
out.extend(u16b(if i == c { full } else { 0 }));
}
}
let strips: Vec<u16> = (0..h.div_ceil(per)).collect();
let offsets_at = out.len() as u32;
for k in &strips {
out.extend(u32b(data_at + u32::from(k * per) * row));
}
let counts_at = out.len() as u32;
for k in &strips {
out.extend(u32b(u32::from(per.min(h - k * per)) * row));
}
let ifd = out.len() as u32;
out[4..8].copy_from_slice(&u32b(ifd));
let n = strips.len() as u32;
let entries: [(u16, u16, u32, [u8; 4]); 12] = [
(256, 3, 1, shorts(&[w])),
(257, 3, 1, shorts(&[h])),
(258, 3, 2, shorts(&[8, 8])),
(259, 3, 1, shorts(&[1])),
(262, 3, 1, shorts(&[3])),
(273, 4, n, u32b(if n == 1 { data_at } else { offsets_at })),
(277, 3, 1, shorts(&[2])),
(278, 3, 1, shorts(&[per])),
(279, 4, n, u32b(if n == 1 { u32::from(h) * row } else { counts_at })),
(284, 3, 1, shorts(&[1])),
(320, 3, 768, u32b(map_at)),
(338, 3, 1, shorts(&[1])),
];
out.extend(u16b(entries.len() as u16));
for (tag, kind, count, field) in entries {
out.extend(u16b(tag));
out.extend(u16b(kind));
out.extend(u32b(count));
out.extend(field);
}
out.extend([0; 4]);
out
}
#[test]
fn a_palette_preview_with_alpha_is_placed() {
// #234: the program fails, so the preview is placed.
let ps = eps("0 0 1 setrgbcolor 10 10 80 80 rectfill frobnicate");
for (big, per) in [(false, 5), (true, 5), (false, 10), (true, 3)] {
let tiff = palette_tiff(20, 10, per, big);
let r = import(&crate::dos_eps(&ps, &tiff).unwrap()).unwrap();
assert!(r.preview, "{big} {per}");
assert!(r.warnings[0].contains("preview"), "{:?}", r.warnings);
let o = objects(&r.document);
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?}", o[0].kind) };
assert_eq!((im.width, im.height), (20, 10));
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
for (x, y) in [(0, 0), (1, 0), (2, 0), (19, 9), (7, 4), (3, 6)] {
let [r, g, b] = [[255, 0, 0], [0, 128, 0], [0, 0, 255]][((x + y) % 3) as usize];
assert_eq!(png.get_pixel(x, y).0, [r, g, b, (x * 12) as u8], "({x}, {y}) {big} {per}");
}
}
}
#[test]
fn a_damaged_palette_preview_is_refused_without_panicking() {
let tiff = palette_tiff(20, 10, 5, false);
assert!(crate::tiff::palette_rgba(&tiff).is_some());
// Cut anywhere before the last entry's value (the directory is at the end): nothing.
for cut in 0..tiff.len() - 6 {
assert!(crate::tiff::palette_rgba(&tiff[..cut]).is_none(), "{cut}");
}
// Any byte changed: a picture or nothing.
for big in [false, true] {
let tiff = palette_tiff(20, 10, 5, big);
for at in 0..tiff.len() {
for v in [0, 1, 2, 0x7f, 0x80, 0xff] {
let mut t = tiff.clone();
t[at] = v;
let _ = crate::tiff::palette_rgba(&t);
}
}
}
// A damaged preview of an unreadable program: the hard error.
let ps = eps("frobnicate");
for t in [&tiff[..tiff.len() / 2], b"II*\0garbage", &[0; 64]] {
let e = import(&crate::dos_eps(&ps, t).unwrap()).unwrap_err();
assert!(e.contains("can't be read"), "{e}");
}
}
+20 -12
View File
@@ -110,38 +110,46 @@ impl Interp<'_> {
let category = self.pop()?;
let v = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "defineresource".into()))?;
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
dict.borrow_mut().insert(k, v.clone());
// A category is defined by its implementation dictionary.
if category.text().as_deref() == Some("Category") && !matches!(v, Obj::Dict(_)) {
return ps_err("typecheck", "defineresource");
}
self.instances(&category)?.borrow_mut().insert(k, v.clone());
self.push(v)?;
}
UndefineResource => {
let category = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "undefineresource".into()))?;
self.instances(&category)?.borrow_mut().remove(&k);
}
ResourceStatus => {
let category = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "resourcestatus".into()))?;
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
let known = dict.borrow().contains_key(&k);
let known = self.instances(&category)?.borrow().contains_key(&k);
if known {
self.push(Obj::Int(1))?;
self.push(Obj::Int(0))?;
}
self.push(Obj::Bool(known))?;
}
ResourceForAll => self.resource_for_all()?,
Show => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.show(&s)?;
}
AShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.nums::<2>()?;
self.show(&s)?;
}
WidthShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.pop()?;
self.nums::<2>()?;
self.show(&s)?;
}
AWidthShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.nums::<2>()?;
self.pop()?;
self.nums::<2>()?;
@@ -149,11 +157,11 @@ impl Interp<'_> {
}
XShow | YShow | XYShow => {
self.pop()?;
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.show(&s)?;
}
KShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.pop()?;
self.show(&s)?;
}
@@ -163,14 +171,14 @@ impl Interp<'_> {
self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?;
}
StringWidth => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
let (_, _, v) = self.set_type(&s, Point::ZERO)?;
self.push_num(v.x)?;
self.push_num(v.y)?;
}
CharPath => {
self.pop_bool()?;
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.char_path(&s)?;
}
SetCacheDevice => {
+110 -1
View File
@@ -1,5 +1,6 @@
//! The TIFF preview (little-endian, PackBits strips, written by the raster exports' TIFF writer):
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail.
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail,
//! and a reader for the palette previews other apps write that the `image` crate can't decode.
use std::borrow::Cow;
@@ -50,3 +51,111 @@ pub(crate) fn png(img: &Raster) -> Result<Vec<u8>, String> {
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(out)
}
/// Most pixels a palette preview may have.
const MAX_PIXELS: usize = 1 << 26;
/// Most values a TIFF field may have.
const MAX_VALUES: usize = 1 << 20;
/// A TIFF's bytes in its byte order.
struct Tiff<'a> {
b: &'a [u8],
big: bool,
}
impl Tiff<'_> {
fn u16(&self, at: usize) -> Option<u16> {
let b: [u8; 2] = self.b.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })
}
fn u32(&self, at: usize) -> Option<u32> {
let b: [u8; 4] = self.b.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) })
}
/// The values of the directory entry at `at` (bytes, shorts or longs; in the entry when they
/// fit in four bytes).
fn values(&self, at: usize) -> Option<Vec<u32>> {
let (kind, n) = (self.u16(at.checked_add(2)?)?, self.u32(at.checked_add(4)?)? as usize);
let size = match kind {
1 => 1,
3 => 2,
4 => 4,
_ => return None,
};
if n > MAX_VALUES {
return None;
}
let field = at.checked_add(8)?;
let start = if n * size <= 4 { field } else { self.u32(field)? as usize };
(0..n)
.map(|i| {
let at = start.checked_add(i * size)?;
match size {
1 => self.b.get(at).map(|v| u32::from(*v)),
2 => self.u16(at).map(u32::from),
_ => self.u32(at),
}
})
.collect()
}
}
/// An 8-bit palette TIFF, with or without an alpha sample (`ExtraSamples`), uncompressed and
/// interleaved, as RGBA: the layout of other apps' EPS previews. `None` for anything else.
pub(crate) fn palette_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
let big = match bytes.get(..4)? {
b"II*\0" => false,
b"MM\0*" => true,
_ => return None,
};
let t = Tiff { b: bytes, big };
let ifd = t.u32(4)? as usize;
let entries = usize::from(t.u16(ifd)?);
let mut fields = std::collections::HashMap::new();
for i in 0..entries {
let at = ifd.checked_add(2 + i * 12)?;
fields.insert(t.u16(at)?, at);
}
let get = |tag: u16| fields.get(&tag).and_then(|at| t.values(*at));
let one = |tag: u16| get(tag).and_then(|v| v.first().copied());
let (w, h) = (one(256)? as usize, one(257)? as usize);
let samples = one(277).unwrap_or(1) as usize;
let alpha = samples == 2 && matches!(get(338)?.as_slice(), [0..=2]);
let plain = one(259).unwrap_or(1) == 1 && one(262)? == 3 && one(284).unwrap_or(1) == 1;
if !plain || !(samples == 1 || alpha) || get(258)?.iter().any(|b| *b != 8) || w == 0 || h == 0 || w.checked_mul(h)? > MAX_PIXELS {
return None;
}
let map = get(320)?;
if map.len() != 3 * 256 {
return None;
}
// Strips of `per` rows each, in order.
let (offsets, counts) = (get(273)?, get(279)?);
let per = (one(278).unwrap_or(u32::MAX) as usize).min(h);
let row = w * samples;
// The strips are in the file: so is all that's allocated.
if per == 0 || offsets.len() != counts.len() || offsets.len() < h.div_ceil(per) || row * h > bytes.len() {
return None;
}
let mut data = Vec::with_capacity(row * h);
for (k, (at, n)) in offsets.iter().zip(&counts).take(h.div_ceil(per)).enumerate() {
let want = per.min(h - k * per) * row;
if (*n as usize) < want {
return None;
}
let at = *at as usize;
data.extend_from_slice(bytes.get(at..at.checked_add(want)?)?);
}
// Map entries are 16-bit.
let channel = |c: usize, i: usize| map.get(c * 256 + i).map_or(0, |v| ((u64::from(*v).min(65_535) * 255 + 32_767) / 65_535) as u8);
let rgba = data
.chunks_exact(samples)
.flat_map(|px| {
let i = usize::from(px.first().copied().unwrap_or(0));
[channel(0, i), channel(1, i), channel(2, i), px.get(1).copied().unwrap_or(255)]
})
.collect();
image::RgbaImage::from_raw(w as u32, h as u32, rgba)
}
+7
View File
@@ -16,11 +16,16 @@
mod backend;
mod headless;
pub mod logging;
mod prompts;
mod resources;
mod server;
mod tools;
pub use backend::{Backend, Remote};
pub use headless::Headless;
pub use prompts::{PROMPTS, PromptArg, PromptDef};
pub use resources::{DOC_JSON_URI, DOC_URI, TEMPLATES};
pub use server::{PROTOCOL_VERSION, Server};
pub use tools::{ToolResult, call_tool, tool_definitions};
@@ -48,4 +53,6 @@ mod tests_persp;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_protocol;
#[cfg(test)]
mod tests_svg;
+220
View File
@@ -0,0 +1,220 @@
//! `logging/setLevel` and the `notifications/message` it turns on.
//!
//! VectorCraft's crates log through the `log` facade, and until now nothing installed a logger, so
//! every `log::debug!` went nowhere. This module is the sink for them: a `log` logger that turns
//! records into MCP `notifications/message`, filtered to the level the client asked for.
//!
//! The **binary** owns the choice of logger and calls [`install`]; the library never does. A
//! library that installed a process-wide logger and set the max level would silence whatever
//! logger an embedder had already set up, without being asked — so `Server::new` only forwards a
//! `logging/setLevel` the client explicitly sent.
//!
//! Nothing is sent before the client asks for it, because MCP has no business pushing log lines at
//! a client that never asked.
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};
use log::{Level, LevelFilter};
use serde_json::{Value, json};
/// The severity from the last `logging/setLevel`, as an index into [`LEVELS`]; [`OFF`] means silent.
static LEVEL: AtomicU8 = AtomicU8::new(OFF);
/// The stored value that means "no logging".
const OFF: u8 = u8::MAX;
/// Records waiting to be drained into the next `notifications/message` batch.
static QUEUE: Mutex<Vec<Value>> = Mutex::new(Vec::new());
/// RFC 5424 severities, from the least to the most severe, as the spec orders them.
const LEVELS: [&str; 8] = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
/// The severity a `logging/setLevel` level names, and the `log` level it filters at. `None` for
/// anything the spec doesn't name.
///
/// Four of the eight severities collapse onto `log`'s Error, so the index is kept as well: a
/// client that asked for `emergency` must be able to hear that it is `emergency`, not `error`.
fn parse(name: &str) -> Option<(u8, Level)> {
let level = match name {
"debug" => Level::Debug,
"info" | "notice" => Level::Info,
"warning" => Level::Warn,
"error" | "critical" | "alert" | "emergency" => Level::Error,
_ => return None,
};
Some((LEVELS.iter().position(|l| *l == name)? as u8, level))
}
/// The severity name a `log` record is reported under.
fn severity(level: Level) -> &'static str {
match level {
Level::Trace | Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warning",
Level::Error => "error",
}
}
/// A `logging/setLevel` level the spec does not name. Nothing was changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownLevel;
/// Turn logging on at `level`, or off with `None`.
///
/// `Err(UnknownLevel)` means the name is not one the spec uses and nothing changed; a rejected
/// level must leave the accepted one in place rather than silencing logging entirely.
pub fn set_level(level: Option<&str>) -> Result<(), UnknownLevel> {
let Some(name) = level else {
LEVEL.store(OFF, Ordering::Relaxed);
log::set_max_level(LevelFilter::Off);
clear();
return Ok(());
};
let Some((index, parsed)) = parse(name) else { return Err(UnknownLevel) };
LEVEL.store(index, Ordering::Relaxed);
log::set_max_level(parsed.to_level_filter());
// Records already queued at the old level are dropped rather than leaked at the new one.
clear();
Ok(())
}
/// The level currently requested, if any.
pub fn level() -> Option<&'static str> {
let index = LEVEL.load(Ordering::Relaxed);
if index == OFF {
return None;
}
LEVELS.get(usize::from(index)).copied()
}
/// Take the queued records, so the server can send them as `notifications/message`.
pub fn drain() -> Vec<Value> {
match QUEUE.lock() {
Ok(mut q) => std::mem::take(&mut *q),
// A poisoned lock only means some other thread panicked while logging; the records in it
// are still readable and worth sending.
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
}
}
/// Forget whatever is queued (used when the client stops listening).
pub fn clear() {
if let Ok(mut q) = QUEUE.lock() {
q.clear();
}
}
/// The `log` logger that feeds [`QUEUE`].
struct Sink;
impl log::Log for Sink {
fn enabled(&self, metadata: &log::Metadata) -> bool {
LEVEL.load(Ordering::Relaxed) != OFF && metadata.level() <= log::max_level()
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let message = json!({
"level": severity(record.level()),
"logger": record.target(),
"data": record.args().to_string(),
});
// A full queue means the client is not draining; drop rather than grow without bound.
if let Ok(mut q) = QUEUE.lock()
&& q.len() < MAX_QUEUED
{
q.push(message);
}
}
fn flush(&self) {}
}
/// Records kept while the client is behind.
const MAX_QUEUED: usize = 512;
static SINK: Sink = Sink;
/// Install the sink once per process. The binary calls this; the library does not.
///
/// Later calls are ignored: the second `Server` in a test run finds it already there, which is
/// exactly what it wants.
pub fn install() {
// `set_logger` fails only when a logger is already installed; keeping the first one is correct.
let _already_installed = log::set_logger(&SINK);
// Silent until a client sends `logging/setLevel`, so an embedder that installs this sink for
// its own reasons gets no records it did not ask for.
log::set_max_level(LevelFilter::Off);
}
/// The level and the queue are process-wide, so the tests that move them take turns. The server
/// itself is one thread, so nothing else has to.
#[cfg(test)]
pub(crate) fn test_turn() -> std::sync::MutexGuard<'static, ()> {
match TEST_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(test)]
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_round_trip_and_reject_nonsense() {
let _turn = test_turn();
install();
assert_eq!(set_level(Some("warning")), Ok(()));
assert_eq!(level(), Some("warning"));
assert_eq!(set_level(Some("nope")), Err(UnknownLevel), "an unknown level changes nothing");
assert_eq!(level(), Some("warning"), "the old level survives a bad request");
assert_eq!(set_level(Some("emergency")), Ok(()));
assert_eq!(level(), Some("emergency"));
assert_eq!(set_level(None), Ok(()));
assert_eq!(level(), None);
}
#[test]
fn every_spec_level_is_understood() {
let _turn = test_turn();
install();
for name in LEVELS {
assert_eq!(set_level(Some(name)), Ok(()), "{name}");
assert_eq!(level(), Some(name), "{name}");
}
set_level(None).ok();
}
#[test]
fn records_appear_as_messages_only_when_asked_for() {
let _turn = test_turn();
install();
set_level(None).ok();
clear();
log::error!("before logging was enabled");
assert!(drain().is_empty(), "silent until set_level");
set_level(Some("info")).ok();
clear();
log::warn!("hello {name}", name = "world");
// Another test thread may log while logging is on, so look for ours rather than counting.
let got = drain();
let ours = got.iter().find(|m| m["data"] == "hello world").unwrap_or_else(|| panic!("not in {got:?}"));
assert_eq!(ours["level"], "warning");
assert!(ours["logger"].as_str().unwrap_or_default().starts_with("vectorcraft_mcp"), "{ours}");
// Below the requested level stays out.
set_level(Some("error")).ok();
clear();
log::info!("too chatty");
assert!(drain().is_empty());
set_level(None).ok();
}
}
+558
View File
@@ -0,0 +1,558 @@
//! MCP prompts (`prompts/list`, `prompts/get`) and `completion/complete`.
//!
//! A prompt is a reusable VectorCraft workflow: `prompts/get` hands the model one templated user
//! message, so a client can offer "make a poster" the way it offers a slash command. Every prompt's
//! arguments double as completion sources, and the resource templates in [`crate::resources`] share
//! the same [`Live`] catalogues, so `completion/complete` can suggest real command ids, effect ids,
//! swatch names and export formats instead of a fixed list that goes stale.
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::FORMATS;
use crate::backend::Backend;
/// How many values one `completion/complete` reply may carry (the spec's maximum).
const MAX_VALUES: usize = 100;
/// A catalogue the completion values are read from, so they follow the app instead of a copy.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Live {
/// Export formats the engine writes (`document.formats`).
Formats,
/// Image Trace preset names (`imageTrace.presets`).
TracePresets,
/// Live effect ids (`effect.list`).
Effects,
/// Every command id (`engine.commands`).
Commands,
/// Swatch and colour-group names in the document (`swatch.list`).
Swatches,
/// Layer and object ids in the active document (`document.inspect`).
Objects,
}
/// Where one prompt argument's suggestions come from.
#[derive(Clone, Copy)]
pub enum Values {
/// A fixed list, written out here.
Fixed(&'static [&'static str]),
/// The live catalogue.
Live(Live),
}
impl Values {
fn resolve(&self, b: &mut dyn Backend) -> Vec<String> {
match self {
Values::Fixed(v) => v.iter().map(|s| (*s).to_string()).collect(),
Values::Live(live) => live_values(b, *live),
}
}
}
/// The values of one live catalogue, read fresh so a suggestion is never stale.
pub(crate) fn live_values(b: &mut dyn Backend, live: Live) -> Vec<String> {
match live {
Live::Formats => FORMATS.iter().filter(|f| f.write).map(|f| f.id.to_string()).collect(),
Live::TracePresets => names(b, "imageTrace.presets", Some("presets")),
Live::Effects => names(b, "effect.list", Some("catalog")),
// The command catalogue is a control method, not an engine command.
Live::Commands => b.call("engine.commands", json!({})).map(|v| ids_in(&v, None)).unwrap_or_default(),
Live::Swatches => names(b, "swatch.list", Some("swatches")),
Live::Objects => object_ids(b),
}
}
/// The `id` or `name` of every entry a query command returns, optionally under one key.
///
/// These are engine commands, so they go through `engine.execute`; that reaches the same commands
/// in both backends, where a control method the headless session doesn't implement would not.
fn names(b: &mut dyn Backend, command: &str, key: Option<&str>) -> Vec<String> {
b.call("engine.execute", json!({"command": command, "params": {}})).map(|v| ids_in(&v, key)).unwrap_or_default()
}
fn ids_in(reply: &Value, key: Option<&str>) -> Vec<String> {
let items = match key {
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
None => reply.as_array().map_or(&[][..], Vec::as_slice),
};
// Catalogs are small, but never trust a document that grew one.
items.iter().take(MAX_VALUES * 8).filter_map(|i| i.get("id").or_else(|| i.get("name"))).filter_map(Value::as_str).map(str::to_string).collect()
}
/// Ids of every layer and object in the active document, for `vectorcraft://object/{id}`.
fn object_ids(b: &mut dyn Backend) -> Vec<String> {
let mut out = Vec::new();
let Ok(v) = b.call("document.inspect", json!({})) else { return out };
let Some(layers) = v.get("layers").and_then(Value::as_array) else { return out };
let mut stack: Vec<&Value> = layers.iter().rev().collect();
while let Some(node) = stack.pop() {
if let Some(id) = node.get("id").and_then(Value::as_i64) {
out.push(id.to_string());
}
if let Some(kids) = node.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
}
out
}
/// One prompt argument.
pub struct PromptArg {
pub name: &'static str,
pub description: &'static str,
pub required: bool,
/// Substituted for `{name}` when the caller leaves the argument out.
pub default: &'static str,
pub values: Values,
}
/// One prompt: a workflow with templated arguments.
pub struct PromptDef {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub arguments: &'static [PromptArg],
/// The message body; every `{argument}` is replaced with its value.
pub text: &'static str,
}
/// Recolour methods (`recolor.apply`'s `method`), written here because they are a closed set.
const RECOLOR_METHODS: &[&str] = &["exact", "preserveTints", "scaleTints", "tintsShades", "hueShift"];
static NONE: &[&str] = &[];
/// Every prompt, in `prompts/list` order.
pub static PROMPTS: &[PromptDef] = &[
PromptDef {
name: "poster",
title: "Design a poster",
description: "Lay out a poster from a brief: live shapes, gradients, type and live effects, checked with screenshots before export.",
arguments: &[
PromptArg {
name: "brief",
description: "What the poster is for, and the words it must carry",
required: true,
default: "",
values: Values::Fixed(NONE),
},
PromptArg {
name: "palette",
description: "The colours to use, or how to choose them",
required: false,
default: "a limited scheme of three to five colours taken from the brief",
values: Values::Fixed(&[
"a limited scheme of three to five colours taken from the brief",
"a monochrome scheme in one hue, varying value and opacity",
"a two-colour complementary scheme",
"an analogous scheme across neighbouring hues",
]),
},
PromptArg {
name: "text",
description: "The exact headline and supporting lines to set",
required: false,
default: "invent short, concrete wording that fits the brief",
values: Values::Fixed(NONE),
},
],
text: "\
Design a poster in VectorCraft.
Brief: {brief}
Palette: {palette}
Wording: {text}
Coordinates are points in document space: y points down and the origin is the first
artboard's top-left. Work in the document that is already open (inspect_document); only
run file.new when the brief asks for a different artboard size.
Method:
1. Plan the layout before drawing — a hierarchy of shapes, then the type on top.
2. draw_shape for geometry (rectangle with `radius` for corners, ellipse, polygon, star,
line); draw_path for anything with curves, from `points` or from SVG `d`.
3. set_paint for colour. A fill may be \"#rrggbb\", [r,g,b], {c,m,y,k}, {gray} or a whole
paint.setFill params object — pass {\"gradient\": {…}} for a gradient.
4. add_text for type: point type at (x, y), area type with width and height, or mode
\"area\"/\"onPath\" to flow it in or along a path.
5. apply_effect for live effects; call it with no `effect` first to get the catalogue with
each effect's parameters and defaults.
6. screenshot to look at what you made, fix what is wrong, and only then export.
Keep it editable: live shapes, live effects and live text. Do not flatten and do not
rasterise unless the brief asks for it.",
},
PromptDef {
name: "icon-set",
title: "Draw an icon set",
description: "Build a row of consistent icons as live vector shapes on a grid, then check them as a set.",
arguments: &[
PromptArg { name: "subject", description: "What the icons depict", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
name: "count",
description: "How many icons in the set",
required: false,
default: "6",
values: Values::Fixed(&["4", "6", "8", "12", "16"]),
},
PromptArg {
name: "detail",
description: "How detailed each icon should be",
required: false,
default: "a single flat silhouette, no strokes",
values: Values::Fixed(&[
"a single flat silhouette, no strokes",
"a filled silhouette with a stroke around it",
"an outlined shape with its strokes only",
"two-tone: a filled shape and a smaller accent shape",
]),
},
],
text: "\
Draw a set of {count} icons of {subject} in VectorCraft, {detail}.
Coordinates are points in document space (y down, origin at the first artboard's
top-left).
Method:
1. Decide the cell size and the gap first, then lay every icon into its own cell so the set
reads as one family: the same stroke weight, the same optical size, the same corner radius.
2. Build each icon from draw_shape and draw_path — live shapes, not strokes traced from
somewhere else. Use `points` with `in`/`out` handles for the curves a silhouette needs.
3. set_paint the whole set the same way, then break one colour out on the object that needs
the accent.
4. screenshot the set and judge it as a set: equal weight, aligned baselines, consistent
padding. Fix what looks off rather than adding more detail.
Return the ids of the icons in a grid, and leave them as separate objects on one layer.",
},
PromptDef {
name: "recolor",
title: "Recolour artwork",
description: "Recolour the selection with explicit rows, or cluster its colours first with recolor.colors.",
arguments: &[
PromptArg {
name: "palette",
description: "The colours to map onto, or 'cluster' to group similar colours first",
required: true,
default: "",
values: Values::Fixed(&[
"cluster",
"a monochrome ramp from light to dark",
"a complementary two-colour scheme",
"one hue per layer, keeping the original values",
]),
},
PromptArg {
name: "method",
description: "How a row's colours take their new colour (recolor.apply's own default is exact; scaleTints suits a recolour driven from recolor.reduce)",
required: false,
default: "exact",
values: Values::Fixed(RECOLOR_METHODS),
},
],
text: "\
Recolour the current selection in VectorCraft with {palette}, using the `{method}` recolour method.
Method:
1. inspect_document to see what is selected and what colours it actually uses.
2. If you do not know the colours, run recolor.colors first: it groups similar colours into
rows (k-means in Lab, weighted by use) and hands back the map. Then edit the `to` colour of
each row and apply it. That is the reliable route for artwork you did not draw.
3. If you know the colours, run recolor.apply directly with `map`: one row per source colour,
`from` as keys or \"#rrggbb\", `to` as a colour. Each new colour keeps the model of the one it
replaces, and gradients, meshes, text and pattern tiles follow.
4. Group the colours with `group` so the result stays editable, and use `limitTo` to snap new
colours to a swatch library.
5. screenshot to check the result against the original intent.
One undo step covers the whole change; gradients and linked swatches stay linked.",
},
PromptDef {
name: "trace-and-style",
title: "Trace an image and style it",
description: "Trace a placed raster with Image Trace, then apply a live effect and paint over the result.",
arguments: &[
PromptArg { name: "path", description: "Path to the image file", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
name: "preset",
description: "Image Trace preset to start from",
required: false,
default: "Default",
values: Values::Live(Live::TracePresets),
},
PromptArg {
name: "effect",
description: "A live effect to apply to the traced shapes",
required: false,
default: "stylize.scribble",
values: Values::Live(Live::Effects),
},
],
text: "\
Trace the image at {path} in VectorCraft with the \"{preset}\" preset, then style the result
with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
near 90.
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
catalogue and each effect's parameters.
4. set_paint over the traced shapes to give the artwork its own colours.
5. screenshot, then remove the placed image if it is still underneath.
The trace is real geometry: every traced shape stays selectable and editable.",
},
PromptDef {
name: "export-set",
title: "Export an artboard set",
description: "Export every artboard in several formats at once, with per-format options, and report what each one lost.",
arguments: &[
PromptArg {
name: "formats",
description: "Formats to write, in order",
required: false,
default: "svg, pdf, png",
values: Values::Live(Live::Formats),
},
PromptArg {
name: "directory",
description: "Where the files go",
required: false,
default: "the current directory",
values: Values::Fixed(NONE),
},
PromptArg {
name: "scale",
description: "Raster scale in pixels per point (raster formats only)",
required: false,
default: "2",
values: Values::Fixed(&["1", "2", "3", "4"]),
},
],
text: "\
Export the document in VectorCraft to {formats}, into {directory}, at {scale} pixels per
point for the raster formats.
Method:
1. document.formats lists every format the engine reads and writes, with each one's options and
their defaults. Read it before exporting so the options you pass are real ones.
2. export once per format. A path's extension picks the format; `options` carries the rest, for
example {\"quality\": 80} for jpg. 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.
3. export without a path to get the bytes back as dataBase64 instead of a file.
4. Report the `warnings` each export returns: they name what that format cannot carry.
Live effects are kept on export (geometry baked; shadows, glows and blur become SVG filters),
and text stays text unless you pass outlineText.",
},
];
/// The message body as a client should receive it.
///
/// Our source wraps these texts at the column limit, which would leave line breaks in the middle
/// of a sentence — and a substituted argument landing on one of them. So each source line is
/// folded into the paragraph it belongs to, while list items keep a line of their own.
fn flow(text: &str) -> String {
text.split("\n\n")
.map(|paragraph| {
let mut out = String::new();
for line in paragraph.lines().map(str::trim).filter(|l| !l.is_empty()) {
if !out.is_empty() {
out.push(if is_item(line) { '\n' } else { ' ' });
}
out.push_str(line);
}
out
})
.collect::<Vec<_>>()
.join("\n\n")
}
/// A numbered or dashed list item, which starts a line instead of continuing one.
fn is_item(line: &str) -> bool {
if let Some(body) = line.strip_prefix("- ") {
return !body.is_empty();
}
line.split_once(". ").is_some_and(|(number, _)| !number.is_empty() && number.len() <= 3 && number.chars().all(|c| c.is_ascii_digit()))
}
/// `prompts/list`.
pub fn list() -> Value {
let prompts: Vec<Value> = PROMPTS
.iter()
.map(|p| {
let arguments: Vec<Value> =
p.arguments.iter().map(|a| json!({"name": a.name, "description": a.description, "required": a.required})).collect();
json!({"name": p.name, "title": p.title, "description": p.description, "arguments": arguments})
})
.collect();
json!({ "prompts": prompts })
}
/// `prompts/get`: the prompt's messages with `{argument}` filled in.
pub fn get(name: &str, args: &Value) -> Result<Value, String> {
let Some(p) = PROMPTS.iter().find(|p| p.name == name) else {
let ids: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect();
return Err(format!("unknown prompt `{name}` ({})", ids.join("|")));
};
let mut text = flow(p.text);
for a in p.arguments {
let given = args.get(a.name).filter(|v| !v.is_null()).map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let value = match given {
Some(v) if !v.is_empty() => v,
_ if a.required => return Err(format!("prompt `{name}` needs the `{a_name}` argument", a_name = a.name)),
_ => a.default.to_string(),
};
text = text.replace(&format!("{{{}}}", a.name), &value);
}
Ok(json!({
"description": p.description,
"messages": [{"role": "user", "content": {"type": "text", "text": text}}],
}))
}
/// `completion/complete`: values for a prompt argument or a resource-template variable.
///
/// Unknown references and unknown argument names come back empty rather than as an error: a
/// completion is a convenience, and the caller is usually mid-typing.
pub fn complete(b: &mut dyn Backend, params: &Value) -> Value {
let empty = json!({"completion": {"values": [], "total": 0, "hasMore": false}});
let Some(reference) = params.get("ref") else { return empty };
let typed = params.get("argument").and_then(|a| a.get("value")).and_then(Value::as_str).unwrap_or("");
let name = params.get("argument").and_then(|a| a.get("name")).and_then(Value::as_str).unwrap_or("");
let values = match reference.get("type").and_then(Value::as_str) {
Some("ref/prompt") => match reference.get("name").and_then(Value::as_str).and_then(|n| PROMPTS.iter().find(|p| p.name == n)) {
Some(p) => match p.arguments.iter().find(|a| a.name == name) {
Some(a) => a.values.resolve(b),
None => Vec::new(),
},
None => Vec::new(),
},
Some("ref/resource") => match reference.get("uri").and_then(Value::as_str).map(crate::resources::template_source) {
Some((_, Some(live))) => live_values(b, live),
_ => Vec::new(),
},
_ => Vec::new(),
};
// Rank by prefix first (what the user is typing), then by substring.
let needle = typed.to_lowercase();
let mut matches: Vec<String> = values.iter().filter(|v| v.to_lowercase().starts_with(&needle)).cloned().collect();
if matches.is_empty() && !needle.is_empty() {
matches = values.iter().filter(|v| v.to_lowercase().contains(&needle)).cloned().collect();
}
let total = matches.len();
let has_more = total > MAX_VALUES;
matches.truncate(MAX_VALUES);
json!({"completion": {"values": matches, "total": total, "hasMore": has_more}})
}
#[cfg(test)]
mod tests {
use super::*;
fn b() -> Box<dyn Backend> {
Box::new(crate::Headless::with_document())
}
#[test]
fn every_prompt_lists_and_renders() {
let listed = list();
let entries = listed["prompts"].as_array().expect("prompts");
assert_eq!(entries.len(), PROMPTS.len());
for (p, entry) in PROMPTS.iter().zip(entries) {
assert!(!p.name.is_empty() && !p.text.is_empty(), "{}", p.name);
assert_eq!(entry["name"], p.name);
assert_eq!(entry["description"], p.description);
// Arguments are advertised with the names prompts/get will accept.
let names: Vec<&str> = p.arguments.iter().map(|a| a.name).collect();
let listed: Vec<String> =
entry["arguments"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect());
let names: Vec<String> = names.into_iter().map(str::to_string).collect();
assert_eq!(names, listed, "{}: listed arguments", p.name);
// Every required argument supplied: the prompt renders.
let args: Value = json!(p.arguments.iter().filter(|a| a.required).map(|a| (a.name, "x")).collect::<std::collections::BTreeMap<_, _>>());
let v = get(p.name, &args).unwrap_or_else(|e| panic!("{} renders: {e}", p.name));
assert_eq!(v["messages"].as_array().map(Vec::len), Some(1));
assert_eq!(v["messages"][0]["role"], "user");
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.len() > 200, "{}: too short", p.name);
// Templating left nothing behind, and the source's line wrapping is gone.
// Only a declared argument's braces are placeholders; JSON examples carry braces too.
for a in p.arguments {
assert!(!text.contains(&format!("{{{}}}", a.name)), "{}: `{}` left in place", p.name, a.name);
}
assert!(!text.contains('\n') || text.contains("\n\n") || p.arguments.is_empty(), "{}: stray line break", p.name);
assert!(!text.contains(" "), "{}: double space", p.name);
// Only a missing required argument may stop it.
let missing: Value = json!({});
assert_eq!(get(p.name, &missing).is_err(), p.arguments.iter().any(|a| a.required), "{}", p.name);
}
}
#[test]
fn flow_keeps_paragraphs_and_list_items() {
let flowed = flow("first line\nsecond line\n\nnew paragraph\n1. one\n2. two\n- bullet");
assert_eq!(flowed, "first line second line\n\nnew paragraph\n1. one\n2. two\n- bullet");
assert_eq!(flow(""), "");
assert_eq!(flow("no breaks"), "no breaks");
}
#[test]
fn prompt_arguments_are_filled_in() {
let v = get("export-set", &json!({"formats": "pdf, tiff", "scale": "4"})).unwrap();
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.contains("pdf, tiff"), "{text}");
assert!(text.contains("4 pixels per point"), "{text}");
}
#[test]
fn unknown_prompt_and_missing_argument_are_errors() {
assert!(get("nope", &json!({})).is_err());
let e = get("poster", &json!({"brief": ""})).unwrap_err();
assert!(e.contains("brief"), "{e}");
}
#[test]
fn completions_rank_prefix_first_and_cap_at_100() {
let mut b = b();
let v = complete(
b.as_mut(),
&json!({"ref": {"type": "ref/prompt", "name": "trace-and-style"}, "argument": {"name": "preset", "value": "photo"}}),
);
let values = v["completion"]["values"].as_array().unwrap();
assert!(!values.is_empty(), "imageTrace.presets should answer: {v}");
assert!(values.iter().all(|x| x.as_str().unwrap_or_default().to_lowercase().contains("photo")), "{v}");
// A nonsense prefix falls back to substring matches, and never exceeds 100.
let v = complete(b.as_mut(), &json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": ""}}));
let all = v["completion"]["values"].as_array().unwrap().len();
assert!(all > 3, "formats should answer: {v}");
assert!(v["completion"]["total"].as_u64().unwrap() >= all as u64);
}
#[test]
fn unknown_completion_targets_are_empty_not_errors() {
let mut b = b();
for params in [
json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}),
json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "nope", "value": ""}}),
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://nope/{id}"}, "argument": {"name": "id", "value": ""}}),
json!({"ref": {"type": "ref/other"}, "argument": {"name": "x", "value": ""}}),
json!({}),
] {
let v = complete(b.as_mut(), &params);
assert_eq!(v["completion"]["values"], json!([]), "{params}");
}
}
}
+265
View File
@@ -0,0 +1,265 @@
//! MCP resources: the fixed documents, the templates an agent can address parts of the
//! document by, and reading either.
//!
//! The templates matter for long sessions. `vectorcraft://document/json` returns the whole model,
//! which on a real document is thousands of lines an agent pays for again on every change; the
//! templates hand out one object, one command definition or one effect's parameters instead, and
//! they are what `completion/complete` suggests ids and names from.
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts::Live;
/// The active document's summary: artboards, layer tree, selection, history.
pub const DOC_URI: &str = "vectorcraft://document";
/// The whole document model as JSON.
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
/// Why a resource could not be read. The distinction matters: the server turns it into `-32002`
/// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold.
#[derive(Debug)]
pub enum ReadError {
/// The URI names no resource or template this server serves.
NotFound(String),
/// The template has no value, or the value names nothing.
Invalid(String),
/// The backend could not be asked at all.
Backend(String),
}
/// One addressable template: the URI with `{var}`, and what its variable can be.
pub struct Template {
pub uri: &'static str,
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub mime: &'static str,
pub live: Live,
}
/// Every template, in `resources/templates/list` order.
pub static TEMPLATES: &[Template] = &[
Template {
uri: "vectorcraft://object/{id}",
name: "object",
title: "One layer or object",
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
mime: "application/json",
live: Live::Objects,
},
Template {
uri: "vectorcraft://command/{id}",
name: "command",
title: "One command",
description: "The command with this id: label, menu path, shortcut, parameter description and whether it is enabled now (engine.commands)",
mime: "application/json",
live: Live::Commands,
},
Template {
uri: "vectorcraft://effect/{id}",
name: "effect",
title: "One live effect",
description: "The live effect with this id and its parameters with their defaults (effect.list)",
mime: "application/json",
live: Live::Effects,
},
Template {
uri: "vectorcraft://swatch/{name}",
name: "swatch",
title: "One swatch",
description: "The swatch or gradient/pattern swatch with this name (swatch.list)",
mime: "application/json",
live: Live::Swatches,
},
];
/// `resources/list`.
pub fn list() -> Value {
json!({"resources": [
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
]})
}
/// `resources/templates/list`.
pub fn templates() -> Value {
let list: Vec<Value> = TEMPLATES
.iter()
.map(|t| json!({"uriTemplate": t.uri, "name": t.name, "title": t.title, "description": t.description, "mimeType": t.mime}))
.collect();
json!({ "resourceTemplates": list })
}
/// Which template a URI is an instance of, and what its variable can be.
pub fn template_source(uri: &str) -> (&str, Option<Live>) {
for t in TEMPLATES {
let Some((prefix, _)) = t.uri.split_once('{') else { continue };
if uri.starts_with(prefix) {
return (t.uri, Some(t.live));
}
}
(uri, None)
}
/// `resources/read`: the document behind a resource URI.
pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
if uri == DOC_URI {
return b.call("document.inspect", json!({})).map_err(ReadError::Backend);
}
if uri == DOC_JSON_URI {
return exec(b, "document.json").map_err(ReadError::Backend);
}
let (template, var) = match TEMPLATES.iter().find(|t| template_source(uri).0 == t.uri) {
Some(t) => (t, variable(uri, t.uri)),
None => return Err(ReadError::NotFound(uri.to_string())),
};
if var.is_empty() {
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
}
let found = match template.name {
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
// The command catalogue is a control method; the rest are engine commands.
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
"swatch" => find_in(exec(b, "swatch.list").map_err(ReadError::Backend)?, Some("swatches"), "name", &var),
other => return Err(ReadError::NotFound(format!("no reader for template `{other}`"))),
};
Ok(match found {
Some(v) => v,
None => return Err(ReadError::Invalid(format!("no {} named `{var}` in the active document", template.name))),
})
}
/// Run a query engine command. That reaches both backends, where a control method the headless
/// session doesn't implement would not.
fn exec(b: &mut dyn Backend, command: &str) -> Result<Value, String> {
b.call("engine.execute", json!({"command": command, "params": {}}))
}
/// The part of a URI between the template's `{` and its `}`.
fn variable(uri: &str, template: &str) -> String {
let Some(open) = template.find('{') else { return String::new() };
let prefix = &template[..open];
let rest = uri.strip_prefix(prefix).unwrap_or("");
let raw = rest.split('}').next().unwrap_or("");
decode(raw)
}
/// The variable's name, e.g. `id` or `name`.
fn variable_name(template: &str) -> &str {
template.split_once('{').and_then(|(_, r)| r.split_once('}')).map_or("", |(n, _)| n)
}
/// Percent-decoding, so a swatch called "Brand 40%" can be addressed as `Brand%2040%25`.
fn decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while let Some(b) = bytes.get(i).copied() {
if b == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(s.get(i + 1..i + 3).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(b);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// The layer or object with this id in the active document, with its subtree.
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let doc = exec(b, "document.inspect")?;
Ok(find_node(doc.get("layers"), id))
}
/// Depth-first search of the layer tree for a node whose `id` matches.
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
let nodes = node?.as_array()?;
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
while let Some(n) = stack.pop() {
let hit = match n.get("id") {
Some(Value::String(s)) => s == id,
Some(Value::Number(number)) => number.to_string() == id,
_ => false,
};
if hit {
return Some(n.clone());
}
if let Some(kids) = n.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
}
None
}
/// The item in a query command's array whose `field` equals `value`.
fn find_in(reply: Value, key: Option<&str>, field: &str, value: &str) -> Option<Value> {
let items = match key {
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
None => reply.as_array().map_or(&[][..], Vec::as_slice),
};
items.iter().take(MAX_CATALOGUE).find(|i| i.get(field).and_then(Value::as_str) == Some(value)).cloned()
}
/// Catalogs come from the app, but never trust one that grew without bound.
const MAX_CATALOGUE: usize = 20_000;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variables_are_split_out_and_decoded() {
assert_eq!(variable("vectorcraft://object/42", "vectorcraft://object/{id}"), "42");
assert_eq!(variable("vectorcraft://swatch/Brand%2040%25", "vectorcraft://swatch/{name}"), "Brand 40%");
assert_eq!(variable("vectorcraft://swatch/a%zzb", "vectorcraft://swatch/{name}"), "a%zzb");
assert_eq!(variable("vectorcraft://other", "vectorcraft://object/{id}"), "");
assert_eq!(variable_name("vectorcraft://swatch/{name}"), "name");
assert_eq!(variable_name("no template"), "");
}
#[test]
fn templates_are_addressable() {
assert_eq!(template_source("vectorcraft://object/7"), ("vectorcraft://object/{id}", Some(Live::Objects)));
assert_eq!(template_source("vectorcraft://command/paint.setFill").1, Some(Live::Commands));
assert_eq!(template_source("vectorcraft://document").1, None);
}
#[test]
fn lists_are_well_formed() {
let l = list();
assert_eq!(l["resources"].as_array().map(Vec::len), Some(2));
let t = templates();
let items = t["resourceTemplates"].as_array().expect("templates");
assert_eq!(items.len(), TEMPLATES.len());
for (entry, t) in items.iter().zip(TEMPLATES) {
assert_eq!(entry["uriTemplate"], t.uri);
assert!(t.uri.contains('{'), "{} has no variable", t.uri);
assert!(t.description.len() > 20, "{} needs a description", t.name);
}
}
#[test]
fn missing_and_unknown_uris_are_errors() {
let mut b = Box::new(crate::Headless::with_document());
assert!(read(b.as_mut(), "vectorcraft://nope").is_err());
assert!(read(b.as_mut(), "vectorcraft://object/").is_err());
assert!(read(b.as_mut(), "vectorcraft://object/99999").is_err());
assert!(read(b.as_mut(), DOC_URI).is_ok());
}
#[test]
fn an_object_is_readable_once_it_exists() {
let mut b = Box::new(crate::Headless::with_document());
let made = b.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}})).unwrap();
let id = made["id"].as_i64().unwrap_or_default().to_string();
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
assert_eq!(v["id"].to_string(), id);
assert!(v["bounds"].is_object(), "{v}");
}
}
+63 -25
View File
@@ -1,10 +1,12 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts;
use crate::resources;
use crate::tools::{call_tool, tool_definitions};
/// The MCP revision we implement.
@@ -18,15 +20,14 @@ const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are points in \
document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \
draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \
find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \
selection (or on explicit `ids`).";
/// Resource URIs.
pub const DOC_URI: &str = "vectorcraft://document";
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
Draw with draw_shape / draw_path, change colours with set_paint, look with screenshot and inspect_document. \
Every menu action is a command: find it with list_commands and run it with run_command. New objects become \
the selection, and most commands act on the selection (or on explicit `ids`). prompts/list has ready-made \
workflows, completion/complete finishes a command id, a format, an effect or a swatch name, and \
vectorcraft://object/{id}, vectorcraft://command/{id}, vectorcraft://effect/{id} and \
vectorcraft://swatch/{name} read one thing at a time instead of the whole document.";
/// An MCP server bound to one backend.
pub struct Server {
@@ -56,16 +57,30 @@ impl Server {
self.initialized
}
/// The `notifications/message` records queued since the last call.
///
/// Log records belong to no particular request, so these go out after the reply to whatever
/// line produced them rather than before it. [`serve`] drains this itself; a caller driving
/// [`handle_line`] by hand does the same.
pub fn take_notifications(&mut self) -> Vec<String> {
crate::logging::drain()
.into_iter()
.map(|record| json!({"jsonrpc": "2.0", "method": "notifications/message", "params": record}).to_string())
.collect()
}
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
let reply = self.handle_line(&line);
let notes = self.take_notifications();
for line in reply.iter().chain(notes.iter()) {
output.write_all(line.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
output.flush()?;
}
Ok(())
}
@@ -135,7 +150,13 @@ impl Server {
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {}, "resources": {}},
"capabilities": {
"tools": {},
"resources": {},
"prompts": {"listChanged": false},
"completions": {},
"logging": {},
},
"serverInfo": {"name": "vectorcraft", "title": "VectorCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
@@ -147,21 +168,38 @@ impl Server {
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
}
"resources/list" => Ok(json!({"resources": [
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/list" => Ok(resources::list()),
"resources/templates/list" => Ok(resources::templates()),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let v = match uri {
DOC_URI => self.backend.call("document.inspect", json!({})),
DOC_JSON_URI => self.backend.call("engine.execute", json!({"command": "document.json", "params": {}})),
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
resources::ReadError::Invalid(why) => (INVALID_PARAMS, why),
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
})?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
"prompts/list" => Ok(prompts::list()),
"prompts/get" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e))
}
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
"logging/setLevel" => {
// `level` is required. A null level is our own way of turning logging back off,
// which the spec has no method for and a long session needs.
let Some(asked) = params.get("level") else {
return Err((INVALID_PARAMS, "missing `level`".into()));
};
let level = if asked.is_null() { None } else { asked.as_str() };
crate::logging::set_level(level).map_err(|_| match level {
Some(given) => (INVALID_PARAMS, format!("unknown level `{given}`")),
None => (INVALID_PARAMS, "`level` must be a severity name or null".to_string()),
})?;
log::debug!("client set logging to {}", level.unwrap_or("off"));
Ok(json!({}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}
+219
View File
@@ -0,0 +1,219 @@
//! The protocol surface beyond tools: prompts, completions, resource templates, subscriptions,
//! logging, progress and cancellation, driven the way a client drives them.
use serde_json::{Value, json};
use crate::{Headless, PROMPTS, Server};
fn server() -> Server {
Server::new(Box::new(Headless::with_document()))
}
/// One request, and whatever the server queued while answering it.
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> (Value, Vec<Value>) {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply = s.handle_line(&line).and_then(|r| serde_json::from_str::<Value>(&r).ok());
let notes: Vec<Value> = s.take_notifications().iter().filter_map(|l| serde_json::from_str(l).ok()).collect();
let reply = reply.unwrap_or(Value::Null);
assert_eq!(reply["jsonrpc"], "2.0", "{reply}");
assert_eq!(reply["id"], id, "{reply}");
(reply, notes)
}
fn result(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let (v, _) = rpc(s, id, method, params);
assert!(v.get("error").is_none(), "{method}: {v}");
v["result"].clone()
}
/// Run a tool and parse the JSON it answers with.
fn tool(s: &mut Server, id: u64, name: &str, args: Value) -> Value {
let r = result(s, id, "tools/call", json!({"name": name, "arguments": args}));
assert_eq!(r["isError"], false, "{name}: {r}");
let text = r["content"][0]["text"].as_str().unwrap_or("null");
serde_json::from_str(text).unwrap_or(Value::Null)
}
/// Draw a rectangle and return its id.
fn rectangle(s: &mut Server, id: u64) -> i64 {
let made = tool(s, id, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 60, "height": 40}));
made["id"].as_i64().unwrap_or_default()
}
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
let (v, _) = rpc(s, id, method, params);
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
}
// ---------- capabilities ----------
#[test]
fn initialize_advertises_what_the_server_actually_answers() {
let mut s = server();
let caps = result(&mut s, 1, "initialize", json!({}))["capabilities"].clone();
assert!(caps["tools"].is_object(), "{caps}");
assert!(caps["resources"].is_object(), "{caps}");
// No `subscribe`: this server cannot observe a change while a call is running, so it does not
// claim to. Advertising a capability that silently misses edits is worse than not having it.
assert!(caps["resources"].get("subscribe").is_none(), "{caps}");
assert!(caps["prompts"].is_object(), "prompts are implemented");
assert!(caps["completions"].is_object(), "completion/complete is implemented");
assert!(caps["logging"].is_object(), "logging/setLevel is implemented");
// And the instructions point at them, since that is where a client looks first.
let instructions = result(&mut s, 2, "initialize", json!({}))["instructions"].as_str().unwrap_or_default().to_string();
assert!(instructions.contains("prompts/list"), "{instructions}");
assert!(instructions.contains("points in document space"), "{instructions}");
}
// ---------- prompts ----------
#[test]
fn prompts_are_listed_and_rendered_over_the_protocol() {
let mut s = server();
let listed = result(&mut s, 1, "prompts/list", json!({}))["prompts"].clone();
let entries = listed.as_array().expect("prompts");
assert_eq!(entries.len(), PROMPTS.len());
for e in entries {
assert!(e["name"].as_str().is_some_and(|n| !n.is_empty()), "{e}");
assert!(e["description"].as_str().is_some_and(|d| d.len() > 20), "{e}");
// Arguments are advertised with the flag prompts/get honours.
for a in e["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
assert!(a["required"].is_boolean(), "{a}");
}
}
let got = result(&mut s, 2, "prompts/get", json!({"name": "poster", "arguments": {"brief": "a jazz festival"}}));
assert_eq!(got["messages"].as_array().map(Vec::len), Some(1));
assert_eq!(got["messages"][0]["role"], "user");
let text = got["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.contains("a jazz festival"), "{text}");
}
#[test]
fn prompt_argument_errors_are_invalid_params() {
let mut s = server();
assert_eq!(err_code(&mut s, 1, "prompts/get", json!({"name": "poster"})), -32602);
assert_eq!(err_code(&mut s, 2, "prompts/get", json!({"name": "nope"})), -32602);
assert_eq!(err_code(&mut s, 3, "prompts/get", json!({})), -32602);
}
// ---------- completions ----------
#[test]
fn completions_come_from_the_live_catalogues() {
let mut s = server();
// A prompt argument fed by the export formats.
let v = result(
&mut s,
1,
"completion/complete",
json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": "sv"}}),
);
let values: Vec<&str> = v["completion"]["values"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(Value::as_str).collect());
assert!(values.contains(&"svg"), "{v}");
assert_eq!(v["completion"]["hasMore"], false);
// A resource template variable, after something exists to point at.
rectangle(&mut s, 2);
let v = result(
&mut s,
3,
"completion/complete",
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://object/{id}"}, "argument": {"name": "id", "value": ""}}),
);
let values = v["completion"]["values"].as_array().map_or(0, Vec::len);
assert!(values > 0, "{v}");
assert!(v["completion"]["total"].as_u64().unwrap_or(0) >= values as u64);
}
// ---------- resource templates ----------
#[test]
fn templates_are_listed_and_readable() {
let mut s = server();
let listed = result(&mut s, 1, "resources/templates/list", json!({}));
let templates = listed["resourceTemplates"].as_array().expect("templates");
assert!(templates.len() >= 4, "{listed}");
let id = rectangle(&mut s, 2);
let read = result(&mut s, 3, "resources/read", json!({"uri": format!("vectorcraft://object/{id}")}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
assert!(body["bounds"].is_object(), "{body}");
// A command definition is one small read instead of the whole 400-command catalogue.
let read = result(&mut s, 4, "resources/read", json!({"uri": "vectorcraft://command/paint.setFill"}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"], "paint.setFill", "{body}");
assert!(body["params"].as_str().is_some_and(|p| !p.is_empty()), "{body}");
// An effect's parameters and defaults.
let read = result(&mut s, 5, "resources/read", json!({"uri": "vectorcraft://effect/stylize.dropShadow"}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"], "stylize.dropShadow", "{body}");
// Missing values and unknown resources are distinguishable.
assert_eq!(err_code(&mut s, 6, "resources/read", json!({"uri": "vectorcraft://object/"})), -32602);
assert_eq!(err_code(&mut s, 7, "resources/read", json!({"uri": "vectorcraft://command/nope"})), -32602);
assert_eq!(err_code(&mut s, 8, "resources/read", json!({"uri": "vectorcraft://nope"})), -32002);
}
// ---------- logging ----------
#[test]
fn logging_levels_are_validated_and_off_by_default() {
let _turn = crate::logging::test_turn();
let mut s = server();
assert_eq!(crate::logging::level(), None, "silent until asked for");
result(&mut s, 1, "logging/setLevel", json!({"level": "debug"}));
assert_eq!(crate::logging::level(), Some("debug"));
// A null level turns it back off; a missing one is a bad request.
result(&mut s, 2, "logging/setLevel", json!({"level": null}));
assert_eq!(crate::logging::level(), None);
assert_eq!(err_code(&mut s, 3, "logging/setLevel", json!({"level": "shouty"})), -32602);
assert_eq!(err_code(&mut s, 4, "logging/setLevel", json!({})), -32602);
// A rejected level leaves the accepted one alone.
result(&mut s, 5, "logging/setLevel", json!({"level": "warning"}));
assert_eq!(err_code(&mut s, 6, "logging/setLevel", json!({"level": "shouty"})), -32602);
assert_eq!(crate::logging::level(), Some("warning"));
result(&mut s, 7, "logging/setLevel", json!({"level": null}));
assert_eq!(crate::logging::level(), None);
}
/// Log records reach the client as `notifications/message`, and only after it asked.
#[test]
fn log_records_go_out_as_notifications_once_asked_for() {
let _turn = crate::logging::test_turn();
// The binary installs the logger, not the library, so a test does it for itself.
crate::logging::install();
let mut s = server();
let input = [
json!({"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "debug"}}).to_string(),
json!({"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}).to_string(),
json!({"jsonrpc": "2.0", "id": 3, "method": "logging/setLevel", "params": {"level": null}}).to_string(),
]
.join("\n");
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let notes: Vec<&Value> = lines.iter().filter(|l| l["method"] == "notifications/message").collect();
assert!(!notes.is_empty(), "the level change itself is logged: {lines:?}");
for n in &notes {
assert_eq!(n["jsonrpc"], "2.0", "{n}");
assert!(
["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]
.contains(&n["params"]["level"].as_str().unwrap_or_default()),
"{n}"
);
assert!(n["params"]["data"].is_string(), "{n}");
}
// Every reply still came before the notifications that followed it.
let answered = lines.iter().filter(|l| l.get("id").is_some()).count();
assert_eq!(answered, 3, "{lines:?}");
let last_reply = lines.iter().rposition(|l| l.get("id").is_some()).unwrap_or(0);
assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}");
result(&mut s, 4, "logging/setLevel", json!({"level": null}));
}
+231
View File
@@ -182,6 +182,237 @@ fn resources_are_json() {
}
}
// ---------- prompts, completions, templates, subscriptions ----------
/// Every prompt is well formed and renders whatever it is handed.
#[test]
fn prompts_are_valid_and_render_with_junk_arguments() {
let mut s = server();
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
let prompts = listed["result"]["prompts"].as_array().unwrap();
assert!(!prompts.is_empty());
let mut failures = vec![];
for p in prompts {
let name = p["name"].as_str().unwrap();
assert!(p["description"].as_str().unwrap().len() > 20, "{name}");
let args: Vec<&str> = p["arguments"].as_array().map_or(&[][..], Vec::as_slice).iter().filter_map(|a| a["name"].as_str()).collect();
// Missing, empty, junk and hostile arguments all either render or fail cleanly.
let mut cases = vec![json!({}), Value::Null, json!([]), json!("x")];
for a in &args {
for v in junk_values() {
cases.push(json!({ *a: v }));
cases.push(json!({ *a: { "nested": v.clone() } }));
cases.push(json!({ *a: [v.clone()] }));
}
}
// Filled with every argument, the prompt must render.
let full: Value = json!(args.iter().map(|a| (*a, "value")).collect::<std::collections::BTreeMap<_, _>>());
cases.push(full);
for arguments in cases {
let r = catch_quiet(|| rpc(&mut s, 2, "prompts/get", json!({"name": name, "arguments": arguments.clone()})));
match r {
Err(p) => failures.push(format!("PANIC {name} {arguments}: {p}")),
Ok(v) => {
assert!(v["result"].is_object() || v["error"]["code"] == -32602, "{name} {arguments}: {v}");
// Whatever it returned is valid JSON text in a message.
if let Some(messages) = v["result"]["messages"].as_array() {
for m in messages {
assert!(["user", "assistant"].contains(&m["role"].as_str().unwrap()), "{m}");
assert!(m["content"]["text"].is_string(), "{m}");
}
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// `completion/complete` answers for every prompt argument and never panics on junk.
#[test]
fn completions_are_well_formed_and_survive_junk() {
let mut s = server();
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
let mut cases = vec![];
for p in listed["result"]["prompts"].as_array().unwrap() {
let name = p["name"].as_str().unwrap();
for a in p["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
let arg = a["name"].as_str().unwrap();
for value in ["", "a", "svg", "%20", "..", &"x".repeat(300)] {
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": arg, "value": value}}));
}
}
// An argument the prompt does not have, and a prompt that does not exist.
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": "nope", "value": ""}}));
cases.push(json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}));
}
for t in templates(&mut s) {
for v in ["", "1", "%", "vectorcraft://x"] {
cases.push(json!({"ref": {"type": "ref/resource", "uri": t["uriTemplate"]}, "argument": {"name": "id", "value": v}}));
}
}
// And malformed references of every shape.
for junk in [json!({}), json!(null), json!([]), json!("x")] {
cases.push(junk);
}
for junk in junk_values().into_iter().take(12) {
cases.push(junk);
}
let mut failures = vec![];
for params in cases {
let r = catch_quiet(|| rpc(&mut s, 2, "completion/complete", params.clone()));
match r {
Err(p) => failures.push(format!("PANIC {params}: {p}")),
Ok(v) => {
assert!(v.get("error").is_none(), "{params}: {v}");
let completion = &v["result"]["completion"];
let values = completion["values"].as_array().unwrap_or_else(|| panic!("{params}: no values"));
assert!(values.len() <= 100, "{params}: {} values", values.len());
assert!(values.iter().all(Value::is_string), "{params}: {v}");
if let Some(total) = completion["total"].as_u64() {
assert!(total >= values.len() as u64, "{params}: {v}");
}
assert_eq!(completion["hasMore"], total_exceeds(completion), "{params}: {v}");
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// `hasMore` is set exactly when the catalogue held more than we sent.
fn total_exceeds(completion: &Value) -> Value {
let sent = completion["values"].as_array().map_or(0, Vec::len);
json!(completion["total"].as_u64().is_some_and(|t| t > sent as u64))
}
fn templates(s: &mut Server) -> Vec<Value> {
rpc(s, 9, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().clone()
}
/// Every template lists with a variable, and reading it answers JSON or a clean error.
#[test]
fn resource_templates_are_addressable() {
let mut s = server();
let mut failures = vec![];
for t in templates(&mut s) {
let uri = t["uriTemplate"].as_str().unwrap();
assert!(uri.contains('{') && uri.ends_with('}'), "{t}");
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
assert!(t["mimeType"].is_string(), "{t}");
// A template with its variable left empty, filled with junk, and with a real value.
let prefix = uri.split('{').next().unwrap();
let mut uris = vec![prefix.to_string(), format!("{prefix}{{"), format!("{prefix}%20"), format!("{prefix}-1")];
let made = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 1, "y": 1, "width": 20, "height": 20}));
if let Some(id) =
made["result"]["content"][0]["text"].as_str().and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v["id"].as_i64())
{
uris.push(format!("{prefix}{id}"));
}
for uri in uris {
let r = catch_quiet(|| rpc(&mut s, 10, "resources/read", json!({"uri": uri.clone()})));
match r {
Err(p) => failures.push(format!("PANIC {uri}: {p}")),
Ok(v) => {
if let Some(text) = v["result"]["contents"][0]["text"].as_str() {
serde_json::from_str::<Value>(text).unwrap_or_else(|e| panic!("{uri} is not JSON: {e}"));
} else {
let code = v["error"]["code"].as_i64().unwrap_or_default();
assert!(code == -32602 || code == -32002, "{uri}: {v}");
}
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Anything a client may send for the prompts, completion, logging and template methods leaves the
/// server answering and the document valid.
#[test]
fn the_new_methods_survive_junk_and_keep_the_document_valid() {
let mut failures = vec![];
let mut s = server();
rpc(&mut s, 1, "tools/call", json!({"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 0, "y": 0, "width": 30, "height": 20}}));
let before = doc_json(&document(&mut s));
let mut cases: Vec<(&str, Value)> = vec![];
for uri in ["vectorcraft://document", "vectorcraft://object/1", "vectorcraft://nope", "", "x://y", "%"] {
cases.push(("resources/read", json!({"uri": uri})));
cases.push(("resources/read", json!({})));
cases.push(("resources/read", json!({"uri": 5})));
}
for level in [json!("debug"), json!("emergency"), json!(null), json!("shouty"), json!(5), json!([]), json!({})] {
cases.push(("logging/setLevel", json!({"level": level})));
}
cases.push(("logging/setLevel", json!({})));
for name in [json!("poster"), json!("nope"), json!(""), json!(5), json!(null), json!([])] {
cases.push(("prompts/get", json!({"name": name})));
cases.push(("prompts/get", json!({"name": name, "arguments": []})));
}
for ref_ in [
json!({"type": "ref/prompt", "name": "poster"}),
json!({"type": "ref/resource", "uri": "vectorcraft://object/{id}"}),
json!({"type": "ref/other"}),
json!("x"),
json!([]),
] {
cases.push(("completion/complete", json!({"ref": ref_, "argument": {"name": "brief", "value": ""}})));
cases.push(("completion/complete", json!({"ref": ref_})));
}
for (method, params) in cases {
let r = catch_quiet(|| rpc(&mut s, 11, method, params.clone()));
match r {
Err(p) => failures.push(format!("PANIC {method} {params}: {p}")),
Ok(v) => {
assert!(v.get("result").is_some() != v.get("error").is_some(), "{method} {params}: {v}");
// Whatever it answered, every notification it queued is a JSON-RPC message.
for line in s.take_notifications() {
let n: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad notification {line}: {e}"));
assert_eq!(n["jsonrpc"], "2.0", "{n}");
assert_eq!(n["method"], "notifications/message", "{n}");
assert!(n["params"]["level"].is_string() && n["params"]["data"].is_string(), "{n}");
}
check_document(&document(&mut s)).unwrap_or_else(|e| failures.push(format!("{method} {params}: {e}")));
}
}
}
// None of these edit the document, so it must be exactly as it was.
assert_eq!(doc_json(&document(&mut s)), before, "a read-only method changed the document");
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Every capability the server advertises is one it actually answers.
#[test]
fn advertised_capabilities_all_answer() {
let mut s = server();
let caps = rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"].clone();
let checks: Vec<(&str, &str, Value)> = vec![
("tools", "tools/list", json!({})),
("prompts", "prompts/list", json!({})),
("completions", "completion/complete", json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "brief", "value": ""}})),
("logging", "logging/setLevel", json!({"level": null})),
("resources", "resources/list", json!({})),
("resources", "resources/templates/list", json!({})),
("resources", "resources/read", json!({"uri": doc_uri_json(&mut s)})),
];
for (capability, method, params) in checks {
assert!(caps[capability].is_object(), "{capability} is not advertised: {caps}");
assert!(rpc(&mut s, 2, method, params).get("error").is_none(), "{method} is advertised but does not answer");
}
// And nothing is advertised that the server does not answer.
assert!(caps["resources"].get("subscribe").is_none(), "subscribe is advertised but not implemented: {caps}");
assert_eq!(err_code(&mut s, 3, "resources/subscribe", json!({"uri": "vectorcraft://document"})), -32601);
}
/// A capability the server advertises must be one it answers, and one it does not advertise must
/// not be there.
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
let v = rpc(s, id, method, params);
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
}
#[test]
fn undo_redo_tools_restore_documents() {
let mut s = server();
+86 -22
View File
@@ -444,10 +444,15 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
let tool = app.session.tool_id();
let pan_mode = space || tool == "hand";
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
if pointer.primary_pressed() && resp.hovered() && !middle_pan {
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
if pointer.primary_pressed()
&& resp.hovered()
&& !middle_pan
&& let Some(p) = hover
{
ui.ctx().memory_mut(|mem| mem.stop_text_input());
app.ui.flyout = None;
let p = hover.unwrap_or(rect.center());
let d = if pan_mode {
Drag::Pan { start: p, center: v.center, middle: false }
} else if tool == "zoom" {
@@ -468,9 +473,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
kind
};
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
} else if drag.is_none() && resp.hovered() && pointer.button_pressed(egui::PointerButton::Middle) {
} else if drag.is_none()
&& resp.hovered()
&& pointer.button_pressed(egui::PointerButton::Middle)
&& let Some(start) = hover
{
// Middle-button drag pans the view whatever the tool.
let start = hover.unwrap_or(rect.center());
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true }));
} else if let Some(d) = drag {
let p = pointer.interact_pos().unwrap_or(rect.center());
@@ -1193,6 +1201,12 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
});
}
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
fn names_an_artboard(text: &str) -> bool {
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
}
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
for o in overlays {
match o {
@@ -1228,7 +1242,13 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
}
Overlay::Label { p: pt, text, color } => {
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color));
p.text(
sp,
egui::Align2::LEFT_TOP,
crate::panels::label_or_name(text, !names_an_artboard(text)),
egui::FontId::proportional(11.0),
c32(*color),
);
}
Overlay::Highlight { quad, color } => {
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
@@ -1267,21 +1287,21 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
let ui = &mut child;
ui.label(egui::RichText::new("Welcome to VectorCraft").font(theme::semibold(26.0)).color(t.text));
ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text));
ui.add_space(4.0);
ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim));
ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim));
ui.add_space(22.0);
ui.horizontal(|ui| {
if widgets::primary_button(ui, "New file").clicked() {
if widgets::primary_button(ui, tl!("New file")).clicked() {
app.run("file.newDialog", json!({})).ok();
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Open").clicked() {
if widgets::secondary_button(ui, tl!("Open")).clicked() {
app.run("file.open", json!({})).ok();
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
// A few of New Document's presets (`file.newPresets`), as its cards.
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
@@ -1295,7 +1315,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Community").font(theme::semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
crate::community::links(app, ui);
}
@@ -1326,18 +1346,18 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
if n > 1 {
items.push(("Group", "group", "object.group"));
items.push(("Unite", "squares-unite", "object.pathfinder.unite"));
items.push((tl!("Group"), "group", "object.group"));
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
} else if is_group {
items.push(("Ungroup", "ungroup", "object.ungroup"));
items.push(("Isolate", "square-dashed", "object.isolate"));
items.push((tl!("Ungroup"), "ungroup", "object.ungroup"));
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
} else if is_text {
items.push(("Create Outlines", "type", "type.createOutlines"));
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
} else {
items.push(("Offset Path", "square-dashed", "object.path.offsetPath"));
items.push(("Simplify", "spline", "object.path.simplify"));
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
}
items.push(("Duplicate", "copy", "edit.duplicate"));
items.push((tl!("Duplicate"), "copy", "edit.duplicate"));
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
let anchor = xf.to_screen(Point::new(b.center().x, b.y1));
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
@@ -1377,14 +1397,14 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
Stroke::new(1.0, t.button_border),
StrokeKind::Outside,
);
if resp.on_hover_text("Fill").clicked() {
if resp.on_hover_text(tl!("Fill")).clicked() {
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
if widgets::icon_button(ui, "lock", "Lock (⌘2)", false, 30.0).clicked() {
if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() {
run = Some("object.lock".into());
}
if widgets::icon_button(ui, "ellipsis", "Hide Contextual Task Bar", false, 30.0).clicked() {
if widgets::icon_button(ui, "ellipsis", tl!("Hide Contextual Task Bar"), false, 30.0).clicked() {
run = Some("window.taskBar".into());
}
});
@@ -1407,10 +1427,54 @@ mod tests {
out.textures_delta.clear();
}
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
/// labels are interface text.
#[test]
fn artboard_labels_name_the_artboard() {
assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy"));
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
}
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
}
/// One headless frame with the canvas under a 40-point bar, as it is under the document tabs.
fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
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| {
ui.add_space(40.0);
show(app, ui);
});
out.textures_delta.clear();
}
#[test]
fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
// A square in the middle of the artboard, where the view is centred.
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame_under_bar(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let click = |app: &mut VectorcraftApp, p: Pos2| {
let button =
|pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]);
frame_under_bar(app, &ctx, vec![button(true)]);
frame_under_bar(app, &ctx, vec![button(false)]);
};
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone();
// Two pixels above the canvas, within egui's interaction radius: nothing happens.
click(&mut app, pos2(rect.center().x, rect.top() - 2.0));
assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app));
// At the canvas's centre: the square.
click(&mut app, rect.center());
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
}
#[test]
fn middle_drag_pans_the_view_with_any_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+68 -26
View File
@@ -30,7 +30,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::brand::paint_mark(ui, r);
ui.add_space(4.0);
let on_home = app.ui.home.is_some() || app.session.active().is_none();
if widgets::icon_button(ui, "house", "Home", on_home, 24.0).clicked() {
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
app.run("app.home", json!({})).ok();
}
ui.add_space(2.0);
@@ -48,7 +48,10 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
let room = right.width();
let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text);
// Built-in workspace names translate; the user's own stay as typed.
let ws_name =
if crate::workspaces::is_builtin(&app.ui.workspace) { tl!(&app.ui.workspace).to_string() } else { app.ui.workspace.clone() };
let ws = ui.painter().layout_no_wrap(ws_name, egui::FontId::proportional(12.0), t.text);
let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0);
let gap = ui.spacing().item_spacing.x;
let with_search = ws_w + 8.0 + gap + 200.0;
@@ -62,7 +65,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
let wresp = wresp.on_hover_text("Switch workspace");
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
ui.add_space(8.0);
// Search box → command palette.
@@ -79,13 +82,13 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().text(
r.left_center() + vec2(26.0, 0.0),
egui::Align2::LEFT_CENTER,
"Search commands and tools",
tl!("Search commands and tools"),
egui::FontId::proportional(11.5),
t.text_dim,
);
resp.clicked()
} else {
widgets::icon_button(ui, "search", "Search commands and tools", false, 24.0).clicked()
widgets::icon_button(ui, "search", tl!("Search commands and tools"), false, 24.0).clicked()
};
if open_palette {
app.ui.palette_open = true;
@@ -116,7 +119,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled);
}
let Some(st) = app.session.active() else {
ui.label(egui::RichText::new("No Document").color(t.text_dim));
ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim));
return;
};
let sel = st.selection.objects.clone();
@@ -124,15 +127,15 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty();
let label = match &first {
Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point",
Some(_) if sel.len() == 1 && anchor_mode => tl!("Anchor Point"),
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
if im.link.is_some() {
"Linked File"
tl!("Linked File")
} else {
"Embedded"
tl!("Embedded")
}
}
_ => crate::panels::appearance::object_label(app),
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0);
@@ -147,7 +150,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::panels::paint_chip(app, ui, true, 22.0, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
let link = ui.link(egui::RichText::new(tl!("Stroke:")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
stroke_panel::popover(app, &link);
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
@@ -155,7 +158,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space(4.0);
ui.separator();
if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() {
if ui.link(egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
@@ -166,19 +169,19 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if !sel.is_empty() {
// Style picker: the selection's graphic style; its menu applies another.
ui.add_space(4.0);
if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() {
if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("graphicStyles".into());
}
let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r))
.on_hover_text("Graphic Style");
.on_hover_text(tl!("Graphic Style"));
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
}
ui.separator();
if sel.is_empty() {
if widgets::flat_button(ui, "Document Setup", 112.0).clicked() {
if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, "Preferences", 90.0).clicked() {
if widgets::flat_button(ui, tl!("Preferences"), 90.0).clicked() {
app.run("edit.preferences", json!({})).ok();
}
return;
@@ -192,7 +195,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
if widgets::icon_button(ui, icon, tl!(tip), false, 24.0).clicked() {
let mut p = p;
if sel.len() == 1 {
p["to"] = json!("artboard");
@@ -211,7 +214,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if k == "height" {
crate::panels::transform::constrain_link(app, ui);
}
ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim));
ui.label(egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
}
@@ -225,10 +228,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
let mode = if d.doc.mask_edit.is_some() {
"<Opacity Mask>/Opacity Mask".to_string()
format!("<{0}>/{0}", tl!("Opacity Mask"))
} else {
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
format!("{color}/{}", if outline { "Outline" } else { "Preview" })
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
};
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
}
@@ -305,6 +308,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
if pattern_edit.is_some() {
let mut x = bar.right() - 6.0;
for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] {
let label = tl!(label);
let w = 12.0 + 7.0 * label.len() as f32;
let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0));
let resp = ui.interact(r, ui.id().with(("pat-bar", cmd)), Sense::click());
@@ -346,10 +350,10 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.separator();
if ui.selectable_label(false, "Fit on Screen").clicked() {
if ui.selectable_label(false, tl!("Fit on Screen")).clicked() {
app.run("view.fitArtboard", json!({})).ok();
}
if ui.selectable_label(false, "Fit All").clicked() {
if ui.selectable_label(false, tl!("Fit All")).clicked() {
app.run("view.fitAll", json!({})).ok();
}
},
@@ -377,7 +381,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.separator();
let tool = vectorcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("");
let tool = vectorcraft_tools::tool_info(app.session.tool_id())
.map(|t| {
// The catalog keys carry the full label ("Selection Tool"); only English drops the suffix.
let shown = tl!(t.label);
if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown }
})
.unwrap_or("");
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
@@ -563,19 +573,51 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
/// The hint bar's segments for `tool`, with keys named as the menus name them (Alt and Ctrl on
/// Windows and Linux, ⌥ and ⌘ on macOS). Tools without a hint point to Search Commands.
/// Every hint-bar fragment (the action after the last `+` of a chord included), so the catalog tests
/// can insist a complete language covers them.
pub fn hint_strings() -> Vec<&'static str> {
let mut v = Vec::new();
for tool in vectorcraft_tools::catalog::all_tools() {
for &(text, bold) in hint_for(tool.id).unwrap_or(&[]) {
let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text };
if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) {
v.push(text);
}
}
}
v
}
fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> {
let search;
let segments = match hint_for(tool) {
Some(s) => s,
None => {
search = match menus::shortcut_of("help.commandPalette") {
Some(sc) => [("Press ", false), (sc, true), (" to search every command", false)],
None => [("Use ", false), ("Help › Search Commands…", true), (" to search every command", false)],
Some(sc) => [(tl!("Press "), false), (sc, true), (tl!(" to search every command"), false)],
None => [(tl!("Use "), false), (tl!("Help › Search Commands…"), true), (tl!(" to search every command"), false)],
};
&search
}
};
segments.iter().map(|&(text, bold)| (if bold { Cow::Owned(menus::pretty_shortcut(text)) } else { Cow::Borrowed(text) }, bold)).collect()
segments
.iter()
.map(|&(text, bold)| {
// Key names ("Click", "Esc") translate; in a chord ("Alt+Drag") the modifiers are named for
// the platform and only the action after the last `+` translates.
if !bold {
return (Cow::Borrowed(tl!(text)), bold);
}
match text.rsplit_once('+') {
Some((_, key)) if !key.is_empty() => {
let pretty = menus::pretty_shortcut(text);
let shown = pretty.strip_suffix(key).map_or(pretty.clone(), |m| format!("{m}{}", tl!(key)));
(Cow::Owned(shown), bold)
}
_ => (Cow::Owned(menus::pretty_shortcut(tl!(text))), bold),
}
})
.collect()
}
pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
+5 -5
View File
@@ -12,7 +12,7 @@ const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2);
/// The Discord button's label, height and width.
fn discord_layout(ui: &Ui, large: bool) -> (std::sync::Arc<egui::Galley>, f32, f32) {
let (h, font, label) = if large { (36.0, theme::semibold(14.0), "Join our Discord") } else { (24.0, theme::semibold(12.0), "Discord") };
let (h, font, label) = if large { (36.0, theme::semibold(14.0), tl!("Join our Discord")) } else { (24.0, theme::semibold(12.0), "Discord") };
let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::WHITE);
let w = galley.size().x + h * 0.55 + h * 0.9;
(galley, h, w)
@@ -33,7 +33,7 @@ pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egu
let ir = egui::Rect::from_center_size(r.left_center() + vec2(h * 0.35 + icon / 2.0, 0.0), vec2(icon, icon));
icons::paint(ui, "message-circle", ir, Color32::WHITE);
ui.painter().galley(egui::pos2(ir.right() + h * 0.2, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
let resp = resp.on_hover_text("Join the ArtCraft community on Discord (discord.gg/artcraft)");
let resp = resp.on_hover_text(tl!("Join the ArtCraft community on Discord (discord.gg/artcraft)"));
if resp.clicked() {
app.open_link("help.discord");
}
@@ -58,9 +58,9 @@ pub fn links(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(8.0);
let l = app.session.execute("help.links", &serde_json::json!({})).unwrap_or_default();
let s = |k: &str| l[k].as_str().unwrap_or("").to_string();
link(app, ui, "globe", "ArtCraft website", "help.website", &s("website"));
link(app, ui, "external-link", "VectorCraft on getartcraft.com", "help.appPage", &s("appPage"));
link(app, ui, "git-branch", "Source code on GitHub", "help.github", &s("github"));
link(app, ui, "globe", tl!("ArtCraft website"), "help.website", &s("website"));
link(app, ui, "external-link", tl!("VectorCraft on getartcraft.com"), "help.appPage", &s("appPage"));
link(app, ui, "git-branch", tl!("Source code on GitHub"), "help.github", &s("github"));
}
#[cfg(test)]
+8 -5
View File
@@ -9,7 +9,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
let mut open = true;
egui::Window::new("About VectorCraft")
egui::Window::new(tl!("About VectorCraft"))
.collapsible(false)
.resizable(false)
.open(&mut open)
@@ -22,16 +22,19 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
crate::brand::paint_mark(ui, r);
ui.vertical(|ui| {
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
ui.label(crate::i18n::fmt(
tl!("Version {version} — open-source vector illustration in pure Rust."),
&[("version", env!("CARGO_PKG_VERSION"))],
));
});
});
ui.add_space(12.0);
crate::community::links(app, ui);
ui.add_space(12.0);
ui.label(
egui::RichText::new(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.",
)
egui::RichText::new(tl!(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft."
))
.size(11.0),
);
});
+4 -2
View File
@@ -4,14 +4,16 @@ use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "All Tools".into(), body, ok: None, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, ..DialogSpec::FORM };
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
let mut picked = false;
for g in vectorcraft_tools::TOOL_GROUPS {
ui.horizontal_wrapped(|ui| {
for tool in g.iter() {
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
let shown = tl!(tool.label);
let shown = if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown };
if ui.button(shown).clicked() {
app.select_tool(tool.id);
picked = true;
}
@@ -7,7 +7,7 @@ use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Artboard Options".into(), confirm, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
+4 -4
View File
@@ -28,7 +28,7 @@ const LABEL_W: f32 = 82.0;
const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Blend Options".into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Blend Options").into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
/// Open Blend Options on the selected blend's options (else those new blends start with).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -63,7 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let mode = d.str("spacing");
let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l);
let labels = SPACING.map(|(_, l)| l);
widgets::label_row(ui, "Spacing:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| {
if let Some((value, _)) = widgets::dropdown(ui, "blend-spacing", shown, &labels, 150.0).and_then(|i| SPACING.get(i)) {
d.fields.insert("spacing".into(), json!(value));
}
@@ -84,9 +84,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.add_space(6.0);
widgets::label_row(ui, "Orientation:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Orientation:"), LABEL_W, |ui| {
let path = d.str("orientation") == "path";
for (value, along, tip) in [("page", false, "Align to Page"), ("path", true, "Align to Path")] {
for (value, along, tip) in [("page", false, tl!("Align to Page")), ("path", true, tl!("Align to Path"))] {
if orientation_button(ui, along, path == along, tip) {
d.fields.insert("orientation".into(), json!(value));
}
+6 -6
View File
@@ -21,7 +21,7 @@ pub const KIND: &str = "colorBalance";
const CMD: &str = "edit.colors.adjustBalance";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Adjust Colors".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Adjust Colors").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
/// A colour mode: its `mode` id, label and channels as (field, label).
struct Mode {
@@ -83,28 +83,28 @@ fn track(key: &str, x: f32) -> egui::Color32 {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let m = mode(d);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color Mode:");
widgets::dim_label(ui, tl!("Color Mode:"));
let labels: Vec<&str> = MODES.iter().map(|m| m.label).collect();
if let Some(i) = widgets::dropdown(ui, "balance-mode", m.label, &labels, 120.0) {
d.fields.insert("mode".into(), json!(MODES[i].id));
}
ui.add_space(12.0);
if widgets::check(ui, "Convert", d.bool("convert"), m.id != GLOBAL) {
if widgets::check(ui, tl!("Convert"), d.bool("convert"), m.id != GLOBAL) {
d.fields.insert("convert".into(), json!(!d.bool("convert")));
}
});
ui.add_space(8.0);
let m = mode(d);
if m.id == GLOBAL {
widgets::dim_label(ui, "Shifts the tints of global and spot colors; other colors stay.");
widgets::dim_label(ui, tl!("Shifts the tints of global and spot colors; other colors stay."));
}
for (k, label) in m.channels {
form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Adjust Options:");
for (k, label) in [("fill", "Fill"), ("stroke", "Stroke")] {
widgets::dim_label(ui, tl!("Adjust Options:"));
for (k, label) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] {
if widgets::check(ui, label, d.bool(k), true) {
d.fields.insert(k.into(), json!(!d.bool(k)));
}
@@ -17,7 +17,8 @@ use crate::theme::Tokens;
/// The dialog kind of Color Guide Options.
pub const KIND: &str = "colorGuideOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Color Guide Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Color Guide Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Open the dialog with the panel's current options.
pub fn open(app: &mut VectorcraftApp) {
@@ -38,14 +39,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let v = (60.0 + 160.0 * x) as u8;
egui::Color32::from_rgb(v, v, v)
};
form::slider(ui, d, "steps", "Steps:", 1.0..=max, "", &ramp);
form::slider(ui, d, "amount", "Variation:", 0.0..=100.0, "%", &ramp);
form::slider(ui, d, "steps", tl!("Steps:"), 1.0..=max, "", &ramp);
form::slider(ui, d, "amount", tl!("Variation:"), 0.0..=100.0, "%", &ramp);
ui.horizontal(|ui| {
ui.add_space(form::SLIDER_LABEL + ui.spacing().item_spacing.x);
let (r, _) = ui.allocate_exact_size(vec2(form::SLIDER_WIDTH, 14.0), Sense::hover());
let font = egui::FontId::proportional(11.0);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, "Less", font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, "More", font, t.text_dim);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!("Less"), font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, tl!("More"), font, t.text_dim);
});
ui.add_space(10.0);
// Preview: the base colour's row of variations.
+6 -6
View File
@@ -24,7 +24,7 @@ use crate::{VectorcraftApp, widgets};
pub(super) const KIND: &str = "colorPicker";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Color Picker".into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
/// Side of the colour field (and height of the channel slider).
const FIELD: f32 = 240.0;
@@ -222,7 +222,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
toggle_swatches = widgets::flat_button(ui, if swatches { "Color Models" } else { "Color Swatches" }, 110.0).clicked();
toggle_swatches = widgets::flat_button(ui, if swatches { tl!("Color Models") } else { tl!("Color Swatches") }, 110.0).clicked();
});
});
ui.add_space(10.0);
@@ -232,7 +232,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
});
ui.add_space(8.0);
if widgets::check(ui, "Only Web Colors", web, true) {
if widgets::check(ui, tl!("Only Web Colors"), web, true) {
d.fields.insert("webOnly".into(), json!(!web));
}
if let Some(ch) = new_channel {
@@ -254,8 +254,8 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
widgets::paint_chip(ui, top, &Paint::solid(*new));
widgets::paint_chip(ui, bottom, &Paint::solid(*original));
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text("New");
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text("Original: click to restore").clicked()
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New"));
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked()
}
/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour
@@ -334,7 +334,7 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Col
egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| {
ui.set_width(FIELD + 24.0);
let Some(st) = app.session.active() else {
widgets::dim_label(ui, "Open a document to see its swatches.");
widgets::dim_label(ui, tl!("Open a document to see its swatches."));
return;
};
let doc = &st.doc;
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
heading: |d| tl!(&d.str("__label")).to_string(),
body: |app, ui, d| {
let lengths = lengths(&d.str("__command"));
form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit());
+4 -2
View File
@@ -2,7 +2,8 @@
//! as deleting swatches.
//!
//! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it),
//! `__command` and `__params` (the command OK runs).
//! `__command` and `__params` (the command OK runs). Both texts are shown as given: callers
//! translate their template before filling in a name, so the name is never translated.
use serde_json::{Value, json};
@@ -28,7 +29,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
..DialogSpec::FORM
};
/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`.
/// Ask `message` (with an optional `detail` line), both in the UI language; OK runs `command`
/// with `params`.
pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) {
let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params});
app.ui.dialog = Some(Dialog::new(KIND, fields));
+24 -22
View File
@@ -21,7 +21,7 @@ const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"];
pub(super) const LABEL: f32 = 130.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Document Setup").into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
/// Open Document Setup on the active document's settings.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -79,28 +79,28 @@ fn unit(d: &Dialog) -> Unit {
fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect();
choice(ui, d, "units", "Units:", &units);
choice(ui, d, "units", tl!("Units:"), &units);
ui.add_space(6.0);
let unit = unit(d);
widgets::label_row(ui, "Bleed:", LABEL, |ui| form::bleed(ui, d, unit, 64.0));
widgets::label_row(ui, tl!("Bleed:"), LABEL, |ui| form::bleed(ui, d, unit, 64.0));
ui.add_space(6.0);
widgets::label_row(ui, "", LABEL, |ui| {
// Applies the settings, then edits the artboards with the Artboard tool.
if widgets::flat_button(ui, "Edit Artboards", 120.0).clicked() && confirm(app, d).is_ok() {
if widgets::flat_button(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() {
app.select_tool("artboard");
}
});
ui.add_space(12.0);
check(ui, d, "outlineImages", "Show Images in Outline Mode");
check(ui, d, "highlightSubstitutedFonts", "Highlight Substituted Fonts");
check(ui, d, "highlightSubstitutedGlyphs", "Highlight Substituted Glyphs");
check(ui, d, "outlineImages", tl!("Show Images in Outline Mode"));
check(ui, d, "highlightSubstitutedFonts", tl!("Highlight Substituted Fonts"));
check(ui, d, "highlightSubstitutedGlyphs", tl!("Highlight Substituted Glyphs"));
}
fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Transparency Grid");
widgets::subheader(ui, tl!("Transparency Grid"));
let sizes: Vec<(&str, &str)> = GridSize::ALL.iter().map(|g| (g.id(), g.label())).collect();
choice(ui, d, "gridSize", "Grid Size:", &sizes);
choice(ui, d, "gridSize", tl!("Grid Size:"), &sizes);
let colors: Vec<String> = d
.fields
.get("gridColors")
@@ -112,7 +112,7 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
.find(|(_, pair)| colors.len() == 2 && colors.iter().zip(pair).all(|(h, c)| h.eq_ignore_ascii_case(&c.to_hex())))
.map_or("Custom", |(n, _)| n);
let names: Vec<&str> = GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
widgets::label_row(ui, "Grid Colors:", LABEL, |ui| {
widgets::label_row(ui, tl!("Grid Colors:"), LABEL, |ui| {
if let Some((_, pair)) = widgets::dropdown(ui, "gridColors", preset, &names, 220.0).and_then(|i| GRID_COLOR_PRESETS.get(i)) {
d.fields.insert("gridColors".into(), json!(pair.map(|c| c.to_hex())));
}
@@ -136,27 +136,29 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
});
ui.add_space(4.0);
check(ui, d, "simulatePaper", "Simulate Colored Paper");
check(ui, d, "simulatePaper", tl!("Simulate Colored Paper"));
ui.add_space(14.0);
widgets::subheader(ui, "Export and Clipboard Transparency Flattener Settings");
widgets::subheader(ui, tl!("Export and Clipboard Transparency Flattener Settings"));
let presets: Vec<String> = app.session.flattener_presets().into_iter().map(|p| p.name).collect();
let options: Vec<(&str, &str)> = presets.iter().map(|p| (p.as_str(), p.as_str())).collect();
choice(ui, d, "flattenerPreset", "Preset:", &options);
check(ui, d, "discardWhiteOverprint", "Discard White Overprint in Output");
choice(ui, d, "flattenerPreset", tl!("Preset:"), &options);
check(ui, d, "discardWhiteOverprint", tl!("Discard White Overprint in Output"));
}
fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let langs: Vec<(&str, &str)> = LANGUAGES.iter().map(|(l, _)| (*l, *l)).collect();
let before = d.str("language");
choice(ui, d, "language", "Language:", &langs);
choice(ui, d, "language", tl!("Language:"), &langs);
// A language brings its quotes.
if d.str("language") != before
&& let Some(q) = vectorcraft_doc::setup::language_quotes(&d.str("language"))
{
d.fields.insert("quotes".into(), json!({"double": q.double.iter().collect::<String>(), "single": q.single.iter().collect::<String>()}));
}
for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] {
for (key, label, choices) in
[("double", tl!("Double Quotes:"), Quotes::DOUBLE_CHOICES), ("single", tl!("Single Quotes:"), Quotes::SINGLE_CHOICES)]
{
let pairs: Vec<String> = choices.iter().map(|p| p.iter().collect()).collect();
let labels: Vec<&str> = pairs.iter().map(String::as_str).collect();
let cur = d.fields.get("quotes").and_then(|q| q.get(key)).and_then(Value::as_str).unwrap_or("").to_string();
@@ -168,16 +170,16 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
}
});
}
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
check(ui, d, "typographersQuotes", tl!("Use Typographer's Quotes"));
ui.add_space(14.0);
widgets::subheader(ui, "Options");
widgets::subheader(ui, tl!("Options"));
egui::Grid::new("docsetup-scripts").num_columns(3).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label("");
for h in ["Size", "Position"] {
for h in [tl!("Size"), tl!("Position")] {
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
}
ui.end_row();
for (key, label) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
for (key, label) in [("superscript", tl!("Superscript:")), ("subscript", tl!("Subscript:"))] {
ui.label(egui::RichText::new(label).color(t.text));
for part in ["size", "position"] {
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
@@ -189,14 +191,14 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.end_row();
}
ui.label(egui::RichText::new("Small Caps:").color(t.text));
ui.label(egui::RichText::new(tl!("Small Caps:")).color(t.text));
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
d.fields.insert("smallCapsSize".into(), json!(n));
}
ui.end_row();
});
ui.add_space(6.0);
choice(ui, d, "exportText", "Export:", &[("editable", "Preserve Text Editability"), ("appearance", "Preserve Text Appearance")]);
choice(ui, d, "exportText", tl!("Export:"), &[("editable", tl!("Preserve Text Editability")), ("appearance", tl!("Preserve Text Appearance"))]);
}
#[cfg(test)]
+8 -8
View File
@@ -22,7 +22,7 @@ use crate::{VectorcraftApp, io, widgets};
pub const KIND: &str = "dxfImport";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| if d.str("mode") == "place" { "Place DXF" } else { "DXF Import Options" }.into(),
heading: |d| if d.str("mode") == "place" { tl!("Place DXF") } else { tl!("DXF Import Options") }.into(),
body,
confirm,
min_width: 560.0,
@@ -141,19 +141,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let layouts: Vec<String> =
d.fields.get("layouts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
let rows: Vec<(&str, &str)> = layouts.iter().map(|l| (l.as_str(), l.as_str())).collect();
choice(ui, d, "layout", "Layout:", &rows);
choice(ui, d, "layout", tl!("Layout:"), &rows);
ui.add_space(10.0);
widgets::subheader(ui, "Scaling");
check(ui, d, "fit", "Scale to Fit Artboard");
widgets::subheader(ui, tl!("Scaling"));
check(ui, d, "fit", tl!("Scale to Fit Artboard"));
ui.add_enabled_ui(!d.bool("fit"), |ui| super::dxf_options::scale_row(ui, d));
ui.horizontal(|ui| {
ui.add_space(LABEL + 8.0);
check(ui, d, "scaleLineweights", "Scale Lineweights");
check(ui, d, "scaleLineweights", tl!("Scale Lineweights"));
});
ui.add_space(10.0);
widgets::subheader(ui, "Options");
check(ui, d, "center", "Center Artwork");
check(ui, d, "mergeLayers", "Merge Layers");
widgets::subheader(ui, tl!("Options"));
check(ui, d, "center", tl!("Center Artwork"));
check(ui, d, "mergeLayers", tl!("Merge Layers"));
});
});
false
+22 -15
View File
@@ -18,8 +18,15 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "dxfOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "DXF Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("DXF Options").into(),
body,
confirm,
ok: Some("Export"),
min_width: 420.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Fields that are not remembered between exports (they belong to one export).
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
@@ -59,24 +66,24 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect();
choice(ui, d, "version", "Version:", &versions);
choice(ui, d, "version", tl!("Version:"), &versions);
ui.add_space(4.0);
scale_row(ui, d);
ui.horizontal(|ui| {
ui.add_space(LABEL + 8.0);
check(ui, d, "scaleLineweights", "Scale Lineweights");
check(ui, d, "scaleLineweights", tl!("Scale Lineweights"));
});
ui.add_space(4.0);
let depths: Vec<(&str, &str)> = ColorDepth::ALL.iter().map(|c| (c.id(), c.label())).collect();
choice(ui, d, "colors", "Number of Colors:", &depths);
choice(ui, d, "colors", tl!("Number of Colors:"), &depths);
let version = DxfVersion::from_id(&d.str("version")).unwrap_or_default();
if ColorDepth::from_id(&d.str("colors")) == Some(ColorDepth::True) && version < DxfVersion::R2004 {
note(ui, "True colour needs version 2004 or later: the nearest of 256 colours is written.");
note(ui, tl!("True colour needs version 2004 or later: the nearest of 256 colours is written."));
}
let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect();
choice(ui, d, "rasterFormat", "Raster File Format:", &rasters);
choice(ui, d, "rasterFormat", tl!("Raster File Format:"), &rasters);
ui.add_space(10.0);
widgets::subheader(ui, "Export Option");
widgets::subheader(ui, tl!("Export Option"));
let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default();
ui.horizontal(|ui| {
for p in Preserve::ALL {
@@ -88,21 +95,21 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(4.0);
let selection = app.session.active().is_some_and(|st| !st.selection.is_empty());
if widgets::check(ui, "Export Selected Art Only", d.bool("selectedOnly") && selection, selection) {
if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) {
d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly")));
}
check(ui, d, "alterPaths", "Alter Paths for Appearance");
check(ui, d, "alterPaths", tl!("Alter Paths for Appearance"));
// Preserve Appearance always outlines type.
let appearance = preserve == Preserve::Appearance;
if widgets::check(ui, "Outline Text", appearance || d.bool("outlineText"), !appearance) {
if widgets::check(ui, tl!("Outline Text"), appearance || d.bool("outlineText"), !appearance) {
d.fields.insert("outlineText".into(), json!(!d.bool("outlineText")));
}
ui.add_space(8.0);
super::form::caption(
ui,
match preserve {
Preserve::Appearance => "Type becomes outlines, and strokes a CAD line can't draw become filled shapes.",
Preserve::Editability => "Type stays text, and every stroke is a line with its lineweight and dashes.",
Preserve::Appearance => tl!("Type becomes outlines, and strokes a CAD line can't draw become filled shapes."),
Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."),
},
);
false
@@ -113,7 +120,7 @@ pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
let t = crate::theme::Tokens::get(ui.ctx());
let units: Vec<&str> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| u.label()).collect();
let unit = Unit::named(&d.str("unit")).unwrap_or(Unit::Millimeters);
widgets::label_row(ui, "Scale:", LABEL, |ui| {
widgets::label_row(ui, tl!("Scale:"), LABEL, |ui| {
ui.label(egui::RichText::new("1").color(t.text));
if let Some(u) = widgets::dropdown(ui, "dxf-unit", unit.label(), &units, 120.0).and_then(|i| units.get(i)) {
d.fields.insert("unit".into(), json!(u));
@@ -124,7 +131,7 @@ pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
let (lo, hi) = (*vectorcraft_cad::SCALE_RANGE.start(), *vectorcraft_cad::SCALE_RANGE.end());
d.fields.insert("scale".into(), json!(v.clamp(lo, hi)));
}
ui.label(egui::RichText::new("Units").color(t.text));
ui.label(egui::RichText::new(tl!("Units")).color(t.text));
});
}
+27 -8
View File
@@ -17,7 +17,19 @@ use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| heading_label(d), body, confirm, preview: true, ..DialogSpec::FORM };
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
fn heading_label(d: &Dialog) -> String {
let label = d.str("__label");
if vectorcraft_plugins::effect::plugin_id(&d.str("__effect")).is_some() {
return label;
}
let dotted = format!("{label}…");
let shown = tl!(&dotted);
if shown != dotted { shown.trim_end_matches('…').to_string() } else { tl!(&label).to_string() }
}
/// The dialog kind asking whether to edit an effect that is already applied or add another.
/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and
@@ -26,9 +38,9 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"),
pub const EXISTS: &str = "effectExists";
pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
heading: |d| format!("{} is already applied", d.str("__label")),
heading: |d| crate::i18n::fmt(tl!("{effect} is already applied"), &[("effect", &heading_label(d))]),
body: |_, ui, _| {
ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim));
ui.label(egui::RichText::new(tl!("Edit the applied effect, or add another one?")).color(Tokens::get(ui.ctx()).text_dim));
false
},
confirm: confirm_exists,
@@ -123,7 +135,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
};
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
let pv_changed = ui.checkbox(&mut pv, tl!("Preview")).changed();
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
@@ -189,7 +201,14 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail);
}
if d.fields.contains_key("channel") {
form::choice(ui, d, "channel", "Channel", (LABEL_W, 120.0), &[("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]);
form::choice(
ui,
d,
"channel",
tl!("Channel"),
(LABEL_W, 120.0),
&[("rgb", "RGB"), ("red", tl!("Red")), ("green", tl!("Green")), ("blue", tl!("Blue"))],
);
}
if d.fields.contains_key("points") {
ui.horizontal(|ui| {
@@ -197,7 +216,7 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
curve_graph(ui, d);
});
}
for (key, label, width) in [("points", "Points", 220.0), ("color", "Color", 120.0)] {
for (key, label, width) in [("points", tl!("Points"), 220.0), ("color", tl!("Color"), 120.0)] {
if d.fields.contains_key(key) {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::text(ui, d, key, width);
@@ -205,14 +224,14 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
if d.fields.contains_key("gamma") {
widgets::label_row(ui, "Gamma", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Gamma"), LABEL_W, |ui| {
let mut g = d.f64("gamma", 1.0);
if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() {
d.fields.insert("gamma".into(), json!(g));
}
});
}
for (key, label) in [("colorize", "Colorize"), ("preserveLightness", "Preserve Lightness")] {
for (key, label) in [("colorize", tl!("Colorize")), ("preserveLightness", tl!("Preserve Lightness"))] {
if d.fields.contains_key(key) {
form::check(ui, d, key, label);
}
+27 -27
View File
@@ -44,10 +44,10 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview
fn heading(d: &Dialog) -> String {
match d.kind.as_str() {
WARP => "Warp Options",
MESH if d.bool("reset") => "Reset Envelope Mesh",
MESH => "Envelope Mesh",
_ => "Envelope Options",
WARP => tl!("Warp Options"),
MESH if d.bool("reset") => tl!("Reset Envelope Mesh"),
MESH => tl!("Envelope Mesh"),
_ => tl!("Envelope Options"),
}
.into()
}
@@ -143,44 +143,44 @@ fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
form::choice(ui, d, "style", tl!("Style:"), (LABEL_W, 160.0), &styles);
let horizontal = d.bool("horizontal");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
if widgets::radio(ui, label, horizontal == h, true) {
set(d, "horizontal", json!(h));
}
ui.add_space(10.0);
}
});
percent(ui, d, "bend", "Bend:");
percent(ui, d, "bend", tl!("Bend:"));
ui.add_space(6.0);
widgets::subheader(ui, "Distortion");
percent(ui, d, "h", "Horizontal:");
percent(ui, d, "v", "Vertical:");
widgets::subheader(ui, tl!("Distortion"));
percent(ui, d, "h", tl!("Horizontal:"));
percent(ui, d, "v", tl!("Vertical:"));
}
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
count(ui, d, "rows", "Rows:");
count(ui, d, "cols", "Columns:");
count(ui, d, "rows", tl!("Rows:"));
count(ui, d, "cols", tl!("Columns:"));
if d.bool("reset") {
ui.add_space(4.0);
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
form::check(ui, d, "maintainShape", tl!("Maintain Envelope Shape"));
}
}
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::subheader(ui, "Rasters");
widgets::subheader(ui, tl!("Rasters"));
ui.horizontal(|ui| {
ui.add_space(12.0);
form::check(ui, d, "antiAlias", "Anti-Alias");
form::check(ui, d, "antiAlias", tl!("Anti-Alias"));
});
ui.add_space(4.0);
widgets::subheader(ui, "Preserve Shape Using:");
widgets::subheader(ui, tl!("Preserve Shape Using:"));
let clip = d.str("preserveShape") != "transparency";
ui.horizontal(|ui| {
ui.add_space(12.0);
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
for (value, label) in [("clippingMask", tl!("Clipping Mask")), ("transparency", tl!("Transparency"))] {
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
set(d, "preserveShape", json!(value));
}
@@ -189,12 +189,12 @@ fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
});
ui.add_space(4.0);
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
form::slider_w(ui, d, ("fidelity", tl!("Fidelity:"), LABEL_W), 0.0..=100.0, "", &move |_| rail);
ui.add_space(4.0);
form::check(ui, d, "distortAppearance", "Distort Appearance");
form::check(ui, d, "distortAppearance", tl!("Distort Appearance"));
// Gradients and patterns bend only with the appearance.
let appearance = d.bool("distortAppearance");
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
for (key, label) in [("distortLinearGradients", tl!("Distort Linear Gradients")), ("distortPatternFills", tl!("Distort Pattern Fills"))] {
let on = d.bool(key);
ui.horizontal(|ui| {
ui.add_space(20.0);
@@ -256,7 +256,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some((editing, bar)) = bar_of(app) else { return };
let mut run: Option<(&str, Value)> = None;
ui.separator();
for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] {
for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
}
@@ -270,23 +270,23 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
Bar::Warp { style, bend, h, v, horizontal } => {
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
widgets::dim_label(ui, "Style:");
widgets::dim_label(ui, tl!("Style:"));
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
run = Some((OPTIONS_CMD, json!({ "style": id })));
}
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
if widgets::radio(ui, label, *horizontal == h, true) {
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
}
}
for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] {
for (key, label, value) in [("bend", tl!("Bend:"), *bend), ("h", tl!("H:"), *h), ("v", tl!("V:"), *v)] {
if let Some(n) = field(ui, key, label, value, "%") {
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
}
}
}
Bar::Mesh { rows, cols } => {
for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] {
for (key, label, value) in [("rows", tl!("Rows:"), *rows), ("cols", tl!("Columns:"), *cols)] {
if let Some(n) = field(ui, key, label, f64::from(value), "") {
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
}
@@ -301,11 +301,11 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
Bar::TopObject => None,
};
if let Some(r) = reset
&& widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked()
&& widgets::flat_button(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked()
{
run = Some(r);
}
if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() {
if widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() {
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
}
if let Some((cmd, p)) = run
+25 -18
View File
@@ -18,8 +18,15 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "epsOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "EPS Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("EPS Options").into(),
body,
confirm,
ok: Some("Export"),
min_width: 420.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Fields that are not remembered between exports (they belong to one export).
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
@@ -62,15 +69,15 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) {
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::subheader(ui, "Preview");
widgets::subheader(ui, tl!("Preview"));
let previews: Vec<(&str, &str)> = Preview::ALL.iter().map(|p| (p.id(), p.label())).collect();
choice(ui, d, "previewFormat", "Format:", &previews);
choice(ui, d, "previewFormat", tl!("Format:"), &previews);
// Only the colour preview can keep transparency.
let color = Preview::from_id(&d.str("previewFormat")) == Some(Preview::TiffColor);
let transparent = d.bool("transparentPreview");
ui.horizontal(|ui| {
ui.add_space(LABEL + 8.0);
for (label, on) in [("Transparent", true), ("Opaque", false)] {
for (label, on) in [(tl!("Transparent"), true), (tl!("Opaque"), false)] {
if widgets::radio(ui, label, color && transparent == on, color) {
d.fields.insert("transparentPreview".into(), json!(on));
}
@@ -78,22 +85,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.add_space(8.0);
widgets::subheader(ui, "Transparency");
widgets::subheader(ui, tl!("Transparency"));
let overprints: Vec<(&str, &str)> = Overprint::ALL.iter().map(|o| (o.id(), o.label())).collect();
choice(ui, d, "overprints", "Overprints:", &overprints);
choice(ui, d, "overprints", tl!("Overprints:"), &overprints);
let presets = app.session.flattener_presets();
let names: Vec<(&str, &str)> = presets.iter().map(|p| (p.name.as_str(), p.name.as_str())).collect();
choice(ui, d, "flattenerPreset", "Preset:", &names);
choice(ui, d, "flattenerPreset", tl!("Preset:"), &names);
ui.add_space(8.0);
widgets::subheader(ui, "Fonts");
widgets::subheader(ui, tl!("Fonts"));
// Type is written as glyph outlines, which need no fonts.
widgets::check(ui, "Embed Fonts (for other applications)", true, false);
widgets::check(ui, tl!("Embed Fonts (for other applications)"), true, false);
ui.add_space(8.0);
widgets::subheader(ui, "Options");
check(ui, d, "includeLinkedFiles", "Include Linked Files");
check(ui, d, "thumbnails", "Include Document Thumbnails");
check(ui, d, "cmykPostScript", "Include CMYK PostScript in RGB Files");
check(ui, d, "compatibleGradients", "Compatible Gradient and Gradient Mesh Printing");
widgets::subheader(ui, tl!("Options"));
check(ui, d, "includeLinkedFiles", tl!("Include Linked Files"));
check(ui, d, "thumbnails", tl!("Include Document Thumbnails"));
check(ui, d, "cmykPostScript", tl!("Include CMYK PostScript in RGB Files"));
check(ui, d, "compatibleGradients", tl!("Compatible Gradient and Gradient Mesh Printing"));
ui.add_space(4.0);
let levels: Vec<(&str, &str)> = Level::ALL.iter().map(|l| (l.id(), l.label())).collect();
// A level set as a number (`level: 2`) reads as its id.
@@ -101,14 +108,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let id = n.to_string();
d.fields.insert("level".into(), json!(id));
}
choice(ui, d, "level", "PostScript:", &levels);
choice(ui, d, "level", tl!("PostScript:"), &levels);
ui.add_space(8.0);
super::form::caption(
ui,
if Level::from_id(&d.str("level")) == Some(Level::Two) || d.bool("compatibleGradients") {
"Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour."
tl!("Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour.")
} else {
"Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth."
tl!("Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth.")
},
);
false
+6 -6
View File
@@ -21,7 +21,7 @@ const CMD: &str = "object.expand";
/// The options and their checkbox labels.
const OPTIONS: [(&str, &str); 3] = [("object", "Object"), ("fill", "Fill"), ("stroke", "Stroke")];
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Expand".into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Expand").into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM };
/// Open Expand for the selection, every option on (as `object.expand {}` does).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -38,7 +38,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let set = |d: &mut Dialog, key: &str, v: Value| {
d.fields.insert(key.into(), v);
};
widgets::subheader(ui, "Expand");
widgets::subheader(ui, tl!("Expand"));
for (key, label) in OPTIONS {
let on = d.bool(key);
ui.horizontal(|ui| {
@@ -49,26 +49,26 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
}
ui.add_space(10.0);
widgets::subheader(ui, "Expand Gradient To");
widgets::subheader(ui, tl!("Expand Gradient To"));
// Only gradient fills that Fill expands care.
let enabled = d.bool("__fill") && d.bool("fill");
let objects = d.str("gradient") != "mesh";
ui.horizontal(|ui| {
ui.add_space(12.0);
if widgets::radio(ui, "Gradient Mesh", !objects, enabled) {
if widgets::radio(ui, tl!("Gradient Mesh"), !objects, enabled) {
set(d, "gradient", json!("mesh"));
}
});
ui.horizontal(|ui| {
ui.add_space(12.0);
if widgets::radio(ui, "Specify:", objects, enabled) {
if widgets::radio(ui, tl!("Specify:"), objects, enabled) {
set(d, "gradient", json!("objects"));
}
ui.add_enabled_ui(enabled && objects, |ui| {
if let Some(n) = widgets::plain_field(ui, "expand-steps", d.f64("steps", DEFAULT_STEPS as f64), "", 0, 52.0) {
set(d, "steps", json!(n.round().clamp(1.0, MAX_STEPS as f64)));
}
widgets::dim_label(ui, "Objects");
widgets::dim_label(ui, tl!("Objects"));
});
});
false
+7 -7
View File
@@ -15,7 +15,7 @@ use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Export As".into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
/// The formats Export As writes (the native format is Save's).
static FORMATS: LazyLock<Vec<&'static Format>> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect());
@@ -63,7 +63,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(f) = chosen(d) else { return false };
let missing = unavailable(d);
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Format:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
let shown = missing.map_or(f.label, |(label, _)| label);
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) {
d.fields.insert("format".into(), json!(g.id));
@@ -75,13 +75,13 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::caption(ui, hint);
}
ui.add_space(10.0);
form::check(ui, d, "useArtboards", "Use Artboards");
form::check(ui, d, "useArtboards", tl!("Use Artboards"));
let use_artboards = d.bool("useArtboards");
ui.add_enabled_ui(use_artboards, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
let mut all = d.bool("all");
if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() {
if ui.radio_value(&mut all, true, tl!("All")).changed() | ui.radio_value(&mut all, false, tl!("Range:")).changed() {
d.fields.insert("all".into(), json!(all));
}
let mut range = d.str("range");
@@ -93,9 +93,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(8.0);
let ext = f.extensions[0];
let hint = match (use_artboards, f.id) {
(true, "pdf") => "One page per artboard.".to_string(),
(true, _) => format!("One file per artboard: <name>-<artboard>.{ext}"),
(false, _) => "The bounds of the visible art.".to_string(),
(true, "pdf") => tl!("One page per artboard.").to_string(),
(true, _) => crate::i18n::fmt(tl!("One file per artboard: <name>-<artboard>.{ext}"), &[("ext", ext)]),
(false, _) => tl!("The bounds of the visible art.").to_string(),
};
ui.label(egui::RichText::new(hint).color(t.text_dim).size(11.5));
false
@@ -22,7 +22,7 @@ use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Export for Screens".into(),
heading: |_| tl!("Export for Screens").into(),
body,
confirm,
ok: Some("Export Artboard"),
@@ -256,7 +256,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let tab = usize::from(d.str("tab") == "assets");
if let Some(i) = widgets::tab_bar(ui, &["Artboards", "Assets"], tab) {
if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets")], tab) {
d.fields.insert("tab".into(), json!(if i == 1 { "assets" } else { "artboards" }));
}
ui.add_space(8.0);
@@ -282,7 +282,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
formats(ui, d);
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Prefix:");
widgets::dim_label(ui, tl!("Prefix:"));
form::text(ui, d, "prefix", 140.0);
});
}
@@ -303,13 +303,13 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
boards.resize(n, true);
let before = boards.clone();
let select = d.str("select");
widgets::dim_label(ui, "Select:");
widgets::dim_label(ui, tl!("Select:"));
ui.horizontal(|ui| {
if widgets::radio(ui, "All", select == "all", true) {
if widgets::radio(ui, tl!("All"), select == "all", true) {
set_select(d, "all");
boards.iter_mut().for_each(|b| *b = true);
}
if widgets::radio(ui, "Range:", select == "range", true) {
if widgets::radio(ui, tl!("Range:"), select == "range", true) {
set_select(d, "range");
}
ui.add_enabled_ui(select == "range", |ui| {
@@ -320,20 +320,20 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
});
});
if widgets::radio(ui, "Full Document", select == "full", true) {
if widgets::radio(ui, tl!("Full Document"), select == "full", true) {
set_select(d, "full");
}
ui.add_space(4.0);
form::check(ui, d, "includeBleed", "Include Bleed");
form::check(ui, d, "includeBleed", tl!("Include Bleed"));
ui.add_space(6.0);
let typed = boards != before;
let checked = boards.clone();
ui.add_enabled_ui(d.str("select") != "full", |ui| {
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
if ui.small_button(tl!("Select All")).clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
if ui.small_button(tl!("Clear")).clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
@@ -381,16 +381,16 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let Some(st) = app.session.active() else { return };
if st.doc.assets.is_empty() {
ui.add_space(24.0);
widgets::dim_label(ui, "No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel.");
widgets::dim_label(ui, tl!("No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel."));
return;
}
let mut on = checked_assets(app, d);
let before = on.clone();
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
if ui.small_button(tl!("Select All")).clicked() {
on = st.doc.assets.iter().map(|a| a.id).collect();
}
if ui.small_button("Clear").clicked() {
if ui.small_button(tl!("Clear")).clicked() {
on.clear();
}
});
@@ -417,23 +417,23 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the
/// web downloads instead.
fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
widgets::dim_label(ui, "Export to:");
widgets::dim_label(ui, tl!("Export to:"));
if io::is_web(app) {
widgets::dim_label(ui, "Your browser downloads the files (several as one .zip).");
widgets::dim_label(ui, tl!("Your browser downloads the files (several as one .zip)."));
} else {
let pick = app.services.pick_folder.is_some();
ui.horizontal(|ui| {
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
if pick
&& widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked()
&& widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick())
{
d.fields.insert("folder".into(), json!(f));
}
});
form::check(ui, d, "openLocation", "Open Location after Export");
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
}
form::check(ui, d, "subfolders", "Create Sub-folders");
form::check(ui, d, "subfolders", tl!("Create Sub-folders"));
}
/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix,
@@ -442,9 +442,9 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let mut rows: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut edited = false;
ui.horizontal(|ui| {
widgets::dim_label(ui, "Formats:");
widgets::dim_label(ui, tl!("Formats:"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button(ui, "settings", "Format Settings", false, 24.0).clicked() {
if widgets::icon_button(ui, "settings", tl!("Format Settings"), false, 24.0).clicked() {
let first = rows.first().and_then(|r| r["format"].as_str()).filter(|f| SETTINGS.iter().any(|(id, _)| id == f));
d.fields.insert("__settings".into(), json!(first.unwrap_or("png")));
}
@@ -469,7 +469,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let k = ((ui.available_width() - 66.0) / 262.0).clamp(0.6, 1.0);
let (scale_w, suffix_w, format_w) = (96.0 * k, 70.0 * k, 96.0 * k);
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
for head in ["Scale", "Suffix", "Format", ""] {
for head in [tl!("Scale"), tl!("Suffix"), tl!("Format"), ""] {
widgets::dim_label(ui, head);
}
ui.end_row();
@@ -507,7 +507,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
edited = true;
}
ui.add_enabled_ui(!one, |ui| {
if widgets::icon_button(ui, "x", "Remove", false, 22.0).clicked() {
if widgets::icon_button(ui, "x", tl!("Remove"), false, 22.0).clicked() {
remove = Some(i);
}
});
@@ -518,7 +518,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
rows.remove(i);
edited = true;
}
if widgets::secondary_button(ui, "+ Add Scale").clicked() {
if widgets::secondary_button(ui, tl!("+ Add Scale")).clicked() {
// One more than the largest factor so far.
let largest = rows
.iter()
@@ -541,7 +541,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
/// Format Settings: the options of format `id` that apply to every row of it, then Done.
fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Format Settings");
widgets::subheader(ui, tl!("Format Settings"));
ui.add_space(6.0);
let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0);
if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) {
@@ -557,8 +557,10 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let labels: Vec<&str> =
d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("PDF Preset:").color(t.text_dim));
if let Some(p) = widgets::dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0).and_then(|i| labels.get(i)) {
ui.label(egui::RichText::new(tl!("PDF Preset:")).color(t.text_dim));
// The built-in presets are ours (translated); the saved ones are names.
let builtin = |k: usize| labels.get(k).is_some_and(|n| super::save_pdf::is_builtin_preset(n));
if let Some(p) = super::mixed_dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0, builtin).and_then(|i| labels.get(i)) {
s.fields.insert("preset".into(), json!(p));
}
});
@@ -571,7 +573,7 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
all.insert(id.into(), Value::Object(s.fields));
}
ui.add_space(10.0);
if widgets::secondary_button(ui, "Done").clicked() {
if widgets::secondary_button(ui, tl!("Done")).clicked() {
d.fields.insert("__settings".into(), json!(""));
}
}
+5 -5
View File
@@ -15,7 +15,7 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Eyedropper Options.
pub const KIND: &str = "eyedropperOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Eyedropper Options".into(), body, confirm, min_width: 460.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Eyedropper Options").into(), body, confirm, min_width: 460.0, ..DialogSpec::FORM };
/// The raster sample sizes: (pixels square, label).
const SAMPLE_SIZES: [(u64, &str); 3] = [(1, "Point Sample"), (3, "3 x 3 Average"), (5, "5 x 5 Average")];
@@ -70,7 +70,7 @@ fn set_all(v: &mut Value, on: bool) {
/// One tree (`key`: `pickUp` or `apply`) of checkboxes; a branch's box sets everything under it.
fn tree(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) {
ui.vertical(|ui| {
ui.label(egui::RichText::new(title).strong());
ui.label(egui::RichText::new(tl!(title)).strong());
ui.add_space(4.0);
let Some(t) = d.fields.get_mut(key) else { return };
for (path, label, depth) in TREE {
@@ -90,7 +90,7 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let size = d.fields.get("sampleSize").and_then(Value::as_u64).unwrap_or(1);
let current = SAMPLE_SIZES.iter().find(|(n, _)| *n == size).map_or(SAMPLE_SIZES[0].1, |s| s.1);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Raster Sample Size:");
widgets::dim_label(ui, tl!("Raster Sample Size:"));
let labels = SAMPLE_SIZES.map(|s| s.1);
if let Some(i) = widgets::dropdown(ui, "eyedropper-sample", current, &labels, 150.0) {
d.fields.insert("sampleSize".into(), json!(SAMPLE_SIZES[i].0));
@@ -98,9 +98,9 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(10.0);
ui.horizontal_top(|ui| {
tree(ui, d, "pickUp", "Eyedropper Picks Up:");
tree(ui, d, "pickUp", tl!("Eyedropper Picks Up:"));
ui.add_space(24.0);
tree(ui, d, "apply", "Eyedropper Applies:");
tree(ui, d, "apply", tl!("Eyedropper Applies:"));
});
false
}
+18 -16
View File
@@ -22,7 +22,7 @@ const PENDING: &str = "__keyword";
const FIELD: f32 = 320.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "File Info".into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("File Info").into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM };
/// Open File Info on the active document's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -62,19 +62,19 @@ fn add_keywords(d: &mut Dialog) {
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
for (key, label) in [("title", "Document Title:"), ("author", "Author:"), ("authorTitle", "Author Title:")] {
for (key, label) in [("title", tl!("Document Title:")), ("author", tl!("Author:")), ("authorTitle", tl!("Author Title:"))] {
widgets::label_row(ui, label, LABEL, |ui| {
form::text(ui, d, key, FIELD);
});
ui.add_space(4.0);
}
top_row(ui, "Description:", |ui| {
top_row(ui, tl!("Description:"), |ui| {
form::text_area(ui, d, "description", FIELD, 3);
});
ui.add_space(4.0);
widgets::label_row(ui, "Rating:", LABEL, |ui| rating(ui, d));
widgets::label_row(ui, tl!("Rating:"), LABEL, |ui| rating(ui, d));
ui.add_space(4.0);
top_row(ui, "Keywords:", |ui| {
top_row(ui, tl!("Keywords:"), |ui| {
ui.vertical(|ui| {
ui.set_max_width(FIELD + 12.0);
chips(ui, d);
@@ -87,28 +87,28 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
r.request_focus();
}
let typed = !d.str(PENDING).trim().is_empty();
if (widgets::flat_button(ui, "Add", 56.0).clicked() || enter) && typed {
if (widgets::flat_button(ui, tl!("Add"), 56.0).clicked() || enter) && typed {
add_keywords(d);
}
});
form::caption(ui, "Separate several keywords with commas.");
form::caption(ui, tl!("Separate several keywords with commas."));
});
});
ui.add_space(10.0);
widgets::subheader(ui, "Copyright");
widgets::subheader(ui, tl!("Copyright"));
let statuses: Vec<(&str, &str)> = CopyrightStatus::ALL.iter().map(|c| (c.id(), c.label())).collect();
choice(ui, d, "copyrightStatus", "Copyright Status:", &statuses);
choice(ui, d, "copyrightStatus", tl!("Copyright Status:"), &statuses);
ui.add_space(4.0);
top_row(ui, "Copyright Notice:", |ui| {
top_row(ui, tl!("Copyright Notice:"), |ui| {
form::text_area(ui, d, "copyrightNotice", FIELD, 2);
});
ui.add_space(4.0);
widgets::label_row(ui, "Copyright Info URL:", LABEL, |ui| {
widgets::label_row(ui, tl!("Copyright Info URL:"), LABEL, |ui| {
form::text(ui, d, "copyrightUrl", FIELD);
});
ui.add_space(10.0);
widgets::subheader(ui, "Dates");
for (key, label) in [("created", "Created:"), ("modified", "Modified:")] {
widgets::subheader(ui, tl!("Dates"));
for (key, label) in [("created", tl!("Created:")), ("modified", tl!("Modified:"))] {
// ISO 8601 UTC from the engine, shown as "2026-10-05 14:03:00 UTC".
let shown = d
.fields
@@ -127,7 +127,7 @@ fn top_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal_top(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(LABEL, 24.0), egui::Sense::hover());
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text);
add(ui);
});
}
@@ -148,7 +148,7 @@ fn rating(ui: &mut egui::Ui, d: &mut Dialog) {
t.text_dim
};
icons::paint(ui, "star", rect.shrink(2.0), tint);
let resp = resp.on_hover_text(if i == 1 { "1 star".to_string() } else { format!("{i} stars") });
let resp = resp.on_hover_text(crate::i18n::tn(u64::from(i), "{n} star", "{n} stars"));
if resp.clicked() {
d.fields.insert("rating".into(), json!(if i == current { 0 } else { i }));
}
@@ -173,7 +173,9 @@ fn chips(ui: &mut egui::Ui, d: &mut Dialog) {
ui.painter().rect_stroke(rect, egui::CornerRadius::same(11), egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong);
let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0));
let resp = ui.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click()).on_hover_text(format!("Remove “{k}”"));
let resp = ui
.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click())
.on_hover_text(crate::i18n::fmt(tl!("Remove “{keyword}”"), &[("keyword", k.as_str())]));
icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim });
if resp.clicked() {
remove = Some(i);
+34 -21
View File
@@ -31,7 +31,7 @@ const PRESET_NAME: &str = "__presetName";
const CUSTOM: &str = "[Custom]";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Flatten Transparency".into(), body, confirm, preview: true, min_width: 420.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Flatten Transparency").into(), body, confirm, preview: true, min_width: 420.0, ..DialogSpec::FORM };
/// Open Flatten Transparency for the selection with the default preset.
pub fn open(app: &mut VectorcraftApp) {
@@ -86,12 +86,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let current = options(d, saved);
let presets = app.session.flattener_presets();
ui.horizontal(|ui| {
widgets::dim_label(ui, "Preset:");
widgets::dim_label(ui, tl!("Preset:"));
if let Some(p) = preset_dropdown(ui, "flatten-preset", &presets, &d.str("preset"), current.as_ref().ok(), 220.0) {
put_options(&mut d.fields, &p.options);
applied(d, &p.name);
}
if widgets::flat_button(ui, "Save Preset…", 100.0).clicked() {
if widgets::flat_button(ui, tl!("Save Preset…"), 100.0).clicked() {
let open = !d.bool(SAVING);
d.fields.insert(SAVING.into(), json!(open));
d.fields.insert(PRESET_NAME.into(), json!(app.session.new_preset_name()));
@@ -118,22 +118,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Name:");
widgets::dim_label(ui, tl!("Name:"));
let r = form::text_edit(ui, d, PRESET_NAME, 180.0);
// Enter saves the preset instead of flattening.
let enter = r.lost_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
if (widgets::flat_button(ui, "Save", 60.0).clicked() || enter)
if (widgets::flat_button(ui, tl!("Save"), 60.0).clicked() || enter)
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() {
d.fields.insert(SAVING.into(), json!(false));
}
});
let name = d.str(PRESET_NAME);
if app.session.prefs.flattener_presets.iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) {
widgets::dim_label(ui, "Replaces the saved preset of that name.");
widgets::dim_label(ui, tl!("Replaces the saved preset of that name."));
}
}
@@ -153,7 +153,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
/// The flattener preset dropdown: `presets` by name, showing `name` while `options` are that
/// preset's, else [Custom]. Returns the preset chosen.
/// preset's, else [Custom]. The built-in presets are ours (translated); the saved ones are names.
/// Returns the preset chosen.
pub(crate) fn preset_dropdown<'a>(
ui: &mut egui::Ui,
id: &str,
@@ -162,9 +163,14 @@ pub(crate) fn preset_dropdown<'a>(
options: Option<&FlattenOptions>,
width: f32,
) -> Option<&'a FlattenerPreset> {
let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(CUSTOM, |p| p.name.as_str());
let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(tl!(CUSTOM), |p| p.name.as_str());
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
widgets::dropdown(ui, id, shown, &names, width).map(|i| &presets[i])
super::mixed_dropdown(ui, id, shown, &names, width, |k| names.get(k).is_some_and(|n| is_builtin(n))).and_then(|i| presets.get(i))
}
/// Is `name` a built-in flattener preset's?
fn is_builtin(name: &str) -> bool {
FlattenOptions::PRESETS.iter().any(|id| FlattenOptions::preset_label(id) == Some(name))
}
/// The width of the option value fields.
@@ -177,25 +183,25 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
let t = Tokens::get(ui.ctx());
let before = o.clone();
let dim = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(s).color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(tl!(s)).color(t.text_dim).size(11.5));
};
ui.add_enabled_ui(enabled, |ui| {
widgets::dim_label(ui, "Raster/Vector Balance:");
widgets::dim_label(ui, tl!("Raster/Vector Balance:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
dim(ui, "Rasters");
dim(ui, tl!("Rasters"));
let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0);
if let (Some(x), _) = widgets::color_slider(ui, (id, "balance-slider"), (o.balance / 100.0) as f32, rail, &|_| t.input_border) {
o.balance = (x as f64 * 100.0).round();
}
dim(ui, "Vectors");
dim(ui, tl!("Vectors"));
if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) {
o.balance = v.round().clamp(0.0, 100.0);
}
});
ui.add_space(4.0);
egui::Grid::new((id, "resolutions")).num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
for (label, key) in [("Line Art and Text Resolution:", 0), ("Gradient and Mesh Resolution:", 1)] {
for (label, key) in [(tl!("Line Art and Text Resolution:"), 0), (tl!("Gradient and Mesh Resolution:"), 1)] {
widgets::dim_label(ui, label);
ui.horizontal(|ui| {
let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi };
@@ -209,12 +215,12 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
});
ui.add_space(4.0);
let switches: [(&str, &mut bool); 6] = [
("Convert All Text to Outlines", &mut o.text_to_outlines),
("Convert All Strokes to Outlines", &mut o.strokes_to_outlines),
("Clip Complex Regions", &mut o.clip_complex_regions),
("Anti-alias Rasters", &mut o.anti_alias),
("Preserve Alpha Transparency", &mut o.preserve_alpha),
("Preserve Overprints and Spot Colors", &mut o.preserve_overprints),
(tl!("Convert All Text to Outlines"), &mut o.text_to_outlines),
(tl!("Convert All Strokes to Outlines"), &mut o.strokes_to_outlines),
(tl!("Clip Complex Regions"), &mut o.clip_complex_regions),
(tl!("Anti-alias Rasters"), &mut o.anti_alias),
(tl!("Preserve Alpha Transparency"), &mut o.preserve_alpha),
(tl!("Preserve Overprints and Spot Colors"), &mut o.preserve_overprints),
];
for (label, v) in switches {
if widgets::check(ui, label, *v, enabled) {
@@ -230,6 +236,13 @@ mod tests {
use super::*;
use vectorcraft_engine::Session;
/// The built-in presets are told from saved ones by name (only theirs are translated).
#[test]
fn built_in_presets_are_known_by_name() {
assert!(FlattenOptions::builtin_presets().iter().all(|p| is_builtin(&p.name)));
assert!(!is_builtin("Regular") && !is_builtin("high"));
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
@@ -24,7 +24,7 @@ pub const KIND: &str = "flattenerPresets";
const SHOWN: &str = "__shown";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Transparency Flattener Presets".into(),
heading: |_| tl!("Transparency Flattener Presets").into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
@@ -67,13 +67,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(232.0);
widgets::dim_label(ui, "Presets:");
widgets::dim_label(ui, tl!("Presets:"));
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("flattener-presets").min_scrolled_height(250.0).max_height(250.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(250.0);
for (k, p) in presets.iter().enumerate() {
let label = if k < builtins { format!("[{}]", p.name) } else { p.name.clone() };
let label = if k < builtins { format!("[{}]", tl!(&p.name)) } else { p.name.clone() };
if ui.selectable_label(k == i, label).clicked() {
act = Some(Action::Select(p.name.clone()));
}
@@ -83,16 +83,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New", 44.0).on_hover_text("A saved copy of the selected preset").clicked() {
if widgets::flat_button(ui, tl!("New"), 44.0).on_hover_text(tl!("A saved copy of the selected preset")).clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Delete"), 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 62.0).clicked() {
if widgets::flat_button(ui, tl!("Import…"), 62.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 62.0).on_hover_text("Save the selected preset to a file").clicked() {
if widgets::flat_button(ui, tl!("Export…"), 62.0).on_hover_text(tl!("Save the selected preset to a file")).clicked() {
act = Some(Action::Export);
}
});
@@ -100,7 +100,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(18.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Name:");
widgets::dim_label(ui, tl!("Name:"));
if builtin {
ui.label(egui::RichText::new(&preset.name).color(t.text_strong));
} else {
@@ -114,7 +114,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
if builtin {
ui.add_space(6.0);
ui.label(egui::RichText::new("Built-in presets don't change: New makes a copy you can edit.").color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(tl!("Built-in presets don't change: New makes a copy you can edit.")).color(t.text_dim).size(11.5));
}
});
});
+23 -13
View File
@@ -18,7 +18,7 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// [`field`] `width` points wide.
pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
text(ui, d, key, width);
ui.end_row();
}
@@ -26,7 +26,7 @@ pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str,
/// A labelled [`length`] field (one grid row).
pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
length(ui, d, key, unit, FIELD_W);
ui.end_row();
}
@@ -101,7 +101,7 @@ pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32
/// A checkbox bound to `d.fields[key]`.
pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let mut b = d.bool(key);
if ui.checkbox(&mut b, label).changed() {
if ui.checkbox(&mut b, tl!(label)).changed() {
d.fields.insert(key.into(), Value::Bool(b));
}
}
@@ -130,9 +130,9 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
continue;
}
if lengths.contains(&k.as_str()) {
length_field(ui, d, &k, &humanize(&k), unit);
length_field(ui, d, &k, &humanized(&k), unit);
} else {
field(ui, d, &k, &humanize(&k));
field(ui, d, &k, &humanized(&k));
}
}
});
@@ -153,7 +153,7 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn
let keys: Vec<(String, Value)> =
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
ui.label(egui::RichText::new(humanize(&k)).color(t.text));
ui.label(egui::RichText::new(humanized(&k)).color(t.text));
if let Some(cur) = crate::widgets::blend_param(&k, &v) {
if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) {
d.fields.insert(k, m);
@@ -207,7 +207,8 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn
/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers
/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns
/// true when a value changed.
/// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's
/// text, shown as they are.
pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool {
use vectorcraft_plugins::ParamSpec;
let t = Tokens::get(ui.ctx());
@@ -232,7 +233,7 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
crate::widgets::dropdown(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0)
crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0)
.and_then(|i| options.get(i))
.map(|o| json!(o))
}
@@ -272,6 +273,15 @@ pub(super) fn humanize(k: &str) -> String {
}
}
/// [`humanize`] translated where it is painted: the ":" stays outside the translated text.
fn humanized(k: &str) -> String {
let h = humanize(k);
match h.strip_suffix(':') {
Some(base) => format!("{}:", tl!(base)),
None => tl!(&h).to_string(),
}
}
/// Widths of [`slider`]'s label column and rail.
pub(super) const SLIDER_LABEL: f32 = 64.0;
pub(super) const SLIDER_WIDTH: f32 = 180.0;
@@ -304,7 +314,7 @@ pub(super) fn slider_w(
let v = d.f64(key, 0.0).clamp(min, max);
let mut new = None;
ui.horizontal(|ui| {
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text)));
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
new = Some((min + x as f64 * (max - min)).round());
}
@@ -327,7 +337,7 @@ pub(super) fn preview(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mu
const LAST: &str = PREVIEWED;
ui.add_space(8.0);
let mut on = d.bool("preview");
if crate::widgets::check(ui, "Preview", on, true) {
if crate::widgets::check(ui, tl!("Preview"), on, true) {
on = !on;
d.fields.insert("preview".into(), json!(on));
}
@@ -386,7 +396,7 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
ui.spacing_mut().item_spacing.x = 6.0;
for (i, side) in ["Top", "Bottom", "Left", "Right"].into_iter().enumerate() {
ui.vertical(|ui| {
ui.label(egui::RichText::new(side).size(11.0).color(t.text_dim));
ui.label(egui::RichText::new(tl!(side)).size(11.0).color(t.text_dim));
if let Some(v) = crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width) {
changed = Some((i, v.clamp(0.0, vectorcraft_doc::setup::MAX_BLEED)));
}
@@ -394,7 +404,7 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
}
ui.vertical(|ui| {
ui.add_space(15.0);
let tip = if linked { "Make the bleed values differ" } else { "Make all bleed settings the same" };
let tip = if linked { tl!("Make the bleed values differ") } else { tl!("Make all bleed settings the same") };
if crate::widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() {
d.fields.insert("bleedLinked".into(), json!(!linked));
if !linked {
@@ -418,5 +428,5 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un
/// A small grey caption above a field (New Document's details column).
pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).size(11.0).color(t.text_dim));
ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim));
}
+5 -5
View File
@@ -100,7 +100,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
egui::Frame::popup(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(250.0);
ui.horizontal(|ui| {
for (icon, tip, on) in [("palette", "Color", !swatches), ("swatch-book", "Swatches", swatches)] {
for (icon, tip, on) in [("palette", tl!("Color"), !swatches), ("swatch-book", tl!("Swatches"), swatches)] {
if widgets::icon_button(ui, icon, tip, on, 24.0).clicked() {
tab = Some(if icon == "palette" { "color" } else { "swatches" });
}
@@ -115,13 +115,13 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Opacity:");
widgets::dim_label(ui, tl!("Opacity:"));
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", target.opacity() as f64 * 100.0, "%", 0, 54.0) {
apply(app, &target, &json!({ "opacity": v }));
}
let (label, key, value) = match &target {
Target::Stop(stops, i) => ("Location:", "location", stops[*i].offset),
Target::Point(p, _) => ("Spread:", "spread", p.spread),
Target::Stop(stops, i) => (tl!("Location:"), "location", stops[*i].offset),
Target::Point(p, _) => (tl!("Spread:"), "spread", p.spread),
};
widgets::dim_label(ui, label);
if let Some(v) = widgets::plain_field(ui, ("stop-pop", key), value as f64 * 100.0, "%", 1, 54.0) {
@@ -145,7 +145,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) {
let colors = swatch_colors(app);
if colors.is_empty() {
widgets::dim_label(ui, "The document has no colour swatches.");
widgets::dim_label(ui, tl!("The document has no colour swatches."));
return;
}
let current = target.color();
@@ -16,7 +16,8 @@ use crate::state::Dialog;
/// The dialog kind of Graphic Style Options.
pub const KIND: &str = "graphicStyleOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Graphic Style Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Graphic Style Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Open Graphic Style Options for style `name`, or (`None`) to name a new style from the selection.
pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, String> {
@@ -45,7 +46,7 @@ pub fn open_merge(app: &mut VectorcraftApp, names: Vec<String>) -> Result<Value,
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
label(ui, "Style Name:");
label(ui, tl!("Style Name:"));
form::text(ui, d, "name", 190.0);
ui.end_row();
});
+9 -8
View File
@@ -21,7 +21,7 @@ const CMD: &str = "object.vectorHalftone";
/// Width of the label column.
const LABEL_W: f32 = 84.0;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Vector Halftone".into(), body, confirm, preview: true, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Vector Halftone").into(), body, confirm, preview: true, ..DialogSpec::FORM };
/// The dialog's fields as it opens: the command's defaults, previewed.
pub fn fields() -> Value {
@@ -29,18 +29,19 @@ pub fn fields() -> Value {
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let shapes = [("circle", "Circle"), ("ellipse", "Ellipse"), ("square", "Square"), ("diamond", "Diamond"), ("line", "Line")];
form::choice(ui, d, "shape", "Shape", (LABEL_W, 140.0), &shapes);
form::slider_w(ui, d, ("frequency", "Frequency", LABEL_W), 1.0..=150.0, " lpi", &|x| c32(&Color::gray(1.0 - x)));
form::slider_w(ui, d, ("angle", "Angle", LABEL_W), -90.0..=90.0, "°", &|x| c32(&Color::gray(0.2 + 0.6 * x)));
form::choice(ui, d, "mode", "Screens", (LABEL_W, 140.0), &[("mono", "Mono"), ("cmyk", "CMYK")]);
let shapes =
[("circle", tl!("Circle")), ("ellipse", tl!("Ellipse")), ("square", tl!("Square")), ("diamond", tl!("Diamond")), ("line", tl!("Line"))];
form::choice(ui, d, "shape", tl!("Shape"), (LABEL_W, 140.0), &shapes);
form::slider_w(ui, d, ("frequency", tl!("Frequency"), LABEL_W), 1.0..=150.0, " lpi", &|x| c32(&Color::gray(1.0 - x)));
form::slider_w(ui, d, ("angle", tl!("Angle"), LABEL_W), -90.0..=90.0, "°", &|x| c32(&Color::gray(0.2 + 0.6 * x)));
form::choice(ui, d, "mode", tl!("Screens"), (LABEL_W, 140.0), &[("mono", tl!("Mono")), ("cmyk", tl!("CMYK"))]);
if d.str("mode") != "cmyk" {
widgets::label_row(ui, "Color", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Color"), LABEL_W, |ui| {
form::text(ui, d, "color", 120.0);
});
}
ui.add_space(4.0);
for (key, label) in [("invert", "Invert"), ("clip", "Clip to Art"), ("keepOriginal", "Keep Original")] {
for (key, label) in [("invert", tl!("Invert")), ("clip", tl!("Clip to Art")), ("keepOriginal", tl!("Keep Original"))] {
form::check(ui, d, key, label);
}
let p = form::params(d);
+13 -13
View File
@@ -22,7 +22,7 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "importPdf";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| if d.str("mode") == "place" { "Place PDF" } else { "Import PDF" }.into(), body, confirm, ..DialogSpec::FORM };
DialogSpec { heading: |d| if d.str("mode") == "place" { tl!("Place PDF") } else { tl!("Import PDF") }.into(), body, confirm, ..DialogSpec::FORM };
/// The longest side of the page preview (pt).
const PREVIEW: f32 = 200.0;
@@ -223,7 +223,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if d.bool("__locked") {
let name = d.str("name");
let file = std::path::Path::new(&name).file_name().map_or(name.clone(), |f| f.to_string_lossy().into_owned());
ui.label(egui::RichText::new(format!("“{file}” is protected by a password.")).color(t.text));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("“{file}” is protected by a password."), &[("file", &file)])).color(t.text));
ui.add_space(8.0);
password_row(ui, d);
return false;
@@ -240,16 +240,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::icon_button_enabled(ui, icon, tip, false, enabled, 22.0).clicked().then_some(to)
};
let go = [
nav(ui, "chevrons-left", "First Page", 1, current > 1),
nav(ui, "chevron-left", "Previous Page", current.saturating_sub(1).max(1), current > 1),
nav(ui, "chevrons-left", tl!("First Page"), 1, current > 1),
nav(ui, "chevron-left", tl!("Previous Page"), current.saturating_sub(1).max(1), current > 1),
];
if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) {
current = (v.max(1.0) as usize).min(count.max(1));
}
ui.label(egui::RichText::new(format!("of {count}")).color(t.text_dim));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {count}"), &[("count", &count.to_string())])).color(t.text_dim));
let go2 = [
nav(ui, "chevron-right", "Next Page", (current + 1).min(count), current < count),
nav(ui, "chevrons-right", "Last Page", count, current < count),
nav(ui, "chevron-right", tl!("Next Page"), (current + 1).min(count), current < count),
nav(ui, "chevrons-right", tl!("Last Page"), count, current < count),
];
if let Some(to) = go.into_iter().chain(go2).flatten().next() {
current = to;
@@ -258,7 +258,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(14.0);
ui.vertical(|ui| {
widgets::label_row(ui, "Crop To", LABEL_WIDTH, |ui| {
widgets::label_row(ui, tl!("Crop To"), LABEL_WIDTH, |ui| {
if let Some(i) = widgets::dropdown(ui, "pdf-crop", crop(d).label(), CropTo::LABELS, 130.0)
&& let Some(id) = CropTo::IDS.get(i)
{
@@ -267,20 +267,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
if d.str("mode") != "place" {
ui.add_space(10.0);
ui.label(egui::RichText::new("Pages").color(t.text));
ui.label(egui::RichText::new(tl!("Pages")).color(t.text));
let all = d.bool("allPages");
if widgets::radio(ui, "All", all, true) {
if widgets::radio(ui, tl!("All"), all, true) {
d.fields.insert("allPages".into(), json!(true));
}
ui.horizontal(|ui| {
if widgets::radio(ui, "Range:", !all, true) {
if widgets::radio(ui, tl!("Range:"), !all, true) {
d.fields.insert("allPages".into(), json!(false));
}
if super::form::text_edit(ui, d, "range", 90.0).gained_focus() {
d.fields.insert("allPages".into(), json!(false));
}
});
widgets::dim_label(ui, "e.g. 1-3, 5");
widgets::dim_label(ui, tl!("e.g. 1-3, 5"));
}
});
});
@@ -289,7 +289,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
fn password_row(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, "Password", LABEL_WIDTH, |ui| {
widgets::label_row(ui, tl!("Password"), LABEL_WIDTH, |ui| {
let mut s = d.str("password");
let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).desired_width(180.0));
if r.changed() {
+20 -18
View File
@@ -53,7 +53,7 @@ fn kind(d: &Dialog) -> LiquifyKind {
}
fn heading(d: &Dialog) -> String {
format!("{} Tool Options", kind(d).label())
crate::i18n::fmt(tl!("{tool} Tool Options"), &[("tool", tl!(kind(d).label()))])
}
/// Open the options of Liquify tool `tool` with its current values.
@@ -79,58 +79,60 @@ fn slider(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), range:
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let k = kind(d);
let unit = app.session.general_unit();
widgets::subheader(ui, "Global Brush Dimensions");
widgets::subheader(ui, tl!("Global Brush Dimensions"));
ui.add_space(4.0);
for (key, label) in [("width", "Width:"), ("height", "Height:")] {
for (key, label) in [("width", tl!("Width:")), ("height", tl!("Height:"))] {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::length(ui, d, key, unit, 110.0);
});
}
widgets::label_row(ui, "Angle:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Angle:"), LABEL_W, |ui| {
if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) {
d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0)));
}
});
let pressure = d.bool("usePressure");
widgets::label_row(ui, "Intensity:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Intensity:"), LABEL_W, |ui| {
ui.add_enabled_ui(!pressure, |ui| {
if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) {
d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0)));
}
});
});
widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "usePressure", "Use Pressure Pen"));
widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "usePressure", tl!("Use Pressure Pen")));
ui.add_space(10.0);
widgets::subheader(ui, &format!("{} Options", k.label()));
widgets::subheader(ui, &crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(k.label()))]));
ui.add_space(4.0);
if k == LiquifyKind::Twirl {
slider(ui, d, ("rate", "Twirl Rate:"), -180.0..=180.0, "°");
slider(ui, d, ("rate", tl!("Twirl Rate:")), -180.0..=180.0, "°");
}
if k == LiquifyKind::Wrinkle {
slider(ui, d, ("horizontal", "Horizontal:"), 0.0..=100.0, "%");
slider(ui, d, ("vertical", "Vertical:"), 0.0..=100.0, "%");
slider(ui, d, ("horizontal", tl!("Horizontal:")), 0.0..=100.0, "%");
slider(ui, d, ("vertical", tl!("Vertical:")), 0.0..=100.0, "%");
}
if k.has_affects() {
slider(ui, d, ("complexity", "Complexity:"), 0.0..=15.0, "");
slider(ui, d, ("complexity", tl!("Complexity:")), 0.0..=15.0, "");
}
slider(ui, d, ("detail", "Detail:"), 1.0..=10.0, "");
slider(ui, d, ("detail", tl!("Detail:")), 1.0..=10.0, "");
if k.simplifies() {
widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "simplifyOn", "Simplify"));
widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "simplifyOn", tl!("Simplify")));
let on = d.bool("simplifyOn");
ui.add_enabled_ui(on, |ui| slider(ui, d, ("simplify", "Simplify:"), 1.0..=100.0, ""));
ui.add_enabled_ui(on, |ui| slider(ui, d, ("simplify", tl!("Simplify:")), 1.0..=100.0, ""));
} else {
ui.add_space(4.0);
widgets::label_row(ui, "Brush Affects:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Brush Affects:"), LABEL_W, |ui| {
ui.vertical(|ui| {
for (key, label) in [("affectAnchors", "Anchor Points"), ("affectIn", "In Tangent Handles"), ("affectOut", "Out Tangent Handles")] {
for (key, label) in
[("affectAnchors", tl!("Anchor Points")), ("affectIn", tl!("In Tangent Handles")), ("affectOut", tl!("Out Tangent Handles"))]
{
check(ui, d, key, label);
}
});
});
}
ui.add_space(10.0);
check(ui, d, "showBrush", "Show Brush Size");
let hint = format!("{} with the tool sizes the brush", crate::menus::pretty_shortcut("Alt+Drag"));
check(ui, d, "showBrush", tl!("Show Brush Size"));
let hint = crate::i18n::fmt(tl!("{shortcut} with the tool sizes the brush"), &[("shortcut", crate::menus::pretty_shortcut("Alt+Drag").as_str())]);
ui.label(egui::RichText::new(hint).size(11.5).color(Tokens::get(ui.ctx()).text_dim));
false
}
+8 -5
View File
@@ -19,7 +19,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "missingLinks";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Linked File Not Found".into(),
heading: |_| tl!("Linked File Not Found").into(),
body,
confirm,
ok: Some("Replace…"),
@@ -62,8 +62,11 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
return;
}
let detail = format!("{} linked image(s) changed since this document was saved. Show the new versions?", ids.len());
super::confirm::ask(app, "Update Modified Links", &detail, "links.update", json!({ "ids": ids }));
let detail = crate::i18n::fmt(
tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"),
&[("count", &ids.len().to_string())],
);
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
@@ -86,11 +89,11 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
});
ui.add_space(8.0);
widgets::dim_label(ui, "Replace it with another file, or ignore it: its images keep showing the preview saved with the document.");
widgets::dim_label(ui, tl!("Replace it with another file, or ignore it: its images keep showing the preview saved with the document."));
if rest > 0 {
ui.add_space(10.0);
let all = d.bool("applyToAll");
let label = format!("Apply to All ({} more missing)", rest);
let label = crate::i18n::fmt(tl!("Apply to All ({count} more missing)"), &[("count", &rest.to_string())]);
if widgets::check(ui, &label, all, true) {
d.fields.insert("applyToAll".into(), json!(!all));
}
+45 -5
View File
@@ -108,7 +108,8 @@ const EDGE_GAP: f32 = 8.0;
pub(crate) struct DialogSpec {
/// Draws its own window instead of the shared frame (the frame fields below are then unused).
pub window: Option<fn(&mut VectorcraftApp, &egui::Context)>,
/// The heading (and window title).
/// The heading (and window title), in the UI language: the spec translates its own text and
/// leaves names in it (a document's, a plug-in's) as they are.
pub heading: fn(&Dialog) -> String,
/// Draws the fields. Returns true to close the dialog as Cancel would.
pub body: fn(&mut VectorcraftApp, &mut egui::Ui, &mut Dialog) -> bool,
@@ -131,7 +132,7 @@ impl DialogSpec {
/// A text field per value; OK just closes. Also the fallback for unregistered kinds.
pub const FORM: Self = Self {
window: None,
heading: |_| "Dialog".into(),
heading: |_| tl!("Dialog").into(),
body: |app, ui, d| {
form::grid(ui, d, app.session.general_unit());
false
@@ -173,6 +174,9 @@ macro_rules! registry {
}
}
/// Every variant, in registry order.
pub const ALL: &[DialogKind] = &[$(Self::$variant,)+];
fn spec(self) -> &'static DialogSpec {
match self {
$(Self::$variant => {
@@ -265,6 +269,19 @@ registry! {
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
}
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
/// so the catalog tests can insist they are translated.
pub fn button_labels() -> Vec<&'static str> {
let mut v = vec!["Cancel", "Close"];
for spec in std::iter::once(&DialogSpec::FORM).chain(DialogKind::ALL.iter().map(|k| k.spec())) {
v.extend(spec.ok);
v.extend(spec.discard);
}
v.sort_unstable();
v.dedup();
v
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
fn spec(kind: &str) -> &'static DialogSpec {
static FALLBACK: DialogSpec = DialogSpec::FORM;
@@ -328,9 +345,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
ui.add_space(12.0);
cancel = (spec.body)(app, ui, &mut d);
ui.add_space(16.0);
// The button row is as tall as the buttons: a right-to-left layout would otherwise take
// all the height left in the window, so the window could never shrink to its content.
let row = egui::vec2(ui.available_width(), ui.spacing().interact_size.y);
// The button row is as wide as the fields above it and as tall as the buttons: a
// right-to-left layout would otherwise take all the room left in the window, so the
// window could never shrink to its content.
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button(ui, label).clicked()
@@ -362,6 +380,28 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
/// What a dropdown shows for `names`, a list mixing built-in labels (`builtin(index)`: translated
/// into `lang`) with names the user saved or a file or the system supplied (shown as they are).
pub(crate) fn shown_names<'a>(lang: crate::i18n::Lang, names: &[&'a str], builtin: impl Fn(usize) -> bool) -> Vec<&'a str> {
names.iter().enumerate().map(|(k, n)| crate::i18n::label_or_name(lang, n, builtin(k))).collect()
}
/// [`widgets::dropdown_names`] over such a mixed list ([`shown_names`] in the UI language), showing
/// `current` as its entry reads; a `current` that isn't among `names` is shown as given (a caller
/// translates its own [Custom]). Returns the index chosen in `names`.
pub(crate) fn mixed_dropdown(
ui: &mut egui::Ui,
id: impl std::hash::Hash + std::fmt::Debug,
current: &str,
names: &[&str],
width: f32,
builtin: impl Fn(usize) -> bool,
) -> Option<usize> {
let shown = shown_names(crate::i18n::current(), names, builtin);
let current = names.iter().position(|n| *n == current).and_then(|i| shown.get(i).copied()).unwrap_or(current);
widgets::dropdown_names(ui, id, current, &shown, width)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
@@ -15,7 +15,7 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of New Color Group.
pub const KIND: &str = "newColorGroup";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Color Group".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("New Color Group").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Open New Color Group for the swatches selected in the panel. It starts from the artwork when
/// art is selected and no swatches are.
@@ -40,20 +40,20 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
d.fields.insert(key.into(), json!(v));
};
grid(ui, |ui| {
label(ui, "Name:");
label(ui, tl!("Name:"));
form::text(ui, d, "name", 190.0);
ui.end_row();
label(ui, "Create From:");
if widgets::radio(ui, "Selected Swatches", !art, true) {
label(ui, tl!("Create From:"));
if widgets::radio(ui, tl!("Selected Swatches"), !art, true) {
set(d, "fromArtwork", false);
}
ui.end_row();
ui.label("");
if widgets::radio(ui, "Selected Artwork", art, d.bool("__art")) {
if widgets::radio(ui, tl!("Selected Artwork"), art, d.bool("__art")) {
set(d, "fromArtwork", true);
}
ui.end_row();
for (key, text) in [("toGlobal", "Convert Process to Global"), ("includeTints", "Include Swatches for Tints")] {
for (key, text) in [("toGlobal", tl!("Convert Process to Global")), ("includeTints", tl!("Include Swatches for Tints"))] {
ui.label("");
ui.horizontal(|ui| {
ui.add_space(18.0);
+67 -44
View File
@@ -225,16 +225,16 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let list = names.get(cat).and_then(|c| newdoc::category(&app.session, c)).unwrap_or_default();
ui.add_space(14.0);
let heading = if cat == 0 {
"RECENT"
tl!("RECENT")
} else if cat == 1 {
"SAVED"
tl!("SAVED")
} else {
"BLANK DOCUMENT PRESETS"
tl!("BLANK DOCUMENT PRESETS")
};
ui.label(egui::RichText::new(format!("{heading} ({})", list.len())).font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(10.0);
if list.is_empty() {
let hint = if cat == 1 { "Presets you save with the Save Preset button appear here." } else { "Documents you create appear here." };
let hint = if cat == 1 { tl!("Presets you save with the Save Preset button appear here.") } else { tl!("Documents you create appear here.") };
ui.label(egui::RichText::new(hint).color(t.text_dim));
return;
}
@@ -252,10 +252,22 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
}
/// Is `name` a built-in preset's? Those are ours (translated); Recent and Saved hold the names of
/// the user's documents and presets, shown as they are.
fn is_builtin_preset(name: &str) -> bool {
newdoc::CATEGORIES.iter().any(|(_, _, _, presets)| presets.iter().any(|(n, ..)| *n == name))
}
/// A preset's name as shown: a built-in one translated, the user's as it is.
fn preset_name(name: &str) -> &str {
if is_builtin_preset(name) { tl!(name) } else { name }
}
/// A preset card: a page-proportioned thumbnail, the name and the size (New Document, the Home
/// screen). Returns the card's response.
pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let label = preset_name(&s.name);
let (r, resp) = ui.allocate_exact_size(egui::vec2(146.0, 150.0), egui::Sense::click());
let p = ui.painter();
p.rect_filled(r, egui::CornerRadius::same(6), if resp.hovered() || selected { t.hover } else { t.panel_darker });
@@ -271,7 +283,7 @@ pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::
p.rect_filled(page, 0.0, egui::Color32::WHITE);
// The name on up to two lines, then the size.
let mut job = egui::text::LayoutJob::single_section(
s.name.clone(),
label.to_string(),
egui::TextFormat { font_id: theme::semibold(11.5), color: t.text_strong, ..Default::default() },
);
job.halign = egui::Align::Center;
@@ -280,7 +292,7 @@ pub fn preset_card(ui: &mut egui::Ui, s: &DocSettings, selected: bool) -> egui::
let name_h = name.size().y;
p.galley(egui::pos2(r.center().x, r.top() + 94.0), name, t.text_strong);
p.text(egui::pos2(r.center().x, r.top() + 98.0 + name_h), egui::Align2::CENTER_TOP, s.size_label(), egui::FontId::proportional(11.0), t.text_dim);
resp.on_hover_text(&s.name)
resp.on_hover_text(label)
}
/// The Preset Details column.
@@ -288,11 +300,11 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing = egui::vec2(6.0, 4.0);
let top = ui.cursor().top();
ui.label(egui::RichText::new("PRESET DETAILS").font(theme::semibold(11.0)).color(t.text_dim));
ui.label(egui::RichText::new(tl!("PRESET DETAILS")).font(theme::semibold(11.0)).color(t.text_dim));
ui.add_space(8.0);
ui.horizontal(|ui| {
form::text(ui, d, "name", DETAILS - 50.0);
if widgets::icon_button(ui, "save", "Save Preset", d.bool(SAVING), 26.0).clicked() {
if widgets::icon_button(ui, "save", tl!("Save Preset"), d.bool(SAVING), 26.0).clicked() {
let saving = !d.bool(SAVING);
d.fields.insert(SAVING.into(), json!(saving));
if saving && d.str("presetName").is_empty() {
@@ -305,7 +317,7 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
}
ui.add_space(8.0);
let unit = unit(d);
form::caption(ui, "Width");
form::caption(ui, tl!("Width"));
ui.horizontal(|ui| {
form::length(ui, d, "width", unit, DETAILS - 124.0);
units_dropdown(ui, d, 112.0);
@@ -313,48 +325,48 @@ fn details(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, b: &mut
ui.add_space(4.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
form::caption(ui, "Height");
form::caption(ui, tl!("Height"));
form::length(ui, d, "height", unit, 104.0);
});
ui.add_space(6.0);
ui.vertical(|ui| {
form::caption(ui, "Orientation");
form::caption(ui, tl!("Orientation"));
orientation(ui, d);
});
ui.add_space(6.0);
ui.vertical(|ui| {
form::caption(ui, "Artboards");
form::caption(ui, tl!("Artboards"));
artboards(ui, d, 76.0);
});
});
ui.add_space(8.0);
form::caption(ui, "Bleed");
form::caption(ui, tl!("Bleed"));
form::bleed(ui, d, unit, 52.0);
ui.add_space(8.0);
form::caption(ui, "Background Contents");
form::caption(ui, tl!("Background Contents"));
background(ui, d, DETAILS - 34.0);
ui.add_space(8.0);
advanced(ui, d, 110.0, DETAILS - 116.0);
ui.add_space(12.0);
if widgets::flat_button(ui, "More Settings", DETAILS).clicked() {
if widgets::flat_button(ui, tl!("More Settings"), DETAILS).clicked() {
d.kind = MORE.into();
}
// Close and Create at the bottom right.
ui.add_space((HEIGHT - (ui.cursor().top() - top) - 28.0).max(12.0));
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, "Create").clicked();
b.create = widgets::primary_button(ui, tl!("Create")).clicked();
ui.add_space(8.0);
b.close = widgets::secondary_button(ui, "Close").clicked();
b.close = widgets::secondary_button(ui, tl!("Close")).clicked();
});
}
/// Save Preset: the preset's name, Save and Cancel.
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(4.0);
form::caption(ui, "Preset Name");
form::caption(ui, tl!("Preset Name"));
ui.horizontal(|ui| {
let enter = form::text_edit(ui, d, "presetName", DETAILS - 140.0).lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if widgets::flat_button(ui, "Save", 54.0).clicked() || enter {
if widgets::flat_button(ui, tl!("Save"), 54.0).clicked() || enter {
let mut p: Map<String, Value> = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect();
p.insert("name".into(), json!(d.str("presetName")));
p.remove("preset");
@@ -368,7 +380,7 @@ fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
Err(e) => app.status(e),
}
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() {
d.fields.insert(SAVING.into(), json!(false));
}
});
@@ -387,7 +399,7 @@ fn orientation(ui: &mut egui::Ui, d: &mut Dialog) {
let landscape = w > h;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (is_landscape, tip) in [(false, "Portrait"), (true, "Landscape")] {
for (is_landscape, tip) in [(false, tl!("Portrait")), (true, tl!("Landscape"))] {
if widgets::orientation_button(ui, is_landscape, landscape == is_landscape, tip) && landscape != is_landscape {
d.fields.insert("width".into(), json!(h));
d.fields.insert("height".into(), json!(w));
@@ -444,7 +456,7 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) {
.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, r, t.icon);
ui.label(egui::RichText::new("Advanced Options").color(t.text));
ui.label(egui::RichText::new(tl!("Advanced Options")).color(t.text));
})
.response
.interact(egui::Sense::click());
@@ -458,9 +470,11 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) {
let ppi: Vec<(Value, String)> = RASTER_PPI.iter().map(|(v, l)| (json!(v), l.to_string())).collect();
let previews: Vec<(Value, String)> =
PreviewMode::ALL.iter().map(|m| (json!(m.id()), form::humanize(m.id()).trim_end_matches(':').to_string())).collect();
for (key, name, options) in
[("colorMode", "Color Mode", &colors), ("rasterEffectsPpi", "Raster Effects", &ppi), ("previewMode", "Preview Mode", &previews)]
{
for (key, name, options) in [
("colorMode", tl!("Color Mode"), &colors),
("rasterEffectsPpi", tl!("Raster Effects"), &ppi),
("previewMode", tl!("Preview Mode"), &previews),
] {
widgets::label_row(ui, name, label, |ui| choice(ui, d, key, options, field));
}
}
@@ -475,15 +489,15 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
const L: f32 = 150.0;
window(ctx, MORE, 22, |ui| {
ui.set_width(560.0);
ui.label(egui::RichText::new("More Settings").font(theme::semibold(16.0)).color(t.text));
ui.label(egui::RichText::new(tl!("More Settings")).font(theme::semibold(16.0)).color(t.text));
ui.add_space(12.0);
widgets::label_row(ui, "Name:", L, |ui| {
widgets::label_row(ui, tl!("Name:"), L, |ui| {
form::text(ui, &mut d, "name", 300.0);
});
// Profile: the preset categories (the tabs); Size: the profile's presets.
let profiles: Vec<&str> = newdoc::CATEGORIES.iter().map(|c| c.0).collect();
let profile = profiles.iter().find(|p| p.eq_ignore_ascii_case(&d.str("category"))).copied().unwrap_or("[Custom]");
widgets::label_row(ui, "Profile:", L, |ui| {
widgets::label_row(ui, tl!("Profile:"), L, |ui| {
if let Some(p) = widgets::dropdown(ui, "newdoc-profile", profile, &profiles, 220.0).and_then(|i| profiles.get(i)) {
d.fields.insert("category".into(), json!(p));
if let Some(first) = newdoc::category(&app.session, p).and_then(|v| v.into_iter().next()) {
@@ -495,7 +509,7 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
let (lay, cols, spacing, rtl) = layout(&d);
let n = d.f64("artboards", 1.0);
let several = n > 1.0;
widgets::label_row(ui, "Number of Artboards:", L, |ui| {
widgets::label_row(ui, tl!("Number of Artboards:"), L, |ui| {
artboards(ui, &mut d, 76.0);
ui.add_space(10.0);
for (l, icon) in [
@@ -509,17 +523,17 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
ui.add_space(6.0);
if widgets::icon_button_enabled(ui, "chevrons-left", "Change to Right-to-Left Layout", rtl, several, 26.0).clicked() {
if widgets::icon_button_enabled(ui, "chevrons-left", tl!("Change to Right-to-Left Layout"), rtl, several, 26.0).clicked() {
set_layout(&mut d, "rightToLeft", json!(!rtl));
}
});
ui.add_enabled_ui(several, |ui| {
widgets::label_row(ui, "Spacing:", L, |ui| {
widgets::label_row(ui, tl!("Spacing:"), L, |ui| {
if let Some(v) = widgets::num_field(ui, "newdoc-spacing", Some(spacing), unit(&d), 100.0) {
set_layout(&mut d, "spacing", json!(v.max(0.0)));
}
ui.add_space(20.0);
ui.label(egui::RichText::new("Columns:").color(t.text));
ui.label(egui::RichText::new(tl!("Columns:")).color(t.text));
let grid = matches!(lay, ArtboardLayout::GridByRow | ArtboardLayout::GridByColumn);
ui.add_enabled_ui(grid, |ui| {
if let Some(v) = widgets::spin_plain(ui, "newdoc-columns", cols as f64, "", 0, 76.0, 1.0, 1.0, &[]) {
@@ -532,39 +546,40 @@ fn show_more(app: &mut VectorcraftApp, ctx: &egui::Context) {
let sizes = newdoc::category(&app.session, profile).unwrap_or_default();
let size_names: Vec<&str> = sizes.iter().map(|s| s.name.as_str()).collect();
let preset = d.str("preset");
widgets::label_row(ui, "Size:", L, |ui| {
let shown = if preset.is_empty() { "Custom" } else { preset.as_str() };
if let Some(s) = widgets::dropdown(ui, "newdoc-size", shown, &size_names, 220.0).and_then(|i| sizes.get(i)) {
widgets::label_row(ui, tl!("Size:"), L, |ui| {
// The profile's sizes are built in; the preset may be one the user saved.
let shown = if preset.is_empty() { tl!("Custom") } else { preset_name(&preset) };
if let Some(s) = super::mixed_dropdown(ui, "newdoc-size", shown, &size_names, 220.0, |_| true).and_then(|i| sizes.get(i)) {
apply(&mut d, s);
}
});
let unit = unit(&d);
widgets::label_row(ui, "Width:", L, |ui| {
widgets::label_row(ui, tl!("Width:"), L, |ui| {
form::length(ui, &mut d, "width", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new("Units:").color(t.text));
ui.label(egui::RichText::new(tl!("Units:")).color(t.text));
units_dropdown(ui, &mut d, 120.0);
});
widgets::label_row(ui, "Height:", L, |ui| {
widgets::label_row(ui, tl!("Height:"), L, |ui| {
form::length(ui, &mut d, "height", unit, 120.0);
ui.add_space(20.0);
ui.label(egui::RichText::new("Orientation:").color(t.text));
ui.label(egui::RichText::new(tl!("Orientation:")).color(t.text));
orientation(ui, &mut d);
});
ui.add_space(4.0);
widgets::label_row(ui, "Bleed:", L, |ui| form::bleed(ui, &mut d, unit, 64.0));
widgets::label_row(ui, tl!("Bleed:"), L, |ui| form::bleed(ui, &mut d, unit, 64.0));
ui.add_space(4.0);
widgets::label_row(ui, "Background Contents:", L, |ui| background(ui, &mut d, 220.0));
widgets::label_row(ui, tl!("Background Contents:"), L, |ui| background(ui, &mut d, 220.0));
ui.add_space(8.0);
advanced(ui, &mut d, L, 220.0);
ui.add_space(16.0);
ui.horizontal(|ui| {
templates = widgets::secondary_button(ui, "Templates…").on_hover_text("New from Template").clicked();
templates = widgets::secondary_button(ui, tl!("Templates…")).on_hover_text(tl!("New from Template")).clicked();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
b.create = widgets::primary_button(ui, "Create Document").clicked();
b.create = widgets::primary_button(ui, tl!("Create Document")).clicked();
ui.add_space(8.0);
// Cancel goes back to New Document.
if widgets::secondary_button(ui, "Cancel").clicked() {
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
d.kind = KIND.into();
}
});
@@ -587,6 +602,14 @@ mod tests {
use crate::VectorcraftApp;
/// Cards translate the built-in presets' names only: Recent and Saved show the user's names.
#[test]
fn only_built_in_preset_names_are_translated() {
assert!(super::is_builtin_preset("Letter") && super::is_builtin_preset("Phone 390×844"));
assert!(!super::is_builtin_preset("Black") && !super::is_builtin_preset("Untitled-1"));
assert_eq!(super::preset_name("Black"), "Black");
}
fn frame(app: &mut VectorcraftApp) -> egui::FullOutput {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
+1 -1
View File
@@ -19,7 +19,7 @@ use crate::state::Dialog;
/// The dialog kind of New Swatch.
pub const KIND: &str = "newSwatch";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Swatch".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("New Swatch").into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
/// Open New Swatch for the active paint: a spot colour with `spot`, going into colour `group`.
pub fn open(app: &mut VectorcraftApp, spot: bool, group: Option<&str>) -> Result<Value, String> {
+6 -6
View File
@@ -12,7 +12,7 @@ use crate::{VectorcraftApp, io, widgets};
pub(crate) const KIND: &str = "saveForOffice";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Save for Office Documents".into(), body, confirm, ok: Some("Save…"), min_width: 340.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Save for Office Documents").into(), body, confirm, ok: Some("Save…"), min_width: 340.0, ..DialogSpec::FORM };
/// Open the dialog (150 ppi on white, the first artboard).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -28,7 +28,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let ppi = d.f64("ppi", RESOLUTIONS[1]);
let board = (d.f64("artboard", 0.0).max(0.0) as usize).min(doc.artboards.len().saturating_sub(1));
egui::Grid::new("office-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, "Resolution:");
label(ui, tl!("Resolution:"));
let at = RESOLUTIONS.iter().position(|r| *r == ppi).unwrap_or(1);
if let Some(i) = widgets::dropdown(ui, "office-ppi", RESOLUTION_LABELS[at], &RESOLUTION_LABELS[..RESOLUTIONS.len()], 160.0)
&& let Some(r) = RESOLUTIONS.get(i)
@@ -37,22 +37,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
ui.end_row();
if doc.artboards.len() > 1 {
label(ui, "Artboard:");
label(ui, tl!("Artboard:"));
let names: Vec<&str> = doc.artboards.iter().map(|a| a.name.as_str()).collect();
if let Some(i) = widgets::dropdown(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) {
if let Some(i) = widgets::dropdown_names(ui, "office-artboard", names.get(board).copied().unwrap_or_default(), &names, 160.0) {
d.fields.insert("artboard".into(), json!(i));
}
ui.end_row();
}
if let Some(a) = doc.artboards.get(board) {
label(ui, "Size:");
label(ui, tl!("Size:"));
let (w, h) = vectorcraft_render::region_pixels(a.rect, ppi / 72.0);
ui.label(egui::RichText::new(format!("{w} × {h} px")).color(t.text));
ui.end_row();
}
});
ui.add_space(8.0);
form::check(ui, d, "transparent", "Transparent Background");
form::check(ui, d, "transparent", tl!("Transparent Background"));
false
}
+31 -10
View File
@@ -26,8 +26,15 @@ const OPTIONS: [(&str, &str); 5] = [
("report", "Create Report"),
];
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Package".into(), body, confirm, ok: Some("Package"), min_width: 440.0, max_width: Some(460.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Package").into(),
body,
confirm,
ok: Some("Package"),
min_width: 440.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Is there no file system to write a folder to (the web downloads a zip)?
fn downloads(app: &VectorcraftApp) -> bool {
@@ -38,7 +45,13 @@ fn downloads(app: &VectorcraftApp) -> bool {
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let Some(path) = st.path.clone() else {
ask(app, "Save the document first?", "Package collects a saved document with its linked files and fonts.", "file.saveAs", json!({}));
ask(
app,
tl!("Save the document first?"),
tl!("Package collects a saved document with its linked files and fonts."),
"file.saveAs",
json!({}),
);
return Ok(Value::Null);
};
let p = std::path::Path::new(&path);
@@ -55,10 +68,10 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
if !downloads(app) {
widgets::label_row(ui, "Location:", LABEL, |ui| {
widgets::label_row(ui, tl!("Location:"), LABEL, |ui| {
form::text(ui, d, "folder", 230.0);
if let Some(pick) = app.services.pick_folder.as_mut()
&& ui.button("Choose…").clicked()
&& ui.button(tl!("Choose…")).clicked()
&& let Some(f) = pick()
{
d.fields.insert("folder".into(), json!(f));
@@ -66,11 +79,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(4.0);
}
widgets::label_row(ui, "Folder Name:", LABEL, |ui| {
widgets::label_row(ui, tl!("Folder Name:"), LABEL, |ui| {
form::text(ui, d, "name", 230.0);
});
ui.add_space(10.0);
widgets::subheader(ui, "Options");
widgets::subheader(ui, tl!("Options"));
for (k, label) in OPTIONS {
// The link options apply only when the links are copied.
let enabled = !matches!(k, "linksFolder" | "relink") || d.bool("copyLinks");
@@ -80,7 +93,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Fonts whose licence doesn't allow embedding are not copied.").size(11.5).color(t.text_dim));
ui.label(egui::RichText::new(tl!("Fonts whose licence doesn't allow embedding are not copied.")).size(11.5).color(t.text_dim));
false
}
@@ -110,8 +123,16 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
let folder = r["folder"].as_str().unwrap_or_default().to_string();
app.status(format!("Packaged {n} file(s) in {folder}{note}"));
let detail = format!("{n} file(s) in {folder}{note}. Show the package folder?");
ask(app, "Package created", &detail, "file.showPackage", json!({ "folder": folder }));
let shown_note = if missing > 0 {
format!(" ({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())]))
} else {
String::new()
};
let detail = crate::i18n::fmt(
tl!("{count} file(s) in {folder}{note}. Show the package folder?"),
&[("count", &n.to_string()), ("folder", &folder), ("note", &shown_note)],
);
ask(app, tl!("Package created"), &detail, "file.showPackage", json!({ "folder": folder }));
Ok(r)
}
+4 -4
View File
@@ -10,10 +10,10 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).int
fn title(kind: &str) -> &'static str {
match kind {
"average" => "Average",
"offsetPath" => "Offset Path",
"simplify" => "Simplify",
_ => "Split Into Grid",
"average" => tl!("Average"),
"offsetPath" => tl!("Offset Path"),
"simplify" => tl!("Simplify"),
_ => tl!("Split Into Grid"),
}
}
+12 -12
View File
@@ -20,7 +20,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "pdfPresets";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "PDF Presets".into(),
heading: |_| tl!("PDF Presets").into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
@@ -57,13 +57,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
widgets::dim_label(ui, "Presets:");
widgets::dim_label(ui, tl!("Presets:"));
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("pdf-presets").min_scrolled_height(260.0).max_height(260.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(260.0);
for (k, p) in presets.iter().enumerate() {
let label = if k < builtins { format!("[{}]", p.name) } else { p.name.clone() };
let label = if k < builtins { format!("[{}]", tl!(&p.name)) } else { p.name.clone() };
if ui.selectable_label(k == i, label).clicked() {
act = Some(Action::Select(p.name.clone()));
}
@@ -73,19 +73,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() {
if widgets::flat_button(ui, tl!("New…"), 48.0).on_hover_text(tl!("A new preset starting from the selected one")).clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Edit…"), 48.0)).inner.clicked() {
act = Some(Action::Edit);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Delete"), 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 64.0).clicked() {
if widgets::flat_button(ui, tl!("Import…"), 64.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 64.0).on_hover_text("Save the selected preset to a file").clicked() {
if widgets::flat_button(ui, tl!("Export…"), 64.0).on_hover_text(tl!("Save the selected preset to a file")).clicked() {
act = Some(Action::Export);
}
});
@@ -113,14 +113,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
/// What sets the preset apart: its settings that differ from the app default, and the notes on
/// what Save PDF can't honour or apply yet.
fn details(ui: &mut egui::Ui, t: &Tokens, preset: &vectorcraft_pdf::PdfPreset, builtin: bool) {
let dim = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5));
widgets::dim_label(ui, &format!("Differences from {}:", pdf::DEFAULT_PRESET));
let dim = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(tl!(text)).color(t.text_dim).size(11.5));
widgets::dim_label(ui, &crate::i18n::fmt(tl!("Differences from {preset}:"), &[("preset", pdf::DEFAULT_PRESET)]));
let base = vectorcraft_pdf::builtin_preset(pdf::DEFAULT_PRESET).map(|p| p.settings).unwrap_or_default();
let mut changed = pdf::changes(&preset.settings, &base);
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()));
egui::ScrollArea::vertical().id_salt("pdf-preset-details").max_height(180.0).show(ui, |ui| {
if changed.is_empty() {
dim(ui, "None.");
dim(ui, tl!("None."));
}
option_rows(ui, &changed, option_label);
});
@@ -133,7 +133,7 @@ fn details(ui: &mut egui::Ui, t: &Tokens, preset: &vectorcraft_pdf::PdfPreset, b
}
if builtin {
ui.add_space(6.0);
dim(ui, "Built-in presets are read-only: New… starts an editable copy.");
dim(ui, tl!("Built-in presets are read-only: New… starts an editable copy."));
}
}
+30 -23
View File
@@ -14,6 +14,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_tools::distort::perspective::define::is_builtin;
use vectorcraft_tools::distort::perspective::{GridDefinition, Rgb};
use super::{DialogSpec, form};
@@ -61,9 +62,9 @@ const TYPES: [&str; 3] = ["One Point Perspective", "Two Point Perspective", "Thr
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| {
match d.str(MODE).as_str() {
"save" => "Save Grid as Preset",
"edit" => "Perspective Grid Preset",
_ => "Define Perspective Grid",
"save" => tl!("Save Grid as Preset"),
"edit" => tl!("Perspective Grid Preset"),
_ => tl!("Define Perspective Grid"),
}
.into()
},
@@ -204,7 +205,7 @@ fn color(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
/// Preset…, else the preset's name.
fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !d.str(MODE).is_empty() {
widgets::label_row(ui, "Name:", LABEL, |ui| {
widgets::label_row(ui, tl!("Name:"), LABEL, |ui| {
form::text(ui, d, "name", 200.0);
});
return;
@@ -219,15 +220,18 @@ fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
let shown = current.map_or(CUSTOM, |p| p.name.as_str());
let names: Vec<&str> = std::iter::once(CUSTOM).chain(presets.iter().map(|p| p.name.as_str())).collect();
widgets::label_row(ui, "Preset:", LABEL, |ui| {
if let Some(p) = widgets::dropdown(ui, "persp-preset", shown, &names, 200.0).and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) {
// [Custom] and the built-in presets are ours (translated); the saved ones are names.
let builtin = |k: usize| k == 0 || names.get(k).is_some_and(|n| is_builtin(n));
widgets::label_row(ui, tl!("Preset:"), LABEL, |ui| {
let picked = super::mixed_dropdown(ui, "persp-preset", shown, &names, 200.0, builtin);
if let Some(p) = picked.and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) {
let mut fields = json!(p);
fields[SHOWN] = fields["units"].clone();
if let Some(o) = fields.as_object() {
d.fields.extend(o.clone());
}
}
if widgets::flat_button(ui, "Save Preset…", 96.0).on_hover_text("Save these settings as a preset").clicked() {
if widgets::flat_button(ui, tl!("Save Preset…"), 96.0).on_hover_text(tl!("Save these settings as a preset")).clicked() {
d.fields.insert(MODE.into(), json!("save"));
d.fields.insert(FROM.into(), json!("define"));
d.fields.insert("name".into(), json!(app.session.new_perspective_preset_name()));
@@ -239,16 +243,16 @@ fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
name_row(app, ui, d);
widgets::subheader(ui, "Perspective Grid Settings");
widgets::subheader(ui, tl!("Perspective Grid Settings"));
ui.add_space(6.0);
let k = kind(d);
widgets::label_row(ui, "Type:", LABEL, |ui| {
widgets::label_row(ui, tl!("Type:"), LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "persp-type", TYPES[usize::from(k - 1)], &TYPES, 200.0) {
d.fields.insert("kind".into(), json!(i + 1));
}
});
let units: Vec<(&str, &str)> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| (u.key(), u.label())).collect();
form::choice(ui, d, "units", "Units:", (LABEL, 200.0), &units);
form::choice(ui, d, "units", tl!("Units:"), (LABEL, 200.0), &units);
// A new unit (the menu or `ui.dialog.set`) keeps the lengths: they are converted.
let unit = unit(d);
let before = Unit::named(&d.str(SHOWN)).unwrap_or(unit);
@@ -269,7 +273,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let named = SCALES.iter().find(|(_, s)| *s == sc).map(|(l, _)| *l);
let mut labels: Vec<&str> = SCALES.iter().map(|(l, _)| *l).collect();
labels.push("Custom");
widgets::label_row(ui, "Scale:", LABEL, |ui| {
widgets::label_row(ui, tl!("Scale:"), LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "persp-scale", named.unwrap_or("Custom"), &labels, 200.0) {
// Custom starts from the scale shown.
let s = SCALES.get(i).map_or(sc, |(_, s)| *s);
@@ -278,7 +282,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
if named.is_none() {
widgets::label_row(ui, "", LABEL, |ui| {
for (i, label) in ["Artboard:", "Real World:"].into_iter().enumerate() {
for (i, label) in [tl!("Artboard:"), tl!("Real World:")].into_iter().enumerate() {
ui.label(egui::RichText::new(label).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("persp-scale", i), sc[i], "", 3, 56.0).filter(|v| *v > 0.0 && v.is_finite()) {
let mut s = sc;
@@ -288,41 +292,44 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
}
widgets::label_row(ui, "Gridline every:", LABEL, |ui| {
widgets::label_row(ui, tl!("Gridline every:"), LABEL, |ui| {
length(ui, d, "gridline", unit, FIELD);
});
if sc[0] != sc[1] {
let real = unit.number(unit.to_pt(sc[1] / sc[0]));
let note = format!("1 {s} on the artboard stands for {real} {s} in the scene.", s = unit.suffix());
let note = crate::i18n::fmt(
tl!("1 {unit} on the artboard stands for {real} {unit} in the scene."),
&[("unit", unit.suffix()), ("real", &real.to_string())],
);
widgets::label_row(ui, "", LABEL, |ui| {
ui.label(egui::RichText::new(note).size(11.5).color(t.text_dim));
});
}
ui.add_space(4.0);
widgets::label_row(ui, "Viewing Angle:", LABEL, |ui| {
widgets::label_row(ui, tl!("Viewing Angle:"), LABEL, |ui| {
let a = d.f64("angle", 45.0);
if let Some(v) = ui.add_enabled_ui(k != 1, |ui| widgets::plain_field(ui, "persp-angle", a, "°", 2, FIELD)).inner {
d.fields.insert("angle".into(), json!(v));
}
});
widgets::label_row(ui, "Viewing Distance:", LABEL, |ui| {
widgets::label_row(ui, tl!("Viewing Distance:"), LABEL, |ui| {
length(ui, d, "distance", unit, FIELD);
});
widgets::label_row(ui, "Horizon Height:", LABEL, |ui| {
widgets::label_row(ui, tl!("Horizon Height:"), LABEL, |ui| {
length(ui, d, "horizonHeight", unit, FIELD);
});
widgets::label_row(ui, "Third Vanishing Point:", LABEL, |ui| {
widgets::label_row(ui, tl!("Third Vanishing Point:"), LABEL, |ui| {
third_vp(ui, d, 0, unit, k == 3);
third_vp(ui, d, 1, unit, k == 3);
});
ui.add_space(12.0);
widgets::subheader(ui, "Grid Color & Opacity");
widgets::subheader(ui, tl!("Grid Color & Opacity"));
ui.add_space(6.0);
color(ui, d, "leftColor", "Left Grid:");
color(ui, d, "rightColor", "Right Grid:");
color(ui, d, "groundColor", "Horizontal Grid:");
color(ui, d, "leftColor", tl!("Left Grid:"));
color(ui, d, "rightColor", tl!("Right Grid:"));
color(ui, d, "groundColor", tl!("Horizontal Grid:"));
// Opacity: a slider and its field, in the label column the rows above use.
widgets::label_row(ui, "Opacity:", LABEL, |ui| {
widgets::label_row(ui, tl!("Opacity:"), LABEL, |ui| {
let v = d.f64("opacity", 50.0).clamp(0.0, 100.0);
let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8);
let mut new =
@@ -15,7 +15,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "perspectiveGridOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Perspective Grid Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Perspective Grid Options").into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
/// Open the dialog with the current options.
pub fn open(app: &mut VectorcraftApp) {
@@ -32,11 +32,11 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let on = d.bool("show");
if widgets::check(ui, "Show Active Plane Widget", on, true) {
if widgets::check(ui, tl!("Show Active Plane Widget"), on, true) {
d.fields.insert("show".into(), json!(!on));
}
ui.add_space(8.0);
let corners: Vec<(&str, &str)> = WidgetCorner::ALL.iter().map(|c| (c.id(), c.label())).collect();
ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", "Widget Position:", (110.0, 160.0), &corners));
ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", tl!("Widget Position:"), (110.0, 160.0), &corners));
false
}
@@ -23,9 +23,9 @@ const OBJECTS: [(&str, &str); 3] = [("none", "Do Not Move"), ("move", "Move All
fn heading(d: &Dialog) -> String {
match Plane::parse(&d.str("plane")) {
Some(Plane::Right) => "Right Plane",
Some(Plane::Ground) => "Horizontal Plane",
_ => "Left Plane",
Some(Plane::Right) => tl!("Right Plane"),
Some(Plane::Ground) => tl!("Horizontal Plane"),
_ => tl!("Left Plane"),
}
.into()
}
@@ -49,7 +49,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
grid(ui, |ui| {
label(ui, "Location:");
label(ui, tl!("Location:"));
if let Some(v) = widgets::num_field(ui, "pp-location", Some(d.f64("location", 0.0)), unit, 120.0) {
d.fields.insert("location".into(), json!(v));
}
@@ -32,7 +32,7 @@ pub const SLOT: &str = "ui.perspectiveUserPreset";
pub const SLOTS: usize = 5;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Perspective Grid Presets".into(),
heading: |_| tl!("Perspective Grid Presets").into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
@@ -64,19 +64,26 @@ fn details(p: &GridDefinition) -> Vec<(&'static str, String)> {
let u = p.unit();
let len = |v: f64| u.format(u.to_pt(v));
let mut rows = vec![
("Type:", ["One Point", "Two Point", "Three Point"].get(usize::from(p.kind.clamp(1, 3) - 1)).copied().unwrap_or_default().to_string()),
("Units:", u.label().to_string()),
("Scale:", format!("{}:{}", Unit::Points.number(p.scale[0]), Unit::Points.number(p.scale[1]))),
("Gridline every:", len(p.gridline)),
(
tl!("Type:"),
[tl!("One Point"), tl!("Two Point"), tl!("Three Point")]
.get(usize::from(p.kind.clamp(1, 3) - 1))
.copied()
.unwrap_or_default()
.to_string(),
),
(tl!("Units:"), u.label().to_string()),
(tl!("Scale:"), format!("{}:{}", Unit::Points.number(p.scale[0]), Unit::Points.number(p.scale[1]))),
(tl!("Gridline every:"), len(p.gridline)),
];
if p.kind != 1 {
rows.push(("Viewing Angle:", format!("{:.1}°", p.angle)));
rows.push((tl!("Viewing Angle:"), format!("{:.1}°", p.angle)));
}
rows.extend([("Viewing Distance:", len(p.distance)), ("Horizon Height:", len(p.horizon_height))]);
rows.extend([(tl!("Viewing Distance:"), len(p.distance)), (tl!("Horizon Height:"), len(p.horizon_height))]);
if p.kind == 3 {
rows.push(("Third Vanishing Point:", format!("{}, {}", len(p.third_vp[0]), len(p.third_vp[1]))));
rows.push((tl!("Third Vanishing Point:"), format!("{}, {}", len(p.third_vp[0]), len(p.third_vp[1]))));
}
rows.push(("Opacity:", format!("{:.0}%", p.opacity)));
rows.push((tl!("Opacity:"), format!("{:.0}%", p.opacity)));
rows
}
@@ -91,13 +98,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(260.0);
widgets::dim_label(ui, "Presets:");
widgets::dim_label(ui, tl!("Presets:"));
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("perspective-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(240.0);
for (k, p) in presets.iter().enumerate() {
if ui.selectable_label(k == i, &p.name).clicked() {
let shown = if is_builtin(&p.name) { tl!(&p.name) } else { p.name.as_str() };
if ui.selectable_label(k == i, shown).clicked() {
act = Some(Action::Select(p.name.clone()));
}
}
@@ -106,19 +114,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() {
if widgets::flat_button(ui, tl!("New…"), 48.0).on_hover_text(tl!("A new preset starting from the selected one")).clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Edit…"), 48.0)).inner.clicked() {
act = Some(Action::Edit);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Delete"), 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Import…"), 60.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 60.0).on_hover_text("Save the selected preset to a file").clicked() {
if widgets::flat_button(ui, tl!("Export…"), 60.0).on_hover_text(tl!("Save the selected preset to a file")).clicked() {
act = Some(Action::Export);
}
});
@@ -126,7 +134,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(18.0);
ui.vertical(|ui| {
ui.set_width(280.0);
ui.label(egui::RichText::new(&preset.name).color(t.text_strong).strong());
ui.label(egui::RichText::new(if builtin { tl!(&preset.name) } else { preset.name.as_str() }).color(t.text_strong).strong());
ui.add_space(8.0);
egui::Grid::new("perspective-preset-details").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
for (label, value) in details(preset) {
@@ -137,7 +145,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
if builtin {
ui.add_space(6.0);
ui.label(egui::RichText::new("Built-in presets don't change: New… starts an editable copy.").color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(tl!("Built-in presets don't change: New… starts an editable copy.")).color(t.text_dim).size(11.5));
}
});
});
+5 -5
View File
@@ -16,7 +16,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "place";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Place".into(),
heading: |_| tl!("Place").into(),
body,
confirm: crate::place::confirm,
ok: Some("Place"),
@@ -36,7 +36,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let files: Vec<Value> = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
let info = d.fields.get("__info").cloned().unwrap_or_default();
widgets::subheader(ui, if files.len() == 1 { "File" } else { "Files" });
widgets::subheader(ui, if files.len() == 1 { tl!("File") } else { tl!("Files") });
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().max_height(180.0).auto_shrink([false, true]).show(ui, |ui| {
ui.set_width(ui.available_width());
@@ -63,7 +63,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
if files.len() > 1 {
ui.add_space(4.0);
widgets::dim_label(ui, "Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one.");
widgets::dim_label(ui, tl!("Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one."));
}
ui.add_space(10.0);
// The options row.
@@ -72,11 +72,11 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
for (key, label, tip) in OPTIONS {
let on = d.bool(key);
let enabled = key != "replace" || d.bool("__replace");
let resp = ui.scope(|ui| widgets::check(ui, label, on && enabled, enabled));
let resp = ui.scope(|ui| widgets::check(ui, tl!(label), on && enabled, enabled));
if resp.inner {
d.fields.insert(key.into(), json!(!on));
}
resp.response.on_hover_text(tip);
resp.response.on_hover_text(tl!(tip));
}
});
false
@@ -27,7 +27,7 @@ const ALIGN: [&str; 9] = ["topLeft", "top", "topRight", "left", "center", "right
const LABEL: f32 = 90.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Placement Options".into(), body, confirm, min_width: 400.0, max_width: Some(420.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Placement Options").into(), body, confirm, min_width: 400.0, max_width: Some(420.0), ..DialogSpec::FORM };
/// Open the dialog on the placement options of images `ids` (default: the selected ones).
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
@@ -48,12 +48,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::choice(ui, d, "preserve", "Preserve:", (LABEL, 240.0), &PRESERVE);
form::choice(ui, d, "preserve", tl!("Preserve:"), (LABEL, 240.0), &PRESERVE);
ui.add_space(8.0);
// Bounds stretches the art into the old bounds: nothing to align or clip.
let free = d.str("preserve") != "bounds";
ui.add_enabled_ui(free, |ui| {
widgets::label_row(ui, "Alignment:", LABEL, |ui| {
widgets::label_row(ui, tl!("Alignment:"), LABEL, |ui| {
let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4);
if let Some(i) = widgets::reference_point(ui, current)
&& let Some(a) = ALIGN.get(i)
@@ -64,12 +64,12 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(8.0);
widgets::label_row(ui, "", LABEL, |ui| {
let clip = d.bool("clip");
if widgets::check(ui, "Clip to Bounding Box", clip, free) {
if widgets::check(ui, tl!("Clip to Bounding Box"), clip, free) {
d.fields.insert("clip".into(), json!(!clip));
}
});
});
ui.add_space(6.0);
widgets::dim_label(ui, "Applies when the linked file is relinked or updated.");
widgets::dim_label(ui, tl!("Applies when the linked file is relinked or updated."));
false
}
+1
View File
@@ -23,6 +23,7 @@ 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";
/// Headed by the plug-in's name, as it is (plug-in text is never translated).
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM };
+21 -21
View File
@@ -124,15 +124,15 @@ fn export_size(doc: &vectorcraft_doc::Document, p: &Value) -> Option<(f64, f64)>
}
fn heading(d: &Dialog) -> String {
format!("{} Options", format_of(d).map_or("Export", |f| f.label))
crate::i18n::fmt(tl!("{format} Options"), &[("format", format_of(d).map_or(tl!("Export"), |f| f.label))])
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let id = format_of(d).map_or("png", |f| f.id);
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(tl!(text)).color(t.text_dim));
egui::Grid::new("raster-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, "Resolution:");
label(ui, tl!("Resolution:"));
let ppi = d.f64("ppi", 72.0);
let preset = RESOLUTIONS.iter().position(|p| *p == ppi).filter(|_| !d.bool("__otherPpi"));
ui.horizontal(|ui| {
@@ -155,7 +155,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if let Some([w, h]) =
d.fields.get("__size").and_then(Value::as_array).map(|a| [0, 1].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)))
{
label(ui, "Size:");
label(ui, tl!("Size:"));
let (pw, ph) = vectorcraft_render::region_pixels(vectorcraft_geom::Rect::new(0.0, 0.0, w, h), d.f64("ppi", 72.0) / 72.0);
ui.label(egui::RichText::new(format!("{pw} × {ph} px")).color(t.text));
ui.end_row();
@@ -169,8 +169,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
/// choose (JPEG quality) and what writes other files (image maps).
pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens: bool) {
let t = Tokens::get(ui.ctx());
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
label(ui, "Background Color:");
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(tl!(text)).color(t.text_dim));
label(ui, tl!("Background Color:"));
let bg = d.str("background");
let choice = BACKGROUNDS.iter().position(|v| bg.eq_ignore_ascii_case(v)).unwrap_or(3);
// JPEG has no transparency, nor have some TIFF, BMP and Targa options: their list starts at
@@ -187,7 +187,7 @@ pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens:
});
ui.end_row();
label(ui, "Anti-aliasing:");
label(ui, tl!("Anti-aliasing:"));
let aa = AntiAlias::from_id(&d.str("antiAlias")).unwrap_or_default();
if let Some(i) = widgets::dropdown(ui, "ro-aa", aa.label(), &AntiAlias::ALL.map(AntiAlias::label), 150.0) {
d.fields.insert("antiAlias".into(), json!(AntiAlias::ALL[i].id()));
@@ -197,14 +197,14 @@ pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens:
match id {
"png" => {
ui.label("");
form::check(ui, d, "interlaced", "Interlaced");
form::check(ui, d, "interlaced", tl!("Interlaced"));
ui.end_row();
}
"jpg" => jpeg_rows(ui, d, &label, screens),
"gif" | "png8" => palette_rows(ui, d, &label),
"webp" => {
ui.label("");
label(ui, "Lossless (lossy WebP isn't available yet)");
label(ui, tl!("Lossless (lossy WebP isn't available yet)"));
ui.end_row();
}
"psd" => psd_options::rows(ui, d, &label),
@@ -215,7 +215,7 @@ pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens:
/// The JPEG Options rows: colour model, quality, method and scans, profile and image map (not
/// quality and image map for Export for Screens).
fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response, screens: bool) {
label(ui, "Color Model:");
label(ui, tl!("Color Model:"));
choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label));
ui.end_row();
@@ -224,19 +224,19 @@ fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &s
}
method_row(ui, d, label);
if !screens {
label(ui, "Image Map:");
label(ui, tl!("Image Map:"));
choice(ui, d, "imageMap", &IMAGE_MAPS, &IMAGE_MAP_LABELS);
ui.end_row();
}
ui.label("");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
form::check(ui, d, "embedIcc", tl!("Embed ICC Profile"));
ui.end_row();
}
/// JPEG quality on the dialog's 0–10 scale (stored ×10).
fn quality_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Quality:");
label(ui, tl!("Quality:"));
let mut q = (d.f64("quality", 90.0) / 10.0).round().clamp(0.0, 10.0) as u8;
ui.horizontal(|ui| {
if ui.add(egui::Slider::new(&mut q, 0..=10)).changed() {
@@ -249,11 +249,11 @@ fn quality_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
/// The JPEG method, and its scans when progressive.
fn method_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Method:");
label(ui, tl!("Method:"));
ui.horizontal(|ui| {
choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label));
if Method::from_id(&d.str("method")) == Some(Method::Progressive) {
label(ui, "Scans:");
label(ui, tl!("Scans:"));
let mut scans = d.f64("scans", 3.0).round().clamp(*jpeg::SCANS.start() as f64, *jpeg::SCANS.end() as f64) as u8;
if ui.add(egui::DragValue::new(&mut scans).range(jpeg::SCANS)).changed() {
d.fields.insert("scans".into(), json!(scans));
@@ -266,11 +266,11 @@ fn method_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &
/// The PNG-8 and GIF Options rows: the palette (reduction, colours, dither and amount),
/// transparency and matte, interlacing.
fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Color Reduction:");
label(ui, tl!("Color Reduction:"));
choice(ui, d, "reduction", &Reduction::ALL.map(Reduction::id), &Reduction::ALL.map(Reduction::label));
ui.end_row();
label(ui, "Colors:");
label(ui, tl!("Colors:"));
let mut n = d.f64("colors", 256.0).round().clamp(2.0, 256.0) as u16;
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-colors", &n.to_string(), &COLOR_COUNTS, 70.0) {
@@ -283,7 +283,7 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
});
ui.end_row();
label(ui, "Dither:");
label(ui, tl!("Dither:"));
ui.horizontal(|ui| {
choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label));
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
@@ -295,7 +295,7 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
});
ui.end_row();
label(ui, "Matte:");
label(ui, tl!("Matte:"));
let matte = d.str("matte");
let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3);
ui.horizontal(|ui| {
@@ -310,8 +310,8 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
ui.label("");
ui.horizontal(|ui| {
form::check(ui, d, "transparency", "Transparency");
form::check(ui, d, "interlaced", "Interlaced");
form::check(ui, d, "transparency", tl!("Transparency"));
form::check(ui, d, "interlaced", tl!("Interlaced"));
});
ui.end_row();
}
+112 -99
View File
@@ -36,13 +36,13 @@ use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET;
pub(super) const KIND: &str = "print";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Print".into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Print").into(), body, confirm, ok: Some("Print"), discard: Some("Done"), ..DialogSpec::FORM };
/// The dialog kind of the preset editor (New / Edit in Edit → Print Presets).
pub(super) const PRESET_KIND: &str = "printPreset";
pub(super) const PRESET_SPEC: DialogSpec = DialogSpec {
heading: |d| if d.str(EDITING).is_empty() { "New Print Preset" } else { "Edit Print Preset" }.into(),
heading: |d| if d.str(EDITING).is_empty() { tl!("New Print Preset") } else { tl!("Edit Print Preset") }.into(),
body,
confirm: confirm_preset,
ok: Some("Save Preset"),
@@ -218,7 +218,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
app.status(e);
}
if d.kind == PRESET_KIND {
row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| {
row_with(ui, tl!("Preset name:"), TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, "name", 288.0);
});
} else {
@@ -237,7 +237,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(14.0);
ui.vertical(|ui| {
ui.set_width(500.0);
ui.label(egui::RichText::new(section).font(theme::semibold(14.0)).color(t.text_strong));
ui.label(egui::RichText::new(tl!(section)).font(theme::semibold(14.0)).color(t.text_strong));
egui::ScrollArea::vertical().id_salt(("print", section)).max_height(360.0).auto_shrink([false, false]).show(ui, |ui| match section {
"Marks and Bleed" => super::save_pdf::marks_and_bleeds(app, ui, d),
"Output" => output(ui, d, &preview),
@@ -263,11 +263,12 @@ fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let mut picked = None;
let asking = d.fields.contains_key(SAVE_AS);
let mut ask = false;
row_with(ui, "Print Preset:", TOP_LABEL_WIDTH, |ui| {
picked =
widgets::dropdown(ui, "print-preset", if same { &name } else { CUSTOM }, &names, 300.0).and_then(|i| names.get(i)).map(|n| n.to_string());
let save = ui.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0)).inner;
ask = save.on_hover_text("Save these settings as a print preset").clicked();
row_with(ui, tl!("Print Preset:"), TOP_LABEL_WIDTH, |ui| {
// [Default] is ours (translated); the saved presets are names.
let current = if same { name.as_str() } else { tl!(CUSTOM) };
picked = super::mixed_dropdown(ui, "print-preset", current, &names, 300.0, |k| k == 0).and_then(|i| names.get(i)).map(|n| n.to_string());
let save = ui.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, tl!("Save Preset…"), 96.0)).inner;
ask = save.on_hover_text(tl!("Save these settings as a print preset")).clicked();
});
if let Some(n) = picked {
// Picking the preset shown again brings its settings back.
@@ -281,14 +282,14 @@ fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
d.fields.insert(SAVE_AS.into(), json!(app.session.new_print_preset_name()));
}
if d.fields.contains_key(SAVE_AS) {
row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| {
row_with(ui, tl!("Save as preset:"), TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, SAVE_AS, 200.0);
if widgets::flat_button(ui, "Save", 52.0).clicked()
if widgets::flat_button(ui, tl!("Save"), 52.0).clicked()
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() {
d.fields.remove(SAVE_AS);
}
});
@@ -317,23 +318,25 @@ fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
entries.push(Destination::Default);
}
entries.push(Destination::File);
let label = |e: &Destination| match e {
Destination::Printer(name) => name.clone(),
Destination::Default => DEFAULT_PRINTER.into(),
Destination::File => PDF_FILE.into(),
};
let labels: Vec<String> = entries.iter().map(label).collect();
// The system's printers by their names; the default printer and PDF File are ours.
let options: Vec<&str> = entries
.iter()
.map(|e| match e {
Destination::Printer(name) => name.as_str(),
Destination::Default => tl!(DEFAULT_PRINTER),
Destination::File => tl!(PDF_FILE),
})
.collect();
let printer = d.str("printer");
let current = if d.bool("toFile") {
PDF_FILE
tl!(PDF_FILE)
} else if printer.is_empty() {
DEFAULT_PRINTER
tl!(DEFAULT_PRINTER)
} else {
printer.as_str()
};
row_with(ui, "Printer:", TOP_LABEL_WIDTH, |ui| {
let options: Vec<&str> = labels.iter().map(String::as_str).collect();
match widgets::dropdown(ui, "print-printer", current, &options, 300.0).and_then(|i| entries.get(i)) {
row_with(ui, tl!("Printer:"), TOP_LABEL_WIDTH, |ui| {
match widgets::dropdown_names(ui, "print-printer", current, &options, 300.0).and_then(|i| entries.get(i)) {
Some(Destination::Printer(name)) => {
d.fields.insert("printer".into(), json!(name));
d.fields.insert("toFile".into(), json!(false));
@@ -348,8 +351,8 @@ fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
None => {}
}
let to_file = d.bool("toFile");
let tip = if setup { "The system's settings of this printer" } else { "No printer settings to open here" };
if ui.add_enabled_ui(setup && !to_file, |ui| widgets::flat_button(ui, "Setup…", 72.0)).inner.on_hover_text(tip).clicked()
let tip = if setup { tl!("The system's settings of this printer") } else { tl!("No printer settings to open here") };
if ui.add_enabled_ui(setup && !to_file, |ui| widgets::flat_button(ui, tl!("Setup…"), 72.0)).inner.on_hover_text(tip).clicked()
&& let Err(e) = app.run("print.printerSetup", json!({ "printer": d.str("printer") }))
{
app.status(e);
@@ -395,9 +398,9 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
let sheet = pv.as_ref().ok().and_then(|v| current_sheet(d, v));
let Some((index, count, sheet)) = sheet else {
let text = match pv {
_ if app.session.active().is_none() => "Open a document to preview its pages",
Ok(_) => "Nothing to print",
Err(_) => "Can't print these settings: see the Summary",
_ if app.session.active().is_none() => tl!("Open a document to preview its pages"),
Ok(_) => tl!("Nothing to print"),
Err(_) => tl!("Can't print these settings: see the Summary"),
};
let font = egui::FontId::proportional(11.0);
let galley = ui.painter().layout(text.into(), font, t.text_dim, rect.width() - 16.0);
@@ -462,26 +465,26 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv:
clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle);
}
let resp = if tiling { resp } else { resp.on_hover_cursor(egui::CursorIcon::Grab) };
resp.on_hover_text(if tiling { "The page as it prints" } else { "Drag the art to move it on the paper" });
resp.on_hover_text(if tiling { tl!("The page as it prints") } else { tl!("Drag the art to move it on the paper") });
// ◀ n of N ▶, then what the page is.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
if widgets::icon_button_enabled(ui, "chevron-left", "Previous page", false, index > 0, 20.0).clicked() {
if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous page"), false, index > 0, 20.0).clicked() {
d.fields.insert("__sheet".into(), json!(index - 1));
}
let text = format!("{} of {count}", index + 1);
let text = crate::i18n::fmt(tl!("{page} of {count}"), &[("page", &(index + 1).to_string()), ("count", &count.to_string())]);
ui.add_sized([PREVIEW.x - 44.0, 20.0], egui::Label::new(egui::RichText::new(text).size(11.5).color(t.text)));
if widgets::icon_button_enabled(ui, "chevron-right", "Next page", false, index + 1 < count, 20.0).clicked() {
if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next page"), false, index + 1 < count, 20.0).clicked() {
d.fields.insert("__sheet".into(), json!(index + 1));
}
});
let mut what = vec![];
match sheet["artboard"].as_u64() {
Some(a) => what.push(format!("Artboard {}", a + 1)),
None => what.push("All the art".into()),
Some(a) => what.push(crate::i18n::fmt(tl!("Artboard {number}"), &[("number", &(a + 1).to_string())])),
None => what.push(tl!("All the art").into()),
}
if let Some(tile) = sheet["tile"].as_u64() {
what.push(format!("tile {tile}"));
what.push(crate::i18n::fmt(tl!("tile {number}"), &[("number", &tile.to_string())]));
}
if let Some(ink) = sheet["ink"].as_str() {
what.push(ink.to_string());
@@ -563,17 +566,17 @@ fn whole(ui: &mut egui::Ui, d: &mut Dialog, path: &str, range: std::ops::RangeIn
fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let unit = app.session.general_unit();
row(ui, "Copies:", |ui| {
row(ui, tl!("Copies:"), |ui| {
whole(ui, d, "copies", 1.0..=f64::from(vectorcraft_pdf::MAX_COPIES), true);
ui.add_space(12.0);
let copies = get(d, "copies").as_u64().unwrap_or(1);
flag(ui, d, "collate", "Collate", copies > 1);
flag(ui, d, "reverse", "Reverse Order", true);
flag(ui, d, "collate", tl!("Collate"), copies > 1);
flag(ui, d, "reverse", tl!("Reverse Order"), true);
});
heading(ui, "Artboards");
heading(ui, tl!("Artboards"));
let artboards = choice::<PrintArtboards>(d, "artboards").unwrap_or(PrintArtboards::All);
ui.horizontal(|ui| {
for (a, label) in [(PrintArtboards::All, "All"), (PrintArtboards::Range, "Range:")] {
for (a, label) in [(PrintArtboards::All, tl!("All")), (PrintArtboards::Range, tl!("Range:"))] {
if widgets::radio(ui, label, artboards == a, true) {
set(d, "artboards", json!(a.id()));
}
@@ -584,43 +587,43 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
set(d, "range", json!(range));
}
});
if widgets::radio(ui, "Ignore Artboards (all the art as one page)", artboards == PrintArtboards::Ignore, true) {
if widgets::radio(ui, tl!("Ignore Artboards (all the art as one page)"), artboards == PrintArtboards::Ignore, true) {
set(d, "artboards", json!(PrintArtboards::Ignore.id()));
}
flag(ui, d, "skipBlank", "Skip Blank Artboards", artboards != PrintArtboards::Ignore);
heading(ui, "Media");
row(ui, "Size:", |ui| {
flag(ui, d, "skipBlank", tl!("Skip Blank Artboards"), artboards != PrintArtboards::Ignore);
heading(ui, tl!("Media"));
row(ui, tl!("Size:"), |ui| {
pick::<Media>(ui, d, "media", 160.0, |_| true);
});
// A paper size shows its size; Custom takes one.
let media = choice::<Media>(d, "media").unwrap_or_default();
row(ui, "Width:", |ui| match media.size() {
row(ui, tl!("Width:"), |ui| match media.size() {
Some((w, h)) => {
ui.add_enabled_ui(false, |ui| widgets::num_field(ui, "print-media-w", Some(w), unit, 80.0));
ui.label("Height:");
ui.label(tl!("Height:"));
ui.add_enabled_ui(false, |ui| widgets::num_field(ui, "print-media-h", Some(h), unit, 80.0));
}
None => {
length(ui, d, "width", unit, true);
ui.label("Height:");
ui.label(tl!("Height:"));
length(ui, d, "height", unit, true);
}
});
heading(ui, "Orientation");
flag(ui, d, "autoRotate", "Auto-Rotate (turn the paper to each artboard)", true);
heading(ui, tl!("Orientation"));
flag(ui, d, "autoRotate", tl!("Auto-Rotate (turn the paper to each artboard)"), true);
let auto = get(d, "autoRotate").as_bool() == Some(true);
row(ui, "Orientation:", |ui| {
row(ui, tl!("Orientation:"), |ui| {
ui.add_enabled_ui(!auto, |ui| pick::<Orientation>(ui, d, "orientation", 160.0, |_| true));
});
flag(ui, d, "transverse", "Transverse (the page a quarter turn on the paper)", true);
heading(ui, "Options");
row(ui, "Print Layers:", |ui| {
flag(ui, d, "transverse", tl!("Transverse (the page a quarter turn on the paper)"), true);
heading(ui, tl!("Options"));
row(ui, tl!("Print Layers:"), |ui| {
pick::<PrintLayers>(ui, d, "printLayers", 220.0, |_| true);
});
let scaling = choice::<PrintScaling>(d, "scaling").unwrap_or(PrintScaling::None);
let tiles = scaling.tiles();
let placed = get(d, PLACED).as_bool() == Some(true);
row(ui, "Placement:", |ui| {
row(ui, tl!("Placement:"), |ui| {
ui.add_enabled_ui(!tiles && !placed, |ui| {
let origin = choice::<Origin>(d, "placement.origin").unwrap_or_default();
let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4);
@@ -635,50 +638,50 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
if placed {
row(ui, "", |ui| {
note(ui, "The Print Tiling tool placed the pages.");
if widgets::flat_button(ui, "Reset", 56.0).on_hover_text("Place the pages with the placement again").clicked() {
note(ui, tl!("The Print Tiling tool placed the pages."));
if widgets::flat_button(ui, tl!("Reset"), 56.0).on_hover_text(tl!("Place the pages with the placement again")).clicked() {
set(d, PLACED, json!(false));
}
});
}
row(ui, "Scaling:", |ui| {
row(ui, tl!("Scaling:"), |ui| {
pick::<PrintScaling>(ui, d, "scaling", 180.0, |_| true);
});
row(ui, "Scale:", |ui| {
row(ui, tl!("Scale:"), |ui| {
let on = scaling == PrintScaling::Custom || tiles;
ui.label("W:");
ui.label(tl!("W:"));
number(ui, d, "scale.width", "%", on);
ui.label("H:");
ui.label(tl!("H:"));
number(ui, d, "scale.height", "%", on);
});
row(ui, "Overlap:", |ui| length(ui, d, "overlap", unit, tiles));
row(ui, "Tile Range:", |ui| text(ui, d, "tileRange", tiles));
row(ui, tl!("Overlap:"), |ui| length(ui, d, "overlap", unit, tiles));
row(ui, tl!("Tile Range:"), |ui| text(ui, d, "tileRange", tiles));
if tiles {
note(ui, "Tiles are numbered across, then down; leave the range empty for every tile.");
note(ui, tl!("Tiles are numbered across, then down; leave the range empty for every tile."));
}
}
fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) {
row(ui, "Mode:", |ui| {
row(ui, tl!("Mode:"), |ui| {
pick::<OutputMode>(ui, d, "output.mode", 160.0, |_| true);
});
row(ui, "Emulsion:", |ui| {
row(ui, tl!("Emulsion:"), |ui| {
pick::<Emulsion>(ui, d, "output.emulsion", 160.0, |_| true);
});
row(ui, "Image:", |ui| {
row(ui, tl!("Image:"), |ui| {
pick::<PrintImage>(ui, d, "output.image", 160.0, |_| true);
});
let separations = choice::<OutputMode>(d, "output.mode") == Some(OutputMode::Separations);
flag(ui, d, "output.spotsToProcess", "Convert All Spot Colors to Process", separations);
heading(ui, "Document Ink Options");
flag(ui, d, "output.spotsToProcess", tl!("Convert All Spot Colors to Process"), separations);
heading(ui, tl!("Document Ink Options"));
let inks = pv.as_ref().ok().and_then(|v| v["inks"].as_array()).map(Vec::as_slice).unwrap_or_default();
if !separations || inks.is_empty() {
note(ui, "Separations list the document's inks here: one page per ink that prints.");
note(ui, tl!("Separations list the document's inks here: one page per ink that prints."));
return;
}
let t = Tokens::get(ui.ctx());
egui::Grid::new("print-inks").num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| {
for h in ["Print", "Ink", "Frequency", "Angle"] {
for h in [tl!("Print"), tl!("Ink"), tl!("Frequency"), tl!("Angle")] {
ui.label(egui::RichText::new(h).size(11.5).color(t.text_dim));
}
ui.end_row();
@@ -688,7 +691,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) {
if widgets::check(ui, "", on, true) {
set_ink(d, name, "print", json!(!on));
}
let spot = if ink["spot"] == true { " (spot)" } else { "" };
let spot = if ink["spot"] == true { format!(" ({})", tl!("spot")) } else { String::new() };
ui.label(egui::RichText::new(format!("{name}{spot}")).color(t.text));
for (k, suffix) in [("frequency", " lpi"), ("angle", "°")] {
let v = ink[k].as_f64().unwrap_or(0.0);
@@ -699,7 +702,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>) {
ui.end_row();
}
});
note(ui, "Frequencies and angles are the output device's to apply.");
note(ui, tl!("Frequencies and angles are the output device's to apply."));
}
/// Set option `key` of ink `name` in `output.inks` (the ink gets its own entry the first time).
@@ -717,15 +720,15 @@ fn set_ink(d: &mut Dialog, name: &str, key: &str, value: Value) {
}
fn graphics(ui: &mut egui::Ui, d: &mut Dialog) {
heading(ui, "Paths");
flag(ui, d, "graphics.autoFlatness", "Automatic flatness", true);
heading(ui, tl!("Paths"));
flag(ui, d, "graphics.autoFlatness", tl!("Automatic flatness"), true);
let auto = get(d, "graphics.autoFlatness").as_bool() == Some(true);
row(ui, "Flatness:", |ui| {
row(ui, tl!("Flatness:"), |ui| {
number(ui, d, "graphics.flatness", "", !auto);
ui.label(egui::RichText::new("0.2 (quality) to 100 (speed)").size(11.5).color(Tokens::get(ui.ctx()).text_dim));
ui.label(egui::RichText::new(tl!("0.2 (quality) to 100 (speed)")).size(11.5).color(Tokens::get(ui.ctx()).text_dim));
});
heading(ui, "Fonts");
row(ui, "Download:", |ui| {
heading(ui, tl!("Fonts"));
row(ui, tl!("Download:"), |ui| {
pick::<FontDownload>(ui, d, "graphics.fonts", 160.0, |_| true);
});
}
@@ -733,37 +736,41 @@ fn graphics(ui: &mut egui::Ui, d: &mut Dialog) {
fn color(ui: &mut egui::Ui, d: &mut Dialog) {
let profiles = vectorcraft_color::cms::profiles();
let names: Vec<&str> = std::iter::once(SAME_AS_SOURCE).chain(profiles.iter().map(|p| p.name.as_str())).collect();
row(ui, "Printer profile:", |ui| super::save_pdf::profile_pick(ui, d, "color.profile", &names, true));
note(ui, "Composite colours are converted to it; separations separate with it when it is a CMYK profile.");
row(ui, "Rendering intent:", |ui| {
row(ui, tl!("Printer profile:"), |ui| super::save_pdf::profile_pick(ui, d, "color.profile", &names, 1, true));
note(ui, tl!("Composite colours are converted to it; separations separate with it when it is a CMYK profile."));
row(ui, tl!("Rendering intent:"), |ui| {
let current = serde_json::from_value::<Intent>(get(d, "color.intent").clone()).unwrap_or_default();
let labels: Vec<&str> = Intent::ALL.iter().map(Intent::label).collect();
if let Some(i) = widgets::dropdown(ui, "color.intent", current.label(), &labels, 200.0).and_then(|i| Intent::ALL.get(i)) {
set(d, "color.intent", serde_json::to_value(i).unwrap_or_default());
}
});
note(ui, "How colours outside the press's gamut are separated.");
flag(ui, d, "color.preserveNumbers", "Preserve CMYK Numbers", true);
note(ui, "CMYK colours print with their own values; off, they go through the colour settings too.");
note(ui, tl!("How colours outside the press's gamut are separated."));
flag(ui, d, "color.preserveNumbers", tl!("Preserve CMYK Numbers"), true);
note(ui, tl!("CMYK colours print with their own values; off, they go through the colour settings too."));
}
fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
heading(ui, "Printer");
row(ui, "Unprintable margin:", |ui| length(ui, d, "margin", app.session.general_unit(), true));
note(ui, "The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed.");
heading(ui, tl!("Printer"));
row(ui, tl!("Unprintable margin:"), |ui| length(ui, d, "margin", app.session.general_unit(), true));
note(ui, tl!("The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed."));
let composite = choice::<OutputMode>(d, "output.mode") != Some(OutputMode::Separations);
flag(ui, d, "advanced.printAsBitmap", "Print as Bitmap", composite);
note(ui, "Each page prints as one image, at the document's raster effects resolution.");
heading(ui, "Overprint and Transparency Flattener Options");
row(ui, "Overprints:", |ui| {
flag(ui, d, "advanced.printAsBitmap", tl!("Print as Bitmap"), composite);
note(ui, tl!("Each page prints as one image, at the document's raster effects resolution."));
heading(ui, tl!("Overprint and Transparency Flattener Options"));
row(ui, tl!("Overprints:"), |ui| {
ui.add_enabled_ui(composite, |ui| pick::<PrintOverprints>(ui, d, "advanced.overprints", 160.0, |_| true));
});
let presets = app.session.flattener_presets();
let names: Vec<&str> = std::iter::once(KEEP_TRANSPARENCY).chain(presets.iter().map(|p| p.name.as_str())).collect();
row(ui, "Preset:", |ui| super::save_pdf::profile_pick(ui, d, "advanced.flattenerPreset", &names, true));
// None and the built-in presets are ours; the saved presets are names.
let builtins = names.len().saturating_sub(app.session.prefs.flattener_presets.len());
row(ui, tl!("Preset:"), |ui| super::save_pdf::profile_pick(ui, d, "advanced.flattenerPreset", &names, builtins, true));
note(
ui,
"Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it).",
tl!(
"Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it)."
),
);
}
@@ -827,28 +834,34 @@ fn summary(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result<Value, String>, has_do
let t = Tokens::get(ui.ctx());
match pv {
Ok(v) => {
heading(ui, "Pages");
heading(ui, tl!("Pages"));
let per_copy = v["sheets"].as_array().map_or(0, Vec::len);
ui.label(egui::RichText::new(format!("{} in all, {per_copy} per copy", v["pages"])).color(t.text));
ui.label(
egui::RichText::new(crate::i18n::fmt(
tl!("{pages} in all, {count} per copy"),
&[("pages", &v["pages"].to_string()), ("count", &per_copy.to_string())],
))
.color(t.text),
);
}
// A preset edited without a document has no pages to count.
Err(_) if !has_doc => {}
Err(e) => {
heading(ui, "Error");
heading(ui, tl!("Error"));
ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text));
}
}
heading(ui, "Options");
heading(ui, tl!("Options"));
let mut changed = vec![];
vectorcraft_engine::cmd::fileio::pdf::changed("", &settings(d), &DEFAULTS, &mut changed);
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()).0);
labelled(&mut changed);
if changed.is_empty() {
note(ui, "Every option is at its default.");
note(ui, tl!("Every option is at its default."));
}
super::save_pdf::option_rows(ui, &changed, option_label);
if let Ok(v) = pv {
heading(ui, "Warnings");
heading(ui, tl!("Warnings"));
super::save_pdf::warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default());
}
}
+11 -11
View File
@@ -20,7 +20,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "printPresets";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Print Presets".into(),
heading: |_| tl!("Print Presets").into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
@@ -58,13 +58,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(280.0);
widgets::dim_label(ui, "Presets:");
widgets::dim_label(ui, tl!("Presets:"));
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("print-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(240.0);
for (k, n) in std::iter::once(DEFAULT_PRESET).chain(saved.iter().map(|p| p.name.as_str())).enumerate() {
if ui.selectable_label(k == i, n).clicked() {
if ui.selectable_label(k == i, if k == 0 { tl!(n) } else { n }).clicked() {
act = Some(Action::Select(n.to_string()));
}
}
@@ -73,19 +73,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() {
if widgets::flat_button(ui, tl!("New…"), 48.0).on_hover_text(tl!("A new preset starting from the selected one")).clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Edit…"), 48.0)).inner.clicked() {
act = Some(Action::Edit);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, tl!("Delete"), 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Import…"), 60.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 60.0).on_hover_text("Save the selected preset to a file").clicked() {
if widgets::flat_button(ui, tl!("Export…"), 60.0).on_hover_text(tl!("Save the selected preset to a file")).clicked() {
act = Some(Action::Export);
}
});
@@ -95,19 +95,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.set_width(300.0);
ui.label(egui::RichText::new(&name).color(t.text_strong).strong());
ui.add_space(8.0);
widgets::dim_label(ui, &format!("Differences from {DEFAULT_PRESET}:"));
widgets::dim_label(ui, &crate::i18n::fmt(tl!("Differences from {preset}:"), &[("preset", DEFAULT_PRESET)]));
let settings = i.checked_sub(1).and_then(|k| saved.get(k)).map(|p| &p.settings);
let mut changed = settings.map(|s| changes(s, &Default::default())).unwrap_or_default();
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()).0);
egui::ScrollArea::vertical().id_salt("print-preset-details").max_height(200.0).show(ui, |ui| {
if changed.is_empty() {
ui.label(egui::RichText::new("None.").color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(tl!("None.")).color(t.text_dim).size(11.5));
}
option_rows(ui, &changed, option_label);
});
if builtin {
ui.add_space(6.0);
ui.label(egui::RichText::new("[Default] is protected: New… starts an editable copy.").color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(tl!("[Default] is protected: New… starts an editable copy.")).color(t.text_dim).size(11.5));
}
});
});
+5 -5
View File
@@ -25,18 +25,18 @@ pub(super) fn defaults(cmyk: bool, o: &mut Map<String, Value>) {
/// The grid rows of PSD's own options.
pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Color Model:");
label(ui, tl!("Color Model:"));
choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label));
ui.end_row();
label(ui, "Options:");
label(ui, tl!("Options:"));
ui.vertical(|ui| {
let mut layers = d.bool("layers");
if ui.radio_value(&mut layers, false, "Flat Image").changed() | ui.radio_value(&mut layers, true, "Write Layers").changed() {
if ui.radio_value(&mut layers, false, tl!("Flat Image")).changed() | ui.radio_value(&mut layers, true, tl!("Write Layers")).changed() {
d.fields.insert("layers".into(), json!(layers));
}
ui.add_enabled_ui(layers, |ui| {
for (key, text) in [("maxEditability", "Maximum Editability"), ("hiddenLayers", "Include Hidden Layers")] {
for (key, text) in [("maxEditability", tl!("Maximum Editability")), ("hiddenLayers", tl!("Include Hidden Layers"))] {
ui.horizontal(|ui| {
ui.add_space(22.0);
form::check(ui, d, key, text);
@@ -47,6 +47,6 @@ pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::
ui.end_row();
ui.label("");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
form::check(ui, d, "embedIcc", tl!("Embed ICC Profile"));
ui.end_row();
}
+14 -14
View File
@@ -17,7 +17,7 @@ const CMD: &str = "document.rasterEffectsSettings";
const RESOLUTIONS: [(f64, &str); 3] = [(72.0, "Screen (72 ppi)"), (150.0, "Medium (150 ppi)"), (300.0, "High (300 ppi)")];
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Document Raster Effects Settings".into(),
heading: |_| tl!("Document Raster Effects Settings").into(),
body,
confirm,
min_width: 440.0,
@@ -44,9 +44,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// Resolution: a preset, or Other with its own value.
let ppi = d.f64("resolution", 72.0);
let preset = RESOLUTIONS.iter().position(|(p, _)| (*p - ppi).abs() < 1e-9);
let mut labels: Vec<&str> = RESOLUTIONS.iter().map(|(_, l)| *l).collect();
labels.push("Other");
widgets::label_row(ui, "Resolution:", LABEL, |ui| {
let mut labels: Vec<&str> = RESOLUTIONS.iter().map(|(_, l)| tl!(l)).collect();
labels.push(tl!("Other"));
widgets::label_row(ui, tl!("Resolution:"), LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "raster-resolution", labels[preset.unwrap_or(RESOLUTIONS.len())], &labels, 170.0) {
// Other keeps the current value to edit.
if let Some((p, _)) = RESOLUTIONS.get(i) {
@@ -64,11 +64,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(4.0);
let own =
if app.session.active().is_some_and(|s| s.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk) { ("cmyk", "CMYK") } else { ("rgb", "RGB") };
choice(ui, d, "colorModel", "Color Model:", &[own, ("grayscale", "Grayscale"), ("bitmap", "Bitmap")]);
choice(ui, d, "colorModel", tl!("Color Model:"), &[own, ("grayscale", tl!("Grayscale")), ("bitmap", tl!("Bitmap"))]);
ui.add_space(6.0);
widgets::label_row(ui, "Background:", LABEL, |ui| {
widgets::label_row(ui, tl!("Background:"), LABEL, |ui| {
let cur = d.str("background");
for (id, label) in [("white", "White"), ("transparent", "Transparent")] {
for (id, label) in [("white", tl!("White")), ("transparent", tl!("Transparent"))] {
if widgets::radio(ui, label, cur == id, true) {
d.fields.insert("background".into(), json!(id));
}
@@ -76,21 +76,21 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.add_space(10.0);
widgets::subheader(ui, "Options");
check(ui, d, "antiAlias", "Anti-alias");
check(ui, d, "clippingMask", "Create Clipping Mask");
widgets::subheader(ui, tl!("Options"));
check(ui, d, "antiAlias", tl!("Anti-alias"));
check(ui, d, "clippingMask", tl!("Create Clipping Mask"));
let unit = app.session.general_unit();
let t = crate::theme::Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Add").color(t.text));
ui.label(egui::RichText::new(tl!("Add")).color(t.text));
if let Some(v) = widgets::num_field(ui, "raster-add-around", Some(d.f64("addAround", 0.0)), unit, 80.0) {
d.fields.insert("addAround".into(), json!(v));
}
ui.label(egui::RichText::new("Around Object").color(t.text));
ui.label(egui::RichText::new(tl!("Around Object")).color(t.text));
});
check(ui, d, "preserveSpotColors", "Preserve Spot Colors");
check(ui, d, "preserveSpotColors", tl!("Preserve Spot Colors"));
ui.add_space(8.0);
super::form::caption(ui, "Changing these settings changes how raster effects look when exported, flattened or expanded.");
super::form::caption(ui, tl!("Changing these settings changes how raster effects look when exported, flattened or expanded."));
false
}
+44 -29
View File
@@ -28,7 +28,7 @@ use vectorcraft_engine::cmd::swatchlib;
use super::{DialogSpec, form};
use crate::panels::c32;
use crate::panels::swatches::{colour_libraries, limit_key, limit_name};
use crate::panels::swatches::{DOCUMENT_SWATCHES, colour_libraries, limit_key};
use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
@@ -36,8 +36,15 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Recolor Artwork.
pub const KIND: &str = "recolor";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Recolor Artwork".into(), body, confirm, preview: true, min_width: 540.0, max_width: Some(560.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Recolor Artwork").into(),
body,
confirm,
preview: true,
min_width: 540.0,
max_width: Some(560.0),
..DialogSpec::FORM
};
const CMD: &str = "recolor.apply";
/// Width of the current colours of a row.
@@ -203,7 +210,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let tab = d.str("tab");
for (id, label) in [("assign", "Assign"), ("edit", "Edit")] {
for (id, label) in [("assign", tl!("Assign")), ("edit", tl!("Edit"))] {
if ui.selectable_label(tab == id || (id == "assign" && tab != "edit"), egui::RichText::new(label).font(theme::semibold(12.5))).clicked() {
d.fields.insert("tab".into(), json!(id));
}
@@ -212,7 +219,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if let Some(i) = widgets::dropdown(ui, "recolor-preset", PRESETS[preset_of(d)], &PRESETS, 130.0) {
set_preset(app, d, i);
}
ui.label(egui::RichText::new("Preset:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim));
});
});
widgets::divider(ui);
@@ -228,7 +235,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Color Group:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Color Group:")).color(t.text_dim));
form::text(ui, d, "groupName", 200.0);
});
}
@@ -289,16 +296,16 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
// Reduction and the preserve rules apply to art (a colour group alone has a row per colour).
if d.bool("__art") {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Colors:").color(t.text_dim));
let labels: Vec<String> = std::iter::once("Auto".to_string()).chain((1..=total).map(|n| n.to_string())).collect();
ui.label(egui::RichText::new(tl!("Colors:")).color(t.text_dim));
let labels: Vec<String> = std::iter::once(tl!("Auto").to_string()).chain((1..=total).map(|n| n.to_string())).collect();
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
let cur = d.fields.get("colors").and_then(Value::as_u64).map_or("Auto".to_string(), |n| n.to_string());
let cur = d.fields.get("colors").and_then(Value::as_u64).map_or(tl!("Auto").to_string(), |n| n.to_string());
if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) {
d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) });
}
ui.add_space(18.0);
ui.label(egui::RichText::new("Preserve:").color(t.text_dim));
for (k, label) in [("preserveWhite", "White"), ("preserveBlack", "Black"), ("preserveGrays", "Grays")] {
ui.label(egui::RichText::new(tl!("Preserve:")).color(t.text_dim));
for (k, label) in [("preserveWhite", tl!("White")), ("preserveBlack", tl!("Black")), ("preserveGrays", tl!("Grays"))] {
let on = d.bool(k);
if widgets::check(ui, label, on, true) {
d.fields.insert(k.into(), json!(!on));
@@ -309,9 +316,14 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
}
ui.horizontal(|ui| {
ui.add_space(6.0);
ui.add_sized([CURRENT_W, 16.0], egui::Label::new(egui::RichText::new(format!("Current Colors ({total})")).color(t.text_dim)));
ui.add_sized(
[CURRENT_W, 16.0],
egui::Label::new(
egui::RichText::new(crate::i18n::fmt(tl!("Current Colors ({total})"), &[("total", &total.to_string())])).color(t.text_dim),
),
);
ui.add_space(60.0);
ui.label(egui::RichText::new("New").color(t.text_dim));
ui.label(egui::RichText::new(tl!("New")).color(t.text_dim));
});
let mut sel = selection(d);
let mut clicked: Option<usize> = None;
@@ -321,7 +333,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
ui.set_min_height(TABLE_H);
if rows.is_empty() {
ui.add_space(8.0);
widgets::dim_label(ui, "No colors to recolor (preserved colors are left out).");
widgets::dim_label(ui, tl!("No colors to recolor (preserved colors are left out)."));
}
for (i, row) in rows.iter_mut().enumerate() {
let bg = ui.painter().add(egui::Shape::Noop);
@@ -340,7 +352,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
}
let (ar, aresp) = ui.allocate_exact_size(vec2(44.0, 20.0), Sense::click());
arrow(ui, ar, !off, aresp.hovered());
if aresp.on_hover_text(if off { "Recolor this row" } else { "Keep this row's colors (exclude)" }).clicked() {
if aresp.on_hover_text(if off { tl!("Recolor this row") } else { tl!("Keep this row's colors (exclude)") }).clicked() {
row["exclude"] = json!(!off);
changed = true;
}
@@ -379,20 +391,20 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
ui.add_space(4.0);
ui.horizontal(|ui| {
let can_merge = sel.len() > 1;
if widgets::icon_button_enabled(ui, "combine", "Merge colors into one row", false, can_merge, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "combine", tl!("Merge colors into one row"), false, can_merge, 24.0).clicked() {
merge(rows, &sel);
d.fields.insert("__sel".into(), json!([sel.iter().min()]));
changed = true;
}
let can_split = sel.iter().any(|i| rows.get(*i).is_some_and(|r| r["from"].as_array().is_some_and(|a| a.len() > 1)));
if widgets::icon_button_enabled(ui, "ungroup", "Separate colors into rows", false, can_split, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "ungroup", tl!("Separate colors into rows"), false, can_split, 24.0).clicked() {
separate(rows, &sel);
d.fields.insert("__sel".into(), json!([]));
changed = true;
}
ui.add_space(12.0);
for (icon, tip, sb) in
[("arrow-left-right", "Randomly change color order", false), ("sparkles", "Randomly change saturation and brightness", true)]
[("arrow-left-right", tl!("Randomly change color order"), false), ("sparkles", tl!("Randomly change saturation and brightness"), true)]
{
if widgets::icon_button_enabled(ui, icon, tip, false, rows.len() > 1 || sb, 24.0).clicked() {
let seed = d.f64("__seed", 0.0) as u64 + 1;
@@ -407,29 +419,32 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
}
}
}
if widgets::icon_button(ui, "rotate-ccw", "Reset the rows to the artwork's colors", false, 24.0).clicked() {
if widgets::icon_button(ui, "rotate-ccw", tl!("Reset the rows to the artwork's colors"), false, 24.0).clicked() {
d.fields.remove("__reduced");
}
});
ui.add_space(8.0);
egui::Grid::new("recolor-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Method:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Method:")).color(t.text_dim));
let m = Method::parse(&d.str("method")).unwrap_or_default();
let labels: Vec<&str> = Method::ALL.iter().map(|m| m.label()).collect();
if let Some(i) = widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0) {
d.fields.insert("method".into(), json!(Method::ALL[i].id()));
}
ui.end_row();
ui.label(egui::RichText::new("Limit to Library:").color(t.text_dim));
// None, the document's swatches, then the colour libraries.
ui.label(egui::RichText::new(tl!("Limit to Library:")).color(t.text_dim));
// None, the document's swatches, then the colour libraries: the built-in ones (at the top
// level of the library menu) translated, those the user saved or loaded by their names.
let libs = colour_libraries(app);
let keys: Vec<&str> = ["", swatchlib::DOCUMENT_SWATCHES].into_iter().chain(libs.iter().map(|l| l.id.as_str())).collect();
let labels: Vec<String> = keys.iter().map(|k| limit_name(app, k).unwrap_or_else(|| "None".into())).collect();
let names: Vec<&str> = ["None", DOCUMENT_SWATCHES].into_iter().chain(libs.iter().map(|l| l.name.as_str())).collect();
let builtin = |k: usize| k.checked_sub(2).is_none_or(|i| libs.get(i).is_some_and(|l| l.submenu.is_none()));
let shown = super::shown_names(crate::i18n::current(), &names, builtin);
let cur = d.str("limitTo");
let name = keys.iter().position(|k| *k == cur).map_or("None", |i| labels[i].as_str());
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
if let Some(i) = widgets::dropdown(ui, "recolor-library", name, &labels, 180.0) {
d.fields.insert("limitTo".into(), json!(keys[i]));
let at = keys.iter().position(|k| *k == cur).unwrap_or(0);
let current = shown.get(at).copied().unwrap_or_default();
if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) {
d.fields.insert("limitTo".into(), json!(k));
}
ui.end_row();
});
@@ -513,7 +528,7 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
let active = active_rows(rows);
let base = selection(d).into_iter().find(|i| active.contains(i)).or(active.first().copied());
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Harmony Rules:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Harmony Rules:")).color(t.text_dim));
let rule = Harmony::parse(&d.str("rule")).unwrap_or(Harmony::Complementary);
let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect();
// Choosing a rule gives the new colours its colours, from the base colour.
@@ -525,7 +540,7 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool {
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let tip = if linked { "Unlink harmony colors" } else { "Link harmony colors" };
let tip = if linked { tl!("Unlink harmony colors") } else { tl!("Link harmony colors") };
if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() {
d.fields.insert("linked".into(), json!(!linked));
}
+3 -3
View File
@@ -8,10 +8,10 @@ use super::DialogSpec;
use crate::theme::Tokens;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Recover Documents".into(),
heading: |_| tl!("Recover Documents").into(),
body: |_, ui, d| {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new("VectorCraft didn't quit normally last time. These documents had unsaved changes:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("VectorCraft didn't quit normally last time. These documents had unsaved changes:")).color(t.text_dim));
ui.add_space(8.0);
let copies = d.fields.get("copies").and_then(Value::as_array);
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
@@ -25,7 +25,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
}
});
ui.add_space(8.0);
ui.label(egui::RichText::new("Restored documents open unsaved: save them to keep them.").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Restored documents open unsaved: save them to keep them.")).color(t.text_dim));
false
},
confirm: crate::recovery::confirm,
+2 -2
View File
@@ -16,7 +16,7 @@ pub const KIND: &str = "saturate";
const CMD: &str = "edit.colors.saturate";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Saturate".into(), body, confirm, preview: true, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Saturate").into(), body, confirm, preview: true, ..DialogSpec::FORM };
pub fn open(app: &mut VectorcraftApp) {
app.ui.dialog = Some(Dialog::new(KIND, json!({"intensity": 0, "preview": true})));
@@ -28,7 +28,7 @@ fn params(d: &Dialog) -> Value {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// The rail runs from grey to a fully saturated colour.
form::slider(ui, d, "intensity", "Intensity:", -100.0..=100.0, "%", &|x| c32(&Color::from_hsb(12.0, x, 0.85)));
form::slider(ui, d, "intensity", tl!("Intensity:"), -100.0..=100.0, "%", &|x| c32(&Color::from_hsb(12.0, x, 0.85)));
let p = params(d);
form::preview(app, ui, d, "Saturate", CMD, p);
false
+2 -2
View File
@@ -4,10 +4,10 @@
use super::DialogSpec;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| format!("Do you want to save the changes you made to “{}”?", d.str("name")),
heading: |d| crate::i18n::fmt(tl!("Do you want to save the changes you made to “{name}”?"), &[("name", &d.str("name"))]),
body: |_, ui, _| {
let t = crate::theme::Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Your changes will be lost if you don't save them.")).color(t.text_dim));
false
},
confirm: |app, _| crate::unsaved::confirm(app),
+53 -46
View File
@@ -33,7 +33,7 @@ use crate::{VectorcraftApp, io, widgets};
pub const KIND: &str = "saveForWeb";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Save for Web".into(),
heading: |_| tl!("Save for Web").into(),
body,
confirm,
ok: Some("Save…"),
@@ -409,8 +409,8 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set
d.fields.insert("__kbps".into(), json!(s));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add_enabled(!io::is_web(app), |ui: &mut egui::Ui| widgets::secondary_button(ui, "Preview in Browser"));
browser = b.on_disabled_hover_text("Needs the desktop app").clicked();
let b = ui.add_enabled(!io::is_web(app), |ui: &mut egui::Ui| widgets::secondary_button(ui, tl!("Preview in Browser")));
browser = b.on_disabled_hover_text(tl!("Needs the desktop app")).clicked();
});
});
browser
@@ -484,15 +484,16 @@ fn draw_pane(
match (settings, shot.bytes) {
(Some(s), Some(bytes)) => {
let secs = download_seconds(bytes, kbps);
let time = if secs < 1.0 { "< 1 sec".into() } else { format!("{} sec", secs.ceil()) };
let time =
if secs < 1.0 { tl!("< 1 sec").into() } else { crate::i18n::fmt(tl!("{secs} sec"), &[("secs", &secs.ceil().to_string())]) };
let detail = match s.format {
WebFormat::Jpg => format!("Quality {}", s.quality),
WebFormat::Jpg => crate::i18n::fmt(tl!("Quality {quality}"), &[("quality", &s.quality.to_string())]),
WebFormat::Png24 => String::new(),
_ => format!("{} colors", shot.colors.len()),
_ => crate::i18n::tn(shot.colors.len() as u64, "{n} color", "{n} colors"),
};
(s.format.label().to_string(), format!("{} {time} @ {}", size_label(bytes), speed_label(kbps)), detail)
}
_ => ("Original".into(), format!("{} × {} px", shot.size[0], shot.size[1]), String::new()),
_ => (tl!("Original").into(), format!("{} × {} px", shot.size[0], shot.size[1]), String::new()),
}
}
};
@@ -519,36 +520,36 @@ fn side(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&
let choice = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]| {
form::choice(ui, d, key, label, (LABEL_W, FIELD_W), options);
};
choice(ui, d, "format", "Format:", &pairs(&WebFormat::ALL, WebFormat::id, WebFormat::label));
choice(ui, d, "format", tl!("Format:"), &pairs(&WebFormat::ALL, WebFormat::id, WebFormat::label));
let format = s.map_or(WebFormat::Gif, |s| s.format);
match format {
WebFormat::Gif | WebFormat::Png8 => {
choice(ui, d, "reduction", "Reduction:", &pairs(&Reduction::ALL, Reduction::id, Reduction::label));
choice(ui, d, "reduction", tl!("Reduction:"), &pairs(&Reduction::ALL, Reduction::id, Reduction::label));
colors_row(ui, d);
choice(ui, d, "dither", "Dither:", &pairs(&Dither::ALL, Dither::id, Dither::label));
choice(ui, d, "dither", tl!("Dither:"), &pairs(&Dither::ALL, Dither::id, Dither::label));
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
slider_row(ui, d, "ditherAmount", "Amount:", "%");
slider_row(ui, d, "ditherAmount", tl!("Amount:"), "%");
}
check_pair(ui, d, ("transparency", "Transparency"), ("interlaced", "Interlaced"));
check_pair(ui, d, ("transparency", tl!("Transparency")), ("interlaced", tl!("Interlaced")));
matte_row(ui, d);
slider_row(ui, d, "webSnap", "Web Snap:", "%");
slider_row(ui, d, "webSnap", tl!("Web Snap:"), "%");
if format == WebFormat::Gif {
slider_row(ui, d, "lossy", "Lossy:", "%");
slider_row(ui, d, "lossy", tl!("Lossy:"), "%");
}
}
WebFormat::Jpg => {
quality_row(ui, d);
check_pair(ui, d, ("progressive", "Progressive"), ("optimized", "Optimized"));
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "embedProfile", "ICC Profile"));
check_pair(ui, d, ("progressive", tl!("Progressive")), ("optimized", tl!("Optimized")));
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "embedProfile", tl!("ICC Profile")));
matte_row(ui, d);
}
WebFormat::Png24 => {
check_pair(ui, d, ("transparency", "Transparency"), ("interlaced", "Interlaced"));
check_pair(ui, d, ("transparency", tl!("Transparency")), ("interlaced", tl!("Interlaced")));
matte_row(ui, d);
}
}
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "convertToSrgb", "Convert to sRGB"));
choice(ui, d, "metadata", "Metadata:", &pairs(&WebMetadata::ALL, WebMetadata::key, WebMetadata::label));
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "convertToSrgb", tl!("Convert to sRGB")));
choice(ui, d, "metadata", tl!("Metadata:"), &pairs(&WebMetadata::ALL, WebMetadata::key, WebMetadata::label));
if format.palette() {
widgets::divider(ui);
color_table(ui, d, s);
@@ -557,9 +558,9 @@ fn side(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&
image_size(app, ui, d, s);
widgets::divider(ui);
if d.bool("__hasSlices") {
choice(ui, d, "slices", "Slices:", &pairs(&SliceScope::ALL, SliceScope::key, SliceScope::label));
choice(ui, d, "slices", tl!("Slices:"), &pairs(&SliceScope::ALL, SliceScope::key, SliceScope::label));
}
choice(ui, d, "output", "Output:", &pairs(&WebOutput::ALL, WebOutput::key, WebOutput::label));
choice(ui, d, "output", tl!("Output:"), &pairs(&WebOutput::ALL, WebOutput::key, WebOutput::label));
});
}
@@ -569,23 +570,27 @@ fn presets_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: O
let saved = app.session.prefs.web_export_presets.len();
let current = s.and_then(|s| presets.iter().rposition(|p| p.settings == *s));
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
// The built-in presets come first (translated); the saved ones are names.
let builtins = presets.len().saturating_sub(saved);
let labels = super::shown_names(crate::i18n::current(), &names, |k| k < builtins);
let mut run: Option<(&str, Value)> = None;
widgets::label_row(ui, "Preset:", LABEL_W, |ui| {
let shown = current.and_then(|i| names.get(i).copied()).unwrap_or("[Unnamed]");
if let Some(i) = widgets::dropdown(ui, "sfw-preset", shown, &names, FIELD_W - 60.0)
widgets::label_row(ui, tl!("Preset:"), LABEL_W, |ui| {
let shown = current.and_then(|i| labels.get(i).copied()).unwrap_or(tl!("[Unnamed]"));
if let Some(i) = widgets::dropdown_names(ui, "sfw-preset", shown, &labels, FIELD_W - 60.0)
&& let Some(p) = presets.get(i)
{
d.fields.extend(settings_fields(&p.settings));
}
let editing = d.fields.contains_key("__presetName");
if widgets::icon_button(ui, "save", "Save the settings as a preset", editing, 24.0).clicked() {
if widgets::icon_button(ui, "save", tl!("Save the settings as a preset"), editing, 24.0).clicked() {
match editing {
true => _ = d.fields.remove("__presetName"),
false => _ = d.fields.insert("__presetName".into(), json!("")),
}
}
let user = current.filter(|i| *i + saved >= presets.len());
let del = ui.add_enabled(user.is_some(), |ui: &mut egui::Ui| widgets::icon_button(ui, "trash-2", "Delete this saved preset", false, 24.0));
let del =
ui.add_enabled(user.is_some(), |ui: &mut egui::Ui| widgets::icon_button(ui, "trash-2", tl!("Delete this saved preset"), false, 24.0));
if del.clicked()
&& let Some(name) = user.and_then(|i| names.get(i))
{
@@ -593,10 +598,10 @@ fn presets_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: O
}
});
if d.fields.contains_key("__presetName") {
widgets::label_row(ui, "Name:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Name:"), LABEL_W, |ui| {
form::text(ui, d, "__presetName", FIELD_W - 70.0);
let name = d.str("__presetName");
if ui.add_enabled(!name.trim().is_empty(), |ui: &mut egui::Ui| widgets::flat_button(ui, "Save", 52.0)).clicked() {
if ui.add_enabled(!name.trim().is_empty(), |ui: &mut egui::Ui| widgets::flat_button(ui, tl!("Save"), 52.0)).clicked() {
let mut p = params(d);
p["name"] = json!(name.trim());
run = Some(("webExport.presets.save", p));
@@ -635,7 +640,7 @@ fn slider_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, suffix:
/// The palette size: the usual counts, or any 2–256.
fn colors_row(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, "Colors:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Colors:"), LABEL_W, |ui| {
let mut n = d.f64("colors", 128.0).round().clamp(2.0, 256.0) as u16;
if let Some(c) = widgets::dropdown(ui, "sfw-colors", &n.to_string(), &COLOR_COUNTS, 80.0).and_then(|i| COLOR_COUNTS.get(i)) {
n = c.parse().unwrap_or(n);
@@ -651,7 +656,7 @@ fn colors_row(ui: &mut egui::Ui, d: &mut Dialog) {
fn quality_row(ui: &mut egui::Ui, d: &mut Dialog) {
let q = d.f64("quality", 60.0).round().clamp(0.0, 100.0) as u8;
let step = QUALITIES.iter().rev().find(|(_, v)| q >= *v).map_or("Low", |(l, _)| *l);
widgets::label_row(ui, "Quality:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Quality:"), LABEL_W, |ui| {
let labels = QUALITIES.map(|(l, _)| l);
if let Some((_, v)) = widgets::dropdown(ui, "sfw-quality", step, &labels, FIELD_W).and_then(|i| QUALITIES.get(i)) {
d.fields.insert("quality".into(), json!(v));
@@ -664,7 +669,7 @@ fn quality_row(ui: &mut egui::Ui, d: &mut Dialog) {
fn matte_row(ui: &mut egui::Ui, d: &mut Dialog) {
let matte = d.str("matte");
let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3);
widgets::label_row(ui, "Matte:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Matte:"), LABEL_W, |ui| {
if let Some(i) = widgets::dropdown(ui, "sfw-matte", MATTE_LABELS[at], &MATTE_LABELS, 120.0) {
d.fields.insert("matte".into(), json!(MATTES.get(i).copied().unwrap_or("#808080")));
}
@@ -683,11 +688,11 @@ fn color_table(ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
let key = s.map(|s| s.to_json().to_string());
let shot = cache.and_then(|c| c.shots.into_iter().find(|(k, _)| Some(k) == key.as_ref())).and_then(|(_, s)| s.ok());
let Some(shot) = shot else {
widgets::subheader(ui, "Color Table");
widgets::dim_label(ui, "Shown with the optimised preview.");
widgets::subheader(ui, tl!("Color Table"));
widgets::dim_label(ui, tl!("Shown with the optimised preview."));
return;
};
widgets::subheader(ui, &format!("Color Table ({} colors)", shot.colors.len()));
widgets::subheader(ui, &crate::i18n::tn(shot.colors.len() as u64, "Color Table ({n} color)", "Color Table ({n} colors)"));
let selected = d.str("__color");
// (colour shown, source, transparent, locked, web-safe, mapped by hand)
let rows: Vec<(TableColor, bool)> = shot
@@ -744,9 +749,11 @@ fn color_table(ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
let on = |list: &[String]| list.iter().any(|h| h.eq_ignore_ascii_case(&selected));
ui.horizontal(|ui| {
ui.add_enabled_ui(!selected.is_empty(), |ui| {
for (list, on_label, off_label) in
[("transparent", "Restore", "Transparent"), ("webShift", "Unshift", "Web Shift"), ("locked", "Unlock", "Lock")]
{
for (list, on_label, off_label) in [
("transparent", tl!("Restore"), tl!("Transparent")),
("webShift", tl!("Unshift"), tl!("Web Shift")),
("locked", tl!("Unlock"), tl!("Lock")),
] {
let current = match list {
"transparent" => &table.transparent,
"webShift" => &table.web_shift,
@@ -760,7 +767,7 @@ fn color_table(ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
});
});
let sort = table.sort;
widgets::label_row(ui, "Order:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Order:"), LABEL_W, |ui| {
let labels = ColorSort::ALL.map(ColorSort::label);
if let Some(i) = widgets::dropdown(ui, "sfw-sort", sort.label(), &labels, FIELD_W)
&& let Some(o) = ColorSort::ALL.get(i)
@@ -792,7 +799,7 @@ pub(super) fn set_table(d: &mut Dialog, key: &str, value: Value) {
/// Image Size: width and height in pixels (proportional) or a percentage, anti-aliasing, Clip to
/// Artboard and which artboard.
fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option<&WebSettings>) {
widgets::subheader(ui, "Image Size");
widgets::subheader(ui, tl!("Image Size"));
let Some(st) = app.session.active() else { return };
let region = s.and_then(|s| webexport::region(&st.doc, s).ok());
let (w, h) = region.map_or((0, 0), |(r, scale)| vectorcraft_render::region_pixels(r, scale));
@@ -802,29 +809,29 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option
}
d.fields.insert(key.into(), v);
};
widgets::label_row(ui, "W:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("W:"), LABEL_W, |ui| {
if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) {
set(d, "width", json!(v.round().max(1.0)));
}
ui.label("H:");
ui.label(tl!("H:"));
if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) {
set(d, "height", json!(v.round().max(1.0)));
}
});
let percent = region.map_or(100.0, |(_, scale)| (scale * 100.0 * 100.0).round() / 100.0);
widgets::label_row(ui, "Percent:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Percent:"), LABEL_W, |ui| {
if let Some(v) = widgets::plain_field(ui, "sfw-pct", percent, "%", 2, 80.0) {
set(d, "percent", json!(v));
}
});
form::choice(ui, d, "antiAlias", "Anti-aliasing:", (LABEL_W, FIELD_W), &pairs(&AntiAlias::ALL, AntiAlias::id, AntiAlias::label));
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "clipToArtboard", "Clip to Artboard"));
form::choice(ui, d, "antiAlias", tl!("Anti-aliasing:"), (LABEL_W, FIELD_W), &pairs(&AntiAlias::ALL, AntiAlias::id, AntiAlias::label));
widgets::label_row(ui, "", LABEL_W, |ui| form::check(ui, d, "clipToArtboard", tl!("Clip to Artboard")));
let boards = &st.doc.artboards;
if boards.len() > 1 && d.bool("clipToArtboard") {
let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect();
let at = s.and_then(|s| s.artboard).unwrap_or(0).min(names.len() - 1);
widgets::label_row(ui, "Artboard:", LABEL_W, |ui| {
if let Some(i) = widgets::dropdown(ui, "sfw-artboard", names.get(at).copied().unwrap_or_default(), &names, FIELD_W) {
widgets::label_row(ui, tl!("Artboard:"), LABEL_W, |ui| {
if let Some(i) = widgets::dropdown_names(ui, "sfw-artboard", names.get(at).copied().unwrap_or_default(), &names, FIELD_W) {
d.fields.insert("artboard".into(), json!(i));
}
});
+13 -13
View File
@@ -27,13 +27,13 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_wid
fn heading(d: &Dialog) -> String {
if d.bool("__pick") {
return match SaveMode::of(&d.str("__action")) {
Some(SaveMode::Copy) => "Save a Copy",
Some(SaveMode::Template) => "Save as Template",
_ => "Save As",
Some(SaveMode::Copy) => tl!("Save a Copy"),
Some(SaveMode::Template) => tl!("Save as Template"),
_ => tl!("Save As"),
}
.into();
}
format!("{} Options", fileio::format(&d.str("format")).map_or("", |f| f.label))
crate::i18n::fmt(tl!("{format} Options"), &[("format", fileio::format(&d.str("format")).map_or("", |f| f.label))])
}
/// `f`'s options with the values a save would use (`file.formatOptions`: as last saved in that
@@ -74,11 +74,11 @@ fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> {
/// others from their names.
fn label(o: &FormatOption) -> String {
match o.name {
"separateArtboards" => "Save each artboard to a separate file".into(),
"includeLinked" => "Include Linked Files".into(),
"embedProfiles" => "Embed ICC Profiles".into(),
"pdfCompatible" => "Create PDF-Compatible File".into(),
"compress" => "Use Compression".into(),
"separateArtboards" => tl!("Save each artboard to a separate file").into(),
"includeLinked" => tl!("Include Linked Files").into(),
"embedProfiles" => tl!("Embed ICC Profiles").into(),
"pdfCompatible" => tl!("Create PDF-Compatible File").into(),
"compress" => tl!("Use Compression").into(),
_ => form::humanize(o.name),
}
}
@@ -96,7 +96,7 @@ fn artboard_range(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
ui.add_space(22.0);
let mut all = all_artboards(d);
if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() {
if ui.radio_value(&mut all, true, tl!("All")).changed() | ui.radio_value(&mut all, false, tl!("Range:")).changed() {
d.fields.insert("all".into(), json!(all));
}
let mut range = d.str("range");
@@ -121,9 +121,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let mut switch = None;
egui::Grid::new("save-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if d.bool("__pick") {
form::field_w(ui, d, "path", "File Name:", 240.0);
form::field_w(ui, d, "path", tl!("File Name:"), 240.0);
if SaveMode::of(&d.str("__action")) != Some(SaveMode::Template) {
ui.label(egui::RichText::new("Format:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
switch = widgets::dropdown(ui, "save-format", f.label, save_labels(), 254.0).and_then(|i| SAVE_FORMATS.get(i).copied());
ui.end_row();
}
@@ -164,7 +164,7 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
if let Some(boards) = artboards {
let i = value.as_u64().unwrap_or(0) as usize;
let names: Vec<&str> = boards.iter().map(|a| a.name.as_str()).collect();
if let Some(i) = widgets::dropdown(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) {
if let Some(i) = widgets::dropdown_names(ui, ("save-option", o.name), names.get(i).copied().unwrap_or(""), &names, 254.0) {
d.fields.insert(o.name.into(), json!(i));
}
} else {
+110 -86
View File
@@ -27,13 +27,13 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "savePdf";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save PDF".into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Save PDF").into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM };
/// The dialog kind of the preset editor (New / Edit in Edit → PDF Presets).
pub(super) const PRESET_KIND: &str = "pdfPreset";
pub(super) const PRESET_SPEC: DialogSpec = DialogSpec {
heading: |d| if d.str(EDITING).is_empty() { "New PDF Preset" } else { "Edit PDF Preset" }.into(),
heading: |d| if d.str(EDITING).is_empty() { tl!("New PDF Preset") } else { tl!("Edit PDF Preset") }.into(),
body,
confirm: confirm_preset,
ok: Some("Save Preset"),
@@ -50,6 +50,13 @@ pub(super) const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Ble
/// The default settings as JSON: what a field an agent left out reads as.
static DEFAULTS: LazyLock<Value> = LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default());
/// Is `name` a built-in PDF preset's? Those are ours (translated where listed); the saved ones are
/// names.
pub(super) fn is_builtin_preset(name: &str) -> bool {
static NAMES: LazyLock<Vec<String>> = LazyLock::new(|| vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).collect());
NAMES.iter().any(|n| n == name)
}
/// Trim mark weights offered (pt).
const WEIGHTS: [f64; 3] = [0.125, 0.25, 0.5];
const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"];
@@ -304,20 +311,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let editor = d.kind == PRESET_KIND;
if editor {
row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| {
row_with(ui, tl!("Preset name:"), TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, "name", 288.0);
});
} else {
preset_rows(app, ui, d);
}
row_with(ui, "Standard:", TOP_LABEL_WIDTH, |ui| {
row_with(ui, tl!("Standard:"), TOP_LABEL_WIDTH, |ui| {
let picked = pick::<Standard>(ui, d, "standard", 170.0, |_| true);
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
if picked {
standard_chosen(d, standard);
}
ui.add_space(16.0);
ui.label(egui::RichText::new("Compatibility:").color(t.text));
ui.label(egui::RichText::new(tl!("Compatibility:")).color(t.text));
pick::<Compatibility>(ui, d, "compatibility", 110.0, |c| standard.allows(c));
});
ui.add_space(10.0);
@@ -327,7 +334,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(14.0);
ui.vertical(|ui| {
ui.set_width(500.0);
ui.label(egui::RichText::new(section).font(theme::semibold(14.0)).color(t.text_strong));
ui.label(egui::RichText::new(tl!(section)).font(theme::semibold(14.0)).color(t.text_strong));
egui::ScrollArea::vertical().id_salt(("save-pdf", section)).max_height(360.0).auto_shrink([false, false]).show(ui, |ui| match section {
"Compression" => compression(ui, d),
"Marks and Bleeds" => marks_and_bleeds(app, ui, d),
@@ -363,7 +370,7 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str],
ui.spacing_mut().item_spacing.y = 1.0;
for s in sections {
let sel = *s == section;
let label = egui::RichText::new(*s).size(12.5).color(if sel { t.text_strong } else { t.text });
let label = egui::RichText::new(tl!(*s)).size(12.5).color(if sel { t.text_strong } else { t.text });
if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
d.fields.insert("__section".into(), json!(s));
}
@@ -391,32 +398,36 @@ fn standard_chosen(d: &mut Dialog, standard: Standard) {
/// The Preset row (the presets, built-in and saved, and Save Preset…) and, while Save Preset…
/// asks for a name, the row taking it.
fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
row_with(ui, "Preset:", TOP_LABEL_WIDTH, |ui| {
row_with(ui, tl!("Preset:"), TOP_LABEL_WIDTH, |ui| {
let presets = pdf::presets(&app.session);
let names: Vec<&str> = presets.iter().map(String::as_str).collect();
let chosen = widgets::dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0).and_then(|i| names.get(i)).map(|p| p.to_string());
// The built-in presets come first (translated); the saved ones are names.
let builtins = names.len().saturating_sub(app.session.prefs.pdf_presets.len());
let chosen = super::mixed_dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0, |k| k < builtins)
.and_then(|i| names.get(i))
.map(|p| p.to_string());
if let Some(name) = chosen {
apply_preset(app, d, &name);
}
let asking = d.fields.contains_key(SAVE_AS);
if ui
.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0))
.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, tl!("Save Preset…"), 96.0))
.inner
.on_hover_text("Save these settings as a preset")
.on_hover_text(tl!("Save these settings as a preset"))
.clicked()
{
d.fields.insert(SAVE_AS.into(), json!(app.session.new_pdf_preset_name()));
}
});
if d.fields.contains_key(SAVE_AS) {
row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| {
row_with(ui, tl!("Save as preset:"), TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, SAVE_AS, 200.0);
if widgets::flat_button(ui, "Save", 52.0).clicked()
if widgets::flat_button(ui, tl!("Save"), 52.0).clicked()
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() {
d.fields.remove(SAVE_AS);
}
});
@@ -436,27 +447,27 @@ fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) {
/// General: the options, then the artboards (Save PDF) or the description (preset editor).
fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
heading(ui, "Options");
heading(ui, tl!("Options"));
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
flag(ui, d, "preserveEditing", "Preserve editing capabilities", standard == Standard::None);
flag(ui, d, "thumbnails", "Embed page thumbnails", true);
flag(ui, d, "fastWebView", "Optimize for fast web view", true);
flag(ui, d, "viewAfterSaving", "View PDF after saving", true);
flag(ui, d, "preserveEditing", tl!("Preserve editing capabilities"), standard == Standard::None);
flag(ui, d, "thumbnails", tl!("Embed page thumbnails"), true);
flag(ui, d, "fastWebView", tl!("Optimize for fast web view"), true);
flag(ui, d, "viewAfterSaving", tl!("View PDF after saving"), true);
// PDF layers need PDF 1.5, and PDF/X-1a and PDF/X-3 have none.
let layers = standard.allows_layers() && choice::<Compatibility>(d, "compatibility").unwrap_or_default().has_layers();
flag(ui, d, "createLayers", "Create PDF layers from top-level layers", layers);
flag(ui, d, "includeNonPrinting", "Include non-printing layers", true);
flag(ui, d, "createLayers", tl!("Create PDF layers from top-level layers"), layers);
flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true);
if editor {
heading(ui, "Description");
heading(ui, tl!("Description"));
if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) {
d.fields.insert("description".into(), json!(text));
}
return;
}
heading(ui, "Artboards");
heading(ui, tl!("Artboards"));
let mut all = d.bool("__allArtboards");
ui.horizontal(|ui| {
let changed = ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed();
let changed = ui.radio_value(&mut all, true, tl!("All")).changed() | ui.radio_value(&mut all, false, tl!("Range:")).changed();
if changed {
d.fields.insert("__allArtboards".into(), json!(all));
}
@@ -473,19 +484,19 @@ fn image_rows(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) {
let on = get(d, &format!("{base}.downsample")) != Downsample::None.id();
ui.horizontal(|ui| {
pick::<Downsample>(ui, d, &format!("{base}.downsample"), 150.0, |_| true);
ui.label("to");
ui.label(tl!("to"));
number(ui, d, &format!("{base}.ppi"), " ppi", on);
ui.label("above");
ui.label(tl!("above"));
number(ui, d, &format!("{base}.abovePpi"), " ppi", on);
});
ui.horizontal(|ui| {
ui.label("Compression:");
ui.label(tl!("Compression:"));
if key == "mono" {
pick::<MonoCodec>(ui, d, &format!("{base}.compression"), 150.0, |_| true);
} else {
pick::<ImageCodec>(ui, d, &format!("{base}.compression"), 120.0, |_| true);
let lossy = get(d, &format!("{base}.compression")).as_str().is_some_and(|c| c != ImageCodec::None.id() && c != ImageCodec::Zip.id());
ui.label("Quality:");
ui.label(tl!("Quality:"));
ui.add_enabled_ui(lossy, |ui| {
pick::<JpegQuality>(ui, d, &format!("{base}.quality"), 110.0, |_| true);
});
@@ -494,11 +505,11 @@ fn image_rows(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) {
}
fn compression(ui: &mut egui::Ui, d: &mut Dialog) {
image_rows(ui, d, "color", "Color images");
image_rows(ui, d, "gray", "Grayscale images");
image_rows(ui, d, "mono", "Monochrome images");
image_rows(ui, d, "color", tl!("Color images"));
image_rows(ui, d, "gray", tl!("Grayscale images"));
image_rows(ui, d, "mono", tl!("Monochrome images"));
ui.add_space(8.0);
flag(ui, d, "compression.compressText", "Compress text and line art", true);
flag(ui, d, "compression.compressText", tl!("Compress text and line art"), true);
}
pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
@@ -509,9 +520,9 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut
("marks.colorBars", "Color bars"),
("marks.pageInfo", "Page information"),
];
heading(ui, "Marks");
heading(ui, tl!("Marks"));
let all = MARKS.iter().all(|(p, _)| get(d, p).as_bool() == Some(true));
if widgets::check(ui, "All printer's marks", all, true) {
if widgets::check(ui, tl!("All printer's marks"), all, true) {
for (p, _) in MARKS {
set(d, p, json!(!all));
}
@@ -519,15 +530,15 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut
egui::Grid::new("pdf-marks").num_columns(2).spacing([24.0, 4.0]).show(ui, |ui| {
for pair in MARKS.chunks(2) {
for (p, label) in pair {
flag(ui, d, p, label, true);
flag(ui, d, p, tl!(label), true);
}
ui.end_row();
}
});
row(ui, "Printer mark type:", |ui| {
row(ui, tl!("Printer mark type:"), |ui| {
pick::<MarkKind>(ui, d, "marks.kind", 130.0, |_| true);
});
row(ui, "Trim mark weight:", |ui| {
row(ui, tl!("Trim mark weight:"), |ui| {
let w = get(d, "marks.weight").as_f64().unwrap_or(0.25);
let current =
WEIGHTS.iter().zip(WEIGHT_LABELS).find(|(x, _)| (*x - w).abs() < 1e-9).map_or_else(|| format!("{w} pt"), |(_, l)| l.to_string());
@@ -535,13 +546,16 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut
set(d, "marks.weight", json!(x));
}
});
row(ui, "Offset:", |ui| length(ui, d, "marks.offset", unit, true));
heading(ui, "Bleeds");
flag(ui, d, "bleed.useDocument", "Use document bleed settings", true);
row(ui, tl!("Offset:"), |ui| length(ui, d, "marks.offset", unit, true));
heading(ui, tl!("Bleeds"));
flag(ui, d, "bleed.useDocument", tl!("Use document bleed settings"), true);
// With the document's bleed the fields show it (Document Setup's `[top, bottom, left, right]`).
let document = (get(d, "bleed.useDocument").as_bool() == Some(true)).then(|| app.session.active().map_or([0.0; 4], |s| s.doc.setup.bleed));
egui::Grid::new("pdf-bleed").num_columns(4).spacing([10.0, 6.0]).show(ui, |ui| {
for pair in [[(0, "bleed.top", "Top:"), (1, "bleed.bottom", "Bottom:")], [(2, "bleed.left", "Left:"), (3, "bleed.right", "Right:")]] {
for pair in [
[(0, "bleed.top", tl!("Top:")), (1, "bleed.bottom", tl!("Bottom:"))],
[(2, "bleed.left", tl!("Left:")), (3, "bleed.right", tl!("Right:"))],
] {
for (i, p, label) in pair {
ui.label(label);
match document {
@@ -557,12 +571,13 @@ pub(super) fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut
}
/// A dropdown of names such as colour profiles or flattener presets (`names`, the first one
/// standing for "" at `path`), showing the name at `path` (one that isn't among them too).
pub(super) fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) {
/// standing for "" at `path`), showing the name at `path` (one that isn't among them too). The
/// first `builtins` entries are ours (translated); the others (profiles, saved presets) are names.
pub(super) fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], builtins: usize, enabled: bool) {
let blank = names.first().copied().unwrap_or_default();
let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string();
ui.add_enabled_ui(enabled, |ui| {
if let Some(i) = widgets::dropdown(ui, path, &current, names, 300.0) {
if let Some(i) = super::mixed_dropdown(ui, path, &current, names, 300.0, |k| k < builtins) {
set(d, path, json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() }));
}
});
@@ -572,69 +587,71 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) {
let profiles = vectorcraft_color::cms::profiles();
let names = |blank: &'static str| -> Vec<&str> { std::iter::once(blank).chain(profiles.iter().map(|p| p.name.as_str())).collect() };
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
heading(ui, "Color");
row(ui, "Color conversion:", |ui| {
heading(ui, tl!("Color"));
row(ui, tl!("Color conversion:"), |ui| {
pick::<ColorConversion>(ui, d, "output.conversion", 300.0, |_| true);
});
// PDF/X-1a converts to CMYK even without a conversion.
let converting = get(d, "output.conversion") != ColorConversion::None.id() || standard.cmyk_only();
row(ui, "Destination:", |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), converting));
row(ui, "Profile inclusion:", |ui| {
row(ui, tl!("Destination:"), |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), 1, converting));
row(ui, tl!("Profile inclusion:"), |ui| {
// A standard decides whether colours are tagged.
ui.add_enabled_ui(standard == Standard::None, |ui| pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true));
});
if standard.cmyk_only() {
note(ui, "PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged.");
note(ui, tl!("PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged."));
} else if standard != Standard::None {
note(ui, "Files of this standard tag their colours with ICC profiles.");
note(ui, tl!("Files of this standard tag their colours with ICC profiles."));
}
heading(ui, "Output Intent");
heading(ui, tl!("Output Intent"));
// PDF/A files carry their own output intent; PDF/X files always have one.
let own = standard != Standard::PdfA2b;
let blank = if standard.is_pdfx() { PDFX_INTENT } else { NO_PROFILE };
row(ui, "Output intent profile:", |ui| profile_pick(ui, d, "output.outputIntent", &names(blank), own));
row(ui, tl!("Output intent profile:"), |ui| profile_pick(ui, d, "output.outputIntent", &names(blank), 1, own));
for (p, label) in [
("output.outputCondition", "Output condition:"),
("output.outputConditionId", "Condition identifier:"),
("output.registry", "Registry name:"),
("output.outputCondition", tl!("Output condition:")),
("output.outputConditionId", tl!("Condition identifier:")),
("output.registry", tl!("Registry name:")),
] {
row(ui, label, |ui| text(ui, d, p, own));
}
flag(ui, d, "output.trapped", "Mark as trapped", own);
flag(ui, d, "output.trapped", tl!("Mark as trapped"), own);
if !own {
note(ui, "PDF/A files carry their own output intent.");
note(ui, tl!("PDF/A files carry their own output intent."));
}
}
fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
heading(ui, "Fonts");
heading(ui, tl!("Fonts"));
let outline = get(d, "advanced.outlineText").as_bool() == Some(true);
row(ui, "Subset fonts below:", |ui| {
row(ui, tl!("Subset fonts below:"), |ui| {
number(ui, d, "advanced.fontSubsetPercent", "%", !outline);
ui.label("of characters used");
ui.label(tl!("of characters used"));
});
flag(ui, d, "advanced.outlineText", "Convert text to outlines", true);
heading(ui, "Overprint and Transparency Flattener");
row(ui, "Overprint:", |ui| {
flag(ui, d, "advanced.outlineText", tl!("Convert text to outlines"), true);
heading(ui, tl!("Overprint and Transparency Flattener"));
row(ui, tl!("Overprint:"), |ui| {
pick::<Overprint>(ui, d, "advanced.overprint", 130.0, |_| true);
});
// PDF 1.3 files (PDF/X-1a and PDF/X-3 ones too) have no transparency: it is flattened.
let flat = choice::<Compatibility>(d, "compatibility") == Some(Compatibility::Pdf13)
|| choice::<Standard>(d, "standard").is_some_and(Standard::flattens);
row(ui, "Flattener preset:", |ui| {
row(ui, tl!("Flattener preset:"), |ui| {
let presets = app.session.flattener_presets();
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
// The built-in presets come first (translated); the saved ones are names.
let builtins = names.len().saturating_sub(app.session.prefs.flattener_presets.len());
let name = get(d, "flattenerPreset").as_str().map(str::trim).filter(|n| !n.is_empty()).unwrap_or(pdf::DEFAULT_FLATTENER);
// A built-in preset named by id (`high`) shows as its name.
let current = FlattenOptions::preset_label(&name.to_ascii_lowercase()).unwrap_or(name).to_string();
ui.add_enabled_ui(flat, |ui| {
if let Some(n) = widgets::dropdown(ui, "flattenerPreset", &current, &names, 200.0).and_then(|i| names.get(i)) {
if let Some(n) = super::mixed_dropdown(ui, "flattenerPreset", &current, &names, 200.0, |k| k < builtins).and_then(|i| names.get(i)) {
set(d, "flattenerPreset", json!(n));
}
});
});
if !flat {
note(ui, "PDF 1.3 files have no transparency: the preset flattens it.");
note(ui, tl!("PDF 1.3 files have no transparency: the preset flattens it."));
}
}
@@ -665,24 +682,24 @@ fn password_field(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool)
fn security(ui: &mut egui::Ui, d: &mut Dialog) {
let plain = choice::<Standard>(d, "standard").unwrap_or_default() == Standard::None;
if !plain {
note(ui, "Files of a PDF standard can't be password-protected.");
note(ui, tl!("Files of a PDF standard can't be password-protected."));
}
heading(ui, "Document open password");
let open = password_check(ui, d, OPEN_PASSWORD, "__requireOpenPassword", "Require a password to open the document", plain);
row(ui, "Open password:", |ui| password_field(ui, d, OPEN_PASSWORD, open));
heading(ui, "Permissions");
let restrict = password_check(ui, d, PERMISSIONS_PASSWORD, "__restrictPermissions", "Restrict printing, editing and other tasks", plain);
row(ui, "Permissions password:", |ui| password_field(ui, d, PERMISSIONS_PASSWORD, restrict));
heading(ui, tl!("Document open password"));
let open = password_check(ui, d, OPEN_PASSWORD, "__requireOpenPassword", tl!("Require a password to open the document"), plain);
row(ui, tl!("Open password:"), |ui| password_field(ui, d, OPEN_PASSWORD, open));
heading(ui, tl!("Permissions"));
let restrict = password_check(ui, d, PERMISSIONS_PASSWORD, "__restrictPermissions", tl!("Restrict printing, editing and other tasks"), plain);
row(ui, tl!("Permissions password:"), |ui| password_field(ui, d, PERMISSIONS_PASSWORD, restrict));
ui.add_enabled_ui(restrict, |ui| {
row(ui, "Printing allowed:", |ui| {
row(ui, tl!("Printing allowed:"), |ui| {
pick::<Printing>(ui, d, "security.printing", 260.0, |_| true);
});
row(ui, "Changes allowed:", |ui| {
row(ui, tl!("Changes allowed:"), |ui| {
pick::<Changes>(ui, d, "security.changes", 260.0, |_| true);
});
});
flag(ui, d, "security.copy", "Enable copying of text, images and other content", restrict);
let reader = "Enable text access for screen readers";
flag(ui, d, "security.copy", tl!("Enable copying of text, images and other content"), restrict);
let reader = tl!("Enable text access for screen readers");
if get(d, "security.copy").as_bool() == Some(true) {
// What can be copied can be read out.
widgets::check(ui, reader, true, false);
@@ -694,11 +711,11 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) {
ui,
d,
"security.plaintextMetadata",
"Enable plaintext metadata",
tl!("Enable plaintext metadata"),
(open || restrict) && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14),
);
ui.add_space(6.0);
note(ui, &format!("Encryption: {} (set by Compatibility)", Encryption::for_compatibility(compatibility).label()));
note(ui, &crate::i18n::fmt(tl!("Encryption: {name} (set by Compatibility)"), &[("name", Encryption::for_compatibility(compatibility).label())]));
}
/// The section a changed option belongs to (for the Summary's order).
@@ -745,20 +762,20 @@ fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let v = match summary_of(app, ui.ctx(), &params(d)) {
Ok(v) => v,
Err(e) => {
heading(ui, "Error");
heading(ui, tl!("Error"));
ui.label(egui::RichText::new(format!("⚠ {e}")).color(t.text));
return;
}
};
heading(ui, "Options");
heading(ui, tl!("Options"));
let mut changed: Vec<&Value> = v["changed"].as_array().map(|a| a.iter().collect()).unwrap_or_default();
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()));
if changed.is_empty() {
let base = if d.kind == PRESET_KIND { pdf::DEFAULT_PRESET } else { "the preset" };
note(ui, &format!("Every option matches {base}."));
let base = if d.kind == PRESET_KIND { pdf::DEFAULT_PRESET } else { tl!("the preset") };
note(ui, &crate::i18n::fmt(tl!("Every option matches {base}."), &[("base", base)]));
}
option_rows(ui, changed, option_label);
heading(ui, "Warnings");
heading(ui, tl!("Warnings"));
warning_rows(ui, v["warnings"].as_array().map(Vec::as_slice).unwrap_or_default());
}
@@ -767,7 +784,7 @@ pub(super) fn option_rows<'a>(ui: &mut egui::Ui, changed: impl IntoIterator<Item
let t = Tokens::get(ui.ctx());
for c in changed {
let value = match &c["value"] {
Value::Bool(b) => if *b { "On" } else { "Off" }.to_string(),
Value::Bool(b) => if *b { tl!("On") } else { tl!("Off") }.to_string(),
Value::String(s) => s.clone(),
other => other.to_string(),
};
@@ -779,7 +796,7 @@ pub(super) fn option_rows<'a>(ui: &mut egui::Ui, changed: impl IntoIterator<Item
pub(super) fn warning_rows(ui: &mut egui::Ui, warnings: &[Value]) {
let t = Tokens::get(ui.ctx());
if warnings.is_empty() {
note(ui, "None.");
note(ui, tl!("None."));
}
for w in warnings {
ui.label(egui::RichText::new(format!("⚠ {}", w.as_str().unwrap_or_default())).color(t.text));
@@ -798,6 +815,13 @@ mod tests {
type Log = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// The built-in presets are told from saved ones by name (only theirs are translated).
#[test]
fn built_in_presets_are_known_by_name() {
assert!(vectorcraft_pdf::builtin_presets().iter().all(|p| is_builtin_preset(&p.name)));
assert!(is_builtin_preset(pdf::DEFAULT_PRESET) && !is_builtin_preset("Default") && !is_builtin_preset("My Preset"));
}
/// An app with two artboards whose writer and URL opener record what they get.
fn app() -> (VectorcraftApp, Log, Log) {
let (written, opened) = (Log::default(), Log::default());
@@ -18,7 +18,7 @@ use crate::state::Dialog;
pub const KIND: &str = "saveGraphicStyleLibrary";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Save Graphic Style Library".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Save Graphic Style Library").into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// Open Save Graphic Style Library for the document's styles (`names`: the ones selected in the
/// panel).
@@ -31,7 +31,7 @@ pub fn open(app: &mut VectorcraftApp, names: Vec<String>) -> Result<Value, Strin
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
name_row(ui, d);
destination(ui, d, "Selected Styles Only");
destination(ui, d, tl!("Selected Styles Only"));
});
false
}
@@ -20,7 +20,8 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Save Swatch Library.
pub const KIND: &str = "saveSwatchLibrary";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save Swatch Library".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Save Swatch Library").into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// Open Save Swatch Library for the document's swatches (`names`: the ones selected in the panel).
pub fn open(app: &mut VectorcraftApp, names: Vec<String>) -> Result<Value, String> {
@@ -51,22 +52,22 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let labels = PaletteFormat::ALL.map(PaletteFormat::label);
grid(ui, |ui| {
name_row(ui, d);
label(ui, "Format:");
label(ui, tl!("Format:"));
if let Some(i) = widgets::dropdown(ui, "library-format", format_of(d).label(), &labels, 230.0) {
set(d, "format", json!(PaletteFormat::ALL[i].id()));
}
ui.end_row();
destination(ui, d, "Selected Swatches Only");
destination(ui, d, tl!("Selected Swatches Only"));
});
if format_of(d) == PaletteFormat::Gpl {
widgets::dim_label(ui, "GPL palettes keep solid colours only, as RGB.");
widgets::dim_label(ui, tl!("GPL palettes keep solid colours only, as RGB."));
}
false
}
/// The Name row of a Save … Library dialog's [`grid`].
pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) {
label(ui, "Name:");
label(ui, tl!("Name:"));
form::text(ui, d, "name", 220.0);
ui.end_row();
}
@@ -75,14 +76,14 @@ pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) {
/// one) or a file, and `only` (Selected Swatches Only) with the number of selected items.
pub(super) fn destination(ui: &mut egui::Ui, d: &mut Dialog, only: &str) {
let names = d.fields.get("names").and_then(Value::as_array).map_or(0, Vec::len);
label(ui, "Save To:");
label(ui, tl!("Save To:"));
let (user, has_user) = (d.bool("user"), d.bool("__user"));
if widgets::radio(ui, "User Defined Libraries", user, has_user) {
if widgets::radio(ui, tl!("User Defined Libraries"), user, has_user) {
set(d, "user", json!(true));
}
ui.end_row();
ui.label("");
if widgets::radio(ui, "A File…", !user, true) {
if widgets::radio(ui, tl!("A File…"), !user, true) {
set(d, "user", json!(false));
}
ui.end_row();
+6 -6
View File
@@ -11,12 +11,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).int
fn title(kind: &str) -> &'static str {
match kind {
"rectangle" => "Rectangle",
"roundedRectangle" => "Rounded Rectangle",
"ellipse" => "Ellipse",
"polygon" => "Polygon",
"star" => "Star",
_ => "Line Segment Tool Options",
"rectangle" => tl!("Rectangle"),
"roundedRectangle" => tl!("Rounded Rectangle"),
"ellipse" => tl!("Ellipse"),
"polygon" => tl!("Polygon"),
"star" => tl!("Star"),
_ => tl!("Line Segment Tool Options"),
}
}
+20 -12
View File
@@ -23,10 +23,10 @@ pub const OPTIONS: &str = "sliceOptions";
pub const DIVIDE: &str = "divideSlices";
pub(super) const OPTIONS_SPEC: DialogSpec =
DialogSpec { heading: |_| "Slice Options".into(), body: options_body, confirm: options_confirm, min_width: 380.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Slice Options").into(), body: options_body, confirm: options_confirm, min_width: 380.0, ..DialogSpec::FORM };
pub(super) const DIVIDE_SPEC: DialogSpec =
DialogSpec { heading: |_| "Divide Slices".into(), body: divide_body, confirm: divide_confirm, min_width: 320.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Divide Slices").into(), body: divide_body, confirm: divide_confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Label column and field widths of the dialogs.
const LABEL_W: f32 = 110.0;
@@ -61,29 +61,31 @@ fn pairs<T: Copy>(all: &[T], key: fn(T) -> &'static str, label: fn(T) -> &'stati
fn options_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let kinds: Vec<_> = SliceKind::ALL.iter().filter(|k| **k != SliceKind::HtmlText || d.bool("__html")).copied().collect();
form::choice(ui, d, "kind", "Slice Type:", (LABEL_W, FIELD_W), &pairs(&kinds, SliceKind::key, SliceKind::label));
form::choice(ui, d, "kind", tl!("Slice Type:"), (LABEL_W, FIELD_W), &pairs(&kinds, SliceKind::key, SliceKind::label));
ui.add_space(6.0);
let kind = SliceKind::parse(&d.str("kind")).unwrap_or_default();
match kind {
SliceKind::Image => {
for (key, label) in [("name", "Name:"), ("url", "URL:"), ("target", "Target:"), ("message", "Message:"), ("alt", "Alt:")] {
for (key, label) in
[("name", tl!("Name:")), ("url", tl!("URL:")), ("target", tl!("Target:")), ("message", tl!("Message:")), ("alt", tl!("Alt:"))]
{
widgets::label_row(ui, label, LABEL_W, |ui| {
form::text(ui, d, key, FIELD_W - 12.0);
});
}
}
SliceKind::NoImage => {
widgets::dim_label(ui, "Text Displayed in Cell:");
widgets::dim_label(ui, tl!("Text Displayed in Cell:"));
form::text_area(ui, d, "text", LABEL_W + FIELD_W - 12.0, 4);
}
SliceKind::HtmlText => {
widgets::dim_label(ui, "The cell shows the type object's text as HTML.");
widgets::dim_label(ui, tl!("The cell shows the type object's text as HTML."));
}
}
if kind != SliceKind::Image {
ui.add_space(6.0);
form::choice(ui, d, "hAlign", "Horizontal:", (LABEL_W, FIELD_W), &pairs(CellAlign::ALL, CellAlign::key, CellAlign::label));
form::choice(ui, d, "vAlign", "Vertical:", (LABEL_W, FIELD_W), &pairs(CellVAlign::ALL, CellVAlign::key, CellVAlign::label));
form::choice(ui, d, "hAlign", tl!("Horizontal:"), (LABEL_W, FIELD_W), &pairs(CellAlign::ALL, CellAlign::key, CellAlign::label));
form::choice(ui, d, "vAlign", tl!("Vertical:"), (LABEL_W, FIELD_W), &pairs(CellVAlign::ALL, CellVAlign::key, CellVAlign::label));
}
ui.add_space(6.0);
background_row(ui, d);
@@ -101,7 +103,7 @@ fn background_row(ui: &mut egui::Ui, d: &mut Dialog) {
"#000000" => 3,
_ => 4,
};
widgets::label_row(ui, "Background:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Background:"), LABEL_W, |ui| {
if let Some(i) = widgets::dropdown(ui, "slice-background", NAMES[shown], &NAMES, 120.0) {
let v = match i {
0 => "",
@@ -152,16 +154,22 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys:
d.fields.insert(mode.into(), json!("size"));
}
form::length(ui, d, size, app.session.general_unit(), 70.0);
widgets::dim_label(ui, "per slice");
widgets::dim_label(ui, tl!("per slice"));
});
});
});
}
fn divide_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
divide_section(app, ui, d, ["divideRows", "rows", "rowMode", "rowHeight"], ["Divide Horizontally Into", "slices down, evenly spaced"]);
divide_section(app, ui, d, ["divideRows", "rows", "rowMode", "rowHeight"], [tl!("Divide Horizontally Into"), tl!("slices down, evenly spaced")]);
ui.add_space(8.0);
divide_section(app, ui, d, ["divideColumns", "columns", "columnMode", "columnWidth"], ["Divide Vertically Into", "slices across, evenly spaced"]);
divide_section(
app,
ui,
d,
["divideColumns", "columns", "columnMode", "columnWidth"],
[tl!("Divide Vertically Into"), tl!("slices across, evenly spaced")],
);
false
}
+4 -4
View File
@@ -13,7 +13,7 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Spot Colors.
pub const KIND: &str = "spotColors";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Spot Colors".into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Spot Colors").into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// The choices: (`useLab`, label, what it does).
const CHOICES: [(bool, &str, &str); 2] = [
@@ -33,15 +33,15 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
for (i, (value, text, help)) in CHOICES.into_iter().enumerate() {
if i == 0 {
label(ui, "Lab Spot Colors:");
label(ui, tl!("Lab Spot Colors:"));
} else {
ui.label("");
}
ui.vertical(|ui| {
if widgets::radio(ui, text, use_lab == value, true) {
if widgets::radio(ui, tl!(text), use_lab == value, true) {
d.fields.insert("useLab".into(), json!(value));
}
ui.indent(text, |ui| label(ui, help));
ui.indent(text, |ui| label(ui, tl!(help)));
});
ui.end_row();
}
+26 -19
View File
@@ -12,8 +12,15 @@ use crate::state::Dialog;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "SVG Options".into(), body, confirm, ok: Some("OK"), min_width: 380.0, max_width: Some(560.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("SVG Options").into(),
body,
confirm,
ok: Some("OK"),
min_width: 380.0,
max_width: Some(560.0),
..DialogSpec::FORM
};
/// `Dialog::kind` of this dialog.
pub const KIND: &str = "svgOptions";
@@ -166,11 +173,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if Mode::of(d) == Mode::Export {
artboards(app, ui, d);
} else {
check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true);
check(ui, d, "preserveEditing", tl!("Preserve Editing Capabilities"), true);
}
ui.add_space(10.0);
let show = d.bool("showCode");
if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() {
if widgets::secondary_button(ui, if show { tl!("Hide Code") } else { tl!("Show Code") }).clicked() {
d.fields.insert("showCode".into(), json!(!show));
}
if show {
@@ -188,47 +195,47 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
// SVG Tiny has presentation attributes alone and no web fonts.
let tiny = d.str("profile") == "tiny12";
egui::Grid::new("svg-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
choice(ui, d, "profile", "SVG Profile:", &PROFILES, None);
choice(ui, d, "styling", "Styling:", &STYLING, tiny.then_some("presentation"));
choice(ui, d, "outlineText", "Font:", &FONTS, None);
choice(ui, d, "profile", tl!("SVG Profile:"), &PROFILES, None);
choice(ui, d, "styling", tl!("Styling:"), &STYLING, tiny.then_some("presentation"));
choice(ui, d, "outlineText", tl!("Font:"), &FONTS, None);
if !screens {
choice(ui, d, "images", "Images:", &IMAGES, None);
choice(ui, d, "images", tl!("Images:"), &IMAGES, None);
}
choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS, None);
ui.label(egui::RichText::new("Decimal:").color(t.text_dim));
choice(ui, d, "objectIds", tl!("Object IDs:"), &OBJECT_IDS, None);
ui.label(egui::RichText::new(tl!("Decimal:")).color(t.text_dim));
let decimals = d.f64("decimals", 3.0);
if let Some(v) = widgets::spin_plain(ui, "svg-decimals", decimals, "", 0, 70.0, 1.0, 1.0, &[]) {
let (lo, hi) = (*vectorcraft_svg::DECIMALS.start() as f64, *vectorcraft_svg::DECIMALS.end() as f64);
d.fields.insert("decimals".into(), json!(v.round().clamp(lo, hi) as u8));
}
ui.end_row();
choice(ui, d, "encoding", "Encoding:", &ENCODINGS, None);
choice(ui, d, "encoding", tl!("Encoding:"), &ENCODINGS, None);
});
ui.add_space(6.0);
ui.horizontal(|ui| {
check(ui, d, "minify", "Minify", true);
check(ui, d, "minify", tl!("Minify"), true);
ui.add_space(12.0);
check(ui, d, "responsive", "Responsive", true);
check(ui, d, "responsive", tl!("Responsive"), true);
});
check(ui, d, "embedFonts", "Embed Fonts (Glyphs Used)", !d.bool("outlineText") && !tiny);
check(ui, d, "fewerTspans", "Fewer <tspan> Elements", !d.bool("outlineText"));
check(ui, d, "metadata", "Include Metadata", true);
check(ui, d, "embedFonts", tl!("Embed Fonts (Glyphs Used)"), !d.bool("outlineText") && !tiny);
check(ui, d, "fewerTspans", tl!("Fewer <tspan> Elements"), !d.bool("outlineText"));
check(ui, d, "metadata", tl!("Include Metadata"), true);
}
/// Use Artboards: all of them or a range; off = the bounds of all art.
fn artboards(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let count = app.session.active().map_or(0, |st| st.doc.artboards.len());
ui.add_space(4.0);
check(ui, d, "useArtboards", "Use Artboards", count > 0);
check(ui, d, "useArtboards", tl!("Use Artboards"), count > 0);
let on = d.bool("useArtboards") && count > 0;
ui.add_enabled_ui(on, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
let all = d.bool("allArtboards");
if ui.radio(all, "All").clicked() {
if ui.radio(all, tl!("All")).clicked() {
d.fields.insert("allArtboards".into(), json!(true));
}
if ui.radio(!all, "Range:").clicked() {
if ui.radio(!all, tl!("Range:")).clicked() {
d.fields.insert("allArtboards".into(), json!(false));
}
let mut range = d.str("range");
@@ -20,7 +20,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "swatchConflict";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Swatch Conflict".into(), body, confirm, min_width: 380.0, max_width: Some(440.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Swatch Conflict").into(), body, confirm, min_width: 380.0, max_width: Some(440.0), ..DialogSpec::FORM };
/// The choices: (`choice` value, label, what it does).
const CHOICES: [(&str, &str, &str); 2] = [
@@ -67,33 +67,33 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let i = index(d);
let (count, Some(c)) = (conflicts(d).len(), conflicts(d).get(i).cloned()) else { return true };
let name = c["name"].as_str().unwrap_or_default();
ui.label(format!("This document already has a swatch named \u{201c}{name}\u{201d} with another color."));
ui.label(crate::i18n::fmt(tl!("This document already has a swatch named “{name}” with another color."), &[("name", name)]));
if count > 1 {
label(ui, &format!("Conflict {} of {count}", i + 1));
label(ui, &crate::i18n::fmt(tl!("Conflict {n} of {count}"), &[("n", &(i + 1).to_string()), ("count", &count.to_string())]));
}
ui.add_space(10.0);
ui.horizontal(|ui| {
chip(ui, c["document"].as_str(), "Document");
chip(ui, c["document"].as_str(), tl!("Document"));
ui.add_space(12.0);
chip(ui, c["clipboard"].as_str(), "Pasted");
chip(ui, c["clipboard"].as_str(), tl!("Pasted"));
});
ui.add_space(10.0);
let choice = d.str("choice");
grid(ui, |ui| {
for (k, (value, text, help)) in CHOICES.into_iter().enumerate() {
label(ui, if k == 0 { "Options:" } else { "" });
label(ui, if k == 0 { tl!("Options:") } else { "" });
ui.vertical(|ui| {
if widgets::radio(ui, text, choice == value, true) {
if widgets::radio(ui, tl!(text), choice == value, true) {
d.fields.insert("choice".into(), json!(value));
}
ui.indent(text, |ui| label(ui, help));
ui.indent(text, |ui| label(ui, tl!(help)));
});
ui.end_row();
}
});
ui.add_space(6.0);
let all = d.bool("applyToAll");
if widgets::check(ui, "Apply to All", all, count > 1) {
if widgets::check(ui, tl!("Apply to All"), all, count > 1) {
d.fields.insert("applyToAll".into(), json!(!all));
}
false
+6 -6
View File
@@ -23,7 +23,7 @@ use crate::widgets;
pub const KIND: &str = "swatchOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Swatch Options".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Swatch Options").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
/// Colour modes, in menu order, with their `mode` field / `swatch.edit` ids.
const MODES: [(Mode, &str); 6] =
@@ -117,7 +117,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
};
ui.add_space(8.0);
let mut pv = d.bool("preview");
let pv_changed = widgets::check(ui, "Preview", pv, true);
let pv_changed = widgets::check(ui, tl!("Preview"), pv, true);
if pv_changed {
pv = !pv;
d.fields.insert("preview".into(), json!(pv));
@@ -151,7 +151,7 @@ pub(super) fn label(ui: &mut egui::Ui, s: &str) {
/// The Swatch Name row of the grid. Returns true when it changed.
pub(super) fn name_row(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, "Swatch Name:");
label(ui, tl!("Swatch Name:"));
let changed = form::text(ui, d, "name", 190.0);
ui.end_row();
changed
@@ -163,7 +163,7 @@ pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let mut changed = false;
grid(ui, |ui| {
changed |= name_row(ui, d);
label(ui, "Color Type:");
label(ui, tl!("Color Type:"));
let spot = d.bool("spot");
if let Some(i) = widgets::dropdown(ui, "swatch-type", TYPES[usize::from(spot)], &TYPES, 200.0) {
d.fields.insert("spot".into(), json!(i == 1));
@@ -173,12 +173,12 @@ pub(super) fn editor(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.label("");
// Spot colours are always global.
let global = spot || d.bool("global");
if widgets::check(ui, "Global", global, !spot) {
if widgets::check(ui, tl!("Global"), global, !spot) {
d.fields.insert("global".into(), json!(!global));
changed = true;
}
ui.end_row();
label(ui, "Color Mode:");
label(ui, tl!("Color Mode:"));
let labels = MODES.map(|m| m.0.label());
if let Some(i) = widgets::dropdown(ui, "swatch-mode", mode_of(d).label(), &labels, 200.0) {
let m = MODES[i].0;
+52
View File
@@ -246,3 +246,55 @@ fn dialogs_do_not_stretch_to_the_screen() {
}
assert!(checked >= 10, "only {checked} dialogs drew in the shared frame");
}
/// The dialog window of `app`'s open dialog after a few frames in a `w` × 900 window.
fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 900.0));
let kind = app.ui.dialog.as_ref().unwrap().kind.clone();
for _ in 0..3 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear();
}
ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str())))).unwrap()
}
#[test]
fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
let mut app = app();
// Clean Up: three check boxes in the generic parameter dialog.
let params = json!({"emptyTextPaths": true, "strayPoints": true, "unpaintedObjects": true});
app.run("ui.paramDialog", json!({"command": "object.path.cleanUp", "label": "Clean Up", "params": params})).unwrap();
let wide = dialog_rect(&mut app, 1600.0);
assert!(wide.width() < 500.0, "Clean Up is {:.0} wide in a 1600-point window", wide.width());
// The same dialog in a narrow window still fits it.
let narrow = dialog_rect(&mut app, 360.0);
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
}
/// A list mixing built-in labels with names translates only the built-in entries: names that
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
#[test]
fn mixed_lists_translate_only_their_built_in_entries() {
use crate::i18n::{Lang, tr};
let zh = Lang::from_code("zh-hant").unwrap();
for key in ["None", "Black", "Regular", "Default"] {
assert_ne!(tr(zh, key), key, "{key} must be a catalog key for this test");
}
let names = ["None", "Black", "Regular", "Default"];
assert_eq!(shown_names(zh, &names, |k| k == 0 || k == 3), [tr(zh, "None"), "Black", "Regular", tr(zh, "Default")]);
assert_eq!(shown_names(zh, &names, |_| false), names);
assert_eq!(shown_names(Lang::EN, &names, |_| true), names);
}
/// Confirmations and plug-in dialogs are headed by the text they are given (callers translate
/// their own templates; a plug-in's name is its own).
#[test]
fn given_headings_are_shown_as_they_are() {
let d = Dialog::new(confirm::KIND, json!({"message": "Delete “Black”?", "detail": "Regular"}));
assert_eq!((spec(&d.kind).heading)(&d), "Delete “Black”?");
let d = Dialog::new(plugin::KIND, json!({"__label": "Black", "__plugin": "x"}));
assert_eq!((spec(&d.kind).heading)(&d), "Black");
}
+4 -4
View File
@@ -12,7 +12,7 @@ use crate::{VectorcraftApp, io};
pub(crate) const KIND: &str = "txtOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Text Export Options".into(), body, confirm, ok: Some("Export"), min_width: 340.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Text Export Options").into(), body, confirm, ok: Some("Export"), min_width: 340.0, ..DialogSpec::FORM };
const ENCODINGS: [&str; 2] = ["utf8", "utf16"];
const ENCODING_LABELS: [&str; 2] = ["UTF-8", "UTF-16 (Unicode)"];
@@ -31,16 +31,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim));
egui::Grid::new("text-export").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
label(ui, "Encoding:");
label(ui, tl!("Encoding:"));
choice(ui, d, "encoding", &ENCODINGS, &ENCODING_LABELS);
ui.end_row();
label(ui, "Line Endings:");
label(ui, tl!("Line Endings:"));
choice(ui, d, "lineEndings", &LINE_ENDINGS, &LINE_ENDING_LABELS);
ui.end_row();
});
ui.add_space(8.0);
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", "Selection Only"));
ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", tl!("Selection Only")));
false
}
+10 -9
View File
@@ -15,7 +15,8 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Text Import Options.
pub const KIND: &str = "textImport";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Text Import Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Text Import Options").into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
/// The label column of the Encoding rows.
const LABEL: f32 = 104.0;
@@ -42,13 +43,13 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value {
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::subheader(ui, "Encoding");
form::choice(ui, d, "platform", "Platform:", (LABEL, 180.0), &[("windows", "Windows"), ("mac", "Mac")]);
form::choice(ui, d, "characterSet", "Character Set:", (LABEL, 180.0), &[("unicode", "Unicode"), ("ansi", "ANSI")]);
widgets::subheader(ui, tl!("Encoding"));
form::choice(ui, d, "platform", tl!("Platform:"), (LABEL, 180.0), &[("windows", "Windows"), ("mac", "Mac")]);
form::choice(ui, d, "characterSet", tl!("Character Set:"), (LABEL, 180.0), &[("unicode", "Unicode"), ("ansi", "ANSI")]);
ui.add_space(10.0);
widgets::subheader(ui, "Line Breaks");
widgets::subheader(ui, tl!("Line Breaks"));
for (key, label) in
[("removeLineReturns", "Join the lines of each paragraph"), ("removeParagraphReturns", "Remove blank lines between paragraphs")]
[("removeLineReturns", tl!("Join the lines of each paragraph")), ("removeParagraphReturns", tl!("Remove blank lines between paragraphs"))]
{
let on = d.bool(key);
if widgets::check(ui, label, on, true) {
@@ -56,17 +57,17 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
ui.add_space(10.0);
widgets::subheader(ui, "Spaces");
widgets::subheader(ui, tl!("Spaces"));
let on = d.bool("replaceSpaces");
ui.horizontal(|ui| {
if widgets::check(ui, "Replace", on, true) {
if widgets::check(ui, tl!("Replace"), on, true) {
d.fields.insert("replaceSpaces".into(), json!(!on));
}
ui.add_enabled_ui(on, |ui| {
if let Some(n) = widgets::plain_field(ui, "text-import-spaces", d.f64("spaces", 3.0), "", 0, 44.0) {
d.fields.insert("spaces".into(), json!(n.round().clamp(2.0, 100.0)));
}
widgets::dim_label(ui, "or more spaces with a tab");
widgets::dim_label(ui, tl!("or more spaces with a tab"));
});
});
false
+12 -12
View File
@@ -76,7 +76,7 @@ pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) {
"tiff" => tiff_rows(ui, d, label),
"bmp" => bmp_rows(ui, d, label),
"tga" => {
label(ui, "Depth:");
label(ui, tl!("Depth:"));
depth_row(ui, d, "tga-depth", &tga::DEPTHS, &TGA_DEPTH_LABELS, |_| true);
ui.end_row();
}
@@ -85,51 +85,51 @@ pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) {
}
fn tiff_rows(ui: &mut egui::Ui, d: &mut Dialog, label: Label) {
label(ui, "Color Model:");
label(ui, tl!("Color Model:"));
choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label));
ui.end_row();
label(ui, "Byte Order:");
label(ui, tl!("Byte Order:"));
choice(ui, d, "byteOrder", &ByteOrder::ALL.map(ByteOrder::id), &ByteOrder::ALL.map(ByteOrder::label));
ui.end_row();
ui.label("");
ui.horizontal(|ui| {
form::check(ui, d, "lzw", "LZW Compression");
form::check(ui, d, "embedIcc", "Embed ICC Profile");
form::check(ui, d, "lzw", tl!("LZW Compression"));
form::check(ui, d, "embedIcc", tl!("Embed ICC Profile"));
});
ui.end_row();
}
fn bmp_rows(ui: &mut egui::Ui, d: &mut Dialog, label: Label) {
label(ui, "Color Model:");
label(ui, tl!("Color Model:"));
choice(ui, d, "colorModel", &BMP_MODELS, &BMP_MODEL_LABELS);
ui.end_row();
label(ui, "File Format:");
label(ui, tl!("File Format:"));
choice(ui, d, "fileFormat", &LAYOUTS, &LAYOUT_LABELS);
ui.end_row();
let os2 = d.str("fileFormat") == LAYOUTS[1];
label(ui, "Depth:");
label(ui, tl!("Depth:"));
depth_row(ui, d, "bmp-depth", &bmp::DEPTHS, &BMP_DEPTH_LABELS, |depth| !os2 || bmp::OS2_DEPTHS.contains(&depth));
ui.end_row();
// The palette of a colour image at 4 or 8 bits (1 bit is black and white; greys are fixed).
let depth = d.f64("depth", 24.0) as u8;
if matches!(depth, 4 | 8) && d.str("colorModel") != BMP_MODELS[1] {
label(ui, "Color Reduction:");
label(ui, tl!("Color Reduction:"));
choice(ui, d, "reduction", &Reduction::ALL.map(Reduction::id), &Reduction::ALL.map(Reduction::label));
ui.end_row();
label(ui, "Dither:");
label(ui, tl!("Dither:"));
choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label));
ui.end_row();
}
ui.label("");
ui.horizontal(|ui| {
ui.add_enabled_ui(!os2 && matches!(depth, 4 | 8), |ui| form::check(ui, d, "rle", "Compress (RLE)"));
ui.add_enabled_ui(!os2 && !d.bool("rle"), |ui| form::check(ui, d, "flipRows", "Flip Row Order"));
ui.add_enabled_ui(!os2 && matches!(depth, 4 | 8), |ui| form::check(ui, d, "rle", tl!("Compress (RLE)")));
ui.add_enabled_ui(!os2 && !d.bool("rle"), |ui| form::check(ui, d, "flipRows", tl!("Flip Row Order")));
});
ui.end_row();
normalize_bmp(d);
@@ -16,7 +16,7 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Tile Edge Color.
pub const KIND: &str = "tileEdgeColor";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Tile Edge Color".into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Tile Edge Color").into(), body, confirm, min_width: 300.0, ..DialogSpec::FORM };
/// Open Tile Edge Color on the current preference.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -38,7 +38,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]);
let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
grid(ui, |ui| {
label(ui, "Color:");
label(ui, tl!("Color:"));
ui.horizontal(|ui| {
if let Some((_, [r, g, b])) =
widgets::dropdown(ui, "tile-edge", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 140.0).and_then(|i| LAYER_COLORS.get(i))
+8 -8
View File
@@ -11,11 +11,11 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).int
fn title(kind: &str) -> &'static str {
match kind {
"move" => "Move",
"rotate" => "Rotate",
"scale" => "Scale",
"reflect" => "Reflect",
_ => "Shear",
"move" => tl!("Move"),
"rotate" => tl!("Rotate"),
"scale" => tl!("Scale"),
"reflect" => tl!("Reflect"),
_ => tl!("Shear"),
}
}
@@ -23,10 +23,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d, app.session.general_unit());
ui.add_space(6.0);
if d.kind == "scale" {
form::check(ui, d, "uniform", "Uniform");
form::check(ui, d, "uniform", tl!("Uniform"));
scale_options(app, ui, d);
}
form::check(ui, d, "copy", "Copy (make a transformed copy)");
form::check(ui, d, "copy", tl!("Copy (make a transformed copy)"));
false
}
@@ -45,7 +45,7 @@ pub(super) fn scale_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dia
for (field, _, label) in SCALE_OPTIONS {
let on = scale_option(app, d, field);
d.fields.insert(field.into(), Value::Bool(on));
form::check(ui, d, field, label);
form::check(ui, d, field, tl!(label));
}
}
+10 -10
View File
@@ -20,7 +20,7 @@ pub const KIND: &str = "transformEach";
const CMD: &str = "object.transformEach";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Transform Each".into(), body, confirm, preview: true, min_width: 360.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Transform Each").into(), body, confirm, preview: true, min_width: 360.0, ..DialogSpec::FORM };
/// The fields a fresh dialog starts with.
pub fn fields() -> Value {
@@ -55,9 +55,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.vertical(|ui| {
egui::Grid::new("te-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| {
for (head, rows) in [
("Scale", [("scaleH", "Horizontal:", "%"), ("scaleV", "Vertical:", "%")].as_slice()),
("Move", &[("moveH", "Horizontal:", "pt"), ("moveV", "Vertical:", "pt")]),
("Rotate", &[("rotate", "Angle:", "°")]),
(tl!("Scale"), [("scaleH", tl!("Horizontal:"), "%"), ("scaleV", tl!("Vertical:"), "%")].as_slice()),
(tl!("Move"), &[("moveH", tl!("Horizontal:"), "pt"), ("moveV", tl!("Vertical:"), "pt")]),
(tl!("Rotate"), &[("rotate", tl!("Angle:"), "°")]),
] {
widgets::subheader(ui, head);
ui.end_row();
@@ -69,20 +69,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(16.0);
ui.vertical(|ui| {
widgets::subheader(ui, "Options");
widgets::subheader(ui, tl!("Options"));
ui.add_space(4.0);
scale_options(app, ui, d);
form::check(ui, d, "reflectX", "Reflect X");
form::check(ui, d, "reflectY", "Reflect Y");
form::check(ui, d, "reflectX", tl!("Reflect X"));
form::check(ui, d, "reflectY", tl!("Reflect Y"));
ui.horizontal(|ui| {
let cur = d.f64("reference", 4.0).clamp(0.0, 8.0) as usize;
if let Some(i) = widgets::reference_point(ui, cur) {
d.fields.insert("reference".into(), json!(i));
}
widgets::dim_label(ui, "Reference Point");
widgets::dim_label(ui, tl!("Reference Point"));
});
form::check(ui, d, "random", "Random");
form::check(ui, d, "copy", "Copy");
form::check(ui, d, "random", tl!("Random"));
form::check(ui, d, "copy", tl!("Copy"));
});
});
let mut p = params(d);
+6 -6
View File
@@ -19,7 +19,7 @@ use crate::{VectorcraftApp, widgets};
pub const KIND: &str = "widthPoint";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Width Point Edit".into(), body, confirm, discard: Some("Delete"), min_width: 300.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Width Point Edit").into(), body, confirm, discard: Some("Delete"), min_width: 300.0, ..DialogSpec::FORM };
/// Width point `index` of `id`: its place and side widths (points).
fn point(app: &VectorcraftApp, id: u64, index: usize) -> Option<(f64, f64, f64)> {
@@ -44,12 +44,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let (s1, s2, linked) = (d.f64("side1", 0.0), d.f64("side2", 0.0), d.bool("linked"));
let mut sides = None;
grid(ui, |ui| {
label(ui, "Side 1:");
label(ui, tl!("Side 1:"));
ui.horizontal(|ui| {
if let Some(v) = widgets::num_field(ui, "wp-side1", Some(s1), unit, 90.0) {
sides = Some((v, if linked { v } else { s2 }));
}
if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, "Keep both sides the same width", linked, 22.0).clicked() {
if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tl!("Keep both sides the same width"), linked, 22.0).clicked() {
d.fields.insert("linked".into(), json!(!linked));
if !linked {
sides = Some((s1, s1));
@@ -57,12 +57,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.end_row();
label(ui, "Side 2:");
label(ui, tl!("Side 2:"));
if let Some(v) = widgets::num_field(ui, "wp-side2", Some(s2), unit, 90.0) {
sides = Some((if linked { v } else { s1 }, v));
}
ui.end_row();
label(ui, "Total Width:");
label(ui, tl!("Total Width:"));
if let Some(v) = widgets::num_field(ui, "wp-total", Some(s1 + s2), unit, 90.0) {
// Both sides in proportion (equal halves from nothing).
let k = if s1 + s2 > 1e-9 { v / (s1 + s2) } else { 0.0 };
@@ -76,7 +76,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
ui.add_space(4.0);
ui.allocate_ui(vec2(ui.available_width(), 22.0), |ui| {
if widgets::check(ui, "Adjust Adjoining Width Points", d.bool("adjustAdjoining"), true) {
if widgets::check(ui, tl!("Adjust Adjoining Width Points"), d.bool("adjustAdjoining"), true) {
d.fields.insert("adjustAdjoining".into(), json!(!d.bool("adjustAdjoining")));
}
});
+36 -5
View File
@@ -28,7 +28,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut x = strip.left();
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
let active = app.ui.dock_tab == tab;
let galley = ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let galley =
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
@@ -87,19 +88,22 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let x = screen.right() - ICON_COL - 300.0 - 262.0;
// Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256
// points grows towards the canvas rather than over the panel icons.
let right = screen.right() - ICON_COL - 300.0 - 6.0;
let mut open = true;
egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, 110.0)).show(ctx, |ui| {
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(256.0);
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let tab = egui::Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, *label, theme::semibold(12.0), t.text);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
@@ -127,6 +131,33 @@ mod tests {
use super::*;
use crate::toolbar::tests::{wheel, widget_rects};
#[test]
fn every_flyout_stays_left_of_the_icon_column() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [id] })).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1200.0));
// Where the icon column begins: the flyout must stay left of it.
let column = screen.right() - ICON_COL - 300.0;
let mut too_wide = vec![];
for (panel, _, _) in crate::state::ICON_PANELS {
app.ui.open_panel = Some(panel.to_string());
// Two frames: the first lays the panel out, the second places the settled area.
for _ in 0..2 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| floating_panel(&mut app, ui.ctx())).textures_delta.clear();
}
let rect = ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
if rect.right() > column {
too_wide.push(format!("{panel}: {:.0} past the column", rect.right() - column));
}
}
assert!(too_wide.is_empty(), "flyouts reaching over the icon column: {too_wide:?}");
}
#[test]
fn the_icon_column_scrolls_in_a_short_window() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more