Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 199a464140 #964: start again when the first frame never reaches the screen
With High Performance chosen on a hybrid laptop, the window's first
frame never reached the screen: no error, no panic, the app stayed in
the background without a window and the log stopped after "rendering
with". The start-up recovery of #502 only saw failures.

A watch thread now gives the first frame 20 seconds from the app's
creation (after the files named at the start opened). When the second
frame hasn't begun by then, so the first was never presented, the app
starts again without that adapter, as it does after a graphics failure,
and this process ends. A window minimized at the start isn't counted.
When the hung processor was chosen in Preferences, the status bar's
note after the restart points to Automatic.

docs/development.md explains it and how to recover from a Graphics
Processor choice that doesn't work; ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:38:58 +07:00
BFlat 45516a6a96 Merge pull request #761 from fahrim/mcp-variables
Variables (data merge): text/visibility variables, datasets, panel
2026-10-10 23:35:20 +07:00
bflatasticandClaude Opus 5.5 d0015a496b variables: Window › Variables opens the panel
As Window's other panel items do, and as the reference's Window menu has
it, Window › Variables is the panel itself instead of a submenu of
eighteen commands. The panel menu gains Make Text Dynamic, Make
Visibility Dynamic, Unbind Variable and Delete Variable beside the
bottom bar's buttons, which share one reading of what the selection
allows; every command stays in the command palette. The labels only
the submenu used leave the catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
bflatasticandClaude Opus 5.5 2e07f887bf docs: Variables in the ROADMAP's last-updated list (#761)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
bflatasticandClaude Opus 5.5 500e19680d variables: review fixes for the panel, dialogs and translations
- Dataset and variable names are the user's: the panel's Data Set dropdown and
  the dialogs' name dropdowns show them as they are instead of translating
  them; the dialog's Kind dropdown shows translated kind names.
- Choosing the empty option in a dialog dropdown picked the first name.
- The panel reads the art once per frame for every row's italics, the dialog
  no longer clones the document's variables every frame, and slices are
  matched instead of indexed.
- "No document" and "Define a variable first" go through tl!, and the dialog
  headings Variable Options / Edit Data Set, Text and Visibility have rows in
  every complete catalog; the unused "{name} · {kind} · {count}" row is gone.
- German rows for every Variables string (the catalog predates this branch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
bflatasticandClaude Opus 5.5 b3fa281282 variables: review fixes for hidden objects, namespaces and agent params
- A visibility variable could hide an object but never show it again: dataset
  application, capture and binding refused any object that wasn't visible
  (`is_editable`), so a row that hid an object left it hidden for good and a
  capture after hiding it in the Layers panel didn't record it. They now use
  `Document::variable_target`, which ignores the object's own visibility
  (locked art and art on hidden or locked layers are still skipped).
- Renaming a variable renamed the active dataset when the two shared a name,
  leaving it pointing at a dataset that doesn't exist.
- `variable.delete` and `dataset.delete` without `names` act on the
  highlighted variable and the active dataset, so Window > Variables > Delete
  Variable / Delete Data Set no longer fail with "missing `names`".
- `ids` is checked like other commands' explicit targets (a non-list no longer
  falls back to the selection, bad ids are errors), names are trimmed and
  capped at 255 characters, and the bind checks are shared by Bind and Make
  Dynamic.
- Make Dynamic's fallback name no longer becomes " 2" for unnamed art.
- `dataset.list` reads the art once for every row's drift check, and
  `Variables::prune` looks names up in a set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:21 +07:00
Fahri Meral f2252b0d9c variables: highlighted row owns Delete, Options and Select Bound Object
Select Bound Object ran variable.unbind on the selection, so choosing it silently
removed the bindings; Delete Variable and Delete Data Set deleted every row. Now
the panel keeps a highlighted variable row (a click highlights and selects what
it drives, as the reference panel does): Select Bound Object selects what the
highlighted variable drives via select.set, Delete Variable deletes only it, and
Delete Data Set deletes only the current set. Variable Options and Rename Data
Set open their dialogs preselected with the highlighted variable / active set
(the double-click used to invoke variable.rename with a bare name, which only
errored). Unbind enables only over a bound selection; Make Text Dynamic only for
exactly one type object.

The highlight is per-document session state with its own command
(variable.highlight, no undo step, like layer.highlight): rename carries it,
delete clears it when stale. New engine tests (18) and panel tests cover the
reviewer's regression case, verified to fail on the old wiring.
2026-10-10 23:34:21 +07:00
Fahri Meral 06dbe5b1e8 variables: put the panel in the collapsed icon column
Opening the panel from the Window menu worked, but collapsing the dock did not
show it: `ICON_PANELS` only registers a panel (so `window.panel` finds it) while
`ICON_PANEL_GROUPS` is what the collapsed column actually draws, and `variables`
was in the first list only. Sixteen panels are registered without a group; for
the ones meant to live in the column, the entry in `ICON_PANELS` alone is not
enough.

`variables` gets a row of its own at the bottom of the column, where the
reference app keeps it. Both lists now say which is which, so the next panel
does not repeat this, and a test asserts a registered panel reaches the column
(it fails without the group entry) and that every grouped id names a registered
panel with an icon.

`floating.rs`'s icon test counted the column's icons, which only means
"one left" while the column is tall enough to show them all — past that, one
scrolling into view replaces the one that left. It runs on a window tall enough
for every icon now, and checks the count is the number of registered groups'
icons rather than a number that happens to pass.

Dragging a panel into the icon column still doesn't work, and is not something
this PR changes: tearing a panel off makes it float, for every panel. Docking it
back into the column would be a dock feature of its own.

cargo xtask ci: all 7 steps pass on Rust 1.99.
2026-10-10 23:34:21 +07:00
Fahri Meral 5a638b0a80 variables: the panel does the workflow itself, and rows can be captured
The engine side was called a solid base, but the panel could only list and
apply: variables and datasets were made through commands, which most people
won't find. The reference app's Variables workflow is now what the panel does,
and every button runs a `variable.*` / `dataset.*` command, so the panel and an
agent drive the document the same way.

New commands (all also in Window › Variables, the palette and over MCP):

- `variable.makeTextDynamic` / `variable.makeVisibilityDynamic`: define a
  variable of that kind over the selection, named after what it drives, and
  bind it — one step, as the panel's two buttons.
- `dataset.capture`: record what the bound objects hold right now as a new row
  ("Data Set 1"… ) and make it active. This, rather than typing values, is how
  rows are made: change the art, then capture.
- `dataset.update`: write what the art holds now into the active row. Without
  an active row it says so rather than picking one.
- `variable.rename` / `dataset.rename` (Variable Options…, Rename Data Set…):
  a name is how a variable is referred to, so renaming carries its bindings and
  its values in every row with it.
- `dataset.list` reports `matches` per row: whether the art still holds what
  that row says.

`Document::captured_values` is the one place that reads a bound object's value,
so capture, update and the drift check can't disagree about what the art says.

Panel: the current data set across the top as a dropdown with ◀ ▶; variable
rows with their kind's icon and the name of the object they drive ("Multiple
objects" when it is more than one), a click selecting it and a double click
opening Variable Options; a dataset row in italics once an edit has moved the
art away from what it says; Make Text/Visibility Dynamic, New, Unbind and Delete
below, Capture and Update on a second line; and a (≡) menu with the same
commands for the ones that act on a row rather than the selection.

Checked on Rust 1.99 (the current stable), which is what the review asked for:
cargo xtask ci, all 7 steps pass.
2026-10-10 23:34:21 +07:00
Fahri Meral eb0f2ff2e4 variables: let one object be driven by several variables
`bindings` mapped an object id to a single variable name, so binding an object
to a second variable silently dropped the first — a binding lost with nothing
to show for it. The reference app binds the selected object to the selected
variable, one click at a time, and nowhere says a second binding replaces the
first (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/
automate-actions/work-with-variables.html), so this allows several instead.

`bindings` is now a set of variable names per object. `dataset.select` walks
them in the order the variables are defined, which keeps two text variables on
one object deterministic (the later one's value is what is left, and `applied`
counts both). `variable.bind` reports `bound` (added) and `already` (was there
already); `variable.unbind` takes an optional `variable` to drop one binding
instead of all of them.

This is done before the field ships rather than after: `variables` has never
been in a released file, so the shape can still change for free. Once a version
with it is out, the same change would need a format version bump and a
migration, since `crates/format` keeps every version from 1 loading.

A strict superset of what worked before: every binding a document had is still
there and still applied, in the same order.

cargo xtask ci: all 7 steps pass.
2026-10-10 23:34:21 +07:00
Fahri Meral 202894372a variables: fix binding lifetime, dataset editing and the missing UI to define
Review of #761 turned up three real defects and a feature that was visible
but not usable. All of it is fixed here, each defect with a regression test
that fails without the fix.

- A binding outlived the object it named. Deleting art left its entry in
  `variables.bindings` forever: it counted as a skip in every dataset
  application, `variable.list` counted it, and the saved file grew with every
  delete. `Document::prune_variable_bindings` now lets them go after each
  edit, as `prune_assets` does for Asset Export's.
- `Variables::prune` cleared `active_dataset` by comparing it against the
  *variable* names being deleted. A dataset named like a variable lost its
  active state when the variable went. The two are separate namespaces; the
  block is gone (dataset.delete already did it right).
- `variable.delete` reported the number of names asked for, not the number
  deleted, so deleting a name that was not there still said `deleted: 1`.

- `dataset.set` (new): a dataset's values could only be set when it was
  created, so changing one meant deleting and rebuilding the row — losing its
  place in the list and the active one. This replaces the row's values and
  reports what it dropped.
- Window › Variables, and dialogs for the "…" commands. Every command had an
  empty menu path and the command palette runs those with `{}`, so nothing
  could be defined from the UI at all. The commands now carry their menu
  path, the "…" ones open a Variables dialog (a name and a kind; a name and a
  field per variable, its kind's editor), and `dataset.set` opens with the
  row's own values.
- Locked art is neither bound nor rewritten by a dataset, and says so.
- `set_plain_text` told a node that existed but was not type that it was
  missing.
- The panel had Document Info's icon, so the two were indistinguishable in
  the collapsed column; it gets its own, plus New/Delete buttons, and its
  rows say what their count is.

Format round trips now cover variables (the testkit fixture defines both
kinds, binds them and applies a row), and ROADMAP/README no longer list the
feature as missing.

cargo xtask ci: all 7 steps pass.
2026-10-10 23:34:21 +07:00
Fahri Meral c89906a13a docs: note variables in ROADMAP automation row 2026-10-10 23:32:59 +07:00
Fahri Meral ba2069c057 variables: panel, window menu entry and translations 2026-10-10 23:32:59 +07:00
Fahri Meral 555b0e60d3 variables: data-merge model and engine commands 2026-10-10 23:32:59 +07:00
BFlat e3874e89e7 Merge pull request #981 from storytold/feat/distort-effects
Part of gap 2: Effect › Distort (Diffuse Glow, Glass, Ocean Ripple)
2026-10-10 23:24:59 +07:00
bflatasticandClaude Opus 5.5 8a0cd9dbf6 Part of gap 2: Effect › Distort (Diffuse Glow, Glass, Ocean Ripple)
Three raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::distort), under a new Effect › Distort
submenu between Blur and Pixelate in the Raster Effects section (the
vector Distort & Transform stays its own submenu):

- Diffuse Glow (graininess 0..10, default 6; glowAmount 0..20, 10;
  clearAmount 0..20, 15): the highlights, softened over 2 points, glow
  white from the brightness Clear Amount leaves clear; white grain as
  dense as Graininess says is sprinkled over the object, thicker in the
  glow. Transparency stays as it was.
- Glass (distortion 0..20, 5; smoothness 1..15, 3; texture Blocks,
  Canvas, Frosted or Tiny Lens, default Frosted; scaling % 50..200, 100;
  invert): the object seen through a glass surface; each pixel takes the
  colour the surface's slope bends it to, sampled between pixels. Slopes
  are taken over a span Smoothness widens. The surfaces are made in code
  (Canvas is Texturizer's weave); Load Texture is left out.
- Ocean Ripple (rippleSize 1..15, 9; rippleMagnitude 0..20, 9): smooth
  noise ripples about Ripple Size + 2 points across shift the object by
  up to 0.03 x magnitude x their size.

The shifts are capped, so the effects' outsets and reaches are exact,
and every surface lies in document space around the object's centre:
the same at any zoom or resolution, moving with the object, identical
on every redraw.

Catalogue rows with parameter docs, menu entries, the Texture dropdown,
translations (and German's missing Canvas), tests (parameters and
clamping, outsets, each texture and Invert, the ripples' reach, the glow
and its grain, resolution and move invariance, canvas, multithreaded,
PDF and SVG, the command sweep with junk parameters, the menu) and
ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:23:36 +07:00
BFlat f147eb58c7 Merge pull request #974 from IHAGI-c/fix/graph-tick-marks
Graph Type: tick mark length and count per axis, category tick marks, axis label prefix and suffix
2026-10-10 23:07:36 +07:00
bflatasticandClaude Opus 5.5 3a48de555e Review: translate the tick mark lengths; ROADMAP
Short and Full Width, the Graph Type dialog's new Length choices, in the
complete catalogs (None was there). The Value Axis dropdown keeps the
one that landed with #971. ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:49:15 +07:00
IHAGI-c d7fb5ec727 Graph Type: tick mark length and count per axis, category tick marks, axis label prefix and suffix
Each axis now takes its own tick marks, and the value axes a prefix and
suffix for their numbers:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-graph-value-axes.html

- Value axes (the left one, or a bar graph's bottom one, and the right
  one): tick marks of no, short or full length, and a count per
  division; labels stay at the divisions.
- Category axis: the same, at the labels or, with Draw Tick Marks
  Between Labels, between them (halfway between labels when they sit
  at the plot's edges).
- Add Labels: prefix and suffix per value axis ($, %).
- The Graph Type dialog offers the lengths and value axis sides as
  choices, and counts it sends back as floats are read.

Own choices, as the page doesn't say: the lengths are none, short (the
4 pt ticks as before) and full (across the plot); category axes start
with no tick marks, as they had none before. Counts are capped at 20
per division and 10,000 category tick marks, affixes at one line of 64
characters. A full-length tick on the zero line or an axis line is left
out.

The legend moves past a bar graph's last value label when a suffix
runs it into the legend's gap. New fields are written only when set;
graphs with the default options draw the same art as before.
2026-10-10 22:38:35 +07:00
BFlat 6794385731 Merge pull request #971 from IHAGI-c/fix/graph-value-axes
Graph Type: value axis on the left, right or both sides; separate scales
2026-10-10 22:35:33 +07:00
bflatasticandClaude Opus 5.5 bea68002e9 Review: Graph Type's Value Axis is a dropdown
The generic Graph Type dialog showed `valueAxis` as a text field. It now
lists On Left Side, On Right Side and On Both Sides, translated in the
complete catalogs, with a test that the choices match the command's
params and that OK moves the axis. ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:48:51 +07:00
IHAGI-c 2f97c8ab49 Graph Type: value axis on the left, right or both sides; separate scales
The value axis can now go on the left side (as before), the right side
or both sides of column, stacked column, line, area and scatter graphs:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-graph-value-axes.html

With the axis on both sides, Separate Scales gives the series assigned
to the right axis a scale of their own there, with its own tick values
(rightTicks, rightAxisMin, rightAxisMax):
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/assign-different-scales-to-value-axes.html

- A series goes on an axis with Group Selection (or `seriesIndexes`)
  and `valueAxis` left or right. Without separate scales both sides
  show the one scale.
- Each axis stacks its own stacked columns and areas; stacked columns
  of the two axes take a slot of the cluster each instead of covering
  each other.
- An axis with no series of its own shows the other one's scale. The
  right scale's zero gets a line of its own when it isn't the left's.
- The right axis' labels push the legend further right.
- With series on both axes picked, the Graph Type query returns no
  value axis, so the dialog's OK leaves each series where it is.
- Graph Data drops right-axis assignments for series the new data no
  longer has.

Own choices, as the pages don't say: bar graphs keep their value axis
along the bottom, and scatter graphs draw every series on one scale.

New fields (valueAxis, separateScales, rightSeries, rightTicks,
rightAxisMin, rightAxisMax) are written only when set, so other files
are unchanged and older versions draw the left axis as before. Graphs
with the axis on the left draw the same art as before.
2026-10-10 21:38:15 +07:00
BFlat 2b000d828e Merge pull request #934 from dwetscher/feat/i18n-german
German interface language (de)
2026-10-10 21:28:40 +07:00
bflatasticandClaude Opus 5.5 a644c818c9 Review: German for the features landed since the catalog was written
Pixelate, Texture and Video raster effects, Libraries groups, search,
import and export, Update Changed Links, System Title Bar, Scatter, Art
and Pattern Brush Options with Colorization, and the PSD messages, in
the catalog's own conventions. ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:28:15 +07:00
dwetscherandClaude Opus 5.5 194968bc27 German interface language (de)
Add the German catalog: every menu string, every `tl!` literal, the plural
forms and the status and error messages (3,818 rows), written from the meaning
of the English labels in the vocabulary German-speaking users of vector
illustration software know (Zeichenfläche, Farbfelder, Kontur, Fläche, Pfad,
Ankerpunkt, Ebene, Verlauf, Deckkraft). Conventions are in the header of
`de.tsv`.

- Register `de` ("Deutsch") in `i18n::LANGUAGES` as a complete language; every
  `de-*` locale (`de-DE`, `de-AT`, `de-CH` …) resolves to it.
- Tests: German joins the menu-label, menu-string, `tl!`-literal and message
  coverage checks; `german_reads_as_german` pins the core vocabulary, the
  plurals, a nested message, the joined "…format" style names and the theme
  context ("Erscheinungsbild" vs the Appearance panel's "Aussehen"); ä ö Ä Ö ß
  are checked in the bundled UI fonts. The "unsupported locale" cases use
  Dutch now that German is supported.
- README, ROADMAP and docs/development.md list German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:28:15 +07:00
BFlat 58bb72ffc6 Merge pull request #969 from rafi612/system-title-bar
Compact app bar: 32 pt bar, PhotoCraft search icon, no app menu + System title bar option
2026-10-10 21:26:34 +07:00
bflatasticandClaude Opus 5.5 6315086f96 Review: System Title Bar only where it applies, docs, title repaint
- Preferences hides System Title Bar on macOS and the web, where it did
  nothing (it fell through to the plain checkbox row), and lists it with
  the window options instead of under Language.
- The next-launch note reuses "Applies the next time VectorCraft starts."
  instead of a second wording in every catalog.
- A new window title asks for one more frame: AppKit lays the title bar
  out again for it, and the next frame centres the window buttons (#968).
- The menu bar drops the bold branch for the VectorCraft menu it no
  longer shows; theme is imported where the tests use it.
- Docs: Language is Preferences › User Interface › Language (VectorCraft
  › Language on macOS); the control protocol describes System Title Bar;
  ROADMAP Last updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:15:21 +07:00
rafi612 2e643b0e76 Make the menu bar more compact 2026-10-10 21:01:38 +07:00
rafi612 bd895a1de7 Remove the in-window VectorCraft menu (File first, Exit quits, Mac unchanged) 2026-10-10 21:01:38 +07:00
rafi612 12f8a77a00 User Interface › System Title Bar: native decorations on Windows/Linux, file in OS title 2026-10-10 21:01:38 +07:00
BFlat 9f659195c3 Merge pull request #972 from storytold/fix/968-traffic-lights
#968: centre the macOS window buttons on the app bar
2026-10-10 20:56:34 +07:00
bflatasticandClaude Opus 5.5 258d61ced0 #968: centre the macOS window buttons on the app bar
AppKit places close, minimize and zoom in a standard title bar, shorter
than the 44-point app bar they sit over, so they rode high on it. They
are now moved to the middle of the bar, inset from its left as far as
from its top, with the title bar container made as tall as the bar so
they stay clickable. AppKit lays the title bar out again on resize and
when leaving full screen, so the placement runs every frame and sets a
frame only when it is out of place. The app bar keeps the room the
buttons take, measured, instead of a fixed 78 points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:56:18 +07:00
BFlat 5ff1b97376 Merge pull request #970 from storytold/fix/962-inner-glow-source
#962: Inner Glow's Source is a Center/Edge dropdown
2026-10-10 20:45:36 +07:00
bflatasticandClaude Opus 5.5 9216c47204 #962: Inner Glow's Source is a Center/Edge dropdown
The effect dialog showed Inner Glow's `source` as a text field, so the
only way to pick Center was to type the parameter value. It now lists
Center and Edge like the other fields that pick one of a few values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:32:47 +07:00
BFlat 4eaf5e7b50 Merge pull request #967 from storytold/feat/link-watch
Links: Update Links applies while a document is open (from #956)
2026-10-10 20:20:24 +07:00
bflatasticandClaude Opus 5.5 068a934f6a Links: one watch for changed linked files
The link watch checks the active document's linked files every two
seconds and as the window comes back to the front, so the separate check
on focus that #925 added (missing_links::after_focus and its asked-files
set) goes: with both, a change was acted on twice. The watch also
walks the document's links only when a look is due, not every frame, and
wakes the app every two seconds only while the document has links. The
doc comments of status and links::groups are back on their functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:27 +07:00
KattandClaude Opus 5.5 079580257a Links: Update Links applies while a document is open
Linked files another app changes while their document is open now follow
Preferences > File Handling > Update Links, as they do when it opens:
Automatically reads them again (an Update Links undo step), Ask When
Modified offers to (a question worded for files changed on disk while
working), Manually leaves them modified in the Links panel.

The desktop app stamps the active document's linked files (size +
modification time) on a worker thread every two seconds and when its
window comes back to the front; no look starts while a dialog is open.
Stamps are kept per document; a change is acted on once; a file still
being written is tried again. links.updateChanged reads changed files
again regardless of the preference (CLI, MCP).

New strings translated in all ten catalogs; ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:27 +07:00
BFlat f81c0f51d7 Merge pull request #959 from IHAGI-c/fix/graph-combine-types
Graph Type: combine graph types, one type per series
2026-10-10 20:09:10 +07:00
IHAGI-c 01a65c44ab Graph Type: combine graph types, one type per series
Graph Type on series picked with Group Selection (or listed in the new
`seriesIndexes` param) now sets the type of those series only, so a
column graph can carry a line series:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/combine-different-graph-types.html

- Column, stacked column, line and area mix on the vertical value axis;
  bar and stacked bar on the horizontal one. Scatter, pie and radar take
  no other type. The help page doesn't list which types mix, so this is
  our own choice: types that share the value axis.
- Stacked columns take one slot of the column cluster (a graph of
  stacked columns only is unchanged); stacked series stack with each
  other, areas with each other, and the value axis covers both totals.
- Areas draw at the back, lines in front of columns. Category labels,
  areas and lines sit at the category centres once a series that isn't
  edge to edge is in the graph.
- With only series selected, the Graph Type query returns their type,
  so the dialog's OK keeps it, and they stay selected after the change.
  An explicit `id`, or anything else in the selection, is the whole
  graph, as before.
- A series given the graph's own type follows the graph again. A type
  the graph's type can't take is drawn as the graph's and kept.

Series types are stored in a new `seriesKinds` field, written only when
a series has its own type; versions that don't know it draw the whole
graph as the graph's type. Graphs of one type draw the same art as
before.
2026-10-10 20:08:33 +07:00
BFlat 11c3a99bfb Merge pull request #960 from storytold/feat/texture-effects
Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
2026-10-10 19:44:02 +07:00
bflatasticandClaude Opus 5.5 30ba774e6f Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
Six raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::texture), under a new Effect › Texture
submenu between Sharpen and Video in the Raster Effects section:

- Craquelure (crackSpacing pt 2..100, default 15; crackDepth 0..10, 6;
  crackBrightness 0..10, 9): the object on relief plaster that cracks into
  plates about Crack Spacing apart (a Voronoi network over jittered points)
  and along the quarter, half and three-quarter contours of its softened
  tones. Depth sets how dark and wide the cracks are and how high the
  plates' bevels and the tones stand (lit from the top left); Brightness
  how brightly the surface is lit.
- Grain (intensity 0..100, 40; contrast 0..100, 50 = unchanged; grainType
  Regular, Soft, Sprinkles, Clumped, Contrasty, Enlarged, Stippled,
  Horizontal, Vertical, Speckle; default Regular): coloured 1-point grains,
  smooth ones, clumps, 2-point grains, streaks along rows or columns, dark
  specks; Sprinkles and Stippled use the background colour, white.
- Mosaic Tiles (tileSize pt 2..100, 12; groutWidth 1..15, 3; lightenGrout
  0..10, 9): irregular tiles bevelled at their edges, with sunken grout half
  a point wide per step, lightened as Lighten Grout says.
- Patchwork (squareSize pt 0..10, 4; relief 0..25, 8): squares filled with
  the colour around their centres, raised to heights that follow the
  highlights and shadows, a little more or less at random.
- Stained Glass (cellSize pt 2..50, 10; borderThickness 1..20, 4;
  lightIntensity 0..10, 3): single-coloured panes leaded in the foreground
  colour (black, half a point per step), lit from behind at the object's
  centre and fading out towards its corners (PixelSpace gains `extent`).
- Texturizer (texture Brick, Burlap, Canvas, Sandstone, default Canvas;
  scaling % 50..200, 100; relief 0..50, 4; lightDirection Bottom, Bottom
  Left, Left, Top Left, Top, Top Right, Right, Bottom Right, default Top;
  invert): the object on a surface in relief. The four surfaces are height
  fields generated in code (bricks in stretcher bond, plain weaves coarse
  and fine, octaves of smooth noise); no image files. Load Texture is left
  out.

Every pattern lies in document space around the object's centre, so the
result is the same at any zoom or raster resolution, moves with the object
and is identical on every redraw (hash noise of cell indices); slopes are
taken in document units. Work per pixel is bounded (a 5 x 5 cell search,
four or five height samples, box blurs); parameters are clamped to their
ranges and finite-checked. The Pixelate helpers (Place, the cell search,
the softened colours) are shared, Mezzotint uses the shared smooth noise,
and the effect dialogs' dropdowns translate their labels in an "effect"
context where the plain word means something else (Soft, Horizontal and
Vertical are type settings in Japanese and Chinese).

Catalogue rows with parameter docs, outsets and reaches, menu entries,
dropdowns (Grain Type, Texture, Light Direction), translations, tests
(parameters and clamping, cracks and contours, ten distinct grains, tiles
and grout, squares in relief, leaded panes and the centre light, the
textures lit and inverted, the same at twice the resolution and when
moved, canvas, multithreaded, PDF and SVG, the command sweep with junk
parameters, the dialogs and the menu) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:31:47 +07:00
BFlat 32a688d68b Merge pull request #953 from crqch/fix-contributor-links
Fix contributor links
2026-10-10 18:50:11 +07:00
BFlat 5d07d646a9 Merge pull request #950 from seldoms/i18n/chinese-translations
i18n: wrap hardcoded UI strings for Chinese localization
2026-10-10 18:40:44 +07:00
bflatasticandClaude Opus 5.5 22443d8711 Keep CJK UI fallback fonts to ui_fonts
The app already finds an installed font for CJK (and other) characters
the UI fonts lack, the first time a frame paints one (ui_fonts): reading
a system CJK font at every startup duplicated that, cost a font file's
read and memory for interfaces that never show CJK, and broke its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:15 +07:00
Michał Kosior crqch a0cc4a4b94 Fix contributor links 2026-10-10 13:35:36 +02:00
seldoms f0349fb2f8 i18n: wrap hardcoded UI strings for Chinese localization
- Wrap remaining hardcoded UI labels and buttons with tr()/tl!() calls
- Add missing Simplified and Traditional Chinese translations
- Add entries to other language catalogs for consistency
2026-10-10 18:27:56 +07:00
BFlat d428de6caa Merge pull request #952 from storytold/fix/949-artboard-label-width
Fix #949: artboard names fit their artboard when zoomed out
2026-10-10 18:25:02 +07:00
bflatasticandClaude Opus 5.5 6a13088840 Fix #949: artboard names fit their artboard when zoomed out
Artboard names are drawn at a fixed screen size above each artboard, so
zoomed out, where artboards shrink on screen, the names ran past them
and over their neighbours. A name is now no wider than its artboard on
screen, ending in an ellipsis when longer, and an artboard narrower
than 24 px shows none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:24:50 +07:00
BFlat 18c9d27c8d Merge pull request #947 from IHAGI-c/fix/graph-blank-values
Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
2026-10-10 18:16:26 +07:00
BFlat 3b32ea9626 Merge pull request #946 from rschuerer/fix/861-draw-non-native-art
Part of #861: draw non-native art in .ai editing data from its PDF
2026-10-10 18:13:52 +07:00
bflatasticandClaude Opus 5.5 42b198e3cb Graph data: constants after the imports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:59:47 +07:00
IHAGI-c 35c4a428ff Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
A graph's data can now hold blank cells: an empty CSV cell, a null in
`rows` (any cell that isn't a finite number), the rest of a short row,
or a row that has only a label.

- Columns, bars and stacked segments leave a blank cell out instead of
  drawing a zero-height mark.
- Line and scatter graphs break their lines around it and draw no
  marker there; the value axis ignores it.
- Area, pie and radar graphs draw it as 0, which is what it adds to
  them.

The help pages don't describe blank cells, so this is our own choice:
a cell with no value has nothing to draw, which also matches how a
series with a missing year reads in a report.

In the file a blank cell keeps a 0 in `rows` and is listed in a new
`blanks` field ([category, series] pairs, written only when there are
any). A version that doesn't know blanks ignores the field and reads
the zeros, so files still open there, and files without blanks are
unchanged.

A number in straight quotes is a label, as the help page on graph
labels says (a year as a category name, for one). The CSV reader used
to strip the quotes first and read it as a value. Label columns are now
decided for the whole column: once any row starts with a label, every
row's first cell is its label, so a row with an empty label no longer
shifts its values one place left. The data window writes a header row,
labels that are empty, read as numbers or have spaces around them in
quotes, and blanks as empty cells, so they come back as they were.
graph.setData {} returns blanks in `rows` as null, so the rows it
returns can be edited and sent back. A label-only row keeps its place
with blank values; a row with neither a value nor a label is dropped,
and a first row that is a label followed by empty cells (`Year,,`) is
still read as a header with no series names.

The data grid is built in full for drawing and for the data window, so
it now stops at 10,000 categories of 256 series, which keeps a file
with one very long row from allocating without bound.

Behaviour (quoted labels): Add graph labels and data sets:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-labels-and-data-sets.html
2026-10-10 17:59:45 +07:00
bflatasticandClaude Opus 5.5 1446b4f5dc Keep the doc comment of unreadable on unreadable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:58:55 +07:00
Reinhard SchuererandClaude Fable 5.1 79dd751d3b Draw non-native art in .ai editing data from its PDF
A /ForeignObject (non-native art: the content of a placed PDF, which
Illustrator shows but doesn't edit) was left out with a note. The
letterhead of #861 keeps its fan of swooshes that way: a radial gradient
in a 3-point PDF, scaled 271 times and clipped by the swoosh paths, so
the page lost its main graphic.

The reader now decodes the object's ASCII85 /Data, has the PDF importer
read the PDF, and fits its art to the object's box as a group named
"Non-native art": /Bounds measured from /Origin with y down, then
/RTransform, which lands exactly on the clipping paths around both
objects of that file; the PDF's content is kept in its own y-up
coordinates, so it is flipped into the box. Ids are the document's and
the PDF's swatches stay unlinked; its images join the document by
content key. A PDF that can't be read is left out with a note, as
before.

The EPS crate now uses the PDF crate (same layer; allowed in the layer
check like render and metafile).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch
2026-10-10 17:58:55 +07:00
BFlat 7d6be31711 Merge pull request #948 from storytold/feat/brush-options-scatter-art-pattern
Part of gap 5: Scatter, Art and Pattern Brush Options
2026-10-10 17:57:25 +07:00
78 changed files with 11701 additions and 547 deletions

No files matched your search

Generated
+2
View File
@@ -4335,6 +4335,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"raw-window-handle",
"rfd",
"serde_json",
"vectorcraft-engine",
@@ -4499,6 +4500,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
+2 -2
View File
@@ -213,7 +213,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
speaks English, German, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
@@ -221,7 +221,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
- 3D and Materials;
- the Photoshop-style raster effects (Effect Gallery);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- Variables and scripting;
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- packaging for Windows and Linux.
+20 -14
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-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -35,7 +35,7 @@ since 2026-10-01.
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~10 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~19 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
@@ -53,13 +53,16 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 45 filters (Artistic, Brush Strokes, Distort,
Sketch, Stylize, Texture) and the Effect Gallery. The pixel-filter pipeline is in place
2. **Photoshop-style raster effects and the Effect Gallery:** about 36 filters (Artistic, Brush Strokes, Sketch,
Stylize) and the Effect Gallery. The pixel-filter pipeline is in place
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
(Normal mode only), Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
Stylize › Glowing Edges and Video › De-Interlace and NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
well across agents. 23–36 h.
(Normal mode only), Distort › Diffuse Glow, Glass (its Blocks, Canvas, Frosted and Tiny Lens surfaces made in
code; Load Texture is missing) and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
Stylize › Glowing Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (its
Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and
NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
well across agents. 19–31 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
@@ -74,7 +77,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by
pen tilt, bearing and barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every
platform. 17–28 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
6. **Automation:** a scripting surface over the command registry, batch, and the rest of
Variables (data merge): image and graph variable kinds, dataset import/export. 6–10 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, Affinity files from more versions and platforms
@@ -100,6 +104,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- Variables (data merge): text and visibility variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility Dynamic, Capture/Update Data Set, and a row in italics when an edit leaves the art not matching it; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI.
- 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. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832).
- 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`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `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).
@@ -112,7 +117,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, German, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
@@ -140,7 +145,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Graph Design, Column and Marker artwork remain open.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph Design, Column and Marker artwork remain open.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
@@ -153,7 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; 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:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; 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.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -177,6 +182,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
@@ -225,7 +231,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 29% | ~45 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize, Texture), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize) and Video (De-Interlace, NTSC Colors) are done | 23–36 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
@@ -237,8 +243,8 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
| Automation | 3 | 65% | scripting surface, batch (Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 13–20 |
| Libraries, Links, Package | 2 | 82% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
+5 -2
View File
@@ -51,10 +51,13 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
# The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR
# Apache-2.0 OR Zlib, already in the build through eframe and winit.
raw-window-handle = "0.6"
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
# objc2-foundation.
+97
View File
@@ -26,9 +26,16 @@
//! the graphics fail while the window is starting up, [`finish`] starts the app again without
//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
//! adapter, so it ends when none is left.
//!
//! An adapter can also hang instead of failing: on a hybrid laptop with High Performance chosen,
//! the discrete GPU never put the window's first frame on the screen, with no error and no panic,
//! and the app stayed in the background without a window (#964). [`watch_first_frame`] gives the
//! first frame [`FIRST_FRAME_TIMEOUT`] once the app is created and, when it doesn't come, starts
//! the app again without that adapter in the same way.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
@@ -50,6 +57,11 @@ const HELP: &str =
/// window is something else (such as the compositor restarting).
pub const STARTUP_FRAMES: u64 = 10;
/// How long the window's first frame may take to reach the screen, from the app's creation,
/// before its adapter counts as hanging ([`watch_first_frame`]). The first frame draws the whole
/// interface and compiles its shaders, a second at most on a slow machine.
pub const FIRST_FRAME_TIMEOUT: Duration = Duration::from_secs(20);
/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
/// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid
@@ -311,6 +323,8 @@ pub struct Startup {
adapter: OnceLock<String>,
/// The UI's context, which counts the frames run.
ui: OnceLock<egui::Context>,
/// When the app was created, the files named at the start opened: the first frame is due.
ready: OnceLock<Instant>,
}
impl Startup {
@@ -319,6 +333,71 @@ impl Startup {
// Set once; the window is created once.
let _ = self.ui.set(ctx.clone());
}
/// The app is ready to draw its first frame.
pub fn ready(&self) {
// Set once, like `created`.
let _ = self.ready.set(Instant::now());
}
}
/// What [`watch_first_frame`] makes of the window's start.
#[derive(Debug, PartialEq, Eq)]
enum FirstFrame {
/// Not due yet.
Wait,
/// On the screen (the second frame began, so the first was presented), or the window was
/// minimized at the start: nothing to watch.
Shown,
/// Overdue.
Hung,
}
/// The window's start after `frames` frames run, `since` its first frame was due.
fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
if frames >= 2 || minimized {
FirstFrame::Shown
} else if since >= FIRST_FRAME_TIMEOUT {
FirstFrame::Hung
} else {
FirstFrame::Wait
}
}
/// Watch the window's first frame from another thread: when it hasn't reached the screen
/// [`FIRST_FRAME_TIMEOUT`] after the app was created ([`Startup::ready`]), start the app again
/// without the adapter, as [`finish`] does for one that failed, and end this process, whose UI
/// thread is stuck in the graphics driver.
pub fn watch_first_frame(startup: Arc<Startup>) {
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
let Some(due) = startup.ready.get() else { continue };
let ui = startup.ui.get();
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Shown => return,
FirstFrame::Hung => {}
}
let mut skip = skipped();
let Some(failed) = restart_without(true, frames, startup.adapter.get().map(String::as_str), &skip) else { return };
log::error!(
"the window's first frame didn't reach the screen in {}s on graphics adapter {failed}: starting again without it",
FIRST_FRAME_TIMEOUT.as_secs()
);
skip.push(failed.to_string());
match restart(&skip.join(",")) {
Ok(()) => std::process::exit(0),
Err(e) => {
log::error!("starting again failed: {e}");
return;
}
}
};
if let Err(e) = std::thread::Builder::new().name("first frame".into()).spawn(watch) {
log::warn!("no watch on the window's first frame: {e}");
}
}
/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a
@@ -729,6 +808,24 @@ mod tests {
assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]);
}
#[test]
fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() {
let s = Duration::from_secs;
// The second frame began: the first was presented.
assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown);
// Still drawing (or presenting) the first frame: waited for, then given up on (#964).
for frames in [0, 1] {
assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait);
assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung);
}
// A window minimized at the start may not draw again until restored.
assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown);
// A hung adapter is left out once, like one that failed.
let skip = vec!["Dx12:10de:28a0".to_string()];
assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None);
assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8"));
}
#[test]
fn a_restart_tries_the_next_adapter() {
let c = issue_502();
+74
View File
@@ -0,0 +1,74 @@
//! macOS: the window buttons (close, minimize, zoom) centred on the app bar (#968).
//!
//! AppKit puts the buttons in a standard title bar, shorter than the app bar they sit over, so they
//! rode high on it. [`center_buttons`] moves them to the middle of the bar, inset from its left as
//! far as from its top, and makes the title bar as tall as the app bar so they stay clickable.
//! AppKit lays its title bar out again when the window resizes or leaves full screen, so this runs
//! every frame and moves the buttons only when they are out of place.
//!
//! Reaching the title bar's views from the window's content view has no safe API, hence the scoped
//! `unsafe_code` allowance.
#![allow(unsafe_code)]
use objc2::runtime::AnyObject;
use objc2_app_kit::{NSView, NSWindowButton, NSWindowStyleMask};
use objc2_foundation::NSPoint;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
/// The room the app bar keeps at its left for the buttons in AppKit's own place, before they are
/// centred (and in full screen, where AppKit shows them elsewhere): their width and a gap.
pub const DEFAULT_INSET: f32 = 78.0;
/// Differences below this (in points) leave a frame as it is, so a frame in place isn't set again.
const SLACK: f64 = 0.25;
/// Centre the window's buttons on an app bar `bar` egui points tall, at the window's `zoom` factor
/// (native points per egui point). Returns the room the app bar keeps clear at its left for them,
/// in egui points, or `None` when they aren't over the bar (full screen) or can't be found.
pub fn center_buttons(frame: &eframe::Frame, zoom: f32, bar: f32) -> Option<f32> {
let RawWindowHandle::AppKit(handle) = frame.window_handle().ok()?.as_raw() else {
return None;
};
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and eframe hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let window = view.window()?;
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
return None;
}
let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
let zoom_button = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// The buttons sit in the title bar view, inside the title bar container that sets its height.
// SAFETY: both views belong to the window, alive while it is; used right away.
let container = unsafe { close.superview().and_then(|titlebar| titlebar.superview()) }?;
// Another layout (a later macOS) is left as AppKit made it.
if AnyObject::class(&container).name() != c"NSTitlebarContainerView" {
return None;
}
let size = close.frame().size;
let gap = minimize.frame().origin.x - (close.frame().origin.x + size.width);
let height = f64::from(bar * zoom);
let margin = (height - size.height) / 2.0;
if !(margin.is_finite() && margin > 0.0 && gap.is_finite() && gap >= 0.0 && zoom > 0.0) {
return None;
}
let mut bounds = container.frame();
let top = window.frame().size.height - height;
if (bounds.size.height - height).abs() > SLACK || (bounds.origin.y - top).abs() > SLACK {
bounds.size.height = height;
bounds.origin.y = top;
container.setFrame(bounds);
}
let mut x = margin;
for button in [close, minimize, zoom_button] {
let at = NSPoint::new(x, (height - button.frame().size.height) / 2.0);
let now = button.frame().origin;
if (now.x - at.x).abs() > SLACK || (now.y - at.y).abs() > SLACK {
button.setFrameOrigin(at);
}
x += size.width + gap;
}
// The last button's right edge (`x` steps one gap past it) and the margin again after it.
Some(((x - gap + margin) / f64::from(zoom)) as f32)
}
+75 -21
View File
@@ -31,6 +31,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
mod prefs_dir;
mod printing;
@@ -69,7 +71,14 @@ impl eframe::App for App {
}
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
@@ -391,6 +400,32 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// macOS keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
/// before the window opens; a missing or unreadable value keeps the default.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}
fn main() -> std::process::ExitCode {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
@@ -469,7 +504,9 @@ fn main() -> std::process::ExitCode {
#[cfg(feature = "wgpu")]
let displays = gpu::preferred_displays(gpu_pref, power_env);
#[cfg(feature = "wgpu")]
if gpu::automatic(gpu_pref, power_env) {
let chosen_gpu = !gpu::automatic(gpu_pref, power_env);
#[cfg(feature = "wgpu")]
if !chosen_gpu {
let listed: Vec<String> = displays.iter().map(ToString::to_string).collect();
log::info!("display GPUs (PCI vendor:device): {}", if listed.is_empty() { "unknown".to_string() } else { listed.join(", ") });
} else {
@@ -477,22 +514,8 @@ fn main() -> std::process::ExitCode {
}
#[cfg(feature = "wgpu")]
let startup = std::sync::Arc::new(gpu::Startup::default());
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
};
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
@@ -513,6 +536,8 @@ fn main() -> std::process::ExitCode {
};
#[cfg(feature = "wgpu")]
gpu::watch_panics();
#[cfg(feature = "wgpu")]
gpu::watch_first_frame(startup.clone());
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
@@ -556,7 +581,15 @@ fn main() -> std::process::ExitCode {
log::info!("rendering with {summary} (power preference {power:?})");
created.created(&cc.egui_ctx);
if !gpu::skipped().is_empty() {
app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}"));
// A processor chosen in Settings can be the one that can't show the window.
let hint = if chosen_gpu {
" (Settings › Performance › Graphics Processor › Automatic uses the one that drives your display)"
} else {
""
};
app.status(format!(
"The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}{hint}"
));
}
app.graphics_adapter = Some(summary);
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
@@ -566,8 +599,7 @@ fn main() -> std::process::ExitCode {
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
app.custom_titlebar = custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx).with_automation_roots(roots.clone());
@@ -589,6 +621,9 @@ fn main() -> std::process::ExitCode {
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
file_access::unconfined(|| open_files(&mut app, files));
// The first frame is due from now (`gpu::watch_first_frame`).
#[cfg(feature = "wgpu")]
created.ready();
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
}),
)
@@ -610,6 +645,25 @@ fn main() -> std::process::ExitCode {
mod tests {
use super::*;
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
// decorations on Windows and Linux; macOS always has them.
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
assert!(!super::custom_titlebar(Some(&on)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
if super::CUSTOM_TITLEBAR {
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
+3
View File
@@ -26,6 +26,9 @@ sync_github_name = false
real_name = "Riccardo Balloch"
sync_github_name = false
[people.crqch]
sync_github_name = true
[people.mo0kid]
sync_github_name = true
+177
View File
@@ -1,8 +1,15 @@
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Rect;
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
/// for the data window, so a file can't make it allocate without bound.
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
pub const MAX_GRAPH_SERIES: usize = 256;
/// The nine graph types, in the Graph tool group's order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -62,6 +69,70 @@ impl GraphKind {
GraphKind::Radar => "Radar",
}
}
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
/// Scatter, pie and radar lay their data out on their own and take no other types.
pub fn combines_with(self, graph: GraphKind) -> bool {
use GraphKind::*;
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
let horizontal = |k| matches!(k, Bar | StackedBar);
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
}
}
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ValueAxisSide {
#[default]
Left,
Right,
Both,
}
impl ValueAxisSide {
pub fn id(self) -> &'static str {
match self {
ValueAxisSide::Left => "left",
ValueAxisSide::Right => "right",
ValueAxisSide::Both => "both",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ValueAxisSide::Left, ValueAxisSide::Right, ValueAxisSide::Both].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_left(&self) -> bool {
*self == ValueAxisSide::Left
}
}
/// Graph Type › Tick Marks › Length: no tick marks, short ones outside the axis, or lines across the whole plot.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TickLength {
None,
#[default]
Short,
Full,
}
impl TickLength {
pub fn id(self) -> &'static str {
match self {
TickLength::None => "none",
TickLength::Short => "short",
TickLength::Full => "full",
}
}
pub fn parse(s: &str) -> Option<Self> {
[TickLength::None, TickLength::Short, TickLength::Full].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_short(&self) -> bool {
*self == TickLength::Short
}
fn is_none(&self) -> bool {
*self == TickLength::None
}
}
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
@@ -88,11 +159,19 @@ pub struct GraphSpec {
/// entries keep the generator's own fill and stroke.
pub series_paints: Vec<SeriesPaint>,
pub kind: GraphKind,
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
/// entries draw as [`Self::kind`].
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_kinds: Vec<Option<GraphKind>>,
/// The plot rectangle (the area the graph tool dragged), document points.
pub rect: Rect,
pub series: Vec<String>,
pub categories: Vec<String>,
pub rows: Vec<Vec<f64>>,
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blanks: Vec<[usize; 2]>,
/// Column/bar width as a % of the available category width (90).
pub column_width: f64,
/// Cluster width as a % (80).
@@ -103,6 +182,50 @@ pub struct GraphSpec {
pub ticks: usize,
pub axis_min: Option<f64>,
pub axis_max: Option<f64>,
/// Graph Type › Value Axis side. Column, stacked column, line, area and scatter graphs (the bar graphs' value axis
/// runs along the bottom and stays there).
#[serde(skip_serializing_if = "ValueAxisSide::is_left")]
pub value_axis: ValueAxisSide,
/// Graph Type › Separate Scales: with the value axis on both sides, the series on the right axis
/// ([`Self::right_series`]) get a scale of their own there.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub separate_scales: bool,
/// Indexes of the series assigned to the right value axis.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub right_series: Vec<usize>,
/// The right value axis' tick values, used with separate scales: tick count (0 = automatic) and min/max override.
#[serde(skip_serializing_if = "is_zero")]
pub right_ticks: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_max: Option<f64>,
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
/// length, and tick marks per division (0 and 1 = one, at the labels).
#[serde(skip_serializing_if = "TickLength::is_short")]
pub tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub tick_marks: usize,
#[serde(skip_serializing_if = "TickLength::is_short")]
pub right_tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub right_tick_marks: usize,
/// Tick Marks for the category axis (none by default), and Draw Tick Marks Between Labels.
#[serde(skip_serializing_if = "TickLength::is_none")]
pub category_tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub category_tick_marks: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub ticks_between_labels: bool,
/// Graph Type › Add Labels: text before and after the value axes' numbers.
#[serde(skip_serializing_if = "String::is_empty")]
pub prefix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub suffix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub right_prefix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub right_suffix: String,
/// Line, scatter and radar graphs: mark data points, connect them.
pub mark_points: bool,
pub connect_points: bool,
@@ -116,26 +239,80 @@ pub struct GraphSpec {
pub placed: Option<Rect>,
}
fn at_most_one(n: &usize) -> bool {
*n <= 1
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
fn edge_to_edge_before_the_option() -> bool {
true
}
impl GraphSpec {
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
pub fn series_kind(&self, s: usize) -> GraphKind {
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
}
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
/// of [`MAX_GRAPH_SERIES`] cells).
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
self.rows
.iter()
.take(MAX_GRAPH_CATEGORIES)
.enumerate()
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
.collect()
}
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
self.blanks =
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
}
}
impl Default for GraphSpec {
fn default() -> Self {
Self {
series_paints: vec![],
kind: GraphKind::Column,
series_kinds: vec![],
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
series: vec![],
categories: vec![],
rows: vec![vec![1.0]],
blanks: vec![],
column_width: 90.0,
cluster_width: 80.0,
legend: true,
ticks: 0,
axis_min: None,
axis_max: None,
value_axis: ValueAxisSide::Left,
separate_scales: false,
right_series: vec![],
right_ticks: 0,
right_axis_min: None,
right_axis_max: None,
tick_length: TickLength::Short,
tick_marks: 1,
right_tick_length: TickLength::Short,
right_tick_marks: 1,
category_tick_length: TickLength::None,
category_tick_marks: 1,
ticks_between_labels: false,
prefix: String::new(),
suffix: String::new(),
right_prefix: String::new(),
right_suffix: String::new(),
mark_points: true,
connect_points: true,
edge_to_edge: false,
+7 -1
View File
@@ -41,6 +41,7 @@ pub mod style_libs;
pub mod swatches;
pub mod text;
pub mod trace;
pub mod variables;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -65,7 +66,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec, SeriesPaint};
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
@@ -91,6 +92,7 @@ pub use text::{
TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;
@@ -561,6 +563,9 @@ pub struct Document {
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub saved_selections: Vec<SavedSelection>,
/// Variables (data merge): named values bound to objects, applied per dataset.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub variables: Variables,
#[serde(default)]
pub grid: GridPrefs,
#[serde(default = "ppi72")]
@@ -693,6 +698,7 @@ impl Document {
guides: vec![],
views: vec![],
saved_selections: vec![],
variables: Variables::default(),
grid: GridPrefs::default(),
raster_effects_ppi: 72.0,
images: BTreeMap::new(),
+207
View File
@@ -0,0 +1,207 @@
//! Variables (data merge): named values bound to objects, with datasets that apply
//! them all at once. One template document produces many variants (badges, price
//! lists, localized copies) by selecting a dataset instead of editing each object.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::{Document, NodeId, NodeKind};
/// What a variable drives.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VariableKind {
/// A type object's characters.
#[default]
Text,
/// Whether bound objects show.
Visibility,
}
impl VariableKind {
/// Parse the `kind` of `variable.define`: `text` or `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both).
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
"visibility" | "visible" => Some(Self::Visibility),
_ => None,
}
}
/// The kind's own name, as `variable.define` takes it and `variable.list` reports it.
pub fn label(self) -> &'static str {
match self {
Self::Text => "text",
Self::Visibility => "visibility",
}
}
}
/// One named variable.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Variable {
pub name: String,
pub kind: VariableKind,
}
/// One value a dataset gives a variable.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DataValue {
Text(String),
Visible(bool),
}
impl DataValue {
/// Whether this value fits the variable's kind (a dataset may carry values for
/// variables of either kind; each binding takes what fits it).
pub fn fits(&self, kind: VariableKind) -> bool {
matches!((self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility))
}
}
/// One named row of values, applied with `dataset.select`.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct DataSet {
pub name: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub values: BTreeMap<String, DataValue>,
}
impl DataSet {
/// Whether the art, as [`Document::captured_values`] read it, still holds what this row
/// says. A value the art can't report (its object is locked or gone) isn't drift: the row
/// keeps it, and applying it again would skip it too.
pub fn matches(&self, current: &BTreeMap<String, DataValue>) -> bool {
self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v))
}
}
/// The variable state of a document: definitions, datasets, bindings
/// (object id → the variables bound to it) and which dataset is active.
///
/// An object may be driven by several variables — the reference app binds the selected object
/// to the selected variable, one click at a time, and never says a second binding replaces the
/// first — so a binding is a set. `dataset.select` applies them in the order the variables are
/// defined, which is what makes two text variables on one object deterministic: the last one
/// wins, and `applied` counts both.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Variables {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub variables: Vec<Variable>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub datasets: Vec<DataSet>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_dataset: Option<String>,
}
impl Variables {
/// The variable `name`, if the document defines it.
pub fn variable(&self, name: &str) -> Option<&Variable> {
self.variables.iter().find(|v| v.name == name)
}
/// The dataset `name`, if the document has it.
pub fn dataset(&self, name: &str) -> Option<&DataSet> {
self.datasets.iter().find(|d| d.name == name)
}
/// Bind `id` to the variable `name`. True when it was not bound to it already.
pub fn bind(&mut self, id: NodeId, name: &str) -> bool {
self.bindings.entry(id).or_default().insert(name.to_string())
}
/// Drop `id`'s binding to `name` (and the binding itself when that was its last variable).
/// True when there was one.
pub fn unbind(&mut self, id: NodeId, name: &str) -> bool {
let Some(bound) = self.bindings.get_mut(&id) else { return false };
let dropped = bound.remove(name);
if bound.is_empty() {
self.bindings.remove(&id);
}
dropped
}
/// The objects bound to the variable `name`, in document order.
pub fn objects_of(&self, name: &str) -> Vec<NodeId> {
let mut ids: Vec<NodeId> = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect();
ids.sort_unstable();
ids
}
/// Drop the variables `names`, the dataset values that name them and the bindings to
/// them. Datasets named here are left alone: their names are their own namespace (the
/// active one is dropped by whoever deletes the dataset).
pub fn prune(&mut self, names: &[String]) -> usize {
// A set, so a long list of names costs a lookup per value rather than a scan.
let names: BTreeSet<&str> = names.iter().map(String::as_str).collect();
let before = self.variables.len();
self.variables.retain(|v| !names.contains(v.name.as_str()));
for dataset in &mut self.datasets {
dataset.values.retain(|k, _| !names.contains(k.as_str()));
}
for bound in self.bindings.values_mut() {
bound.retain(|v| !names.contains(v.as_str()));
}
self.bindings.retain(|_, v| !v.is_empty());
before - self.variables.len()
}
}
impl Document {
/// Forget the bindings of objects that are no longer in the document (after every edit),
/// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object
/// would count as a skip in every dataset application and grow the saved file forever.
pub fn prune_variable_bindings(&mut self) {
if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) {
return;
}
let mut bindings = std::mem::take(&mut self.variables.bindings);
bindings.retain(|id, _| self.node(*id).is_some());
self.variables.bindings = bindings;
}
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
/// able to show it again, and a capture must record it hidden.
pub fn variable_target(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
}
/// What the object `id` holds for the variable of `kind`: its characters, or whether it
/// shows. `None` when the object is gone, is not of that kind, or is locked or on a hidden
/// or locked layer (the document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.variable_target(id) {
return None;
}
let node = self.node(id)?;
match (kind, &node.kind) {
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
(VariableKind::Text, _) => None,
}
}
/// The values a dataset captured right now: what each bound object holds, in the order the
/// variables are defined. `dataset.capture` writes these into a new row and
/// `dataset.update` into the active one; the Variables panel compares the active row with
/// them to tell whether the art still matches what it says.
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
let mut out = BTreeMap::new();
for v in &self.variables.variables {
// An object driven by several variables of the same kind reads from the first one
// bound, so a capture is one value per variable and applies back the same way.
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
if let Some(value) = value {
out.insert(v.name.clone(), value);
}
}
out
}
}
+70 -1
View File
@@ -130,6 +130,10 @@ fn lengths_of(id: &str) -> Lengths {
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
"pixelate.colorHalftone" => always(&["maxRadius"]),
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
"texture.craquelure" => always(&["crackSpacing"]),
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
_ => Lengths::default(),
}
}
@@ -142,8 +146,10 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const DISTORT: &[&str] = &["Effect", "Distort"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
const TEXTURE: &[&str] = &["Effect", "Texture"];
const VIDEO: &[&str] = &["Effect", "Video"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
@@ -264,7 +270,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"stylize.innerGlow",
"Inner Glow…",
STYLIZE,
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}",
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}",
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}),
),
r(
@@ -290,6 +296,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across)} (Normal mode)",
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium"}),
),
r(
"distort.diffuseGlow",
"Diffuse Glow…",
DISTORT,
"{graininess: 0..10 (6; white grain, thicker in the glow), glowAmount: 0..20 (10), clearAmount: 0..20 (15; the higher, the more of the image stays clear of glow)} renders the object as if seen through a soft diffusion filter: its highlights glow white under see-through white grain",
json!({"graininess": 6.0, "glowAmount": 10.0, "clearAmount": 15.0}),
),
r(
"distort.glass",
"Glass…",
DISTORT,
"{distortion: 0..20 (5), smoothness: 1..15 (3), texture: \"blocks\"|\"canvas\"|\"frosted\"|\"tinyLens\" (\"frosted\"; surfaces made in code), scaling: % 50..200 (100), invert: bool (false; turns the surface's heights over)} makes the object look as if seen through glass",
json!({"distortion": 5.0, "smoothness": 3.0, "texture": "frosted", "scaling": 100.0, "invert": false}),
),
r(
"distort.oceanRipple",
"Ocean Ripple…",
DISTORT,
"{rippleSize: 1..15 (9), rippleMagnitude: 0..20 (9)} adds randomly spaced ripples, as if the object were under water",
json!({"rippleSize": 9.0, "rippleMagnitude": 9.0}),
),
r(
"sharpen.unsharpMask",
"Unsharp Mask…",
@@ -332,6 +359,48 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas",
json!({"cellSize": 5.0}),
),
r(
"texture.craquelure",
"Craquelure…",
TEXTURE,
"{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones",
json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}),
),
r(
"texture.grain",
"Grain…",
TEXTURE,
"{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white",
json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}),
),
r(
"texture.mosaicTiles",
"Mosaic Tiles…",
TEXTURE,
"{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them",
json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}),
),
r(
"texture.patchwork",
"Patchwork…",
TEXTURE,
"{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left",
json!({"squareSize": 4.0, "relief": 8.0}),
),
r(
"texture.stainedGlass",
"Stained Glass…",
TEXTURE,
"{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart",
json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}),
),
r(
"texture.texturizer",
"Texturizer…",
TEXTURE,
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
),
r(
"video.deinterlace",
"De-Interlace…",
+200
View File
@@ -0,0 +1,200 @@
//! Effect › Distort (Diffuse Glow, Glass, Ocean Ripple).
//!
//! Glass and Ocean Ripple move each pixel's content by a shift that a surface decides (the slope
//! of Glass's texture, smooth noise for the ripples), sampling the object bilinearly. The shift is
//! capped, so the object reaches no farther than [`glass_shift`] or [`ripple_shift`] past its
//! bounds. Diffuse Glow brightens the object's highlights towards white and sprinkles it with
//! white grain. Every surface lies in document space around the object's centre, as the Texture
//! filters' do.
use vectorcraft_geom::{Point, Vec2};
use super::pixelate::{Place, value_noise};
use super::texture::{Texture, cell_noise, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
const SALT_GLOW: u64 = 120;
const SALT_FROST: u64 = 128;
const SALT_RIPPLE: u64 = 136;
/// Glass's surfaces, in its Texture menu's order: (label, parameter value).
pub const GLASS_TEXTURES: [(&str, &str); 4] = [("Blocks", "blocks"), ("Canvas", "canvas"), ("Frosted", "frosted"), ("Tiny Lens", "tinyLens")];
/// A Glass surface.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlassTexture {
Blocks,
Canvas,
Frosted,
TinyLens,
}
impl GlassTexture {
const ALL: [GlassTexture; 4] = [GlassTexture::Blocks, GlassTexture::Canvas, GlassTexture::Frosted, GlassTexture::TinyLens];
/// The surface of `value` (a [`GLASS_TEXTURES`] value, any case); Frosted otherwise.
pub fn parse(value: &str) -> Self {
pick(&GLASS_TEXTURES, &Self::ALL, value, GlassTexture::Frosted)
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
fn height(self, p: Point) -> f64 {
match self {
GlassTexture::Blocks => blocks(p),
GlassTexture::Canvas => Texture::Canvas.height(p),
GlassTexture::Frosted => frosted(p),
GlassTexture::TinyLens => tiny_lens(p),
}
}
}
/// Square glass blocks 10 points across, flat on top with 2.5-point bevels.
fn blocks(p: Point) -> f64 {
const SIZE: f64 = 10.0;
const BEVEL: f64 = 2.5;
let to_edge = |v: f64| {
let t = v.rem_euclid(SIZE);
t.min(SIZE - t)
};
to_edge(p.x).min(to_edge(p.y)).min(BEVEL)
}
/// Frosted glass: two octaves of fine smooth noise (1.2 and 0.5 points).
fn frosted(p: Point) -> f64 {
0.8 * value_noise(SALT_FROST, p.x / 1.2, p.y / 1.2) + 0.2 * value_noise(SALT_FROST + 1, p.x / 0.5, p.y / 0.5)
}
/// Tiny lenses 4 points across in rows and columns, each a shallow dome.
fn tiny_lens(p: Point) -> f64 {
const SIZE: f64 = 4.0;
let r = 0.5 * SIZE;
let off = |v: f64| v.rem_euclid(SIZE) - r;
let d2 = off(p.x).powi(2) + off(p.y).powi(2);
0.5 * r * (1.0 - d2 / (r * r)).max(0.0)
}
/// The longest shift (points) Glass gives at `distortion` (0..20).
pub(super) fn glass_shift(distortion: f64) -> f64 {
0.5 * distortion.clamp(0.0, 20.0)
}
/// The ripples' size (points) for Ripple Size `size` (1..15).
fn ripple_wave(size: f64) -> f64 {
2.0 + size.clamp(1.0, 15.0)
}
/// The longest shift (points) Ocean Ripple gives for Ripple Size `size` (1..15) and Ripple
/// Magnitude `magnitude` (0..20).
pub(super) fn ripple_shift(size: f64, magnitude: f64) -> f64 {
0.03 * magnitude.clamp(0.0, 20.0) * ripple_wave(size)
}
/// How far (points) Diffuse Glow spreads the highlights (the σ of their blur).
pub(super) const GLOW_SPREAD: f64 = 2.0;
/// Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2), bent by
/// `distortion` (0..20) and smoothed by `smoothness` (1..15); `invert` turns the surface's heights
/// over.
#[allow(clippy::too_many_arguments)] // the effect's five options
pub(super) fn glass(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
distortion: f64,
smoothness: f64,
texture: GlassTexture,
scaling: f64,
invert: bool,
) {
let Some(place) = Place::new(space, w, h) else { return };
if !(scaling.is_finite() && scaling > 0.0 && smoothness.is_finite()) {
return;
}
// Slopes over a quarter point of the surface per step of Smoothness, or half a pixel where
// pixels are larger: a smoother glass bends the image more gently.
let step = (0.25 * smoothness.clamp(1.0, 15.0)).max(0.5 * place.px / scaling);
let gain = 0.25 * distortion.clamp(0.0, 20.0) * if invert { -1.0 } else { 1.0 };
displace(px, w, h, &place, glass_shift(distortion), |q| {
let t = Point::new(q.x / scaling, q.y / scaling);
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
Vec2::new(at(step, 0.0) - at(-step, 0.0), at(0.0, step) - at(0.0, -step)) * (gain / (2.0 * step))
});
}
/// Ocean Ripple: the object as if under rippling water, ripples about `size` (1..15) points plus
/// two across, shifting it by up to [`ripple_shift`].
pub(super) fn ocean_ripple(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, magnitude: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let (wave, most) = (ripple_wave(size), ripple_shift(size, magnitude));
displace(px, w, h, &place, most, |q| {
let n = |salt: u64| 0.7 * smooth_noise(salt, q, wave) + 0.3 * smooth_noise(salt + 2, q, 0.5 * wave);
Vec2::new(n(SALT_RIPPLE), n(SALT_RIPPLE + 1)) * most
});
}
/// Diffuse Glow: the object as seen through a soft diffusion filter. Its highlights, softened over
/// [`GLOW_SPREAD`], glow white as strongly as `glow` (0..20) says, from the brightness that
/// `clear` (0..20) leaves clear of glow; white grain as dense as `graininess` (0..10) says is
/// sprinkled over it, thicker in the glow.
pub(super) fn diffuse_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, graininess: f64, glow: f64, clear: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let mut light: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
blur_plane(&mut light, w, h, GLOW_SPREAD / place.px);
let amount = (glow / 20.0).clamp(0.0, 1.0) as f32;
let from = (0.9 * clear / 20.0).clamp(0.0, 0.9) as f32;
let grain = (graininess / 10.0).clamp(0.0, 1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let g = ((light.get(y * w + x).copied().unwrap_or(0.0) - from) / (1.0 - from)).clamp(0.0, 1.0);
let chance = 0.5 * (cell_noise(SALT_GLOW, place.at(x, y), 1.0, 0) + 1.0);
let dot = if chance < grain * (0.08 + 0.32 * f64::from(g)) { 0.55 } else { 0.0 };
let white = (amount * g + dot).min(1.0);
*p = premultiply(straight(*p).map(|v| v + (1.0 - v) * white), f32::from(p[3]) / 255.0);
});
}
/// Move the object's content by `shift(q)` (document units; `q` a pixel's centre relative to the
/// object's centre, its length capped at `most`): each pixel takes the colour `shift` away from it,
/// interpolated between pixels, transparent beyond the raster.
fn displace(px: &mut [[u8; 4]], w: usize, h: usize, place: &Place, most: f64, shift: impl Fn(Point) -> Vec2) {
if !(most.is_finite() && most > 0.0) {
return;
}
let src = px.to_vec();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let d = shift(q);
if !(d.x.is_finite() && d.y.is_finite()) {
return;
}
let len = d.hypot();
let d = if len > most { d * (most / len) } else { d };
let s = place.to_px * (q + d);
*p = sample(&src, w, h, s.x - 0.5, s.y - 0.5);
});
}
/// The premultiplied colour at (`x`, `y`) of `w` × `h` pixels (pixel centres at whole
/// coordinates), interpolated between the four around it, transparent outside.
fn sample(src: &[[u8; 4]], w: usize, h: usize, x: f64, y: f64) -> [u8; 4] {
if !(x.is_finite() && y.is_finite()) {
return [0; 4];
}
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let at = |i: f64, j: f64| -> [f64; 4] {
if i < 0.0 || j < 0.0 || i >= w as f64 || j >= h as f64 {
return [0.0; 4];
}
src.get(j as usize * w + i as usize).map_or([0.0; 4], |p| p.map(f64::from))
};
let (a, b, c, d) = (at(x0, y0), at(x0 + 1.0, y0), at(x0, y0 + 1.0), at(x0 + 1.0, y0 + 1.0));
std::array::from_fn(|k| {
let top = a[k] + (b[k] - a[k]) * fx;
let bottom = c[k] + (d[k] - c[k]) * fx;
(top + (bottom - top) * fy).round().clamp(0.0, 255.0) as u8
})
}
+140 -8
View File
@@ -1,6 +1,8 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
//! Edges, and Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort › Diffuse
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
//! Sharpen › Unsharp Mask, Stylize › Glowing
//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and
//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//!
//! - Every distance is in document units (points) and becomes pixels through the raster's
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
@@ -10,28 +12,41 @@
//! - Beyond the raster's edges is transparency.
mod blur;
mod distort;
mod edges;
mod pixelate;
mod sharpen;
mod texture;
mod video;
use serde_json::Value;
use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{num, text};
use crate::util::{flag, num, text};
pub use distort::{GLASS_TEXTURES, GlassTexture};
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 10] = [
pub const PIXEL_EFFECTS: [&str; 19] = [
"blur.radial",
"blur.smart",
"distort.diffuseGlow",
"distort.glass",
"distort.oceanRipple",
"pixelate.colorHalftone",
"pixelate.crystallize",
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
"texture.mosaicTiles",
"texture.patchwork",
"texture.stainedGlass",
"texture.texturizer",
"video.deinterlace",
"video.ntscColors",
];
@@ -47,6 +62,16 @@ pub enum PixelFx {
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
/// `samples` × `samples` neighbours at most.
SmartBlur { radius: f64, threshold: f64, samples: u32 },
/// Distort › Diffuse Glow: the highlights glow white as strongly as `glow` (0..20), from the
/// brightness `clear` (0..20) leaves clear, under white grain as dense as `graininess` (0..10).
DiffuseGlow { graininess: f64, glow: f64, clear: f64 },
/// Distort › Glass: the object seen through a `texture` surface scaled by `scaling` (0.5..2),
/// bent by `distortion` (0..20), smoothed by `smoothness` (1..15), its heights turned over when
/// `invert`.
Glass { distortion: f64, smoothness: f64, texture: GlassTexture, scaling: f64, invert: bool },
/// Distort › Ocean Ripple: the object under ripples of `size` (1..15) shifting it as much as
/// `magnitude` (0..20) says.
OceanRipple { size: f64, magnitude: f64 },
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
@@ -62,6 +87,24 @@ pub enum PixelFx {
/// Pixelate › Pointillize: random dots about `cell` across of the object's colours on a white
/// canvas.
Pointillize { cell: f64 },
/// Texture › Craquelure: cracked relief plaster, plates about `spacing` apart, cracks as deep
/// as `depth` (0..10), lit as brightly as `brightness` (0..10).
Craquelure { spacing: f64, depth: f64, brightness: f64 },
/// Texture › Grain: noise of `kind` as strong as `intensity` (0..100), the image's contrast
/// set by `contrast` (0..100, 50 leaves it).
Grain { intensity: f64, contrast: f64, kind: Grain },
/// Texture › Mosaic Tiles: irregular tiles about `tile` across with sunken grout `grout` / 2
/// wide (1..15), lightened by `lighten` (0..10).
MosaicTiles { tile: f64, grout: f64, lighten: f64 },
/// Texture › Patchwork: squares `square` across of the colour around their centres, raised by
/// up to `relief` (0..25).
Patchwork { square: f64, relief: f64 },
/// Texture › Stained Glass: panes about `cell` across leaded `border` / 2 wide (1..20) in
/// black, lit at the centre by `light` (0..10).
StainedGlass { cell: f64, border: f64, light: f64 },
/// Texture › Texturizer: a `texture` surface scaled by `scaling` (0.5..2), in relief as strong
/// as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
Texturizer { texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Video › De-Interlace: the odd (or `even`) field lines made again from the others, by
/// duplication or, with `interpolate`, by averaging.
DeInterlace { even: bool, interpolate: bool },
@@ -100,6 +143,9 @@ pub struct PixelSpace {
/// The height of a line of the document's raster grid (document units: 72 / Document Raster
/// Effects Settings › Resolution), the fields De-Interlace works on.
pub line: f64,
/// How far the corners of the object's bounds lie from its centre (document units): Stained
/// Glass's light fades out over it.
pub extent: f64,
}
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
@@ -119,6 +165,21 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
threshold: num(p, "threshold", 25.0).clamp(0.1, 100.0),
samples: quality([("low", 5), ("medium", 7), ("high", 9)], 7),
},
"distort.diffuseGlow" => PixelFx::DiffuseGlow {
graininess: num(p, "graininess", 6.0).clamp(0.0, 10.0),
glow: num(p, "glowAmount", 10.0).clamp(0.0, 20.0),
clear: num(p, "clearAmount", 15.0).clamp(0.0, 20.0),
},
"distort.glass" => PixelFx::Glass {
distortion: num(p, "distortion", 5.0).clamp(0.0, 20.0),
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
texture: GlassTexture::parse(text(p, "texture", "frosted")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
invert: flag(p, "invert", false),
},
"distort.oceanRipple" => {
PixelFx::OceanRipple { size: num(p, "rippleSize", 9.0).clamp(1.0, 15.0), magnitude: num(p, "rippleMagnitude", 9.0).clamp(0.0, 20.0) }
}
"sharpen.unsharpMask" => PixelFx::UnsharpMask {
amount: num(p, "amount", 50.0).clamp(1.0, 500.0) / 100.0,
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
@@ -137,6 +198,36 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"pixelate.crystallize" => PixelFx::Crystallize { cell: num(p, "cellSize", 10.0).clamp(3.0, 300.0) },
"pixelate.mezzotint" => PixelFx::Mezzotint { kind: Mezzotint::parse(text(p, "type", "fineDots")) },
"pixelate.pointillize" => PixelFx::Pointillize { cell: num(p, "cellSize", 5.0).clamp(3.0, 300.0) },
"texture.craquelure" => PixelFx::Craquelure {
spacing: num(p, "crackSpacing", 15.0).clamp(2.0, 100.0),
depth: num(p, "crackDepth", 6.0).clamp(0.0, 10.0),
brightness: num(p, "crackBrightness", 9.0).clamp(0.0, 10.0),
},
"texture.grain" => PixelFx::Grain {
intensity: num(p, "intensity", 40.0).clamp(0.0, 100.0),
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
kind: Grain::parse(text(p, "grainType", "regular")),
},
"texture.mosaicTiles" => PixelFx::MosaicTiles {
tile: num(p, "tileSize", 12.0).clamp(2.0, 100.0),
grout: num(p, "groutWidth", 3.0).clamp(1.0, 15.0),
lighten: num(p, "lightenGrout", 9.0).clamp(0.0, 10.0),
},
"texture.patchwork" => {
PixelFx::Patchwork { square: num(p, "squareSize", 4.0).clamp(0.0, 10.0), relief: num(p, "relief", 8.0).clamp(0.0, 25.0) }
}
"texture.stainedGlass" => PixelFx::StainedGlass {
cell: num(p, "cellSize", 10.0).clamp(2.0, 50.0),
border: num(p, "borderThickness", 4.0).clamp(1.0, 20.0),
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
},
"texture.texturizer" => PixelFx::Texturizer {
texture: Texture::parse(text(p, "texture", "canvas")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
invert: flag(p, "invert", false),
},
"video.deinterlace" => PixelFx::DeInterlace {
even: text(p, "eliminate", "odd").eq_ignore_ascii_case("even"),
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
@@ -157,11 +248,24 @@ impl PixelFx {
PixelFx::RadialBlur { zoom: false, .. } => (far - near).max(0.0),
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::DiffuseGlow { .. } => 0.0,
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } | PixelFx::DeInterlace { .. } | PixelFx::NtscColors => 0.0,
// A crystal's or dot's point inside the object reaches out by up to its cell.
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } => 1.5 * cell,
PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
| PixelFx::Grain { .. }
| PixelFx::MosaicTiles { .. }
| PixelFx::Texturizer { .. }
| PixelFx::DeInterlace { .. }
| PixelFx::NtscColors => 0.0,
// A crystal's, dot's or pane's point inside the object reaches out by up to its cell.
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
// A square whose centre is on the object is drawn whole.
PixelFx::Patchwork { square, .. } => square.max(1.0),
}
}
@@ -171,6 +275,9 @@ impl PixelFx {
match *self {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, .. } => Some(radius),
PixelFx::DiffuseGlow { .. } => Some(3.0 * distort::GLOW_SPREAD),
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
// The cells around the pixel's, and the cells around theirs.
@@ -179,6 +286,12 @@ impl PixelFx {
PixelFx::Mezzotint { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
PixelFx::Craquelure { spacing, .. } => Some(3.0 * texture::CONTOUR_SOFTNESS * spacing),
PixelFx::Grain { .. } | PixelFx::MosaicTiles { .. } | PixelFx::Texturizer { .. } => Some(0.0),
// The square's or pane's point, and the softened colour around it.
PixelFx::Patchwork { square, .. } => Some(2.0 * square.max(1.0)),
PixelFx::StainedGlass { cell, .. } => Some(2.5 * cell),
// The lines above and below.
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
PixelFx::NtscColors => Some(0.0),
@@ -193,18 +306,37 @@ impl PixelFx {
match *self {
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::DiffuseGlow { graininess, glow, clear } => distort::diffuse_glow(px, w, h, space, graininess, glow, clear),
PixelFx::Glass { distortion, smoothness, texture, scaling, invert } => {
distort::glass(px, w, h, space, distortion, smoothness, texture, scaling, invert)
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
PixelFx::Mezzotint { kind } => pixelate::mezzotint(px, w, h, space, kind),
PixelFx::Pointillize { cell } => pixelate::pointillize(px, w, h, space, cell),
PixelFx::Craquelure { spacing, depth, brightness } => texture::craquelure(px, w, h, space, spacing, depth, brightness),
PixelFx::Grain { intensity, contrast, kind } => texture::grain(px, w, h, space, intensity, contrast, kind),
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
}
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
PixelFx::NtscColors => video::ntsc(px),
}
}
}
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
/// otherwise.
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
let (px, rest) = data.as_chunks_mut::<4>();
+66 -49
View File
@@ -8,7 +8,7 @@ use std::sync::OnceLock;
use vectorcraft_geom::{Affine, Point};
use super::{Channels, PixelSpace, gaussian, to16};
use super::{Channels, PixelSpace, Px16, gaussian, pick, to16};
use crate::util::noise;
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
@@ -62,7 +62,7 @@ impl Mezzotint {
/// The pattern of Type `value` (a [`MEZZOTINT_TYPES`] value, any case); Fine Dots otherwise.
pub fn parse(value: &str) -> Self {
MEZZOTINT_TYPES.iter().zip(MEZZOTINT_KINDS).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(Self::FINE_DOTS, |(_, kind)| kind)
pick(&MEZZOTINT_TYPES, &MEZZOTINT_KINDS, value, Self::FINE_DOTS)
}
}
@@ -70,48 +70,48 @@ impl Mezzotint {
const SEED: u64 = 0x5049_5845_4C41_5445;
/// A random number in [0, 1) for cell (`i`, `j`) of pattern `salt`; `k` picks one of several.
fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
pub(super) fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
0.5 * (noise(SEED ^ salt, i as u64, j as u64, k) + 1.0)
}
/// The cell holding coordinate `v` (in cells); `None` far beyond any raster (or not finite).
fn cell(v: f64) -> Option<i64> {
pub(super) fn cell(v: f64) -> Option<i64> {
let f = v.floor();
(f.abs() < 1e15).then_some(f as i64)
}
/// Where a raster's pixels lie relative to the object's centre, in document units.
struct Place {
pub(super) struct Place {
/// Pixel coordinates → document coordinates relative to the centre.
to_rel: Affine,
pub(super) to_rel: Affine,
/// The other way.
to_px: Affine,
pub(super) to_px: Affine,
/// A pixel's size in document units.
px: f64,
pub(super) px: f64,
w: usize,
h: usize,
}
impl Place {
fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
pub(super) fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
let to_rel = Affine::translate(-space.center.to_vec2()) * space.to_doc;
let to_px = to_rel.inverse();
(to_rel.is_finite() && to_px.is_finite() && space.px.is_finite() && space.px > 0.0).then_some(Self { to_rel, to_px, px: space.px, w, h })
}
/// The centre of pixel (`x`, `y`).
fn at(&self, x: usize, y: usize) -> Point {
pub(super) fn at(&self, x: usize, y: usize) -> Point {
self.to_rel * Point::new(x as f64 + 0.5, y as f64 + 0.5)
}
/// The index of the pixel holding point `p`; `None` outside the raster.
fn index(&self, p: Point) -> Option<usize> {
pub(super) fn index(&self, p: Point) -> Option<usize> {
let q = self.to_px * p;
(q.x >= 0.0 && q.y >= 0.0 && q.x < self.w as f64 && q.y < self.h as f64).then(|| q.y as usize * self.w + q.x as usize)
}
/// Run `f(x, y, pixel)` on every pixel.
fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
pub(super) fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
for (y, row) in px.chunks_exact_mut(self.w.max(1)).enumerate().take(self.h) {
for (x, p) in row.iter_mut().enumerate() {
f(x, y, p);
@@ -122,7 +122,7 @@ impl Place {
/// The cells around a pixel's cell, in the order they can hold its nearest random point: the
/// cell itself, its 8 neighbours, then the ring around them.
const AROUND: [(i64, i64); 25] = [
pub(super) const AROUND: [(i64, i64); 25] = [
(0, 0),
(-1, 0),
(1, 0),
@@ -152,18 +152,18 @@ const AROUND: [(i64, i64); 25] = [
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
/// pixels stay in that cell.
struct Around<T> {
pub(super) struct Around<T> {
cell: Option<(i64, i64)>,
items: [Option<T>; 25],
}
impl<T: Copy> Around<T> {
fn new() -> Self {
pub(super) fn new() -> Self {
Self { cell: None, items: [None; 25] }
}
/// Item `k` of [`AROUND`] cell (`i`, `j`): `make(cell i', j')`.
fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
pub(super) fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
if self.cell != Some((i, j)) {
self.cell = Some((i, j));
self.items = [None; 25];
@@ -175,10 +175,26 @@ impl<T: Copy> Around<T> {
}
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
fn point_in(salt: u64, i: i64, j: i64) -> Point {
pub(super) fn point_in(salt: u64, i: i64, j: i64) -> Point {
Point::new(i as f64 + rand(salt, i, j, 0), j as f64 + rand(salt, i, j, 1))
}
/// How far cell `c` lies from coordinate `t` (in cells) along one axis.
pub(super) fn gap(c: i64, t: f64) -> f64 {
(c as f64 - t).max(t - c as f64 - 1.0).max(0.0)
}
/// Smooth random values in [0, 1) of pattern `salt` at whole coordinates, blended between them.
pub(super) fn value_noise(salt: u64, x: f64, y: f64) -> f64 {
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
}
/// Crystallize: every pixel takes the colour at the nearest of one random point per `cell` ×
/// `cell` square (a Voronoi diagram over a jittered grid): polygon crystals of solid colour.
pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
@@ -188,8 +204,6 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
}
let src = px.to_vec();
let mut around = Around::new();
// How far cell `c` lies from coordinate `t` (in cells) along one axis.
let gap = |c: i64, t: f64| (c as f64 - t).max(t - c as f64 - 1.0).max(0.0);
place.each(px, |x, y, out| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
@@ -215,6 +229,34 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
});
}
/// The object's colours softened by a Gaussian of `sigma` pixels, with its coverage as it is: what
/// the cell filters (Pointillize, Patchwork, Stained Glass) fill their cells with.
pub(super) struct Soft {
colour: Vec<Px16>,
alpha: Vec<u8>,
}
impl Soft {
pub(super) fn new(px: &[[u8; 4]], w: usize, sigma: f64) -> Self {
let mut colour = to16(px);
gaussian(&mut colour, w, sigma);
Self { colour, alpha: px.iter().map(|p| p[3]).collect() }
}
/// The colour around pixel `k` (premultiplied, 0..1), as opaque as the object is at `k`; clear
/// outside the raster.
pub(super) fn at(&self, k: Option<usize>) -> [f32; 4] {
let a = k.and_then(|k| self.alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
match k.and_then(|k| self.colour.get(k)) {
Some(p) if p[3] > 0 => {
let s = a / f32::from(p[3]);
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
}
_ => [0.0; 4],
}
}
}
/// One of Pointillize's dots: where (in cells), its radius (document units), its stacking order
/// and its colour (premultiplied, 0..1).
#[derive(Clone, Copy)]
@@ -237,20 +279,10 @@ pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
}
// Each dot takes the colour around its centre, not of a single pixel there, and is as opaque
// as the object at its centre (whole dots on the object, none beside it).
let alpha: Vec<u8> = px.iter().map(|p| p[3]).collect();
let mut soft = to16(px);
gaussian(&mut soft, w, cell_size / place.px / 4.0);
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
let dot = |i: i64, j: i64| {
let at = point_in(2, i, j);
let k = place.index(Point::new(at.x * cell_size, at.y * cell_size));
let a = k.and_then(|k| alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
let colour = match k.and_then(|k| soft.get(k)) {
Some(p) if p[3] > 0 => {
let s = a / f32::from(p[3]);
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
}
_ => [0.0; 4],
};
let colour = soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
Dot { at, radius: (DOT_RADIUS.0 + DOT_RADIUS.1 * rand(2, i, j, 2)) * cell_size, z: rand(2, i, j, 3), colour }
};
let mut around = Around::new();
@@ -499,19 +531,6 @@ pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
/// Mezzotint's threshold in [0, 1) at point `q` (document units around the centre) of pattern
/// `salt`.
fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
// Random values at whole coordinates, blended smoothly between them along x (and y).
let blend = |x: f64, y: f64, along_y: bool| {
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
if !along_y {
return top;
}
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
};
let grain = |size: f64| match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => rand(salt, i, j, 1),
_ => 0.5,
@@ -520,12 +539,10 @@ fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
Mezzotint::Dots { size, smooth: false, .. } => grain(f64::from(size)),
Mezzotint::Dots { size, grainy, .. } => {
let s = f64::from(size);
let blob = blend(q.x / s, q.y / s, true);
let blob = value_noise(salt, q.x / s, q.y / s);
if grainy { 0.5 * (blob + grain(1.0)) } else { blob }
}
Mezzotint::Lines { length, width } => {
let row = (q.y / f64::from(width)).floor();
blend(q.x / f64::from(length), row, false)
}
// Blended along x only: whole rows.
Mezzotint::Lines { length, width } => value_noise(salt, q.x / f64::from(length), (q.y / f64::from(width)).floor()),
}
}
+462 -5
View File
@@ -5,9 +5,17 @@ use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left).
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0), channels: Channels::Rgb, line: 1.0 }
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
@@ -224,8 +232,14 @@ fn lengths_are_document_units() {
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
let run = |scale: usize| {
let mut d = edge(scale);
let s =
PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO, channels: Channels::Rgb, line: 1.0 };
let s = PixelSpace {
to_doc: Affine::scale(1.0 / scale as f64),
px: 1.0 / scale as f64,
center: Point::ZERO,
channels: Channels::Rgb,
line: 1.0,
extent: 0.0,
};
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
};
@@ -254,7 +268,7 @@ fn gaussian_and_plane_blurs_keep_their_mass() {
fn pixel_timings() {
let (w, h) = (2000, 2000);
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0 };
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0, extent: 0.0 };
let cases = [
("blur.radial", json!({"quality": "draft"})),
("blur.radial", json!({"quality": "good"})),
@@ -268,6 +282,14 @@ fn pixel_timings() {
("pixelate.mezzotint", json!({"type": "grainyDots"})),
("pixelate.pointillize", json!({"cellSize": 3})),
("pixelate.pointillize", json!({"cellSize": 300})),
("texture.craquelure", json!({"crackSpacing": 2})),
("texture.craquelure", json!({"crackSpacing": 100})),
("texture.grain", json!({"grainType": "clumped"})),
("texture.mosaicTiles", json!({"tileSize": 2})),
("texture.patchwork", json!({"squareSize": 0})),
("texture.stainedGlass", json!({"cellSize": 2})),
("texture.texturizer", json!({"texture": "sandstone", "scaling": 50})),
("texture.texturizer", json!({"texture": "burlap"})),
];
for (id, p) in cases {
let mut d = src.clone();
@@ -525,3 +547,438 @@ fn ntsc_colors_tame_only_colours_too_strong_for_the_signal() {
assert!((luma(a) - luma(b)).abs() < 3.0, "{x}: as bright {a:?} {b:?}");
}
}
#[test]
fn texture_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("texture.craquelure", json!({})), PixelFx::Craquelure { spacing: 15.0, depth: 6.0, brightness: 9.0 });
assert_eq!(
fx("texture.craquelure", json!({"crackSpacing": 1e9, "crackDepth": -1, "crackBrightness": "x"})),
PixelFx::Craquelure { spacing: 100.0, depth: 0.0, brightness: 9.0 }
);
assert_eq!(fx("texture.grain", json!({})), PixelFx::Grain { intensity: 40.0, contrast: 50.0, kind: Grain::Regular });
assert_eq!(
fx("texture.grain", json!({"intensity": 1e300, "contrast": -5, "grainType": "SPECKLE"})),
PixelFx::Grain { intensity: 100.0, contrast: 0.0, kind: Grain::Speckle }
);
assert_eq!(fx("texture.mosaicTiles", json!({})), PixelFx::MosaicTiles { tile: 12.0, grout: 3.0, lighten: 9.0 });
assert_eq!(
fx("texture.mosaicTiles", json!({"tileSize": 0, "groutWidth": 99, "lightenGrout": 11})),
PixelFx::MosaicTiles { tile: 2.0, grout: 15.0, lighten: 10.0 }
);
assert_eq!(fx("texture.patchwork", json!({})), PixelFx::Patchwork { square: 4.0, relief: 8.0 });
assert_eq!(fx("texture.patchwork", json!({"squareSize": -1, "relief": 26})), PixelFx::Patchwork { square: 0.0, relief: 25.0 });
assert_eq!(fx("texture.stainedGlass", json!({})), PixelFx::StainedGlass { cell: 10.0, border: 4.0, light: 3.0 });
assert_eq!(
fx("texture.stainedGlass", json!({"cellSize": 1, "borderThickness": 21, "lightIntensity": "1e999"})),
PixelFx::StainedGlass { cell: 2.0, border: 20.0, light: 3.0 }
);
let canvas = PixelFx::Texturizer { texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false };
assert_eq!(fx("texture.texturizer", json!({})), canvas);
assert_eq!(
fx("texture.texturizer", json!({"texture": "Brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})),
PixelFx::Texturizer { texture: Texture::Brick, scaling: 0.5, relief: 50.0, light: Light::BottomRight, invert: true }
);
assert_eq!(
fx("texture.texturizer", json!({"texture": 3, "lightDirection": "up", "scaling": 1e9})),
PixelFx::Texturizer { texture: Texture::Canvas, scaling: 2.0, relief: 4.0, light: Light::Top, invert: false }
);
assert_eq!(fx("texture.grain", json!({"grainType": ["x"]})), fx("texture.grain", json!({})));
// Every menu value is documented in the catalogue and parses to a choice of its own.
for (id, key, table) in [
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
("texture.texturizer", "texture", TEXTURES.as_slice()),
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
] {
let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
for (i, (_, v)) in table.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
}
}
// Panes and squares reach beyond the object; the surfaces stay on it.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(fx("texture.stainedGlass", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("texture.patchwork", json!({"squareSize": 0})).outset(b), 1.0);
for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] {
assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}");
}
assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0));
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0));
}
/// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes.
fn square(d: &[u8], w: usize, lo: usize, hi: usize) -> Vec<[u8; 4]> {
(lo..hi).flat_map(|y| (lo..hi).map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect()
}
/// A `w`-square raster of one colour.
fn flat(w: usize, c: [u8; 4]) -> Vec<u8> {
image(w, w, |_, _| c)
}
#[test]
fn craquelure_cracks_plates_and_contours() {
let w = 96;
let tan = [180, 150, 110, 255];
let mut d = flat(w, tan);
fx("texture.craquelure", json!({"crackBrightness": 10})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let dark = px.iter().filter(|p| p[0] < 120).count() as f64 / px.len() as f64;
assert!((0.03..0.4).contains(&dark), "a network of dark cracks: {dark}");
assert!(px.iter().all(|p| p[3] == 255 && p[0] >= p[1] && p[1] >= p[2]), "the colour stays, darkened or lit");
assert!(px.iter().any(|p| p[0] > 182), "plate edges catch the light");
// Without depth there are no cracks and no relief; brightness lights the surface.
let mut flat_lit = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 10})).apply(&mut flat_lit, w, w, &space(w, w));
assert_eq!(flat_lit, flat(w, tan));
let mut dim = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 0})).apply(&mut dim, w, w, &space(w, w));
assert_eq!(at(&dim, w, 40, 40), [90, 75, 55, 255]);
// A dark half and a light half: a crack follows the contour between them, straight down (a
// dark line between lighter pixels in nearly every row), where one tone has none.
let craquelure = fx("texture.craquelure", json!({"crackSpacing": 100, "crackBrightness": 10}));
let rows_cracked = |src: Vec<u8>| {
let mut d = src;
craquelure.apply(&mut d, w, w, &space(w, w));
let v = |x: usize, y: usize| i32::from(at(&d, w, x, y)[0]);
(20..76).filter(|y| (26..35).any(|x| v(x, *y) + 8 < v(x - 2, *y) && v(x, *y) + 8 < v(x + 2, *y))).count()
};
let (contour, plain) = (rows_cracked(image(w, w, |x, _| grey(if x < 30 { 60 } else { 200 }))), rows_cracked(flat(w, grey(200))));
assert!(contour >= 48 && contour >= plain + 25, "{contour} rows cracked along the contour, {plain} without one");
}
#[test]
fn grain_types_each_add_their_own_noise() {
let w = 48;
let mid = grey(128);
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, kind) in GRAIN_TYPES {
let mut d = flat(w, mid);
fx("texture.grain", json!({ "grainType": kind })).apply(&mut d, w, w, &space(w, w));
assert_ne!(d, flat(w, mid), "{kind} adds grain");
assert!(d.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "{kind}: coverage stays");
assert!(!seen.contains(&d), "{kind}: a grain of its own");
seen.push(d);
}
// Regular grain is 1-point grains of every colour around the image's own.
let mut d = flat(w, mid);
fx("texture.grain", json!({})).apply(&mut d, w, w, &space(w, w));
let px = d.as_chunks::<4>().0;
let mean = px.iter().map(|p| f64::from(p[0])).sum::<f64>() / px.len() as f64;
assert!((mean - 128.0).abs() < 8.0, "{mean}");
assert!(px.iter().any(|p| p[0] != p[1]), "coloured grains");
// Horizontal grain runs in streaks along the rows.
let mut d = flat(w, mid);
fx("texture.grain", json!({"grainType": "horizontal", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
let along = (0..w - 1).map(|x| u32::from(at(&d, w, x, 10)[0].abs_diff(at(&d, w, x + 1, 10)[0]))).sum::<u32>();
let across = (0..w - 1).map(|y| u32::from(at(&d, w, 10, y)[0].abs_diff(at(&d, w, 10, y + 1)[0]))).sum::<u32>();
assert!(along * 3 < across, "{along} along, {across} across");
// No intensity at neutral contrast leaves the image; contrast spreads its tones.
let ramp = image(w, w, |x, _| grey((x * 5) as u8));
let mut d = ramp.clone();
fx("texture.grain", json!({"intensity": 0})).apply(&mut d, w, w, &space(w, w));
assert_eq!(d, ramp);
fx("texture.grain", json!({"intensity": 0, "contrast": 100})).apply(&mut d, w, w, &space(w, w));
assert!(at(&d, w, 2, 0)[0] < at(&ramp, w, 2, 0)[0] && at(&d, w, 45, 0)[0] > at(&ramp, w, 45, 0)[0]);
// Sprinkles are the background colour, white.
let mut d = flat(w, [0, 0, 160, 255]);
fx("texture.grain", json!({"grainType": "sprinkles", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
assert!(d.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[2] > 240));
}
#[test]
fn mosaic_tiles_lay_tiles_in_light_grout() {
let w = 96;
let blue = [40, 60, 160, 255];
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[0] > 170).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout), "light grout between the tiles: {grout}");
assert!(px.iter().filter(|p| **p == blue).count() > px.len() / 10, "tile faces keep the colour");
// Dark grout without lightening.
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({"lightenGrout": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[2] < 100).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout) && px.iter().all(|p| p[0] < 170), "dark grout: {grout}");
// Transparency around the object stays.
let mut d = image(w, w, |x, _| if x < 48 { blue } else { [0; 4] });
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| (48..w).all(|x| at(&d, w, x, y) == [0; 4])));
}
#[test]
fn patchwork_fills_squares_with_one_colour_in_relief() {
let w = 64;
let src = image(w, w, |x, y| [(x * 4) as u8, (y * 4) as u8, 90, 255]);
let mut d = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
// Squares of 8 points (the object's centre is a corner), each one colour.
for (i, j) in [(1, 1), (2, 5), (6, 3)] {
let first = at(&d, w, 8 * i, 8 * j);
assert!((0..8).all(|y| (0..8).all(|x| at(&d, w, 8 * i + x, 8 * j + y) == first)), "square {i} {j}");
}
assert_ne!(at(&d, w, 8, 8), at(&d, w, 16, 8));
// In relief, the squares' sides catch the light from the top left and fall into shade.
let mut lit = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 25})).apply(&mut lit, w, w, &space(w, w));
let (face, top, bottom) = (at(&lit, w, 28, 28)[1], at(&lit, w, 28, 24)[1], at(&lit, w, 28, 31)[1]);
assert!(top > face && bottom < face, "{top} {face} {bottom}");
// Squares around the object's edge are drawn whole.
let mut d = image(w, w, |x, _| if x < 30 { [200, 0, 0, 255] } else { [0; 4] });
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| at(&d, w, 31, y)[3] == 255 && at(&d, w, 33, y)[3] == 0));
}
#[test]
fn stained_glass_leads_panes_and_lights_the_centre() {
let w = 96;
let src = image(w, w, |x, y| [(40 + x) as u8, (40 + y) as u8, 120, 255]);
let mut d = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let lead = px.iter().filter(|p| p[0] == 0 && p[1] == 0 && p[2] == 0).count() as f64 / px.len() as f64;
assert!((0.1..0.6).contains(&lead), "black lead: {lead}");
let same = (16..80).flat_map(|y| (16..79).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) == at(&d, w, x + 1, *y)).count();
assert!(same * 5 > 2 * 64 * 63, "single-coloured panes: {same}");
assert!(px.iter().all(|p| p[3] == 255));
// The light shines through the middle, not the corners.
let mut lit = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 10})).apply(&mut lit, w, w, &space(w, w));
let brighter = |x: usize, y: usize| i32::from(at(&lit, w, x, y)[2]) - i32::from(at(&d, w, x, y)[2]);
let centre = (44..52).flat_map(|y| (44..52).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
let corner = (0..6).flat_map(|y| (0..6).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
assert!(centre > 40 && corner * 3 < centre, "{centre} at the centre, {corner} in the corner");
// Panes beyond the object stay clear; lead outlines it.
let mut d = image(w, w, |x, y| if (24..72).contains(&x) && (24..72).contains(&y) { [200, 40, 40, 255] } else { [0; 4] });
fx("texture.stainedGlass", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..6).all(|y| (0..w).all(|x| at(&d, w, x, y) == [0; 4])));
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
#[test]
fn texturizer_lights_each_surface_from_its_side() {
let w = 64;
let mid = grey(150);
let run = |p: serde_json::Value| {
let mut d = flat(w, mid);
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
d
};
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, texture) in TEXTURES {
let d = run(json!({ "texture": texture, "relief": 20 }));
assert_ne!(d, flat(w, mid), "{texture}");
assert!(!seen.contains(&d), "{texture}: a surface of its own");
// Inverted, the lit slopes are the shaded ones, and the other way round.
let inv = run(json!({ "texture": texture, "relief": 20, "invert": true }));
let shift = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| i32::from(p[0]) - 150).collect::<Vec<_>>();
let (a, b) = (shift(&d), shift(&inv));
let opposite = a.iter().zip(&b).filter(|(a, b)| a.abs() > 4 && b.abs() > 4 && a.signum() != b.signum()).count();
let lit_or_shaded = a.iter().filter(|a| a.abs() > 4).count();
assert!(lit_or_shaded > 100 && opposite * 10 > lit_or_shaded * 8, "{texture}: {opposite} of {lit_or_shaded}");
// From the other side as well.
assert_ne!(run(json!({ "texture": texture, "relief": 20, "lightDirection": "bottom" })), d, "{texture}");
// No relief, no change.
assert_eq!(run(json!({ "texture": texture, "relief": 0 })), flat(w, mid), "{texture}");
seen.push(d);
}
// Scaling enlarges the surface: 200 % at 2 points a pixel draws as 100 % at 1.
let mut big = flat(w, mid);
let coarse = PixelSpace { to_doc: Affine::scale(2.0), px: 2.0, center: Point::new(64.0, 64.0), ..space(w, w) };
fx("texture.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 20})).apply(&mut big, w, w, &coarse);
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
}
/// The Texture filters' patterns lie in document space around the object's centre: the same at
/// twice the resolution, and moving with the object.
#[test]
fn texture_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("texture.craquelure", json!({"crackSpacing": 8})),
("texture.grain", json!({"grainType": "enlarged"})),
("texture.mosaicTiles", json!({"tileSize": 8, "groutWidth": 2})),
("texture.patchwork", json!({"squareSize": 6})),
("texture.stainedGlass", json!({"cellSize": 8, "borderThickness": 2})),
("texture.texturizer", json!({"texture": "sandstone", "relief": 10})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
#[test]
fn distort_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("distort.diffuseGlow", json!({})), PixelFx::DiffuseGlow { graininess: 6.0, glow: 10.0, clear: 15.0 });
assert_eq!(
fx("distort.diffuseGlow", json!({"graininess": 1e9, "glowAmount": -3, "clearAmount": "x"})),
PixelFx::DiffuseGlow { graininess: 10.0, glow: 0.0, clear: 15.0 }
);
assert_eq!(
fx("distort.glass", json!({})),
PixelFx::Glass { distortion: 5.0, smoothness: 3.0, texture: GlassTexture::Frosted, scaling: 1.0, invert: false }
);
assert_eq!(
fx("distort.glass", json!({"distortion": 1e308, "smoothness": 0, "texture": "TINYLENS", "scaling": 1, "invert": true})),
PixelFx::Glass { distortion: 20.0, smoothness: 1.0, texture: GlassTexture::TinyLens, scaling: 0.5, invert: true }
);
assert_eq!(fx("distort.glass", json!({"texture": "bubbles"})), fx("distort.glass", json!({})));
assert_eq!(fx("distort.oceanRipple", json!({})), PixelFx::OceanRipple { size: 9.0, magnitude: 9.0 });
assert_eq!(fx("distort.oceanRipple", json!({"rippleSize": 99, "rippleMagnitude": -1})), PixelFx::OceanRipple { size: 15.0, magnitude: 0.0 });
// Shifted content reaches past the bounds by the longest shift, and comes from as far.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let glass = fx("distort.glass", json!({"distortion": 20}));
assert_eq!((glass.outset(b), glass.reach()), (10.0, Some(10.0)));
let ripple = fx("distort.oceanRipple", json!({}));
assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach() == Some(ripple.outset(b)));
assert_eq!(fx("distort.diffuseGlow", json!({})).outset(b), 0.0);
}
/// Vertical stripes 5 points wide (out of step with every glass surface), alternately light and
/// dark, over the whole raster.
fn stripes(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, _| if (x / 5) % 2 == 0 { [230, 200, 60, 255] } else { [20, 40, 120, 255] })
}
#[test]
fn glass_bends_the_image_by_its_texture() {
let (w, h) = (48, 40);
let src = stripes(w, h);
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.glass", p).apply(&mut d, w, h, &space(w, h));
d
};
// No distortion, no change.
assert_eq!(run(json!({"distortion": 0})), src);
let mut seen = vec![];
for (_, texture) in GLASS_TEXTURES {
let bent = run(json!({"texture": texture, "distortion": 12}));
assert_ne!(bent, src, "{texture}");
assert!(!seen.contains(&bent), "{texture} differs from the other textures");
assert_ne!(run(json!({"texture": texture, "distortion": 12, "invert": true})), bent, "{texture} inverted");
// A colour moves; none is invented: every pixel blends two neighbouring stripes at most
// (those within a shift of the raster's edges also take in the transparency beyond it).
for (x, y) in (6..w - 6).flat_map(|x| (6..h - 6).map(move |y| (x, y))) {
let p = at(&bent, w, x, y);
assert!(p[3] == 255 && p[0] >= 20 && p[0] <= 230, "{texture} at ({x}, {y}): {p:?}");
}
seen.push(bent);
}
// Smoother glass bends the stripes differently (more gently).
assert_ne!(run(json!({"distortion": 12, "smoothness": 15})), run(json!({"distortion": 12, "smoothness": 1})));
}
#[test]
fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
let (w, h) = (64, 32);
// The left half white, the right half transparent.
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
let mut d = src.clone();
fx("distort.oceanRipple", json!({"rippleMagnitude": 0})).apply(&mut d, w, h, &space(w, h));
assert_eq!(d, src, "no magnitude, no ripples");
let f = fx("distort.oceanRipple", json!({"rippleSize": 6, "rippleMagnitude": 20}));
let most = f.reach().unwrap();
let mut d = src.clone();
f.apply(&mut d, w, h, &space(w, h));
assert_ne!(d, src);
// Away from the white's edge, and from the raster's (beyond which is transparency), nothing
// changes.
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
for y in (0..h).filter(|y| inside(*y, h)) {
for x in (0..w).filter(|x| inside(*x, w)) {
let to_edge = (x as f64 + 0.5 - 32.0).abs();
if to_edge > most + 1.0 {
assert_eq!(at(&d, w, x, y), at(&src, w, x, y), "({x}, {y}) is {to_edge} from the edge, past {most}");
}
}
}
// The edge wanders: some rows reach farther right than others.
let ends: Vec<usize> = (0..h).map(|y| (0..w).filter(|x| at(&d, w, *x, y)[3] > 127).count()).collect();
assert!(ends.iter().min() != ends.iter().max(), "{ends:?}");
}
#[test]
fn diffuse_glow_whitens_the_highlights_and_sprinkles_grain() {
let (w, h) = (48, 48);
// Dark on the left, light on the right, a transparent margin round them.
let src = image(w, h, |x, y| {
if !(4..44).contains(&x) || !(4..44).contains(&y) {
[0; 4]
} else if x < 24 {
[30, 30, 60, 255]
} else {
[200, 190, 170, 255]
}
});
let run = |p: serde_json::Value| {
let mut d = src.clone();
fx("distort.diffuseGlow", p).apply(&mut d, w, h, &space(w, h));
d
};
let clean = run(json!({"graininess": 0}));
// Transparency stays; the shadows stay clear; the highlights glow.
for (a, b) in clean.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(a[3], b[3]);
}
assert_eq!(at(&clean, w, 10, 24), at(&src, w, 10, 24));
let (lit, before) = (at(&clean, w, 36, 24), at(&src, w, 36, 24));
assert!((0..3).all(|c| lit[c] > before[c]), "{lit:?} brighter than {before:?}");
// More Clear Amount leaves more of the image clear of glow.
assert!(at(&run(json!({"graininess": 0, "clearAmount": 20})), w, 36, 24)[0] < lit[0]);
assert_eq!(run(json!({"graininess": 0, "glowAmount": 0})), src);
// Grain: white specks, in the shadows too.
let grainy = run(json!({"graininess": 10}));
let specks = (4..24).flat_map(|x| (4..44).map(move |y| (x, y))).filter(|(x, y)| at(&grainy, w, *x, *y)[0] > 100).count();
assert!(specks > 10, "{specks} white specks in the shadows");
}
#[test]
fn distort_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("distort.diffuseGlow", json!({"clearAmount": 5})),
("distort.glass", json!({"texture": "blocks", "distortion": 8})),
("distort.oceanRipple", json!({"rippleMagnitude": 12})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
+576
View File
@@ -0,0 +1,576 @@
//! Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer.
//!
//! Like the Pixelate filters' patterns, theirs (cracks, grain, tiles, squares, panes and the
//! Texturizer's surfaces, all generated here, no image files) lie in document space around the
//! object's centre: the same at any zoom or resolution, moving with the object, and the same on
//! every redraw (hash noise of the pattern's cell indices). Surfaces in relief are height fields
//! whose slopes, taken in document units, are lit from one side.
use std::f64::consts::{FRAC_1_SQRT_2, PI};
use vectorcraft_geom::{Point, Vec2};
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
use super::{PixelSpace, blur_plane, pick};
/// Grain's types, in its Grain Type menu's order: (label, parameter value).
pub const GRAIN_TYPES: [(&str, &str); 10] = [
("Regular", "regular"),
("Soft", "soft"),
("Sprinkles", "sprinkles"),
("Clumped", "clumped"),
("Contrasty", "contrasty"),
("Enlarged", "enlarged"),
("Stippled", "stippled"),
("Horizontal", "horizontal"),
("Vertical", "vertical"),
("Speckle", "speckle"),
];
/// A Grain type ([`GRAIN_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Grain {
Regular,
Soft,
Sprinkles,
Clumped,
Contrasty,
Enlarged,
Stippled,
Horizontal,
Vertical,
Speckle,
}
impl Grain {
const ALL: [Grain; 10] = [
Grain::Regular,
Grain::Soft,
Grain::Sprinkles,
Grain::Clumped,
Grain::Contrasty,
Grain::Enlarged,
Grain::Stippled,
Grain::Horizontal,
Grain::Vertical,
Grain::Speckle,
];
/// The type of Grain Type `value` (a [`GRAIN_TYPES`] value, any case); Regular otherwise.
pub fn parse(value: &str) -> Self {
pick(&GRAIN_TYPES, &Self::ALL, value, Grain::Regular)
}
}
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
/// A Texturizer surface ([`TEXTURES`]), made in code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Texture {
Brick,
Burlap,
Canvas,
Sandstone,
}
impl Texture {
const ALL: [Texture; 4] = [Texture::Brick, Texture::Burlap, Texture::Canvas, Texture::Sandstone];
/// The texture of `value` (a [`TEXTURES`] value, any case); Canvas otherwise.
pub fn parse(value: &str) -> Self {
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
pub(super) fn height(self, p: Point) -> f64 {
match self {
Texture::Brick => brick(p),
Texture::Burlap => weave(p, 7.0, true),
Texture::Canvas => weave(p, 4.0, false),
Texture::Sandstone => sandstone(p),
}
}
}
/// Texturizer's light directions, in its Light menu's order: (label, parameter value).
pub const LIGHT_DIRECTIONS: [(&str, &str); 8] = [
("Bottom", "bottom"),
("Bottom Left", "bottomLeft"),
("Left", "left"),
("Top Left", "topLeft"),
("Top", "top"),
("Top Right", "topRight"),
("Right", "right"),
("Bottom Right", "bottomRight"),
];
/// Where a surface is lit from ([`LIGHT_DIRECTIONS`]), on the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Light {
Bottom,
BottomLeft,
Left,
TopLeft,
Top,
TopRight,
Right,
BottomRight,
}
impl Light {
const ALL: [Light; 8] =
[Light::Bottom, Light::BottomLeft, Light::Left, Light::TopLeft, Light::Top, Light::TopRight, Light::Right, Light::BottomRight];
/// The direction of `value` (a [`LIGHT_DIRECTIONS`] value, any case); Top otherwise.
pub fn parse(value: &str) -> Self {
pick(&LIGHT_DIRECTIONS, &Self::ALL, value, Light::Top)
}
/// The unit vector towards the light (document axes, y down).
fn toward(self) -> Vec2 {
let d = FRAC_1_SQRT_2;
match self {
Light::Bottom => Vec2::new(0.0, 1.0),
Light::BottomLeft => Vec2::new(-d, d),
Light::Left => Vec2::new(-1.0, 0.0),
Light::TopLeft => Vec2::new(-d, -d),
Light::Top => Vec2::new(0.0, -1.0),
Light::TopRight => Vec2::new(d, -d),
Light::Right => Vec2::new(1.0, 0.0),
Light::BottomRight => Vec2::new(d, d),
}
}
}
/// Seeds of the Texture filters' patterns (Pixelate's use smaller salts).
const SALT_CRACKS: u64 = 64;
const SALT_GRAIN: u64 = 72;
const SALT_TILES: u64 = 88;
const SALT_PATCH: u64 = 96;
const SALT_GLASS: u64 = 104;
const SALT_SURFACE: u64 = 112;
/// How soft Craquelure's tones are, as a share of the crack spacing: their contours become cracks.
pub(super) const CONTOUR_SOFTNESS: f64 = 1.0 / 6.0;
/// A pixel's straight colour (0..1).
pub(super) fn straight(p: [u8; 4]) -> [f32; 3] {
let a = f32::from(p[3]);
[p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 })
}
/// The pixel of straight colour `c` at coverage `alpha` (0..1), premultiplied.
pub(super) fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
let a = (alpha.clamp(0.0, 1.0) * 255.0).round();
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * a).round().min(a) as u8);
[r, g, b, a as u8]
}
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
}
/// How a surface whose height rises along `slope` (height per length) faces a light from
/// `toward`: 0 flat, positive facing it, negative turned away.
fn facing(slope: Vec2, toward: Vec2) -> f64 {
-slope.dot(toward)
}
/// The share of a pixel `px` wide that lies within `d` of a line (negative `d`: beyond it).
fn cover(d: f64, px: f64) -> f64 {
(d / px + 0.5).clamp(0.0, 1.0)
}
/// The luma of colour `c` (Rec. 601 weights).
pub(super) fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
/// found among the cells [`AROUND`] the one holding it.
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
let (i, j) = (cell(u)?, cell(v)?);
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
for (k, (di, dj)) in AROUND.iter().enumerate() {
// A cell farther than the second point so far can't hold a nearer one.
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
continue;
}
let Some(p) = around.get((i, j), k, &point) else { continue };
let d = (p.x - u).hypot(p.y - v);
if d < best[0].0 {
best = [(d, Some(p)), best[0]];
} else if d < best[1].0 {
best[1] = (d, Some(p));
}
}
Some((best[0].1?, best[1].1?))
}
/// How far (`u`, `v`) lies from the border between the cells of `near` and `next` (its nearest
/// points, [`nearest_two`]), and the way away from it (unit), in cells.
fn edge(u: f64, v: f64, near: Point, next: Point) -> (f64, Vec2) {
let q = Point::new(u, v);
let apart = near - next;
let len = apart.hypot();
if !(len.is_finite() && len > 1e-12) {
return (0.0, Vec2::ZERO);
}
(((q - next).hypot2() - (q - near).hypot2()) / (2.0 * len), apart / len)
}
/// The slope of a rise of 1 over `width` from a border at distance `d` (smooth at both ends).
fn bevel(d: f64, width: f64) -> f64 {
if !(width.is_finite() && width > 0.0) {
return 0.0;
}
let t = (d / width).clamp(0.0, 1.0);
6.0 * t * (1.0 - t)
}
/// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by
/// coverage so the transparency around it doesn't darken its edges.
struct Tones {
v: Vec<f32>,
w: usize,
h: usize,
}
impl Tones {
fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect();
let mut a: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
blur_plane(&mut l, w, h, sigma);
blur_plane(&mut a, w, h, sigma);
let v = l.iter().zip(&a).map(|(l, a)| if *a > 1e-4 { (l / a).clamp(0.0, 1.0) } else { 0.0 }).collect();
Self { v, w, h }
}
fn get(&self, x: usize, y: usize) -> f64 {
let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1)));
self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v))
}
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
// Per pixel → per document unit: through the transpose of the document → pixel map.
let [a, b, c, d, _, _] = place.to_px.as_coeffs();
(self.get(x, y), Vec2::new(gx * a + gy * b, gx * c + gy * d))
}
}
/// Craquelure: the object painted on relief plaster that cracks into plates about `spacing` apart
/// and along the contours of its tones. `depth` (0..10) sets how deep and wide the cracks are and
/// how high the plates and the tones stand; `brightness` (0..10) how brightly the surface is lit.
pub(super) fn craquelure(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, spacing: f64, depth: f64, brightness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(spacing.is_finite() && spacing > 0.0) {
return;
}
let depth = (depth / 10.0).clamp(0.0, 1.0);
let light = (0.5 + 0.05 * brightness.clamp(0.0, 10.0)) as f32;
let soft = CONTOUR_SOFTNESS * spacing;
let tones = Tones::new(px, w, h, soft / place.px);
let half = (0.15 + 0.6 * depth).min(0.15 * spacing);
let rise = (3.0 * half).max(0.12 * spacing);
let toward = Light::TopLeft.toward();
let mut around = Around::new();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / spacing, q.y / spacing);
// The cracks between the plates; each plate's edge bevels down into them.
let (mut crack, mut slope) = (0.0, Vec2::ZERO);
if let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_CRACKS, i, j)) {
let (e, away) = edge(u, v, near, next);
crack = cover(half - e * spacing, place.px);
slope = away * (0.6 * bevel(e * spacing - half, rise));
}
// The cracks along the tones' contours (quarter, half and three-quarter tone).
let (tone, grad) = tones.slope(x, y, &place);
let steep = grad.hypot();
if steep > 1e-4 {
let level = (tone * 4.0).round().clamp(1.0, 3.0) / 4.0;
crack = crack.max(cover(0.8 * half - (tone - level).abs() / steep, place.px));
}
slope += grad * (0.8 * soft);
let c = lit(straight(*p), 0.5 * depth * facing(slope, toward));
let dark = (1.0 - 0.85 * crack * depth) as f32;
*p = premultiply(c.map(|v| v * light * dark), f32::from(p[3]) / 255.0);
});
}
/// A random value in [-1, 1) for the `size`-point cell holding `q`; `k` picks one of several.
pub(super) fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => 2.0 * rand(salt, i, j, k) - 1.0,
_ => 0.0,
}
}
/// [`value_noise`] in [-1, 1) over cells `size` points wide.
pub(super) fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
2.0 * value_noise(salt, q.x / size, q.y / size) - 1.0
}
/// Grain: noise of `kind` over the object in 1-point grains (2 for Enlarged, clumps for Clumped),
/// as strong as `intensity` (0..100); `contrast` (0..100, 50 leaves it) sets the image's
/// contrast. Sprinkles and Stippled use the background colour, white.
pub(super) fn grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, intensity: f64, contrast: f64, kind: Grain) {
let Some(place) = Place::new(space, w, h) else { return };
let a = (intensity / 100.0).clamp(0.0, 1.0);
let k = (0.5 + contrast / 100.0).clamp(0.5, 1.5) as f32;
let s = SALT_GRAIN;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let mut c = straight(*p).map(|v| (v - 0.5) * k + 0.5);
let chance = 0.5 * (cell_noise(s, q, 1.0, 3) + 1.0);
match kind {
Grain::Regular => add(&mut c, |ch| cell_noise(s, q, 1.0, ch), 0.5 * a),
Grain::Soft => add(&mut c, |ch| smooth_noise(s + ch, q, 1.0), 0.4 * a),
Grain::Clumped => add(&mut c, |ch| 0.65 * smooth_noise(s + ch, q, 3.0) + 0.35 * cell_noise(s, q, 1.0, ch), 0.6 * a),
Grain::Enlarged => add(&mut c, |ch| 0.5 * (smooth_noise(s + ch, q, 2.0) + cell_noise(s, q, 2.0, ch)), 0.5 * a),
Grain::Contrasty => {
c = c.map(|v| (v - 0.5) * 1.4 + 0.5);
add(&mut c, |_| cell_noise(s, q, 1.0, 4), 0.6 * a);
}
Grain::Horizontal | Grain::Vertical => {
// Streaks one point thick: a value per row (column) drifting along it.
let (along, across) = if kind == Grain::Horizontal { (q.x, q.y) } else { (q.y, q.x) };
let line = cell(across).unwrap_or(0);
let n = 0.6 * (2.0 * rand(s, 0, line, 6) - 1.0) + 0.4 * (2.0 * value_noise(s + 7, along / 12.0, line as f64) - 1.0);
add(&mut c, |_| n, 0.5 * a);
}
Grain::Sprinkles => {
if chance < 0.35 * a {
c = c.map(|v| v + (1.0 - v) * 0.9);
}
}
Grain::Speckle => {
if chance < 0.25 * a {
c = c.map(|v| v * 0.25);
} else {
add(&mut c, |_| cell_noise(s, q, 1.0, 5), 0.1 * a);
}
}
Grain::Stippled => {
// Dots of the colour where it is dark, the background between them.
let dots = f64::from(1.0 - luma(c).clamp(0.0, 1.0)) * (0.35 + 0.65 * a);
c = if chance < dots { c.map(|v| v * 0.6) } else { [1.0; 3] };
}
}
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Add noise `n(channel)` (-1..1) times `amount` to colour `c`.
fn add(c: &mut [f32; 3], n: impl Fn(u64) -> f64, amount: f64) {
for (ch, v) in c.iter_mut().enumerate() {
*v += (amount * n(ch as u64)) as f32;
}
}
/// A random point of cell (`i`, `j`) of pattern `salt` within `jitter` (0..1, a share of the
/// cell) of its centre, in cells.
fn jittered(salt: u64, i: i64, j: i64, jitter: f64) -> Point {
Point::new(i as f64 + 0.5 + jitter * (rand(salt, i, j, 0) - 0.5), j as f64 + 0.5 + jitter * (rand(salt, i, j, 1) - 0.5))
}
/// Mosaic Tiles: the object laid in irregular tiles about `tile` across, bevelled at their edges,
/// with sunken grout between them half a point wide per step of `grout` (1..15), as light as
/// `lighten` (0..10) says.
pub(super) fn mosaic_tiles(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, tile: f64, grout: f64, lighten: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(tile.is_finite() && tile > 0.0 && grout.is_finite()) {
return;
}
let half = (0.25 * grout).clamp(0.0, 0.225 * tile);
let rise = (1.2 * half).max(0.08 * tile);
let lighten = (0.08 * lighten.clamp(0.0, 10.0)) as f32;
let toward = Light::TopLeft.toward();
let mut around = Around::new();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / tile, q.y / tile);
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| jittered(SALT_TILES, i, j, 0.5)) else { return };
let (e, away) = edge(u, v, near, next);
let e = e * tile;
let c = straight(*p);
let face = lit(c, 0.35 * facing(away * bevel(e - half, rise), toward));
// Sunk in shade, then lightened.
let sunk = c.map(|v| 0.5 * v + (1.0 - 0.5 * v) * lighten);
let g = cover(half - e, place.px) as f32;
let mut out = face;
for (o, s) in out.iter_mut().zip(sunk) {
*o = *o * (1.0 - g) + s * g;
}
*p = premultiply(out, f32::from(p[3]) / 255.0);
});
}
/// Patchwork: the object in squares `square` points across, each filled with the colour around
/// its centre and raised to a height of its own (brighter squares stand higher, each a little
/// more or less at random), its bevelled sides lit from the top left as strongly as `relief`
/// (0..25) says.
pub(super) fn patchwork(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, square: f64, relief: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let side = square.max(1.0);
if !side.is_finite() {
return;
}
let soft = Soft::new(px, w, side / 3.0 / place.px);
let relief = (relief / 25.0).clamp(0.0, 1.0);
let rise = 0.25 * side;
let toward = Light::TopLeft.toward();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
let (x0, y0) = (i as f64 * side, j as f64 * side);
let [r, g, b, alpha] = soft.at(place.index(Point::new(x0 + 0.5 * side, y0 + 0.5 * side)));
if alpha <= 0.0 {
*p = [0; 4];
return;
}
let c = [r, g, b].map(|v| v / alpha);
let height = relief * (0.35 + 0.65 * (0.5 * rand(SALT_PATCH, i, j, 0) + 0.5 * f64::from(luma(c))));
// The bevels rise inwards from each side.
let (fx, fy) = (q.x - x0, q.y - y0);
let ramp = |d: f64| cover(rise - d, place.px);
let slope = Vec2::new(ramp(fx) - ramp(side - fx), ramp(fy) - ramp(side - fy)) * (1.5 * height);
*p = premultiply(lit(c, 0.9 * facing(slope, toward)), alpha);
});
}
/// Stained Glass: the object as panes of single colours around random points about `cell_size`
/// apart, leaded in the foreground colour (black) half a point wide per step of `border` (1..20),
/// lit from behind at its centre as strongly as `light` (0..10) says, fading out towards its
/// corners.
pub(super) fn stained_glass(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64, border: f64, light: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(cell_size.is_finite() && cell_size > 0.0 && border.is_finite()) {
return;
}
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
let half = (0.25 * border).clamp(0.0, 0.25 * cell_size);
let light = (light / 10.0).clamp(0.0, 1.0);
let extent = space.extent;
let mut around = Around::new();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_GLASS, i, j)) else { return };
let pane = |at: Point| soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
let (glass, other) = (pane(near), pane(next));
let (e, _) = edge(u, v, near, next);
let lead = cover(half - e * cell_size, place.px) as f32;
let glow = if extent.is_finite() && extent > 0.0 { light * (1.0 - (q.to_vec2().hypot() / extent).powi(2)).max(0.0) } else { 0.0 };
let [r, g, b, a] = glass;
let c = if a > 0.0 { lit([r, g, b].map(|v| v / a), 0.6 * glow) } else { [0.0; 3] };
// The lead holds the panes on both sides of it, as opaque as the more opaque one.
let alpha = a * (1.0 - lead) + a.max(other[3]) * lead;
let rgb = c.map(|v| v * a * (1.0 - lead));
*p = if alpha > 0.0 { premultiply(rgb.map(|v| v / alpha), alpha) } else { [0; 4] };
});
}
/// Brick in stretcher bond (bricks 20 × 8 points, 1.5-point mortar): each brick raised with
/// 1-point bevels and a little higher or lower than the next, rough faced, the mortar sandy.
fn brick(p: Point) -> f64 {
const LENGTH: f64 = 20.0;
const COURSE: f64 = 8.0;
const MORTAR: f64 = 1.5;
let Some(row) = cell(p.y / COURSE) else { return 0.0 };
let x = p.x + if row.rem_euclid(2) == 1 { 0.5 * LENGTH } else { 0.0 };
let Some(col) = cell(x / LENGTH) else { return 0.0 };
let (fx, fy) = (x - col as f64 * LENGTH, p.y - row as f64 * COURSE);
let edge = fx.min(LENGTH - fx).min(fy).min(COURSE - fy) - 0.5 * MORTAR;
if edge <= 0.0 {
return 0.2 * value_noise(SALT_SURFACE, p.x / 0.6, p.y / 0.6);
}
edge.min(1.0) + 0.4 * rand(SALT_SURFACE, col, row, 2) + 0.3 * value_noise(SALT_SURFACE + 1, p.x / 1.5, p.y / 1.5)
}
/// A plain weave of threads `period` points apart, each passing over one thread and under the
/// next; `coarse` threads (burlap) wander and are hairy, fine ones (canvas) are even.
fn weave(p: Point, period: f64, coarse: bool) -> f64 {
let (wander, hair) = if coarse { (0.35, 0.35) } else { (0.08, 0.12) };
let drift = |salt: u64, t: f64| wander * (2.0 * value_noise(salt, t / (4.0 * period), 0.5) - 1.0);
let x = p.x / period + drift(SALT_SURFACE + 3, p.y);
let y = p.y / period + drift(SALT_SURFACE + 4, p.x);
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.0 };
let (fx, fy) = (x - i as f64, y - j as f64);
// Each thread is round across and rises and falls along its length, over one crossing thread
// and under the next; the higher of the two threads shows.
let profile = |t: f64| (PI * t).sin().max(0.0).sqrt();
let over = if i.wrapping_add(j).rem_euclid(2) == 0 { 0.5 } else { -0.5 };
let down = profile(fx) * (0.5 + over * (PI * fy).sin());
let across = profile(fy) * (0.5 - over * (PI * fx).sin());
let thread = down.max(across);
let fibres = hair * value_noise(SALT_SURFACE + 5, p.x / (0.25 * period), p.y / (0.25 * period));
(thread + fibres) * period / PI
}
/// Sandstone: four octaves of smooth noise (8 down to 1 point) over a fine sandy grain.
fn sandstone(p: Point) -> f64 {
let (mut h, mut size, mut amp) = (0.0, 8.0, 1.6);
for octave in 0..4 {
h += amp * value_noise(SALT_SURFACE + 8 + octave, p.x / size, p.y / size);
size *= 0.5;
amp *= 0.5;
}
h + 0.06 * cell_noise(SALT_SURFACE + 12, p, 0.5, 0)
}
/// Texturizer: the object on a `texture` surface scaled by `scaling` (0.5..2), in relief as
/// strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
#[allow(clippy::too_many_arguments)] // the effect's five options
pub(super) fn texturizer(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
texture: Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
let Some(place) = Place::new(space, w, h) else { return };
if !(scaling.is_finite() && scaling > 0.0) {
return;
}
// Slopes over a quarter point of the texture, or half a pixel where pixels are larger.
let step = (0.25f64).max(0.5 * place.px / scaling);
let strength = 0.1 * relief.clamp(0.0, 50.0) * if invert { -1.0 } else { 1.0 };
let toward = light.toward();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let t = Point::new(q.x / scaling, q.y / scaling);
if !(t.x.is_finite() && t.y.is_finite()) {
return;
}
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
let (left, right, up, down) = (at(-step, 0.0), at(step, 0.0), at(0.0, -step), at(0.0, step));
let slope = Vec2::new(right - left, down - up) / (2.0 * step);
// Ridges catch some light from any side and hollows stay in shade, so the surface shows
// whichever way it runs.
let ridge = (at(0.0, 0.0) - 0.25 * (left + right + up + down)) / step;
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
});
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
update
),
cmd!(
"links.updateChanged",
"Update Changed Links",
[],
None,
"{} read again the active document's linked files that changed on disk since they were last seen (size or modification time), whatever Preferences → File Handling → Update Links says; the desktop app looks every two seconds and follows that preference. Files seen for the first time are only remembered. One undo step → {updated: [ids], ask: [], modified: []}",
has_doc,
crate::link_watch::update_changed
),
cmd!(
"links.relink",
"Relink",
@@ -158,6 +167,11 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
}
}
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]).
pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect()
}
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
/// given), then, without `ids`, the ones only symbols and patterns show.
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
+2
View File
@@ -77,6 +77,7 @@ pub(crate) mod textwrap;
pub(crate) mod threads;
pub(crate) mod typecmd;
mod typemenu;
mod variablecmds;
pub(crate) mod views;
pub mod wand;
pub mod webexport;
@@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(maskedit::specs());
v.extend(tabs::specs());
v.extend(docmenu::specs());
v.extend(variablecmds::specs());
v.extend(docinfo::specs());
v.extend(panelcmds::specs());
v.extend(buildcmds::specs());
+1
View File
@@ -278,6 +278,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("toolGroupLabels", "User Interface", "", "Show Tool Group Labels", bool),
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
p!("systemTitleBar", "User Interface", "", "System Title Bar", 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.
+17
View File
@@ -112,6 +112,23 @@ fn text_ref(s: &Session, id: NodeId) -> Result<TextObject> {
}
}
/// Replace a type object's whole content: the new text takes the replaced text's
/// style and paragraph styles follow, exactly as `text.editRange` over the full
/// range does. Dataset apply and friends share this so Variables edits behave
/// like Type tool edits.
pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text: &str) -> Result<()> {
let len = match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => edit::runs_len(&t.runs),
Some(_) => return Err(EngineError::Other(format!("node {} is not text", id.0))),
None => return Err(EngineError::NoNode(id)),
};
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
t.splice_paras(0, len, text);
edit::replace_range(&mut t.runs, 0, len, text);
refresh_bounds(t);
Ok(())
}
fn byte_param(p: &Value, k: &str) -> Option<usize> {
p.get(k).and_then(Value::as_u64).map(|v| v as usize)
}
+590
View File
@@ -0,0 +1,590 @@
//! Variables (data merge): named values bound to objects, applied per dataset.
//!
//! One template document produces many variants: bind a type object to a `text`
//! variable and anything to a `visibility` variable, put a row of values in each
//! dataset, and `dataset.select` swaps the whole document to that row in one undo
//! step. Agents reach every command through `run_command`, like all engine commands.
use std::collections::BTreeMap;
use serde_json::{Value, json};
use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables};
use super::edit::selected_roots;
use super::textedit::set_plain_text;
use super::*;
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
const MAX_NAME: usize = 255;
fn var_name(p: &Value, cmd: &str) -> Result<String> {
named(str_param(p, "name"), "name", cmd)
}
/// `key`'s value, as a name: trimmed, not empty and at most [`MAX_NAME`] characters.
fn named(v: Option<&str>, key: &str, cmd: &str) -> Result<String> {
match v.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) if v.chars().count() > MAX_NAME => Err(bad(cmd, format!("`{key}` is longer than {MAX_NAME} characters"))),
Some(v) => Ok(v.to_string()),
None => Err(bad(cmd, format!("missing `{key}`"))),
}
}
/// `key`'s value as a name, or `None` when it is missing or empty.
fn optional_name(p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
match str_param(p, key).map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => named(Some(v), key, cmd).map(Some),
None => Ok(None),
}
}
/// The `names` of a delete, or without them `fallback` (what the panel acts on) as the one name.
fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) -> Result<Vec<String>> {
let Some(given) = p.get("names") else {
return fallback.map(|n| vec![n]).ok_or_else(|| bad(cmd, nothing.to_string()));
};
match given.as_array() {
Some(names) if !names.is_empty() => names.iter().map(|n| named(n.as_str(), "names", cmd)).collect(),
_ => Err(bad(cmd, "`names` must be a list of names")),
}
}
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
}
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
/// layer is not the document's to rewrite, and a text variable only drives type.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
for id in ids {
match doc.node(*id) {
None => return Err(EngineError::NoNode(*id)),
Some(_) if !doc.variable_target(*id) => return Err(bad(cmd, format!("node {} is locked", id.0))),
Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => {
return Err(bad(cmd, format!("node {} is not type", id.0)));
}
Some(_) => {}
}
}
Ok(())
}
fn dataset_value(cmd: &str, var: &str, kind: VariableKind, v: &Value) -> Result<DataValue> {
match kind {
VariableKind::Text => v.as_str().map(|t| DataValue::Text(t.to_string())),
VariableKind::Visibility => v.as_bool().map(DataValue::Visible),
}
.ok_or_else(|| bad(cmd, format!("value for `{var}` must match its variable kind")))
}
/// The `values` object of `dataset.new` / `dataset.set`, each entry checked against its
/// variable's kind. Every name must be a variable the document defines: a value nothing
/// reads is a typo, not a value to keep.
fn values_param(vars: &Variables, p: &Value, cmd: &str) -> Result<BTreeMap<String, DataValue>> {
let mut out = BTreeMap::new();
let Some(given) = p.get("values") else { return Ok(out) };
let Some(obj) = given.as_object() else { return Err(bad(cmd, "`values` must be an object")) };
for (var, v) in obj {
let kind = vars.variable(var).map(|d| d.kind).ok_or_else(|| bad(cmd, format!("no variable named `{var}`")))?;
out.insert(var.clone(), dataset_value(cmd, var, kind, v)?);
}
Ok(out)
}
fn define(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.define";
let name = var_name(p, C)?;
let kind = str_param(p, "kind").and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text` or `visibility`"))?;
if s.doc()?.doc.variables.variable(&name).is_some() {
return Err(bad(C, format!("variable `{name}` is already defined")));
}
s.edit("New Variable", |d, _| {
d.variables.variables.push(Variable { name: name.clone(), kind });
Ok(())
})?;
Ok(json!({"name": name, "kind": kind.label()}))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.rename";
let from = var_name(p, C)?;
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
let st = s.doc()?;
if st.doc.variables.variable(&to).is_some() {
return Err(bad(C, format!("variable `{to}` already exists")));
}
s.edit("Rename Variable", |d, _| {
let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?;
v.name = to.clone();
for bound in d.variables.bindings.values_mut() {
if bound.remove(&from) {
bound.insert(to.clone());
}
}
for ds in &mut d.variables.datasets {
if let Some(value) = ds.values.remove(&from) {
ds.values.insert(to.clone(), value);
}
}
Ok(())
})?;
// The highlight names a variable, so it follows the rename rather than going stale.
let st = s.doc_mut()?;
if st.variables_highlight.as_deref() == Some(from.as_str()) {
st.variables_highlight = Some(to.clone());
}
st.revision += 1;
Ok(json!({"from": from, "name": to}))
}
/// Make Text Dynamic / Make Visibility Dynamic: define a variable of that kind and bind the
/// selection to it in one step, naming it after what it drives — the panel's buttons.
fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result<Value> {
const C: &str = "variable.makeDynamic";
let ids = selected_roots(s)?;
if ids.is_empty() {
return Err(bad(C, "select the objects to bind"));
}
let doc = &s.doc()?.doc;
check_bindable(doc, &ids, kind, C)?;
// Named after the first object it drives, as the reference panel does; a second variable
// over the same art takes a number rather than colliding.
let shown = ids.first().and_then(|id| doc.node(*id)).map(|n| n.display_name()).unwrap_or_default();
let base: String = match shown.trim() {
"" => stem.to_string(),
b => b.chars().take(MAX_NAME - 8).collect(),
};
let mut name = base.clone();
let mut n = 2;
while doc.variables.variable(&name).is_some() {
name = format!("{base} {n}");
n += 1;
}
s.edit("Make Dynamic", |d, _| {
d.variables.variables.push(Variable { name: name.clone(), kind });
for id in &ids {
d.variables.bind(*id, &name);
}
Ok(())
})?;
Ok(json!({"name": name, "kind": kind.label(), "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
// Without `names`, the highlighted variable: what the panel's bin and the menu item delete.
let highlighted = s.doc()?.variables_highlight.clone();
let names = names_param(p, "variable.delete", highlighted, "no variable highlighted")?;
let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?;
// A highlight of a variable that is gone highlights nothing.
let st = s.doc_mut()?;
if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) {
st.variables_highlight = None;
}
st.revision += 1;
Ok(json!({"deleted": n}))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let variables: Vec<Value> = st
.doc
.variables
.variables
.iter()
.map(|v| {
let bindings = st.doc.variables.objects_of(&v.name).len();
json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings})
})
.collect();
Ok(json!({"variables": variables}))
}
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.bind";
let name = named(str_param(p, "variable"), "variable", C)?;
let ids = target_ids(s, p, C)?;
if ids.is_empty() {
return Err(bad(C, "nothing to bind"));
}
let doc = &s.doc()?.doc;
let kind = doc.variables.variable(&name).map(|v| v.kind).ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
check_bindable(doc, &ids, kind, C)?;
// Binding again is not a second binding, and says so rather than counting as a change.
let already: Vec<u64> = ids.iter().filter(|id| doc.variables.objects_of(&name).contains(id)).map(|id| id.0).collect();
let n = s.edit("Bind Variable", |d, _| Ok(ids.iter().filter(|id| d.variables.bind(**id, &name)).count()))?;
Ok(json!({"variable": name, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "bound": n, "already": already}))
}
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.unbind";
let ids = target_ids(s, p, C)?;
let name = defined_variable(s, p, C)?;
let n = s.edit("Unbind Variable", |d, _| {
let mut n = 0;
for id in &ids {
// With `variable` only that one binding goes; without it, every one the object has.
match &name {
Some(v) => n += usize::from(d.variables.unbind(*id, v)),
None => n += usize::from(d.variables.bindings.remove(id).is_some()),
}
}
Ok(n)
})?;
Ok(json!({"unbound": n}))
}
/// The `variable` a command names, which must be defined; `None` when it names none.
fn defined_variable(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
let name = optional_name(p, "variable", cmd)?;
if let Some(v) = &name
&& s.doc()?.doc.variables.variable(v).is_none()
{
return Err(bad(cmd, format!("no variable named `{v}`")));
}
Ok(name)
}
/// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound
/// Object act on. Panel state, not art selection: not saved, not an undo step (an empty
/// or missing `variable` clears it), like `layer.highlight`.
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let name = defined_variable(s, p, "variable.highlight")?;
let st = s.doc_mut()?;
st.variables_highlight = name.clone();
st.revision += 1;
Ok(json!({"variable": name}))
}
fn dataset_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.new";
let name = var_name(p, C)?;
let st = s.doc()?;
// A dataset and a variable are separate namespaces, so a dataset may share a variable's
// name; it is its own row of values either way.
let values = values_param(&st.doc.variables, p, C)?;
if st.doc.variables.dataset(&name).is_some() {
return Err(bad(C, format!("dataset `{name}` already exists")));
}
s.edit("New Data Set", |d, _| {
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
Ok(())
})?;
Ok(json!({"name": name}))
}
/// Replace a dataset's values. A variable left out of `values` loses its value in this row,
/// so the dataset ends up holding exactly what the call asked for.
fn dataset_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.set";
let name = var_name(p, C)?;
let values = values_param(&s.doc()?.doc.variables, p, C)?;
let (set, removed) = s.edit("Edit Data Set", |d, _| {
let ds = d.variables.datasets.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
let removed: Vec<String> = ds.values.keys().filter(|k| !values.contains_key(*k)).cloned().collect();
ds.values = values.clone();
Ok((values.len(), removed))
})?;
Ok(json!({"name": name, "values": set, "removed": removed}))
}
fn dataset_rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.rename";
let from = var_name(p, C)?;
let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?;
let st = s.doc()?;
if st.doc.variables.dataset(&to).is_some() {
return Err(bad(C, format!("dataset `{to}` already exists")));
}
s.edit("Rename Data Set", |d, _| {
let ds = d.variables.datasets.iter_mut().find(|d| d.name == from).ok_or_else(|| bad(C, format!("no dataset named `{from}`")))?;
ds.name = to.clone();
if d.variables.active_dataset.as_deref() == Some(from.as_str()) {
d.variables.active_dataset = Some(to.clone());
}
Ok(())
})?;
Ok(json!({"from": from, "name": to}))
}
/// Capture Data Set: record what the bound objects hold right now as a new row. This, rather
/// than typing values, is how rows are made — change the art, then capture it.
fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.capture";
let st = s.doc()?;
let values = st.doc.captured_values();
if values.is_empty() {
return Err(bad(C, "bind something to a variable first"));
}
let name = match optional_name(p, "name", C)? {
Some(n) => n,
// "Data Set 1", the first free number, as the reference panel's rows are named.
None => (1..).map(|i| format!("Data Set {i}")).find(|n| st.doc.variables.dataset(n).is_none()).unwrap_or_default(),
};
if s.doc()?.doc.variables.dataset(&name).is_some() {
return Err(bad(C, format!("dataset `{name}` already exists")));
}
s.edit("Capture Data Set", |d, _| {
d.variables.datasets.push(DataSet { name: name.clone(), values: values.clone() });
d.variables.active_dataset = Some(name.clone());
Ok(())
})?;
Ok(json!({"name": name, "values": values.len()}))
}
/// Update Data Set: write what the bound objects hold now into the row that is active. Without
/// an active row there is nothing to update, so say so rather than picking one.
fn dataset_update(s: &mut Session, _p: &Value) -> Result<Value> {
const C: &str = "dataset.update";
let values = s.doc()?.doc.captured_values();
let Some(name) = s.doc()?.doc.variables.active_dataset.clone() else {
return Err(bad(C, "no active data set"));
};
if values.is_empty() {
return Err(bad(C, "bind something to a variable first"));
}
s.edit("Update Data Set", |d, _| {
let ds = d.variables.datasets.iter_mut().find(|d| d.name == name).ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
ds.values = values.clone();
Ok(())
})?;
Ok(json!({"name": name, "values": values.len()}))
}
fn dataset_delete(s: &mut Session, p: &Value) -> Result<Value> {
// Without `names`, the active dataset: what the panel's Delete Data Set deletes.
let active = s.doc()?.doc.variables.active_dataset.clone();
let names = names_param(p, "dataset.delete", active, "no active data set")?;
let n = s.edit("Delete Data Sets", |d, _| {
let before = d.variables.datasets.len();
d.variables.datasets.retain(|d| !names.contains(&d.name));
// The active dataset's own name is the one that goes, not a variable's.
if d.variables.active_dataset.as_ref().is_some_and(|a| names.contains(a)) {
d.variables.active_dataset = None;
}
Ok(before - d.variables.datasets.len())
})?;
Ok(json!({"deleted": n}))
}
fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let current = st.doc.captured_values();
let datasets: Vec<Value> = st
.doc
.variables
.datasets
.iter()
// `matches` says whether the art still holds what the row says, which is what the
// panel shows in italics.
.map(|d| json!({"name": d.name, "values": d.values, "matches": d.matches(&current)}))
.collect();
Ok(json!({"datasets": datasets, "active": st.doc.variables.active_dataset}))
}
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
const C: &str = "dataset.select";
let (applied, skipped) = s.edit("Apply Data Set", |d, _| {
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
// One object may be driven by several variables; walk them in definition order, so two
// text variables on the same object land the later one's value and both are counted.
let mut pairs: Vec<(NodeId, String)> = Vec::new();
for v in &d.variables.variables {
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
}
let mut applied = 0usize;
let mut skipped = 0usize;
for (id, var) in pairs {
// A binding whose variable is gone, whose value this row doesn't carry, or whose
// value doesn't fit the variable's kind, is left alone and counted, never
// silently reinterpreted.
let (kind, value) = match (d.variables.variable(&var).map(|v| v.kind), ds.values.get(&var)) {
(Some(kind), Some(value)) if value.fits(kind) => (kind, value.clone()),
_ => {
skipped += 1;
continue;
}
};
// Locked art, art on a locked or hidden layer, and text whose object is gone, can't
// be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a
// row that hid it shows it again.
if !d.variable_target(id) {
skipped += 1;
continue;
}
let done = match (kind, &value) {
// The Type tool's own whole-range replace, so a dataset's text is styled and
// laid out exactly as typing it would be.
(VariableKind::Text, DataValue::Text(text)) => set_plain_text(d, id, text).is_ok(),
(VariableKind::Visibility, DataValue::Visible(show)) => d.node_mut(id).is_some_and(|n| {
n.visible = *show;
true
}),
_ => false,
};
if done {
applied += 1;
} else {
skipped += 1;
}
}
d.variables.active_dataset = Some(name.to_string());
Ok((applied, skipped))
})?;
Ok(json!({"name": name, "applied": applied, "skipped": skipped}))
}
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
apply_dataset(s, &var_name(p, "dataset.select")?)
}
fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result<Value> {
let st = s.doc()?;
let n = st.doc.variables.datasets.len();
if n == 0 {
return Err(bad(cmd, "no datasets"));
}
let at = st.doc.variables.active_dataset.as_deref().and_then(|a| st.doc.variables.datasets.iter().position(|d| d.name == a));
let next = match (at, forward) {
(Some(i), true) => (i + 1) % n,
(Some(i), false) => (i + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
let name = st.doc.variables.datasets.get(next).map(|d| d.name.clone()).ok_or_else(|| bad(cmd, "no datasets"))?;
apply_dataset(s, &name)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("variable.define", "New Variable…", ["Window", "Variables"], None, "{name, kind: text|visibility} define a variable", has_doc, define),
cmd!(
"variable.delete",
"Delete Variable",
["Window", "Variables"],
None,
"{names?} delete variables with their bindings and values (default: the highlighted one)",
has_doc,
delete
),
cmd!(
"variable.rename",
"Rename Variable",
["Window", "Variables"],
None,
"{name, newName} rename a variable, with its bindings and its values in every dataset",
has_doc,
rename
),
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
cmd!(
"variable.makeTextDynamic",
"Make Text Dynamic",
["Window", "Variables"],
None,
"{} define a text variable over the selection, named after it and bound to it",
has_selection,
|s, _| make_dynamic(s, VariableKind::Text, "Text")
),
cmd!(
"variable.makeVisibilityDynamic",
"Make Visibility Dynamic",
["Window", "Variables"],
None,
"{} define a visibility variable over the selection, named after it and bound to it",
has_selection,
|s, _| make_dynamic(s, VariableKind::Visibility, "Visibility")
),
cmd!(
"variable.bind",
"Bind Variable",
["Window", "Variables"],
None,
"{variable, ids?} bind objects (default: the selection) to a variable; text needs type, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}",
has_doc,
bind
),
cmd!(
"variable.unbind",
"Unbind Variable",
["Window", "Variables"],
None,
"{ids?, variable?} drop bindings (default: the selection): `variable` drops that one, else every variable the object has",
has_doc,
unbind
),
cmd!(
"variable.highlight",
"Highlight Variable",
[],
None,
"{variable?} highlight one Variables panel row (a click); what the panel's Delete, Options… and Select Bound Object act on. An empty or missing `variable` clears it. Not an undo step → {variable}",
has_doc,
highlight
),
cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new),
cmd!(
"dataset.set",
"Edit Data Set…",
["Window", "Variables"],
None,
"{name, values?: {variable: value}} replace a dataset's values; a variable left out loses its value in this row → {values, removed}",
has_doc,
dataset_set
),
cmd!("dataset.rename", "Rename Data Set", ["Window", "Variables"], None, "{name, newName} rename a dataset", has_doc, dataset_rename),
cmd!(
"dataset.capture",
"Capture Data Set",
["Window", "Variables"],
None,
"{name?} record what the bound objects hold right now as a new dataset (Data Set 1…), and make it the active one",
has_doc,
dataset_capture
),
cmd!(
"dataset.update",
"Update Data Set",
["Window", "Variables"],
None,
"{} write what the bound objects hold now into the active dataset",
has_doc,
dataset_update
),
cmd!(
"dataset.delete",
"Delete Data Set",
["Window", "Variables"],
None,
"{names?} delete datasets (default: the active one)",
has_doc,
dataset_delete
),
cmd!(
query "dataset.list",
"List Data Sets",
[],
None,
"{} → datasets with their values, the active one, and which rows the art still matches",
has_doc,
dataset_list
),
cmd!(
"dataset.select",
"Select Data Set",
["Window", "Variables"],
None,
"{name} apply a dataset in one undo step → {applied, skipped}",
has_doc,
dataset_select
),
cmd!("dataset.next", "Next Data Set", ["Window", "Variables"], None, "{} apply the next dataset, wrapping", has_doc, |s, _| dataset_step(
s,
"dataset.next",
true
)),
cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| {
dataset_step(s, "dataset.prev", false)
}),
]
}
+29
View File
@@ -10,6 +10,7 @@ pub mod cmd;
pub mod file_access;
pub mod guard;
pub mod inspect;
pub mod link_watch;
pub mod steps;
mod tooling;
pub mod units;
@@ -85,6 +86,7 @@ pub struct Interaction {
/// isolation).
pub active_layer: Option<NodeId>,
pub layer_rows: Vec<NodeId>,
pub variables_highlight: Option<String>,
pub isolation: Option<NodeId>,
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
@@ -120,6 +122,10 @@ pub struct DocState {
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
/// `layer.highlight`).
pub layer_rows: Vec<NodeId>,
/// The variable highlighted in the Variables panel: what its Delete, Options… and Select
/// Bound Object act on. Panel state, not art selection: not saved, not undoable
/// (`variable.highlight`). A name, so renaming a variable moves the highlight with it.
pub variables_highlight: Option<String>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
pub interaction: Option<Interaction>,
@@ -235,6 +241,7 @@ impl DocState {
revision: 1,
active_layer,
layer_rows: vec![],
variables_highlight: None,
isolation: None,
interaction: None,
undo_group: None,
@@ -299,6 +306,7 @@ impl DocState {
self.selection = it.selection;
self.active_layer = it.active_layer;
self.layer_rows = it.layer_rows;
self.variables_highlight = it.variables_highlight;
self.isolation = it.isolation;
self.revision += 1;
}
@@ -511,6 +519,10 @@ pub struct Prefs {
/// 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,
/// Windows and Linux: use the system's title bar and window buttons instead of the app bar
/// acting as the title bar (tiling window managers, desktops that draw their own decorations).
/// Read when the app starts. macOS always uses the system's.
pub system_title_bar: bool,
// Performance
pub gpu_performance: bool,
pub animated_zoom: bool,
@@ -725,6 +737,7 @@ impl Default for Prefs {
ui_scaling: 1.0,
scale_cursor_with_ui: false,
interface_language: s("auto"),
system_title_bar: false,
gpu_performance: true,
animated_zoom: true,
gpu_preference: s("automatic"),
@@ -873,6 +886,11 @@ pub struct Session {
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
/// Each open document's linked files' size and modification time when last seen (linked
/// files changing while the document is open, [`link_watch`]).
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
pub link_scan: Option<link_watch::LinkScan>,
}
impl Default for Session {
@@ -928,6 +946,8 @@ impl Session {
font_search: None,
search_rules: None,
search_threads: None,
link_stamps: Default::default(),
link_scan: None,
}
}
@@ -1213,6 +1233,10 @@ impl Session {
if !st.doc.assets.is_empty() {
Arc::make_mut(&mut st.doc).prune_assets();
}
// Variables: bindings let go of deleted art.
if !st.doc.variables.bindings.is_empty() {
Arc::make_mut(&mut st.doc).prune_variable_bindings();
}
if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
@@ -1277,6 +1301,7 @@ impl Session {
preview: None,
active_layer: st.active_layer,
layer_rows: st.layer_rows.clone(),
variables_highlight: st.variables_highlight.clone(),
isolation: st.isolation,
perspective_again: None,
});
@@ -1506,6 +1531,8 @@ mod tests_links;
#[cfg(test)]
mod tests_linkspanel;
#[cfg(test)]
mod tests_linkwatch;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_live;
@@ -1648,6 +1675,8 @@ mod tests_typearea;
#[cfg(test)]
mod tests_units;
#[cfg(test)]
mod tests_variables;
#[cfg(test)]
mod tests_webexport;
#[cfg(test)]
mod tests_widthpoints;
+150
View File
@@ -0,0 +1,150 @@
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
//! Links decides, as it does when a document opens. Automatically reads them again
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
//! the Links panel.
//!
//! The active document's linked files are compared by size and modification time with what they
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
//!
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
//! A file seen for the first time is only remembered (one modified while its document was closed
//! is handled when the document opens); one that can't be read yet (another app is still writing
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
//! update, or answering No, leaves the link modified until the file changes again.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json};
use crate::{Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
/// object ids, stamp)]).
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str) -> Option<Stamp> {
None
}
/// The linked files of `d` with the ids of the objects showing them.
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
crate::cmd::links::linked_groups(d)
}
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
/// ones again, whatever Update Links says.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
s.apply_link_stamps(uid, now, true)
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
self.link_scan = Some(slot);
}
}
/// Apply a finished look (`None` while there is none, or it is still running): see
/// [`Session::apply_link_stamps`].
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
let (uid, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
Some(self.apply_link_stamps(uid, stamps, false))
}
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
/// or left modified (Manually).
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
let none = || json!({"updated": [], "ask": [], "modified": []});
// The document went to the back meanwhile: it's looked at when it's in front again.
if self.active().map(|d| d.uid) != Some(uid) {
return Ok(none());
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
self.link_stamps.retain(|(u, p), s| {
if *u == uid {
before.insert(p.clone(), *s);
false
} else {
open.contains(u)
}
});
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
for (path, ids, stamp) in now {
match (before.get(&path), stamp) {
// Not there right now (an app saving by delete and rename): keep the last stamp.
(Some(old), None) => {
self.link_stamps.insert((uid, path), *old);
}
(old, Some(new)) => {
if old.is_some_and(|o| *o != new) {
changed.push((path.clone(), ids));
}
self.link_stamps.insert((uid, path), new);
}
(None, None) => {}
}
}
if changed.is_empty() {
return Ok(none());
}
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
match mode {
"automatically" => {
let r = self.execute("links.update", &json!({"ids": ids}))?;
// Not readable yet (still being written): the next look tries again.
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
for (path, ids) in &changed {
if ids.iter().any(|i| unread.contains(i))
&& let Some(old) = before.get(path)
{
self.link_stamps.insert((uid, path.clone()), *old);
}
}
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
}
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
}
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
//! and the background look following Preferences → File Handling → Update Links.
use serde_json::{Value, json};
use super::*;
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
fn save(path: &str, bytes: &[u8]) {
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::write(path, bytes).unwrap();
}
fn colour(s: &Session) -> [u8; 3] {
centre_colour(&s.doc().unwrap().doc)
}
/// One background look, waited for.
fn look(s: &mut Session) -> Value {
s.start_link_scan();
let t0 = std::time::Instant::now();
loop {
if let Some(r) = s.poll_link_scan() {
return r.unwrap();
}
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
std::thread::yield_now();
}
}
#[test]
fn update_changed_reads_changed_files_again_once() {
let dir = Folder::new("watch-update");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
let id = place(&mut s, &path);
// First look: remembered, nothing to read.
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
save(&path, &png(40, 40, BLUE));
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
// Undoing it is respected: the link stays modified until the file changes again.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colour(&s), RED);
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1);
}
#[test]
fn a_background_look_follows_update_links() {
for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] {
let dir = Folder::new(&format!("watch-{mode}"));
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = mode.into();
let id = place(&mut s, &path);
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
save(&path, &png(40, 40, BLUE));
let r = look(&mut s);
assert_eq!(r[key], json!([id.0]), "{mode}: {r}");
assert_eq!(colour(&s), if mode == "automatically" { BLUE } else { RED }, "{mode}");
// Acted on once: the next look finds nothing new.
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
assert!(s.link_scan.is_none());
}
}
#[test]
fn a_file_still_being_written_is_tried_again() {
let dir = Folder::new("watch-partial");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
// Another app has only written the start of the file so far.
let full = png(40, 40, BLUE);
save(&path, &full[..16]);
assert_eq!(look(&mut s)["updated"], json!([]));
assert_eq!(colour(&s), RED, "the last good pixels stay");
save(&path, &full);
assert_eq!(look(&mut s)["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}
+27 -5
View File
@@ -1,5 +1,6 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask): applied and
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Distort ›
//! Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
//! as images of the effected object.
@@ -17,10 +18,25 @@ const EFFECTS: [(&str, &str); 3] = [
("sharpen.unsharpMask", r#"{"amount": 300, "radius": 3}"#),
];
/// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the
/// striped art's reds are dark).
const DISTORT: [(&str, &str); 3] =
[("distort.diffuseGlow", r#"{"clearAmount": 0, "glowAmount": 20}"#), ("distort.glass", "{}"), ("distort.oceanRipple", "{}")];
/// Effect › Pixelate, at their defaults.
const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Texture, at their defaults.
const TEXTURE: [(&str, &str); 6] = [
("texture.craquelure", "{}"),
("texture.grain", "{}"),
("texture.mosaicTiles", "{}"),
("texture.patchwork", "{}"),
("texture.stainedGlass", "{}"),
("texture.texturizer", "{}"),
];
/// Effect › Video (NTSC Colors leaves the striped art's legal colours as they are: see
/// `ntsc_colors_draws_saturated_yellow_safer`).
const VIDEO: [(&str, &str); 1] = [("video.deinterlace", r#"{"create": "interpolation"}"#)];
@@ -61,7 +77,13 @@ fn listed_applied_edited_and_undone() {
let menus: Vec<Value> =
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
for (group, menu) in [(PIXELATE.as_slice(), "Pixelate"), (VIDEO.as_slice(), "Video"), ([("video.ntscColors", "{}")].as_slice(), "Video")] {
for (group, menu) in [
(DISTORT.as_slice(), "Distort"),
(PIXELATE.as_slice(), "Pixelate"),
(TEXTURE.as_slice(), "Texture"),
(VIDEO.as_slice(), "Video"),
([("video.ntscColors", "{}")].as_slice(), "Video"),
] {
for (id, _) in group {
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap_or_else(|| panic!("{id} listed"));
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
@@ -86,7 +108,7 @@ fn listed_applied_edited_and_undone() {
#[test]
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
@@ -115,7 +137,7 @@ fn spin_blur_reaches_past_the_corners() {
#[test]
fn pdf_and_svg_write_the_effected_object_as_an_image() {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(DISTORT).chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
+591
View File
@@ -0,0 +1,591 @@
//! Variables (data merge): definitions with kinds, bindings to objects, datasets
//! applied in one undo step, and validation that rejects instead of reinterpreting.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn text(s: &mut Session, content: &str) -> NodeId {
let r = s.execute("text.create", &json!({"x": 10, "y": 10, "text": content})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn rect(s: &mut Session) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn plain(s: &Session, id: NodeId) -> String {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
}
#[test]
fn text_and_visibility_datasets_apply_in_one_undo_step() {
let mut s = session();
let t = text(&mut s, "Alice");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false}})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)));
assert_eq!(plain(&s, t), "Bob");
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
// One undo step covers the whole dataset application.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(plain(&s, t), "Alice");
assert!(s.doc().unwrap().doc.node(r).unwrap().visible);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(plain(&s, t), "Bob");
}
#[test]
fn dataset_next_and_prev_wrap_around() {
let mut s = session();
let t = text(&mut s, "x");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
for name in ["A", "B"] {
s.execute("dataset.new", &json!({"name": name, "values": {"Name": name}})).unwrap();
}
s.execute("dataset.next", &json!({})).unwrap();
assert_eq!(plain(&s, t), "A");
s.execute("dataset.next", &json!({})).unwrap();
assert_eq!(plain(&s, t), "B");
s.execute("dataset.next", &json!({})).unwrap();
assert_eq!(plain(&s, t), "A");
s.execute("dataset.prev", &json!({})).unwrap();
assert_eq!(plain(&s, t), "B");
let v = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(v["active"], "B");
}
#[test]
fn mismatched_and_missing_bindings_are_skipped_and_counted() {
let mut s = session();
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
// A text variable bound to a rectangle applies to nothing; a dataset value for an
// unbound variable is ignored.
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Ghost": "z"}})).unwrap_err();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [r.0]})).unwrap_err();
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
assert_eq!(plain(&s, t), "y");
}
#[test]
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
let mut s = session();
let t = text(&mut s, "x");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y"}})).unwrap();
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(0));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert!(sets["datasets"][0].get("values").is_none_or(|v| v == &json!({})));
// The dataset named "D" is still the active one: `variable.delete` prunes variables, and
// the dataset's own name is its own namespace.
assert_eq!(sets["active"], "D");
s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(0));
assert!(sets["active"].is_null());
let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap();
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})));
}
/// A dataset may share a variable's name: they are separate namespaces, and deleting the
/// variable must not silently unselect the dataset that happens to be called the same.
#[test]
fn a_dataset_survives_a_variable_of_the_same_name_being_deleted() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Row", "kind": "text"})).unwrap();
s.execute("dataset.new", &json!({"name": "Row", "values": {"Row": "y"}})).unwrap();
s.execute("dataset.select", &json!({"name": "Row"})).unwrap();
s.execute("variable.delete", &json!({"names": ["Row"]})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][0]["name"], "Row", "the dataset is not a variable");
assert_eq!(sets["active"], "Row", "the active dataset keeps its name");
}
/// Deleting art lets its bindings go, as it does for Asset Export's assets: a binding that
/// outlived its object would count as a skip in every dataset application and grow the file.
#[test]
fn deleted_art_lets_its_bindings_go() {
let mut s = session();
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
let of = |name: &str| vars["variables"].as_array().map(|a| a.iter().find(|v| v["name"] == name).unwrap()["bindings"].as_u64().unwrap()).unwrap();
assert_eq!(of("Name"), 0, "the deleted text object took its binding with it");
assert_eq!(of("Show"), 1, "the rectangle is still bound");
// Nothing stale is left for the application to skip.
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(0)));
}
/// `deleted` counts what was deleted, not what was asked for.
#[test]
fn delete_reports_what_it_actually_removed() {
let mut s = session();
s.execute("variable.define", &json!({"name": "A", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "B", "kind": "text"})).unwrap();
s.execute("dataset.new", &json!({"name": "D"})).unwrap();
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 1);
assert_eq!(s.execute("variable.delete", &json!({"names": ["A"]})).unwrap()["deleted"], 0, "already gone");
assert_eq!(s.execute("variable.delete", &json!({"names": ["A", "B"]})).unwrap()["deleted"], 1, "only B was left");
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 1);
assert_eq!(s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap()["deleted"], 0);
}
/// An object may be driven by several variables, so binding it again adds to what it has
/// instead of quietly dropping the earlier one.
#[test]
fn one_object_can_be_driven_by_several_variables() {
let mut s = session();
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap();
// A text object driven by two text variables keeps both.
assert_eq!(s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap()["bound"], 1);
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(1), json!([])), "Title is untouched");
// A rectangle driven by a visibility variable as well as nothing else yet.
s.execute("variable.bind", &json!({"variable": "Badge", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Title": "Hat", "Price": "9.99", "Badge": false}})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(3), Some(0)), "every binding applied");
// Both text variables are counted; the later one in definition order is what is left.
assert_eq!(plain(&s, t), "9.99");
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
let vars = s.execute("variable.list", &json!({})).unwrap();
let of = |n: &str| vars["variables"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap()["bindings"].as_u64();
assert_eq!((of("Title"), of("Price"), of("Badge")), (Some(1), Some(1), Some(1)));
// Binding again to one it already has is not a change, and says so.
let v = s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
assert_eq!((v["bound"].clone(), v["already"].clone()), (json!(0), json!([t.0])));
// Unbinding one variable leaves the object's others alone.
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Price"})).unwrap()["unbound"], 1);
s.execute("edit.undo", &json!({})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"][1]["bindings"], 1, "the other binding is still there");
assert!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Ghost"})).is_err());
// Deleting a variable drops only its own binding.
s.execute("variable.delete", &json!({"names": ["Price"]})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"][0]["bindings"], 1, "Title still drives the text");
}
/// `unbound` counts what was actually dropped, like `variable.delete` does.
#[test]
fn unbind_reports_what_it_actually_removed() {
let mut s = session();
let t = text(&mut s, "x");
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Price", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Price", "ids": [t.0]})).unwrap();
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 1);
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0], "variable": "Title"})).unwrap()["unbound"], 0, "already gone");
// Without `variable` the object's last binding goes and the entry with it.
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 1);
assert_eq!(s.execute("variable.unbind", &json!({"ids": [t.0]})).unwrap()["unbound"], 0);
// An object with no binding left is not kept as an empty set.
let v: Value = serde_json::from_str(&serde_json::to_string(&s.doc().unwrap().doc).unwrap()).unwrap();
assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}");
}
/// Locked art is the document's to rewrite only while it isn't locked.
#[test]
fn locked_art_is_neither_bound_nor_rewritten() {
let mut s = session();
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
assert!(s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).is_err(), "locked art takes no new binding");
// The bindings made before the lock still count, and the application skips the locked art.
let v = s.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)));
assert_eq!(plain(&s, t), "x", "the locked text was left alone");
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible, "the unlocked rectangle still applied");
}
/// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a
/// selection to a bound variable, named after what it drives.
#[test]
fn making_a_selection_dynamic_names_and_binds_it() {
let mut s = session();
let t = text(&mut s, "Alice");
let r = rect(&mut s);
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
let v = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
assert_eq!(v["name"], "Alice", "named after the text it drives");
assert_eq!(v["ids"], json!([t.0]));
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let v = s.execute("variable.makeVisibilityDynamic", &json!({})).unwrap();
assert_eq!(v["kind"], "visibility");
// The same object twice takes a number rather than colliding with the first variable.
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
let again = s.execute("variable.makeTextDynamic", &json!({})).unwrap();
assert_eq!(again["name"], "Alice 2");
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(3));
// Nothing selected, art that can't take the kind, and locked art are errors.
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err());
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
assert!(s.execute("variable.makeTextDynamic", &json!({})).is_err(), "a rectangle is not type");
}
/// Capture Data Set records what the art holds now, which is how rows are made — the art is
/// edited and captured, rather than the values typed.
#[test]
fn capturing_records_what_the_art_holds_now() {
let mut s = session();
let t = text(&mut s, "Alice");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
let v = s.execute("dataset.capture", &json!({})).unwrap();
assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(2)));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"]["Name"], json!({"text": "Alice"}));
assert_eq!(sets["datasets"][0]["values"]["Show"], json!({"visible": true}));
assert_eq!(sets["active"], "Data Set 1");
// Change the art, capture again: a second row, and the first still matches its own text.
s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(false), "the art moved on");
s.execute("dataset.capture", &json!({})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"][1]["name"], "Data Set 2");
assert_eq!(sets["datasets"][1]["values"]["Name"], json!({"text": "Bob"}));
assert_eq!(sets["datasets"][0]["matches"], json!(false), "row 1 still describes the old text");
// A named capture, and a name that is taken is an error rather than a second row.
s.execute("dataset.capture", &json!({"name": "Winter"})).unwrap();
assert!(s.execute("dataset.capture", &json!({"name": "Winter"})).is_err());
// Nothing bound is nothing to capture.
let mut empty = session();
empty.execute("variable.define", &json!({"name": "V", "kind": "text"})).unwrap();
assert!(empty.execute("dataset.capture", &json!({})).is_err());
}
/// Update Data Set writes the art's current values into the row that is active.
#[test]
fn updating_writes_the_art_into_the_active_row() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
// No active row yet: say so rather than quietly writing into some other one.
assert!(s.execute("dataset.update", &json!({})).is_err());
s.execute("dataset.capture", &json!({})).unwrap();
s.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
let v = s.execute("dataset.update", &json!({})).unwrap();
assert_eq!((v["name"].clone(), v["values"].clone()), (json!("Data Set 1"), json!(1)));
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["matches"], json!(true));
// One undo step puts the row back as it was.
s.execute("text.editRange", &json!({"id": t.0, "insert": "Caz"})).unwrap();
s.execute("dataset.update", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(plain(&s, t), "Caz");
}
/// Renaming carries the variable's bindings and its values in every row with it — a name is
/// how a variable is referred to, so leaving the old one behind would orphan both.
#[test]
fn renaming_carries_the_bindings_and_values_with_it() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "Bob"}})).unwrap();
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
assert_eq!(plain(&s, t), "Bob", "the row still applied");
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["datasets"][0]["values"]["Title"], json!({"text": "Bob"}));
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!((vars["variables"][0]["name"].clone(), vars["variables"][0]["bindings"].clone()), (json!("Title"), json!(1)));
// Renaming a dataset keeps it active and keeps its values.
s.execute("dataset.rename", &json!({"name": "D", "newName": "Row 1"})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Row 1"), json!("Row 1")));
// Taken and missing names are errors.
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
assert!(s.execute("variable.rename", &json!({"name": "Title", "newName": "Other"})).is_err());
assert!(s.execute("variable.rename", &json!({"name": "Ghost", "newName": "X"})).is_err());
assert!(s.execute("dataset.rename", &json!({"name": "Ghost", "newName": "X"})).is_err());
// A dataset and a variable are separate namespaces: a dataset may take a variable's name,
// and a variable may take a dataset's.
s.execute("dataset.rename", &json!({"name": "Row 1", "newName": "Title"})).unwrap();
assert_eq!(s.execute("dataset.list", &json!({})).unwrap()["active"], "Title");
s.execute("dataset.new", &json!({"name": "Taken"})).unwrap();
assert!(s.execute("dataset.rename", &json!({"name": "Title", "newName": "Taken"})).is_err());
}
/// `dataset.set` is how a value changes after the row exists: replacing it must not need the
/// dataset to be deleted and rebuilt (which loses its place in the list and the active one).
#[test]
fn dataset_set_replaces_the_row_and_reports_what_it_dropped() {
let mut s = session();
let t = text(&mut s, "x");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "D", "values": {"Name": "y", "Show": false}})).unwrap();
// The dataset keeps its place and stays active while its values change.
let v = s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "z"}})).unwrap();
assert_eq!((v["values"].clone(), v["removed"].clone()), (json!(1), json!(["Show"])));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "z"}}));
s.execute("dataset.select", &json!({"name": "D"})).unwrap();
assert_eq!(plain(&s, t), "z");
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "`Show` has no value in this row now");
// Errors are errors, not a silent rewrite of some other row.
assert!(s.execute("dataset.set", &json!({"name": "Missing"})).is_err());
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Ghost": "z"}})).is_err());
assert!(s.execute("dataset.set", &json!({"name": "D", "values": {"Show": "yes"}})).is_err());
assert!(s.execute("dataset.set", &json!({"name": "D", "values": []})).is_err());
// One step, so one undo puts the row back as it was.
s.execute("dataset.set", &json!({"name": "D", "values": {"Name": "w"}})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(plain(&s, t), "z");
}
#[test]
fn bad_variables_requests_are_errors_not_panics() {
let mut s = session();
s.execute("variable.define", &json!({"name": "N", "kind": "text"})).unwrap();
for (cmd, params) in [
("variable.define", json!({})),
("variable.define", json!({"name": "", "kind": "text"})),
("variable.define", json!({"name": "N", "kind": "colour"})),
("variable.define", json!({"name": "N", "kind": "text"})),
("variable.delete", json!({})),
("variable.bind", json!({})),
("variable.bind", json!({"variable": "N"})),
("variable.bind", json!({"variable": "Missing", "ids": [1]})),
("dataset.new", json!({})),
("dataset.new", json!({"name": "D", "values": []})),
("dataset.new", json!({"name": "D", "values": {"N": 5}})),
("dataset.select", json!({"name": "Missing"})),
("dataset.next", json!({})),
] {
assert!(s.execute(cmd, &params).is_err(), "{cmd} {params}");
}
}
#[test]
fn highlight_selects_without_unbinding_and_deletes_leave_the_rest() {
// The reviewer's regression case: two variables, two rows.
let mut s = session();
let a = text(&mut s, "Alice");
let b = text(&mut s, "Bob");
for (name, id) in [("Name", a), ("Other", b)] {
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap();
}
s.execute("dataset.new", &json!({"name": "One", "values": {"Name": "Alice", "Other": "Bob"}})).unwrap();
s.execute("dataset.new", &json!({"name": "Two", "values": {"Name": "A2", "Other": "B2"}})).unwrap();
s.execute("dataset.select", &json!({"name": "One"})).unwrap();
// What a row click runs (highlight), then what Select Bound Object runs (select).
s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
let bound: Vec<u64> = s.doc().unwrap().doc.variables.objects_of("Name").iter().map(|i| i.0).collect();
s.execute("select.set", &json!({"ids": bound})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// …and the binding is still there: selecting never unbinds.
assert_eq!(s.doc().unwrap().doc.variables.objects_of("Name"), vec![a]);
// Deleting one variable leaves the other; deleting the current dataset leaves the other.
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
assert_eq!(vars["variables"][0]["name"], "Other");
s.execute("dataset.delete", &json!({"names": ["One"]})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
assert_eq!(sets["datasets"][0]["name"], "Two");
}
#[test]
fn highlight_follows_rename_and_clears_with_delete() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
assert!(s.execute("variable.highlight", &json!({"variable": "Missing"})).is_err(), "unknown names stay errors");
let v = s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
assert_eq!(v["variable"], "Name");
// The highlight names a variable, so the rename carries it along.
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
assert_eq!(s.doc().unwrap().variables_highlight.as_deref(), Some("Title"));
// Empty clears it, and deleting the highlighted variable clears it too.
s.execute("variable.highlight", &json!({})).unwrap();
assert!(s.doc().unwrap().variables_highlight.is_none());
s.execute("variable.highlight", &json!({"variable": "Title"})).unwrap();
s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap();
assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing");
}
/// A row that hides an object must be able to show it again, and hiding it on the artboard
/// (as the Layers panel does) is what a capture records: a visibility variable reads and
/// writes its object's own visibility, which therefore never makes the object off limits.
#[test]
fn a_hidden_object_comes_back_and_is_captured_hidden() {
let mut s = session();
let t = text(&mut s, "Alice");
let r = rect(&mut s);
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0, t.0]})).unwrap();
s.execute("dataset.new", &json!({"name": "Shown", "values": {"Name": "Alice", "Show": true}})).unwrap();
s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Name": "Bob", "Show": false}})).unwrap();
s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap();
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap();
assert_eq!(v["skipped"], json!(0), "{v}");
assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row shows it again");
assert_eq!(plain(&s, t), "Alice", "and rewrites the text of the object it had hidden");
// Hidden on the artboard, then captured: the row says hidden, and it matches the art.
s.execute("select.set", &json!({"ids": [t.0, r.0]})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
let captured = sets["datasets"].as_array().unwrap().iter().find(|d| d["name"] == "Captured").unwrap().clone();
assert_eq!(captured["values"]["Show"], json!({"visible": false}), "{captured}");
assert_eq!(captured["matches"], json!(true));
// A hidden object can still be bound by id.
s.execute("variable.define", &json!({"name": "More", "kind": "visibility"})).unwrap();
s.execute("variable.bind", &json!({"variable": "More", "ids": [r.0]})).unwrap();
}
/// Variables and datasets are separate namespaces: renaming a variable leaves a dataset of
/// the same name, and which dataset is active, alone.
#[test]
fn renaming_a_variable_leaves_a_dataset_of_the_same_name_active() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
s.execute("dataset.capture", &json!({"name": "Title"})).unwrap();
s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading"})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!((sets["active"].clone(), sets["datasets"][0]["name"].clone()), (json!("Title"), json!("Title")));
s.execute("dataset.update", &json!({})).unwrap();
}
/// Without `names`, the deletes act on what the panel shows as current: the highlighted
/// variable and the active dataset (Window › Variables runs them with no params).
#[test]
fn deletes_without_names_take_the_highlight_and_the_active_dataset() {
let mut s = session();
let t = text(&mut s, "Alice");
for name in ["A", "B"] {
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
}
s.execute("variable.bind", &json!({"variable": "A", "ids": [t.0]})).unwrap();
s.execute("dataset.capture", &json!({})).unwrap();
s.execute("dataset.capture", &json!({})).unwrap();
assert!(s.execute("variable.delete", &json!({})).is_err(), "nothing highlighted");
s.execute("variable.highlight", &json!({"variable": "B"})).unwrap();
assert_eq!(s.execute("variable.delete", &json!({})).unwrap()["deleted"], json!(1));
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"].as_array().map(Vec::len), Some(1));
assert_eq!(vars["variables"][0]["name"], "A");
assert_eq!(s.execute("dataset.delete", &json!({})).unwrap()["deleted"], json!(1));
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1));
assert_eq!(sets["datasets"][0]["name"], "Data Set 1");
assert!(s.execute("dataset.delete", &json!({})).is_err(), "no active dataset left");
}
/// Ids and names from an agent are checked, never reinterpreted: an `ids` that isn't a list
/// of objects is an error rather than the selection, and names are trimmed and capped.
#[test]
fn ids_and_names_from_an_agent_are_checked() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": " Name ", "kind": "text"})).unwrap();
assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"][0]["name"], "Name");
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
for params in
[json!({"variable": "Name", "ids": 5}), json!({"variable": "Name", "ids": [t.0, "x"]}), json!({"variable": "Name", "ids": [999_999]})]
{
assert!(s.execute("variable.bind", &params).is_err(), "{params}");
}
assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty(), "nothing was bound");
assert!(s.execute("variable.unbind", &json!({"ids": "all"})).is_err());
assert!(s.execute("variable.define", &json!({"name": "x".repeat(256), "kind": "text"})).is_err());
s.execute("variable.define", &json!({"name": "x".repeat(255), "kind": "text"})).unwrap();
assert!(s.execute("variable.delete", &json!({"names": []})).is_err());
assert!(s.execute("variable.delete", &json!({"names": [5]})).is_err());
}
+12
View File
@@ -296,8 +296,20 @@ fn structured_junk() {
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
("effect.apply", json!({"effect": "texture.patchwork", "params": {"squareSize": 1e308, "relief": -1e308}})),
("effect.apply", json!({"effect": "texture.stainedGlass", "params": {"cellSize": 0, "borderThickness": 1e308, "lightIntensity": "x"}})),
(
"effect.apply",
json!({"effect": "texture.texturizer", "params": {"texture": {}, "scaling": -1e308, "relief": 1e308, "lightDirection": 3, "invert": "yes"}}),
),
("effect.apply", json!({"effect": "video.deinterlace", "params": {"eliminate": 7, "create": ["x"]}})),
("effect.apply", json!({"effect": "video.ntscColors", "params": {"junk": 1e308}})),
("effect.apply", json!({"effect": "distort.diffuseGlow", "params": {"graininess": "x", "glowAmount": 1e308, "clearAmount": -1e308}})),
("effect.apply", json!({"effect": "distort.glass", "params": {"distortion": 1e308, "smoothness": "NaN", "texture": 3, "scaling": -1}})),
("effect.apply", json!({"effect": "distort.oceanRipple", "params": {"rippleSize": [1], "rippleMagnitude": 1e308}})),
("effect.apply", json!({"effect": "no.such.effect"})),
("effect.remove", json!({"index": 99})),
("effect.setParams", json!({"index": 0, "params": null})),
+2
View File
@@ -17,6 +17,8 @@ vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
# Windows metafile previews, opened when a file's PostScript can't be read.
vectorcraft-metafile = { workspace = true }
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
vectorcraft-pdf = { workspace = true }
# The TIFF writer (previews) and PackBits, shared with raster export.
vectorcraft-render = { workspace = true }
kurbo = { workspace = true }
+101 -6
View File
@@ -37,6 +37,7 @@ use vectorcraft_geom::{FillRule, PathData};
use super::lex::{Lexer, Tok};
use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
use crate::ps;
/// Deepest nesting of layers, groups and dictionaries.
const MAX_DEPTH: usize = 256;
@@ -248,6 +249,8 @@ struct Reader<'a> {
hidden_ids: HashSet<NodeId>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
foreign_data: Option<Vec<u8>>,
/// The page ended: what follows isn't art.
done: bool,
/// The dictionary closed last was opened on a `%_` line.
@@ -316,8 +319,11 @@ const SKIPPED: &[(&str, &str)] = &[
("Alternate_Content", "End_Versioned_Content"),
];
/// The note for a `/ForeignObject`, art the editor shows but doesn't edit.
pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet";
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
/// shows but doesn't edit) whose PDF can't be read.
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
/// The name of the group a `/ForeignObject`'s art comes in as.
pub const NON_NATIVE_ART: &str = "Non-native art";
/// A section comment's marker without its `AI<version>_` prefix and value (`AI14_BeginSymbol` →
/// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`.
@@ -350,6 +356,7 @@ impl<'a> Reader<'a> {
pending_stroke: None,
spots: Vec::new(),
raster: None,
foreign_data: None,
after_object: false,
nodes: 0,
unsupported: BTreeSet::new(),
@@ -659,8 +666,10 @@ impl<'a> Reader<'a> {
// of it may start with `%_` and read as hidden tokens: a `(` there would open
// a string that swallows the rest of the layer.
let key = o.entries.last().and_then(|(k, _)| k.as_deref());
if (o.ty == "Binary" && key == Some("ASCII85Decode")) || (o.ty == "ForeignObject" && key == Some("Data")) {
if o.ty == "Binary" && key == Some("ASCII85Decode") {
self.lex.skip_past(b"~>");
} else if o.ty == "ForeignObject" && key == Some("Data") {
self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec());
}
}
return Ok(());
@@ -839,9 +848,7 @@ impl<'a> Reader<'a> {
"Document" => self.document(&o),
"AI11Text" => self.text_slot(&o)?,
"SymbolInstance" => self.unreadable("symbols"),
// Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in
// the dictionary, which nothing draws from yet, so the file opens without it.
"ForeignObject" => self.warn(NON_NATIVE_ART),
"ForeignObject" => self.foreign_art(&o)?,
_ => {}
}
Ok(())
@@ -858,6 +865,94 @@ impl<'a> Reader<'a> {
self.add(n, hidden)
}
/// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but
/// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler),
/// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform`
/// maps that space onto the art (both read off files: the box lands on the clipping paths
/// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one
/// group. Without data, or with a PDF that can't be read, nothing is drawn.
fn foreign_art(&mut self, o: &Obj) -> Result<(), String> {
let Some(text) = self.foreign_data.take() else { return Ok(()) };
// One `%` starts each line; the rest, `_` included, is data.
let mut encoded = String::with_capacity(text.len());
for line in text.split(|b| matches!(b, b'\r' | b'\n')) {
encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line)));
}
let Some(pdf) = ps::ascii85_decode(&encoded) else {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
};
let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums));
let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
};
let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]);
let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1));
if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
}
let target = self.ensure_space().transform_rect_bbox(art_box);
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
Ok(r) => r,
Err(e) => {
self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})"));
return Ok(());
}
};
let mut art: Vec<Node> = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect();
let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) };
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
return Ok(());
}
// The art's own box onto the object's box (its measure of the same art), upside down:
// the object's space has y down, and the PDF's content is kept in its own y-up
// coordinates, so the PDF's bottom is the top of the box.
let fit = Affine::translate((target.x0, target.y1))
* Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height())
* Affine::translate((-bbox.x0, -bbox.y0));
for n in &mut art {
n.transform(fit, true);
// The PDF's swatches stay with it: its colours keep their values, unlinked.
self.reid(n);
}
for (key, blob) in read.document.images {
self.doc.images.entry(key).or_insert(blob);
}
let gs = self.gs.clone();
let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs);
group.name = Some(NON_NATIVE_ART.into());
self.add(group, self.obj_hidden)
}
/// Give `n` and its descendants ids of this document, and unlink their swatches.
fn reid(&mut self, n: &mut Node) {
n.id = self.doc.alloc_id();
for it in &mut n.appearance.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p {
g.swatch = None;
}
p.map_links(&mut |_, link, _| {
*link = None;
true
});
}
if let Some(m) = &mut n.mask {
self.reid(Arc::make_mut(&mut m.art));
}
if let Some(children) = n.children_mut() {
for c in children {
self.reid(Arc::make_mut(c));
}
}
}
/// Note something the data has that isn't read: an error on a layer that shows, left out of a
/// layer that doesn't.
fn unreadable(&mut self, what: &str) {
+94 -5
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use super::*;
@@ -332,13 +332,102 @@ fn sections_it_skips() {
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note.
/// 28 wouldn't open: "it ends inside a layer"). Data that isn't a PDF is left out with a note.
#[test]
fn non_native_art_is_skipped_with_its_ascii85_data() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")));
assert_eq!(art(&s.doc).len(), 1);
assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings);
assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings);
}
/// A PDF with one page of `size` points holding `content`.
fn tiny_pdf(size: f64, content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size} {size}] /Contents 4 0 R >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
pdf
}
/// `bytes` as ASCII85 in comment lines of 60 characters, ending with `~>`.
fn ascii85_comment_lines(bytes: &[u8]) -> String {
let mut text = String::new();
for chunk in bytes.chunks(4) {
let mut word = [0u8; 4];
word[..chunk.len()].copy_from_slice(chunk);
let mut v = u32::from_be_bytes(word);
let mut digits = [0u8; 5];
for d in digits.iter_mut().rev() {
*d = (v % 85) as u8 + b'!';
v /= 85;
}
text.extend(digits.iter().take(chunk.len() + 1).map(|d| char::from(*d)));
}
let mut lines = String::new();
for line in text.as_bytes().chunks(60) {
lines.push('%');
lines.push_str(std::str::from_utf8(line).unwrap());
lines.push('\n');
}
lines.push_str("%~>\n");
lines
}
/// The PDF of non-native art is drawn by the PDF importer, its art fitted to the object's box:
/// `/Bounds` in a space measured from `/Origin` with y down, then `/RTransform`. Two squares at
/// 1..5 × 2..8 of a 10-point page, blue below red: bounds (1, 2)–(5, 8) → from the origin
/// (10, 20), y down, (11, 18)–(15, 12) → twice that plus (100, 50): x 122..130, y 74..86 in art
/// space, which is y 14..26 in the document (crop marks 100 high). The space has y down, so the
/// PDF's bottom (blue) is the top of the box.
#[test]
fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() {
let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f");
let foreign = format!(
"q\n/ForeignObject :\n1 /Version ,\n2 0 0 2 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n",
ascii85_comment_lines(&pdf),
rect_path(120.0, 70.0, 130.0, 90.0)
);
let s = read_ok(&layer(&foreign));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "{objects:?}");
// A clipping group: the clip path first, then the non-native art's group.
let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) };
assert_eq!(children.len(), 2);
let group = &children[1];
assert_eq!(group.name.as_deref(), Some(read::NON_NATIVE_ART));
let bounds = group.geometric_bounds().unwrap();
for (got, want) in [(bounds.x0, 122.0), (bounds.y0, 14.0), (bounds.x1, 130.0), (bounds.y1, 26.0)] {
assert!((got - want).abs() < 1e-6, "{bounds:?}");
}
let paths = group.children().unwrap();
assert_eq!(paths.len(), 2);
let fill =
|n: &Node| n.appearance.items.iter().find_map(|it| if let AppearanceItem::Fill(f) = it { Some(f.paint.clone()) } else { None }).unwrap();
let (blue, red) = (fill(&paths[0]), fill(&paths[1]));
assert!(matches!(&blue, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [0, 0, 255, 255]), "{blue:?}");
assert!(matches!(&red, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [255, 0, 0, 255]), "{red:?}");
let (b, r) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap());
assert!((b.y0 - 14.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "blue {b:?}");
assert!((r.y0 - 20.0).abs() < 1e-6 && (r.y1 - 26.0).abs() < 1e-6, "red {r:?}");
// Its ids are this document's.
assert_ne!(group.id, paths[0].id);
assert_ne!(paths[0].id, paths[1].id);
}
#[test]
+2 -2
View File
@@ -419,7 +419,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"create_graph",
"Create graph",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. An empty CSV cell or a null in `rows` is a blank value (no column; lines break around it); a number in straight quotes is a label. Edit later with run_command graph.setData / graph.setType.",
obj(
json!({
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},
@@ -430,7 +430,7 @@ pub fn tool_definitions() -> Vec<Value> {
"csv": {"type": "string"},
"series": {"type": "array", "items": {"type": "string"}},
"categories": {"type": "array", "items": {"type": "string"}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": ["number", "null"]}}},
}),
&["x", "y", "width", "height"],
),
+2 -1
View File
@@ -603,7 +603,8 @@ impl Renderer {
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let space = effects::PixelSpace { to_doc, px, center: content.reach.center(), channels, line: 72.0 / ppi };
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
let (wz, hz) = (wu as usize, hu as usize);
for fx in chain {
match fx {
+9
View File
@@ -154,6 +154,15 @@ pub fn rich_session() -> Session {
exec(&mut s, "symbol.new", json!({"name": "Dot"}));
let t = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 370, "text": "Tap to add", "size": 18})));
exec(&mut s, "text.insertInline", json!({"id": t.0, "at": 4, "symbol": "Dot"}));
// Variables (data merge): both kinds, bound to art, with two dataset rows and one applied,
// so format round trips cover the definitions, the bindings and the rows.
exec(&mut s, "variable.define", json!({"name": "Title", "kind": "text"}));
exec(&mut s, "variable.define", json!({"name": "Badge", "kind": "visibility"}));
exec(&mut s, "variable.bind", json!({"variable": "Title", "ids": [tx.0]}));
exec(&mut s, "variable.bind", json!({"variable": "Badge", "ids": [a]}));
exec(&mut s, "dataset.new", json!({"name": "Row 1", "values": {"Title": "Merged", "Badge": true}}));
exec(&mut s, "dataset.new", json!({"name": "Row 2", "values": {"Title": "Other"}}));
exec(&mut s, "dataset.select", json!({"name": "Row 1"}));
let _ = (a, b, c, l);
exec(&mut s, "select.none", json!({}));
s
+11 -2
View File
@@ -1,6 +1,6 @@
//! The VectorCraft brand mark: the app icon (the engraved dragon, `assets/app-icon/`, see its
//! README). The PNG carries the icon's rounded corners; it is decoded once per context into a
//! mipmapped texture, so it stays crisp from the 22 pt app-bar mark to the About box.
//! mipmapped texture, so it stays crisp from the 18 pt app-bar mark to the About box.
use egui::{Color32, Context, Id, Rect, TextureHandle, TextureOptions, Ui, pos2};
@@ -36,6 +36,12 @@ pub fn paint_mark(ui: &Ui, r: Rect) {
ui.painter().image(texture(ui.ctx()).id(), r, uv, Color32::WHITE);
}
/// The mark's texture id (tests: find the painted marks among the shapes).
#[cfg(test)]
pub fn texture_id(ctx: &Context) -> egui::TextureId {
texture(ctx).id()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -55,6 +61,9 @@ mod tests {
let ctx = Context::default();
crate::theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
// The system title bar hides the in-app mark (the OS draws its own icon), so paint the
// app-drawn bar here.
app.custom_titlebar = true;
let mut frame = || {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::app_bar(&mut app, ui));
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
@@ -72,7 +81,7 @@ mod tests {
let (uploads, marks) = frame();
assert_eq!(uploads, 1);
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].size(), egui::vec2(22.0, 22.0));
assert_eq!(marks[0].size(), egui::vec2(18.0, 18.0));
// Later frames reuse the texture.
let (uploads, marks) = frame();
assert_eq!((uploads, marks.len()), (0, 1));
+42 -7
View File
@@ -367,13 +367,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
}
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
painter.text(
r.left_top() - vec2(0.0, 4.0),
egui::Align2::LEFT_BOTTOM,
format!("{:02} - {}", i + 1, ab.name),
egui::FontId::proportional(11.0),
t.text_dim,
);
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
}
}
if app.ui.view.guides {
@@ -1749,6 +1743,22 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
});
}
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
/// out rather than spilling over its neighbours.
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
if r.width() < MIN_LABELLED_ARTBOARD {
return;
}
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
let galley = painter.layout_job(job);
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
}
/// 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 {
@@ -2219,6 +2229,31 @@ mod tests {
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
}
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
/// longer, and an artboard too narrow shows none.
#[test]
fn artboard_labels_fit_their_artboard() {
let ctx = egui::Context::default();
let drawn = |width: f32| {
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
out.textures_delta.clear();
out.shapes
.into_iter()
.filter_map(|c| match c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)),
_ => None,
})
.collect::<Vec<_>>()
};
let full = drawn(400.0);
assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name"));
// Laid out no wider than the artboard (the galley keeps the whole text; it draws "…").
let (_, w) = drawn(30.0).first().cloned().unwrap();
assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}");
assert!(drawn(10.0).is_empty());
}
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
}
+147 -40
View File
@@ -2,7 +2,7 @@
use std::borrow::Cow;
use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2};
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
use serde_json::json;
use vectorcraft_doc::NodeKind;
@@ -12,25 +12,37 @@ use crate::theme::{self, Tokens};
use crate::widgets;
use crate::{VectorcraftApp, icons, menus, titlebar};
/// The application bar's height, in points (compact, as in PhotoCraft).
pub const APP_BAR_HEIGHT: f32 = 32.0;
/// The brand mark's side.
const MARK: f32 = 18.0;
/// Height of the workspace switcher.
const WIDGET_H: f32 = 22.0;
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
/// at the right. With [`VectorcraftApp::custom_titlebar`] it is also the window's title bar
/// ([`titlebar`]): the caption buttons take the right end and the rest of the bar drags the window.
/// With the system title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and
/// title, so the in-app bar shows Home, menus and workspace controls only: no brand mark.
pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let custom = app.custom_titlebar;
let left = if app.integrated_titlebar { 78 } else { 8 };
let system = system_title_bar(app);
let left = app.titlebar_inset.clamp(8.0, f32::from(i8::MAX)) as i8;
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 });
let bar = egui::Panel::top("app_bar").exact_size(44.0).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
let bar = egui::Panel::top("app_bar").exact_size(APP_BAR_HEIGHT).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
if custom {
titlebar::drag_area(ui, ui.max_rect());
}
ui.horizontal_centered(|ui| {
// Brand mark: the app icon.
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
crate::brand::paint_mark(ui, r);
ui.add_space(4.0);
// With the system title bar the OS draws its own icon: no in-app brand mark.
if !system {
let (r, _) = ui.allocate_exact_size(vec2(MARK, MARK), Sense::hover());
crate::brand::paint_mark(ui, r);
ui.add_space(6.0);
}
let on_home = menus::home_showing(app);
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 20.0).clicked() {
app.run("app.home", json!({})).ok();
}
ui.add_space(2.0);
@@ -43,8 +55,8 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
menus::menu_bar(app, ui)
};
// The right-side group fills the space after the menus from the right; when it runs
// short, Discord goes first (it is also under Help), then the search box becomes an
// icon, then the workspace switcher narrows.
// short, Discord goes first (it is also under Help), then the search icon (the palette
// stays under its shortcut), then the workspace switcher narrows.
let full = ui.max_rect();
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()));
@@ -55,45 +67,31 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
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;
let search_full = room >= with_search;
let discord = room >= with_search + 10.0 + gap + crate::community::discord_width(ui, false);
let ws_w = if search_full { ws_w } else { ws_w.min(room - 8.0 - gap - 24.0).max(64.0) };
// The search icon, as in PhotoCraft: shown while a minimal switcher fits beside it.
let icon = 28.0;
let search = room >= 64.0 + 8.0 + gap + icon;
let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0);
let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false);
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
let ui = &mut rui;
// Workspace switcher: shows the current workspace, opens Window → Workspace.
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, 24.0), Sense::click());
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click());
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(tl!("Switch workspace"));
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
ui.add_space(8.0);
// Search box → command palette.
let open_palette = if search_full {
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());
ui.painter().rect_filled(r, CornerRadius::same(12), t.input);
ui.painter().rect_stroke(
r,
CornerRadius::same(12),
Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }),
StrokeKind::Inside,
);
icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim);
ui.painter().text(
r.left_center() + vec2(26.0, 0.0),
egui::Align2::LEFT_CENTER,
tl!("Search commands and tools"),
egui::FontId::proportional(11.5),
t.text_dim,
);
resp.clicked()
} else {
widgets::icon_button(ui, "search", tl!("Search commands and tools"), false, 24.0).clicked()
};
if open_palette {
app.ui.palette_open = true;
app.ui.palette_query.clear();
// Search → command palette (its shortcut opens it too).
if search {
let tip = match menus::shortcut_of("help.commandPalette") {
Some(sc) => format!("{} ({})", tl!("Search commands and tools"), menus::pretty_shortcut(sc)),
None => tl!("Search commands and tools").to_string(),
};
if widgets::icon_button(ui, "search", &tip, app.ui.palette_open, icon).clicked() {
app.ui.palette_open = !app.ui.palette_open;
app.ui.palette_query.clear();
}
}
if discord {
ui.add_space(10.0);
@@ -106,6 +104,38 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// Whether the window shows the system's title bar instead of the app drawing its own: Windows
/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system
/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor
/// does the web build (the browser tab has no document title), nor an embedder that never
/// turned the preference on.
pub fn system_title_bar(app: &VectorcraftApp) -> bool {
!app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32"))
}
/// The OS window title: the active document's name suffixed with the app name (a `*` prefix
/// marks unsaved changes, like the `*` in the document tabs), or just the app name with no
/// document open.
pub fn window_title(app: &VectorcraftApp) -> String {
app.session
.active()
.map(|d| format!("{}{} \u{2014} VectorCraft", if d.is_dirty() { "*" } else { "" }, d.title()))
.unwrap_or_else(|| "VectorCraft".into())
}
/// Keep the OS window title (and the taskbar / Alt-Tab entry) on the active file. Sends
/// `ViewportCommand::Title` only when the title changed since the last frame.
pub fn sync_window_title(app: &mut VectorcraftApp, ctx: &egui::Context) {
let want = window_title(app);
if app.last_window_title != want {
app.last_window_title = want.clone();
ctx.send_viewport_cmd(egui::ViewportCommand::Title(want));
// One more frame: a new title makes AppKit lay its title bar out again, putting the window
// buttons back where it keeps them until the next frame centres them on the bar (#968).
ctx.request_repaint();
}
}
/// An anchor button: icon, tooltip, command and the convert command's `to`.
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
@@ -962,4 +992,81 @@ mod tests {
let off = hint(&mut app);
assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}");
}
fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.custom_titlebar = custom_titlebar;
// The preference that makes the shell launch without its own title bar.
app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap();
app
}
#[test]
fn without_the_preference_the_mark_stays() {
// The default (e.g. the web build): no custom bar, no preference.
let app = crate::VectorcraftApp::new(Session::new(), Default::default());
assert!(!super::system_title_bar(&app));
}
#[test]
fn window_title_with_no_document_is_the_app_name() {
assert_eq!(super::window_title(&title_test_app(true)), "VectorCraft");
}
#[test]
fn window_title_follows_the_active_file() {
let mut app = title_test_app(true);
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
assert_eq!(super::window_title(&app), "foo.svg \u{2014} VectorCraft");
// Unsaved changes gain a `*` prefix, like the `*` in the document tabs.
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert_eq!(super::window_title(&app), "*foo.svg \u{2014} VectorCraft");
}
#[test]
fn system_mode_hides_the_mark() {
for custom in [true, false] {
let mut app = title_test_app(custom);
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(&mut app, ui));
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
out.textures_delta.clear();
let marks: Vec<egui::Rect> = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx) => Some(m.calc_bounds()),
_ => None,
})
.collect();
let system = super::system_title_bar(&app);
assert_eq!(system, !custom && !cfg!(target_os = "macos"));
assert_eq!(marks.is_empty(), system, "mark in system mode (custom={custom})");
assert_eq!(uploads, if system { 0 } else { 1 }, "mark texture in system mode (custom={custom})");
}
}
#[test]
fn sync_window_title_sends_only_on_change() {
let mut app = title_test_app(true);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
super::sync_window_title(&mut app, &ctx);
assert_eq!(app.last_window_title, "VectorCraft");
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
assert!(cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(t) if t == "VectorCraft")), "{cmds:?}");
// Same document state again: the cached title stops a repeat command.
super::sync_window_title(&mut app, &ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(_))), "repeat Title: {cmds:?}");
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
super::sync_window_title(&mut app, &ctx);
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
}
}
+16 -5
View File
@@ -7,6 +7,8 @@ use std::cmp::Ordering;
use egui::RichText;
use crate::VectorcraftApp;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Contributor {
pub login: &'static str,
@@ -214,7 +216,7 @@ impl Default for View {
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
@@ -254,14 +256,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
table(app, ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
link(app, ui, c, v.names);
}
});
}
@@ -269,7 +271,16 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
fn link(app: &mut VectorcraftApp, ui: &mut egui::Ui, c: &Contributor, names: NameMode) -> egui::Response {
let url = format!("https://github.com/{}", c.login);
let r = ui.add(egui::Link::new(c.name(names))).on_hover_text(c.summary());
if r.clicked() {
app.open_url(&url);
}
r
}
fn table(app: &mut VectorcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
@@ -284,7 +295,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
link(app, ui, c, v.names);
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
+1 -1
View File
@@ -33,7 +33,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
match tab {
1 => {
ui.set_width(660.0);
crate::credits::contributors_ui(ui);
crate::credits::contributors_ui(app, ui);
}
2 => {
ui.set_width(660.0);
+25
View File
@@ -40,6 +40,8 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
("text.areaOptions", "fit") => Some(AREA_FIT),
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS),
("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH),
_ => None,
}
}
@@ -57,6 +59,12 @@ const FIRST_BASELINE: form::Choices =
/// Area Type Options › Align (vertical alignment of the lines in each row/column).
const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")];
/// Graph Type › Value Axis (series picked on both axes show none, and OK leaves each where it is).
const GRAPH_VALUE_AXIS: form::Choices = &[("On Left Side", "left"), ("On Right Side", "right"), ("On Both Sides", "both")];
/// Graph Type › Tick Marks › Length.
const GRAPH_TICK_LENGTH: form::Choices = &[("None", "none"), ("Short", "short"), ("Full Width", "full")];
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
@@ -113,4 +121,21 @@ mod tests {
let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.area.vertical_align, VerticalAlign::Center);
}
/// Graph Type › Value Axis is a dropdown of the values `graph.setType` takes, and OK puts the
/// value axis on the side picked.
#[test]
fn graph_type_value_axis_is_a_dropdown_of_its_sides() {
let doc = vectorcraft_engine::find_command("graph.setType").unwrap().params;
let values: Vec<&str> = choices("graph.setType", "valueAxis").unwrap().iter().map(|(_, v)| *v).collect();
assert!(doc.contains(&format!("valueAxis?: {}", values.join("|"))), "{values:?}");
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
crate::menus::graph_dialog(&mut app, "graph.setType").unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("valueAxis"), "left");
app.ui.dialog.as_mut().unwrap().fields.insert("valueAxis".into(), json!("both"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both");
}
}
+8 -1
View File
@@ -159,9 +159,14 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("blur.radial", "method") => Some(&[("Spin", "spin"), ("Zoom", "zoom")]),
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES),
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]),
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
_ => None,
@@ -415,7 +420,9 @@ mod tests {
#[test]
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
for (effect, key) in [("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points")] {
for (effect, key) in
[("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points"), ("stylize.innerGlow", "source")]
{
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string();
let values: Vec<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
+6 -3
View File
@@ -232,10 +232,13 @@ pub(super) fn param_fields(
continue;
}
if let Some(options) = choices(&k) {
// In the "effect" context: some languages read the plain Soft, Horizontal and
// Vertical (Grain's types) as type settings.
let shown = |label: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "effect", label);
let cur = v.as_str().unwrap_or_default();
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| shown(l));
let labels: Vec<&str> = options.iter().map(|(l, _)| shown(l)).collect();
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
d.fields.insert(k, json!(value));
changed = true;
}
+1 -1
View File
@@ -733,7 +733,7 @@ mod tests {
let fr = crate::i18n::Lang::from_code("fr").unwrap();
let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB");
assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}");
for code in ["es", "fr", "it", "ru"] {
for code in ["de", "es", "fr", "it", "ru"] {
let lang = crate::i18n::Lang::from_code(code).unwrap();
for s in [
"every search thread stopped on an internal error (please report this bug)",
+13 -93
View File
@@ -2,8 +2,8 @@
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
//! offered for update (`links.update`). Files changed while the app was in the background are
//! checked when its window comes back to the front ([`after_focus`]).
//! offered for update (`links.update`). Files another app changes while the document is open are
//! offered the same way ([`ask_update_changed`], from [`vectorcraft_engine::link_watch`]).
//!
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
@@ -61,6 +61,16 @@ fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"));
}
/// The same offer for linked files another app changed while the document is open
/// ([`vectorcraft_engine::link_watch`]).
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
}
fn ask(app: &mut VectorcraftApp, ids: Vec<Value>, question: &str) {
if ids.is_empty() {
return;
}
@@ -68,46 +78,10 @@ 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 = crate::i18n::fmt(
tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"),
&[("count", &ids.len().to_string())],
);
let detail = crate::i18n::fmt(question, &[("count", &ids.len().to_string())]);
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
}
/// A linked file as last seen: its path, and its size and modification time when it exists.
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
/// The window came back to the front (#925): linked files of the active document changed
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
/// asked while a dialog is open.
pub fn after_focus(app: &mut VectorcraftApp) {
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
return;
}
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
if rows.is_empty() {
return;
}
if app.session.prefs.update_links == "automatically" {
// A failure shows in the status bar, as the Links panel's Update Link does.
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
return;
}
// The file each link reads (one found elsewhere after a move), its size and when it was last
// changed (two changes can share a time on file systems that keep it coarsely).
let stamp = |l: &Value| -> FileStamp {
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
let meta = std::fs::metadata(&path).ok();
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
(path, at)
};
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
ask_update(app, ids(&new));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
@@ -296,58 +270,4 @@ mod tests {
assert!(app.ui.dialog.is_none());
let _ = std::fs::remove_dir_all(dir);
}
/// #925: a linked file changed while the app was in the background follows Update Links when
/// the window comes back to the front.
#[test]
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let pic = dir.join("a.png");
// Each version a different size, so no two share a length and modification time.
let mut version = 0;
let mut change = || {
version += 1;
let mut out = vec![];
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
std::fs::write(&pic, out).unwrap();
};
change();
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
// The window coming to the front asks for the check.
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
app.raw_input_hook(&mut raw);
assert!(app.links_check);
// Ask When Modified (the default): asked once per change.
change();
after_focus(&mut app);
let d = app.ui.dialog.take().expect("asks");
assert_eq!(d.str("__command"), "links.update");
after_focus(&mut app);
assert!(app.ui.dialog.is_none(), "not twice for the same change");
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_some(), "a new change asks again");
super::super::confirm(&mut app).unwrap();
assert_eq!(modified(&mut app), 0);
// Automatically: read again without asking.
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 0);
// Manually: left to the Links panel.
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 1);
let _ = std::fs::remove_dir_all(dir);
}
}
+2
View File
@@ -88,6 +88,7 @@ pub mod tile_edge_color;
mod tools;
mod transform;
pub mod transform_each;
pub mod variables;
pub mod width_point;
use serde_json::{Value, json};
@@ -212,6 +213,7 @@ registry! {
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: [recolor::KIND] => recolor::SPEC,
Command: ["command"] => command::SPEC,
Variables: [variables::KIND] => variables::SPEC,
Effect: ["effect"] => effect::SPEC,
SaveChanges: [crate::unsaved::KIND] => save_changes::SPEC,
Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
+10
View File
@@ -176,6 +176,12 @@ fn raster_filter_dialogs_preview_and_apply() {
("pixelate.crystallize", "cellSize", "20"),
("pixelate.mezzotint", "type", "longLines"),
("pixelate.pointillize", "cellSize", "12"),
("texture.craquelure", "crackDepth", "9"),
("texture.grain", "grainType", "stippled"),
("texture.mosaicTiles", "tileSize", "20"),
("texture.patchwork", "relief", "20"),
("texture.stainedGlass", "borderThickness", "2"),
("texture.texturizer", "lightDirection", "bottomLeft"),
] {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
@@ -203,6 +209,10 @@ fn raster_filter_dialogs_preview_and_apply() {
assert_eq!(super::form::humanize("maxRadius"), "Max. Radius:");
assert_eq!(super::form::humanize("channel3"), "Channel 3:");
assert_eq!(super::form::humanize("cellSize"), "Cell Size:");
assert_eq!(super::form::humanize("lightDirection"), "Light Direction:");
let doc = vectorcraft_effects::effect_info("texture.texturizer").unwrap().params;
let ranks: Vec<usize> = ["texture", "scaling", "relief", "lightDirection", "invert"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[4] < usize::MAX, "{ranks:?}");
}
#[test]
+388
View File
@@ -0,0 +1,388 @@
//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set`
//! and `dataset.select`), opened from Window › Variables or the command palette.
//!
//! One dialog covers all of them: what it shows is what the command takes. A variable is a
//! name and a kind; a dataset is a name and one field per variable — a text field for a
//! `text` variable, a checkbox for a `visibility` one. `dataset.set` opens with the row's
//! current values, so editing a row is editing what it already says.
use serde_json::{Value, json};
use vectorcraft_doc::{DataValue, VariableKind, Variables};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// The dialog kind of every Variables dialog.
pub const KIND: &str = "variables";
/// The kinds a variable can have, as `variable.define` names them.
const KINDS: [&str; 2] = ["text", "visibility"];
/// What the dialog shows for each of [`KINDS`].
fn kind_labels() -> [&'static str; 2] {
[tl!("Text"), tl!("Visibility")]
}
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM };
/// The command the dialog runs and what it is called.
fn opened(id: &str) -> Option<&'static str> {
Some(match id {
"variable.define" => "New Variable",
"variable.rename" => "Variable Options",
"variable.bind" => "Bind Variable",
"dataset.new" => "New Data Set",
"dataset.set" => "Edit Data Set",
"dataset.rename" => "Rename Data Set",
"dataset.select" => "Select Data Set",
_ => return None,
})
}
/// The row `id` renames, which the dialog offers as a dropdown when the document has more than
/// one. Variable Options renames the one the panel has highlighted, so it starts there.
fn rename_of(id: &str) -> Option<&'static str> {
Some(match id {
"variable.rename" => "variable",
"dataset.rename" => "name",
_ => return None,
})
}
/// Open `id`'s dialog: OK runs the command with the fields below. `preselect` names the row
/// a rename starts on (the panel's highlighted variable, the active dataset); without it, or
/// when it no longer exists, the first row — the dialog still offers the dropdown.
pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool {
let Some(label) = opened(id) else { return false };
let Some(st) = app.session.active() else {
app.status(tl!("no document").to_string());
return true;
};
let vars = st.doc.variables.clone();
let mut fields = serde_json::Map::new();
match id {
"variable.define" => {
fields.insert("name".into(), json!(""));
fields.insert("kind".into(), json!(KINDS[0]));
}
"variable.bind" => {
fields.insert("variable".into(), json!(""));
}
"dataset.new" => {
fields.insert("name".into(), json!(""));
value_fields(&vars, vars.datasets.first().map(|d| &d.values), &mut fields);
}
"dataset.set" => {
// Without a row to edit there is nothing to open.
let Some(name) = vars.active_dataset.clone().or_else(|| vars.datasets.first().map(|d| d.name.clone())) else {
app.status(tl!("no data set to edit").to_string());
return true;
};
let Some(current) = vars.dataset(&name) else { return true };
fields.insert("name".into(), json!(name));
value_fields(&vars, Some(&current.values), &mut fields);
}
"dataset.select" => {
fields.insert("name".into(), json!(""));
}
_ => {
// A rename: which row, and its new name.
let key = rename_of(id).unwrap_or("name");
let names: Vec<&str> = if key == "variable" {
vars.variables.iter().map(|v| v.name.as_str()).collect()
} else {
vars.datasets.iter().map(|d| d.name.as_str()).collect()
};
if names.is_empty() {
app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() });
return true;
}
fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default()));
fields.insert("newName".into(), json!(""));
}
}
fields.insert("__command".into(), json!(id));
fields.insert("__label".into(), json!(label));
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
true
}
/// Whether `id` opens one of these dialogs.
pub fn opens(id: &str) -> bool {
opened(id).is_some()
}
/// One field per variable, prefilled from `current` (the row being edited, else the first row
/// so a new one starts from something rather than from nothing).
fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap<String, DataValue>>, out: &mut serde_json::Map<String, Value>) {
for v in &vars.variables {
let value = match (current.and_then(|c| c.get(&v.name)), v.kind) {
(Some(DataValue::Text(t)), _) => json!(t),
(Some(DataValue::Visible(b)), _) => json!(b),
// Without a value in the row a text field starts empty (no value in this row) and
// a visibility one starts shown: its false is a value, so it is never "unset".
(None, VariableKind::Text) => json!(""),
(None, VariableKind::Visibility) => json!(true),
};
out.insert(value_key(&v.name), value);
}
}
/// A variable name as a field key. A field key shares the dialog's flat namespace with the
/// command's own parameters, so names are prefixed to stay clear of `name` and `values`.
fn value_key(name: &str) -> String {
format!("value:{name}")
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
widgets::dim_label(ui, tl!("No document"));
return false;
};
let vars = &st.doc.variables;
let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect();
let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect();
egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let label = |ui: &mut egui::Ui, text: &str| widgets::field_label(ui, egui::RichText::new(text).color(t.text));
match d.str("__command").as_str() {
"variable.define" => {
label(ui, tl!("Name"));
text(ui, d, "name", 220.0);
ui.end_row();
label(ui, tl!("Kind"));
// The kinds by their translated names; the field keeps the command's own word.
let labels = kind_labels();
let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default();
if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) {
d.fields.insert("kind".into(), json!(kind));
}
ui.end_row();
}
"variable.bind" => {
label(ui, tl!("Variable"));
dropdown(ui, d, "variable", &variable_names, 220.0);
ui.end_row();
}
"dataset.select" => {
label(ui, tl!("Data Set"));
dropdown(ui, d, "name", &dataset_names, 220.0);
ui.end_row();
}
// A rename: which row, and its new name.
"variable.rename" | "dataset.rename" => {
let (key, label_text, names) = if d.str("__command") == "variable.rename" {
("variable", tl!("Variable").to_string(), &variable_names)
} else {
("name", tl!("Data Set").to_string(), &dataset_names)
};
label(ui, &label_text);
dropdown(ui, d, key, names, 220.0);
ui.end_row();
label(ui, tl!("New Name"));
text(ui, d, "newName", 220.0);
ui.end_row();
}
_ => {
label(ui, tl!("Name"));
text(ui, d, "name", 220.0);
ui.end_row();
if vars.variables.is_empty() {
widgets::dim_label(ui, tl!("Define a variable first"));
ui.end_row();
return;
}
// One row per variable, in the order they are defined, each with the editor
// its kind takes.
for v in &vars.variables {
label(ui, &v.name);
let key = value_key(&v.name);
match v.kind {
VariableKind::Text => text(ui, d, &key, 220.0),
VariableKind::Visibility => checkbox(ui, d, &key),
};
ui.end_row();
}
}
}
});
false
}
/// A text field for `key`, written back as it is typed.
fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
let mut s = d.str(key);
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
if r.changed() {
d.fields.insert(key.into(), json!(s));
}
r
}
/// A checkbox for `key`, written back as it is toggled.
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
let mut b = d.bool(key);
let r = ui.checkbox(&mut b, "");
if r.changed() {
d.fields.insert(key.into(), json!(b));
}
r
}
/// A dropdown over the document's names `options` (shown as they are, never translated) for
/// `key`, written back as it changes. The empty option is first so a fresh dialog can still be
/// filled in; a name that is no longer offered reads as empty rather than being silently
/// swapped for another one.
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) {
let cur = d.str(key);
let mut labels: Vec<&str> = vec![""];
labels.extend_from_slice(options);
let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" };
if let Some(i) = widgets::dropdown_names(ui, ("variables-dd", key), shown, &labels, width) {
// The empty option (0) empties the field; the rest are `options`, one along.
d.fields.insert(key.into(), json!(i.checked_sub(1).and_then(|i| options.get(i)).copied().unwrap_or_default()));
}
}
/// The `values` object the dialog's fields mean: a text variable whose field is empty carries
/// no value in this row, and a visibility checkbox is always a value — its false, too.
fn values_of(d: &Dialog, vars: &Variables) -> Value {
let mut out = serde_json::Map::new();
for v in &vars.variables {
let key = value_key(&v.name);
if v.kind == VariableKind::Text {
let text = d.str(&key);
if !text.is_empty() {
out.insert(v.name.clone(), json!(text));
}
} else {
out.insert(v.name.clone(), json!(d.bool(&key)));
}
}
Value::Object(out)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
let name = d.str("name");
let p = match cmd.as_str() {
"variable.define" => json!({"name": name, "kind": d.str("kind")}),
"variable.bind" => json!({"variable": d.str("variable")}),
"variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}),
"dataset.rename" => json!({"name": name, "newName": d.str("newName")}),
"dataset.new" | "dataset.set" => {
let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({}));
json!({"name": name, "values": values})
}
"dataset.select" => json!({"name": name}),
_ => json!({}),
};
run_and_close(app, &cmd, p)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
fn app_with_variables() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("variable.define", json!({"name": "Title", "kind": "text"})).unwrap();
app.run("variable.define", json!({"name": "Badge", "kind": "visibility"})).unwrap();
app
}
#[test]
fn defining_a_variable_from_the_dialog_works() {
let mut app = app_with_variables();
assert!(open(&mut app, "variable.define", None));
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.kind, KIND);
assert_eq!(d.str("name"), "");
assert_eq!(d.str("kind"), "text", "text is the default kind");
}
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Price"));
crate::dialogs::confirm(&mut app).unwrap();
let vars = app.run("variable.list", json!({})).unwrap();
assert!(vars["variables"].as_array().unwrap().iter().any(|v| v["name"] == "Price"));
}
#[test]
fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() {
let mut app = app_with_variables();
open(&mut app, "dataset.new", None);
{
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("name".into(), json!("Row 1"));
d.fields.insert(value_key("Title"), json!("Hello"));
d.fields.insert(value_key("Badge"), json!(false));
}
crate::dialogs::confirm(&mut app).unwrap();
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"][0]["name"], "Row 1");
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Hello"}));
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": false}));
}
#[test]
fn editing_a_dataset_opens_with_the_rows_own_values() {
let mut app = app_with_variables();
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap();
app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap();
open(&mut app, "dataset.set", None);
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.str("name"), "Row 1");
assert_eq!(d.str(&value_key("Title")), "", "the row carries no value for it");
assert!(d.bool(&value_key("Badge")), "the row's own value");
}
app.ui.dialog.as_mut().unwrap().fields.insert(value_key("Title"), json!("Changed"));
crate::dialogs::confirm(&mut app).unwrap();
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Changed"}));
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": true}));
}
#[test]
fn selecting_a_dataset_from_the_dialog_applies_it() {
let mut app = app_with_variables();
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap();
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap();
open(&mut app, "dataset.select", None);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "");
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.run("text.getRange", json!({"id": id})).unwrap()["text"], "Hi");
}
#[test]
fn only_the_variables_commands_open_one() {
let mut app = app_with_variables();
assert!(opens("dataset.set") && opens("variable.define"));
assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step");
assert!(!open(&mut app, "dataset.list", None), "and it opens nothing");
assert!(app.ui.dialog.is_none());
}
#[test]
fn rename_dialogs_start_on_the_preselected_row() {
let mut app = app_with_variables();
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello"}})).unwrap();
open(&mut app, "variable.rename", Some("Badge"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Badge");
// A preselect the document no longer has falls back to the first row.
open(&mut app, "variable.rename", Some("Gone"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Title");
open(&mut app, "dataset.rename", Some("Row 1"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1");
}
}
+16 -5
View File
@@ -496,12 +496,13 @@ mod tests {
const SCREEN: Vec2 = vec2(1400.0, 900.0);
/// Frames of the whole window, 1400 × 900, with pointer input one event a frame (as the
/// control channel's `ui.drag` sends it).
/// Frames of the whole window, with pointer input one event a frame (as the control
/// channel's `ui.drag` sends it).
struct Harness {
app: VectorcraftApp,
ctx: egui::Context,
time: f64,
screen: Vec2,
}
fn button(pos: Pos2, pressed: bool) -> Event {
@@ -510,16 +511,23 @@ mod tests {
impl Harness {
fn new() -> Self {
Self::on(SCREEN)
}
/// The same window at another size. The icon column scrolls when it is too short for
/// every icon, which hides the ones past the bottom — a test that counts them needs
/// a window tall enough to show them all.
fn on(screen: Vec2) -> Self {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 };
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, screen };
h.settle();
h
}
fn frame(&mut self, events: Vec<Event>) {
self.time += 0.05;
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
let app = &mut self.app;
self.ctx
@@ -651,10 +659,13 @@ mod tests {
#[test]
fn icons_dragged_out_float_stack_onto_another_group_and_tear_out_of_it() {
let mut h = Harness::new();
// Tall enough that the icon column shows every icon, so counting them says what it
// means: one leaves the column, rather than one more scrolling into view.
let mut h = Harness::on(vec2(1400.0, 1600.0));
// Color, Color Guide, Swatches… top to bottom.
let icons = h.icons();
let count = icons.len();
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
h.drag(icons[2].center(), pos2(500.0, 250.0));
assert_eq!(h.groups(), [["swatches"]]);
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
+35 -1
View File
@@ -14,7 +14,6 @@
Align to Path Zarovnat k cestě
Cut Path at Selected Anchor Points Rozdělit cestu ve vybraných kotevních bodech
Join Our Discord Připojit se k našemu Discordu
Settings… Nastavení…
Language Jazyk
UI Brightness Jas rozhraní
Dark Tmavý
@@ -516,6 +515,9 @@
Layers Vrstvy
Libraries Knihovny
Links Propojení
{count} linked image(s) changed on disk. Show the new versions? Na disku se změnilo {count} propojených obrázků. Zobrazit nové verze?
Update Changed Links Aktualizovat změněná propojení
@msg Updated {_1} linked file(s) changed on disk Aktualizováno propojených souborů změněných na disku: {_1}
Magic Wand Kouzelná hůlka
Navigator Navigátor
Pattern Options Možnosti vzorku
@@ -532,6 +534,20 @@
Paragraph Styles Styly odstavců
Tabs Tabulátory
Variables Proměnné
New Variable… Nová proměnná…
Unbind Variable Zrušit přiřazení proměnné
Delete Variable Odstranit proměnnou
New Data Set… Nová sada dat…
Edit Data Set… Upravit sadu dat…
Delete Data Set Odstranit sadu dat
Next Data Set Další sada dat
Previous Data Set Předchozí sada dat
Make Text Dynamic Nastavit text jako dynamický
Make Visibility Dynamic Nastavit viditelnost jako dynamickou
Capture Data Set Pořídit sadu dat
Update Data Set Aktualizovat sadu dat
Rename Data Set… Přejmenovat sadu dat…
Variable Options… Možnosti proměnné…
Brush Libraries Knihovny štětců
Built-in Libraries Vestavěné knihovny
User Defined Uživatelské
@@ -660,3 +676,21 @@
Video Video
De-Interlace… Odstranit prokládání…
NTSC Colors Barvy NTSC
RGB: RGB:
CMYK: CMYK:
CMYK CMYK
Texture Textura
Craquelure… Krakelování…
Grain… Zrno…
Mosaic Tiles… Mozaikové dlaždice…
Patchwork… Patchwork…
Stained Glass… Vitráž…
Texturizer… Texturovač…
System Title Bar Systémové záhlaví okna
Exit Ukončit
Distort Zkreslení
Diffuse Glow… Rozptýlená záře…
Glass… Sklo…
Ocean Ripple… Vlnky na hladině…
Rename Data Set Přejmenovat sadu dat
Rename Variable Přejmenovat proměnnou
Can't render this file because it contains an unexpected character in line 2 and column 73.
File diff suppressed because it is too large. Load diff
+108
View File
@@ -1195,6 +1195,7 @@
Highlight Alternate Glyphs Resaltar glifos alternativos
Highlight anchors on mouse over Resaltar anclas al pasar el puntero
Highlight Rows Resaltar filas
Highlight Variable Resaltar variable
Highlight Substituted Fonts Resaltar fuentes sustituidas
Highlight Substituted Glyphs Resaltar glifos sustituidos
History Historial
@@ -3003,6 +3004,7 @@
Untitled Sin título
Update Link Actualizar vínculo
Update Links Actualizar vínculos
Update Changed Links Actualizar vínculos cambiados
Update Modified Links Actualizar vínculos modificados
Updated {} image(s) Se actualizaron {} imagen(es)
UPPERCASE MAYÚSCULAS
@@ -3054,6 +3056,38 @@
V: V:
Variable Width Profile Perfil de anchura variable
Variables Variables
New Variable Nueva variable
Delete Variable Eliminar variable
List Variables Enumerar variables
Bind Variable Vincular variable
Unbind Variable Desvincular variable
New Data Set Nuevo conjunto de datos
Delete Data Set Eliminar conjunto de datos
List Data Sets Enumerar conjuntos de datos
Select Data Set Seleccionar conjunto de datos
Next Data Set Conjunto de datos siguiente
Previous Data Set Conjunto de datos anterior
Data Sets Conjuntos de datos
New Variable… Nueva variable…
New Data Set… Nuevo conjunto de datos…
Edit Data Set… Editar conjunto de datos…
Data Set Conjunto de datos
Kind Tipo
Variable Variable
no document no hay documento
no data set to edit no hay ningún conjunto de datos que editar
Make Text Dynamic Hacer texto dinámico
Make Visibility Dynamic Hacer visible dinámico
Capture Data Set Capturar conjunto de datos
Update Data Set Actualizar conjunto de datos
Rename Data Set… Renombrar conjunto de datos…
Variable Options… Opciones de variable…
Select Bound Object Seleccionar objeto vinculado
Select All Bound Objects Seleccionar todos los objetos vinculados
Multiple objects Varios objetos
New Name Nuevo nombre
no variable to rename no hay ninguna variable que renombrar
no data set to rename no hay ningún conjunto de datos que renombrar
Variation: Variación:
Vector Effects Efectos vectoriales
Vector Halftone Semitono vectorial
@@ -3138,6 +3172,7 @@
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
fonts not copied: {fonts} fuentes no copiadas: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
{count} linked image(s) changed on disk. Show the new versions? {count} imagen(es) vinculada(s) cambiaron en el disco. ¿Mostrar las versiones nuevas?
{count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel.
{effect} is already applied {effect} ya está aplicado
@@ -3788,6 +3823,7 @@ axis Both Ambos
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
@msg Updated {_1} linked file(s) changed on disk Se actualizaron {_1} archivo(s) vinculado(s) que cambiaron en el disco
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
@msg {command} didn't create an object {command} no creó ningún objeto
@@ -3929,3 +3965,75 @@ axis Both Ambos
Duplication Duplicación
Interpolation Interpolación
@msg Imported library {name} Biblioteca {name} importada
RGB: RGB:
CMYK: CMYK:
Texture Textura
Craquelure… Craquelado…
Grain… Granulado…
Mosaic Tiles… Azulejos de mosaico…
Patchwork… Retazos…
Stained Glass… Vidriera…
Texturizer… Texturizador…
Craquelure Craquelado
Grain Granulado
Mosaic Tiles Azulejos de mosaico
Patchwork Retazos
Stained Glass Vidriera
Texturizer Texturizador
Crack Spacing Espaciado de grietas
Crack Depth Profundidad de grietas
Crack Brightness Brillo de grietas
Intensity Intensidad
Grain Type Tipo de granulado
Tile Size Tamaño de azulejo
Grout Width Anchura de la junta
Lighten Grout Aclarar junta
Square Size Tamaño de cuadrado
Relief Relieve
Border Thickness Grosor del borde
Light Intensity Intensidad de luz
Light Direction Dirección de la luz
Sprinkles Espolvoreado
Clumped Agrupado
Contrasty Contrastado
Enlarged Ampliado
Stippled Punteado
Speckle Moteado
Brick Ladrillo
Burlap Arpillera
Canvas Lienzo
Sandstone Arenisca
Bottom Left Abajo a la izquierda
Top Left Arriba a la izquierda
Top Right Arriba a la derecha
Bottom Right Abajo a la derecha
Edge Borde
System Title Bar Barra de título del sistema
Value Axis Eje de valores
On Left Side En el lado izquierdo
On Right Side En el lado derecho
On Both Sides En ambos lados
Short Corta
Full Width Ancho completo
Distort Distorsionar
Diffuse Glow… Resplandor difuso…
Glass… Vidrio…
Ocean Ripple… Rizo oceánico…
Diffuse Glow Resplandor difuso
Glass Vidrio
Ocean Ripple Rizo oceánico
Graininess Granulosidad
Glow Amount Cantidad de resplandor
Clear Amount Cantidad de claridad
Ripple Size Tamaño de rizo
Ripple Magnitude Magnitud de rizo
Blocks Bloques
Frosted Esmerilado
Tiny Lens Lente pequeña
Rename Data Set Renombrar conjunto de datos
Rename Variable Renombrar variable
Variable Options Opciones de variable
Edit Data Set Editar conjunto de datos
Define a variable first Defina primero una variable
Text Texto
Visibility Visibilidad
Can't render this file because it contains an unexpected character in line 2 and column 73.
+109
View File
@@ -1194,6 +1194,7 @@
Highlight Alternate Glyphs Surligner les glyphes de substitution
Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol
Highlight Rows Surligner les lignes
Highlight Variable Surligner la variable
Highlight Substituted Fonts Surligner les polices remplacées
Highlight Substituted Glyphs Surligner les glyphes remplacés
History Historique
@@ -3000,6 +3001,7 @@
Untitled Sans titre
Update Link Mettre à jour le lien
Update Links Mettre à jour les liens
Update Changed Links Mettre à jour les liens modifiés
Update Modified Links Mettre à jour les liens modifiés
Updated {} image(s) {} image(s) mise(s) à jour
UPPERCASE MAJUSCULES
@@ -3051,6 +3053,38 @@
V: V :
Variable Width Profile Profil de largeur variable
Variables Variables
New Variable Nouvelle variable
Delete Variable Supprimer la variable
List Variables Lister les variables
Bind Variable Lier la variable
Unbind Variable Délier la variable
New Data Set Nouveau jeu de données
Delete Data Set Supprimer le jeu de données
List Data Sets Lister les jeux de données
Select Data Set Sélectionner le jeu de données
Next Data Set Jeu de données suivant
Previous Data Set Jeu de données précédent
Data Sets Jeux de données
New Variable… Nouvelle variable…
New Data Set… Nouveau jeu de données…
Edit Data Set… Modifier le jeu de données…
Data Set Jeu de données
Kind Type
Variable Variable
no document aucun document
no data set to edit aucun jeu de données à modifier
Make Text Dynamic Rendre le texte dynamique
Make Visibility Dynamic Rendre la visibilité dynamique
Capture Data Set Capturer le jeu de données
Update Data Set Mettre à jour le jeu de données
Rename Data Set… Renommer le jeu de données…
Variable Options… Options de variable…
Select Bound Object Sélectionner l’objet lié
Select All Bound Objects Sélectionner tous les objets liés
Multiple objects Plusieurs objets
New Name Nouveau nom
no variable to rename aucune variable à renommer
no data set to rename aucun jeu de données à renommer
Variation: Variation :
Vector Effects Effets vectoriels
Vector Halftone Trame de demi-teintes vectorielle
@@ -3135,6 +3169,7 @@
{count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s)
fonts not copied: {fonts} polices non copiées : {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ?
{count} linked image(s) changed on disk. Show the new versions? {count} image(s) liée(s) modifiée(s) sur le disque. Afficher les nouvelles versions ?
{count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau.
{effect} is already applied {effect} est déjà appliqué
@@ -3785,6 +3820,7 @@ axis Both Les deux
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
@msg Updated {_1} linked file(s) changed on disk {_1} fichier(s) lié(s) modifié(s) sur le disque mis à jour
@msg {_1}: not saved, so not closed {_1} : non enregistré, donc non fermé
@msg {bpp}-bit bitmaps can't be read impossible de lire les images bitmap en {bpp} bits
@msg {command} didn't create an object {command} n’a créé aucun objet
@@ -3940,3 +3976,76 @@ axis Both Les deux
Duplication Duplication
Interpolation Interpolation
@msg Imported library {name} Bibliothèque {name} importée
RGB: RGB:
CMYK: CMYK:
Texture Texture
Craquelure… Craquelure…
Grain… Grain…
Mosaic Tiles… Carreaux de mosaïque…
Patchwork… Patchwork…
Stained Glass… Vitrail…
Texturizer… Placage de texture…
Craquelure Craquelure
Grain Grain
Mosaic Tiles Carreaux de mosaïque
Patchwork Patchwork
Stained Glass Vitrail
Texturizer Placage de texture
Crack Spacing Espacement des craquelures
Crack Depth Profondeur des craquelures
Crack Brightness Luminosité des craquelures
Intensity Intensité
Grain Type Type de grain
Tile Size Taille des carreaux
Grout Width Largeur des joints
Lighten Grout Éclaircir les joints
Square Size Taille des carrés
Relief Relief
Border Thickness Épaisseur de la bordure
Light Intensity Intensité de la lumière
Light Direction Direction de la lumière
Sprinkles Saupoudré
Clumped Agglutiné
Contrasty Contrasté
Enlarged Agrandi
Stippled Pointillé
Speckle Moucheté
Brick Brique
Burlap Toile de jute
Canvas Toile
Sandstone Grès
Bottom Left En bas à gauche
Top Left En haut à gauche
Top Right En haut à droite
Bottom Right En bas à droite
effect Soft Doux
Edge Bord
System Title Bar Barre de titre du système
Value Axis Axe des valeurs
On Left Side À gauche
On Right Side À droite
On Both Sides Des deux côtés
Short Courte
Full Width Toute la largeur
Distort Déformation
Diffuse Glow… Diffusion lumineuse…
Glass… Verre…
Ocean Ripple… Ondulation océanique…
Diffuse Glow Diffusion lumineuse
Glass Verre
Ocean Ripple Ondulation océanique
Graininess Granularité
Glow Amount Intensité de la lueur
Clear Amount Zone claire
Ripple Size Taille de l'ondulation
Ripple Magnitude Amplitude de l'ondulation
Blocks Blocs
Frosted Dépoli
Tiny Lens Petites lentilles
Rename Data Set Renommer le jeu de données
Rename Variable Renommer la variable
Variable Options Options de variable
Edit Data Set Modifier le jeu de données
Define a variable first Définissez d’abord une variable
Text Texte
Visibility Visibilité
Can't render this file because it contains an unexpected character in line 2 and column 73.
+110
View File
@@ -1194,6 +1194,7 @@
Highlight Alternate Glyphs Evidenzia glifi alternativi
Highlight anchors on mouse over Evidenzia ancoraggi al passaggio del mouse
Highlight Rows Evidenzia righe
Highlight Variable Evidenzia variabile
Highlight Substituted Fonts Evidenzia font sostituiti
Highlight Substituted Glyphs Evidenzia glifi sostituiti
History Cronologia
@@ -3003,6 +3004,7 @@
Untitled Senza titolo
Update Link Aggiorna collegamento
Update Links Aggiorna collegamenti
Update Changed Links Aggiorna collegamenti modificati
Update Modified Links Aggiorna collegamenti modificati
Updated {} image(s) Immagini aggiornate: {}
UPPERCASE MAIUSCOLO
@@ -3054,6 +3056,38 @@
V: V:
Variable Width Profile Profilo larghezza variabile
Variables Variabili
New Variable Nuova variabile
Delete Variable Elimina variabile
List Variables Elenca variabili
Bind Variable Collega variabile
Unbind Variable Scollega variabile
New Data Set Nuovo set di dati
Delete Data Set Elimina set di dati
List Data Sets Elenca set di dati
Select Data Set Seleziona set di dati
Next Data Set Set di dati successivo
Previous Data Set Set di dati precedente
Data Sets Set di dati
New Variable… Nuova variabile…
New Data Set… Nuovo set di dati…
Edit Data Set… Modifica set di dati…
Data Set Set di dati
Kind Tipo
Variable Variabile
no document nessun documento
no data set to edit nessun set di dati da modificare
Make Text Dynamic Rendi testo dinamico
Make Visibility Dynamic Rendi visibilità dinamica
Capture Data Set Cattura set di dati
Update Data Set Aggiorna set di dati
Rename Data Set… Rinomina set di dati…
Variable Options… Opzioni variabile…
Select Bound Object Seleziona oggetto associato
Select All Bound Objects Seleziona tutti gli oggetti associati
Multiple objects Più oggetti
New Name Novo nome
no variable to rename nessuna variabile da rinominare
no data set to rename nessun set di dati da rinominare
Variation: Variazione:
Vector Effects Effetti vettoriali
Vector Halftone Retino vettoriale
@@ -3138,6 +3172,7 @@
{count} linked file(s) not found File collegati non trovati: {count}
fonts not copied: {fonts} font non copiati: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni?
{count} linked image(s) changed on disk. Show the new versions? Immagini collegate modificate su disco: {count}. Vuoi mostrare le nuove versioni?
{count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello.
{effect} is already applied L'effetto {effect} è già applicato
@@ -3788,6 +3823,7 @@ axis Both Entrambi
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
@msg Updated {_1} linked file(s) changed on disk File collegati modificati su disco aggiornati: {_1}
@msg {_1}: not saved, so not closed {_1}: non salvato, quindi non chiuso
@msg {bpp}-bit bitmaps can't be read impossibile leggere bitmap a {bpp} bit
@msg {command} didn't create an object {command} non ha creato alcun oggetto
@@ -3929,3 +3965,77 @@ axis Both Entrambi
Duplication Duplicazione
Interpolation Interpolazione
@msg Imported library {name} Libreria {name} importata
RGB: RGB:
CMYK: CMYK:
Texture Texture
Craquelure… Craquelé…
Grain… Granulosità…
Mosaic Tiles… Tessere di mosaico…
Patchwork… Patchwork…
Stained Glass… Vetro colorato…
Texturizer… Applica texture…
Craquelure Craquelé
Grain Granulosità
Mosaic Tiles Tessere di mosaico
Patchwork Patchwork
Stained Glass Vetro colorato
Texturizer Applica texture
Crack Spacing Spaziatura crepe
Crack Depth Profondità crepe
Crack Brightness Luminosità crepe
Intensity Intensità
Grain Type Tipo di granulosità
Tile Size Dimensione tessere
Grout Width Larghezza stuccatura
Lighten Grout Schiarisci stuccatura
Square Size Dimensione quadrati
Relief Rilievo
Border Thickness Spessore bordo
Light Intensity Intensità luce
Light Direction Direzione luce
Sprinkles Spruzzato
Clumped Raggruppato
Contrasty Contrastato
Enlarged Ingrandito
Stippled Punteggiato
Speckle Maculato
Brick Mattone
Burlap Tela di iuta
Canvas Tela
Sandstone Arenaria
Bottom Left In basso a sinistra
Top Left In alto a sinistra
Top Right In alto a destra
Bottom Right In basso a destra
effect Soft Morbido
effect Regular Normale
Edge Bordo
System Title Bar Barra del titolo di sistema
Value Axis Asse dei valori
On Left Side Sul lato sinistro
On Right Side Sul lato destro
On Both Sides Su entrambi i lati
Short Breve
Full Width Larghezza intera
Distort Distorsione
Diffuse Glow… Bagliore diffuso…
Glass… Vetro…
Ocean Ripple… Increspatura oceanica…
Diffuse Glow Bagliore diffuso
Glass Vetro
Ocean Ripple Increspatura oceanica
Graininess Granulosità
Glow Amount Quantità di bagliore
Clear Amount Quantità di chiarezza
Ripple Size Dimensione increspatura
Ripple Magnitude Ampiezza increspatura
Blocks Blocchi
Frosted Smerigliato
Tiny Lens Lente piccola
Rename Data Set Rinomina set di dati
Rename Variable Rinomina variabile
Variable Options Opzioni variabile
Edit Data Set Modifica set di dati
Define a variable first Definisci prima una variabile
Text Testo
Visibility Visibilità
Can't render this file because it contains an unexpected character in line 2 and column 73.
+111
View File
@@ -569,6 +569,39 @@
Paragraph Styles 段落スタイル
Tabs タブ
Variables 変数
New Variable 新規変数
Delete Variable 変数を削除
List Variables 変数一覧
Bind Variable 変数をバインド
Unbind Variable 変数のバインドを解除
New Data Set 新規データセット
Delete Data Set データセットを削除
List Data Sets データセット一覧
Select Data Set データセットを選択
Next Data Set 次のデータセット
Previous Data Set 前のデータセット
Data Sets データセット
New Variable… 新規変数…
New Data Set… 新規データセット…
Edit Data Set… データセットを編集…
Data Set データセット
Kind 種類
Variable 変数
no document ドキュメントがありません
no data set to edit 編集するデータセットがありません
Make Text Dynamic テキストを動的にする
Make Visibility Dynamic 表示を動的にする
Capture Data Set データセットを取得
Update Data Set データセットを更新
Rename Data Set… データセットの名前を変更…
Variable Options… 変数のオプション…
Select Bound Object バインドされたオブジェクトを選択
Select All Bound Objects すべてのバインドされたオブジェクトを選択
Multiple objects 複数のオブジェクト
New Name 新しい名前
Rename Variable 変数の名前を変更
no variable to rename 名前を変更する変数がありません
no data set to rename 名前を変更するデータセットがありません
Brush Libraries ブラシライブラリ
Built-in Libraries 内蔵ライブラリ
User Defined ユーザー定義
@@ -1415,6 +1448,7 @@
High Performance (discrete) 高パフォーマンス (専用 GPU)
Highlight Alternate Glyphs 代替字形をハイライト
Highlight Rows 行をハイライト
Highlight Variable 変数をハイライト表示
Highlight Substituted Fonts 置換フォントをハイライト
Highlight Substituted Glyphs 置換字形をハイライト
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト
@@ -2668,6 +2702,7 @@
Unshift シフトを戻す
Update Link リンクを更新
Update Links リンクを更新
Update Changed Links 変更されたリンクを更新
Update Modified Links 変更されたリンクを更新
Use メニュー
Use Artboards アートボードを使用
@@ -2777,6 +2812,7 @@
{count} linked file(s) not found リンクファイルが {count} 個見つかりません
fonts not copied: {fonts} コピーされなかったフォント:{fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか?
{count} linked image(s) changed on disk. Show the new versions? {count} 個のリンク画像がディスク上で変更されました。新しいバージョンを表示しますか?
{count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。
{effect} is already applied {effect} はすでに適用されています
@@ -3719,6 +3755,7 @@ theme Appearance 外観
@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません
@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます
@msg Updated {_1} linked file(s) changed on disk ディスク上で変更された {_1} 個のリンクファイルを更新しました
@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください
@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note}
@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note}
@@ -3921,3 +3958,77 @@ theme Appearance 外観
Duplication 複製
Interpolation 補間
@msg Imported library {name} ライブラリ {name} を読み込みました
RGB: RGB:
CMYK: CMYK:
Texture テクスチャ
Craquelure… クラッキング…
Grain… 粒状…
Mosaic Tiles… モザイクタイル…
Patchwork… パッチワーク…
Stained Glass… ステンドグラス…
Texturizer… テクスチャライザー…
Craquelure クラッキング
Grain 粒状
Mosaic Tiles モザイクタイル
Patchwork パッチワーク
Stained Glass ステンドグラス
Texturizer テクスチャライザー
Crack Spacing 溝の間隔
Crack Depth 溝の深さ
Crack Brightness 溝の明るさ
Intensity 強さ
Grain Type 粒子の種類
Tile Size タイルのサイズ
Grout Width 目地の幅
Lighten Grout 目地を明るく
Square Size 正方形のサイズ
Relief レリーフ
Border Thickness 境界線の太さ
Light Intensity 光の強さ
Light Direction 光源の方向
Sprinkles 散布
Clumped 集合
Contrasty 高コントラスト
Enlarged 拡大
Stippled スティップル
Speckle 斑点
Brick レンガ
Burlap 麻布
Canvas カンバス
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft ソフト
effect Horizontal 水平
effect Vertical 垂直
Edge エッジ
System Title Bar システムのタイトルバー
Value Axis 数値軸
On Left Side 左側
On Right Side 右側
On Both Sides 両側
Short 短い
Full Width 全幅
Distort 変形
Diffuse Glow… 光彩拡散…
Glass… ガラス…
Ocean Ripple… 海の波紋…
Diffuse Glow 光彩拡散
Glass ガラス
Ocean Ripple 海の波紋
Graininess 粒子
Glow Amount 光彩の量
Clear Amount クリア量
Ripple Size 波紋のサイズ
Ripple Magnitude 波紋の強さ
Blocks ブロック
Frosted すりガラス
Tiny Lens 小さなレンズ
Rename Data Set データセットの名前を変更
Variable Options 変数のオプション
Edit Data Set データセットを編集
Define a variable first 先に変数を定義してください
Text テキスト
Visibility 表示状態
Can't render this file because it contains an unexpected character in line 2 and column 73.
+23 -1
View File
@@ -82,13 +82,23 @@ fn plural_east_slavic(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 11] = [
pub static LANGUAGES: [LangInfo; 12] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Czech: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs not yet.
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: false, catalog: OnceLock::new() },
// German: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `de-*` locale (`de-DE`, `de-AT`, `de-CH`, `de-LU` …) resolves here.
LangInfo {
code: "de",
name: "Deutsch",
source: include_str!("de.tsv"),
plural: plural_one_other,
complete_menus: true,
catalog: OnceLock::new(),
},
// Spanish: the whole interface in neutral, international Spanish, keeping the same names in
// English as Japanese; every `es-*` locale (`es-ES`, `es-MX`, `es-AR`, `es-419` …) resolves here.
LangInfo {
@@ -254,6 +264,13 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
cands.iter().find_map(|c| Lang::from_code(c))
}
/// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on
/// macOS means "no particular language" rather than English: the system language list decides.
fn is_c_locale(tag: &str) -> bool {
let base = tag.split(['.', '@']).next().unwrap_or("").to_ascii_lowercase();
matches!(base.as_str(), "c" | "posix")
}
/// Work out the system language on a background thread, so the first frame doesn't wait for the
/// locale probe (which runs `reg.exe` on Windows and `defaults` on macOS). Call it at startup; a
/// frame that needs the language before the probe finishes waits for it. A no-op on wasm.
@@ -295,6 +312,11 @@ fn detect_system_lang() -> Lang {
// variables. `LANGUAGE` is a colon-separated priority list.
for var in ["VECTORCRAFT_LOCALE", "LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
let Some(v) = std::env::var(var).ok().filter(|v| !v.is_empty()) else { continue };
// `C` / `POSIX` (and variants like `C.UTF-8`, `POSIX.UTF-8`) mean "no particular locale":
// don't force English, fall through to the OS preferred-languages list below.
if v.split(':').all(is_c_locale) {
continue;
}
if let Some(l) = v.split(':').find_map(lang_from_tag) {
return l;
}
+34 -1
View File
@@ -2119,7 +2119,6 @@
Set Zoom Definir Zoom
Set base color to the current color Definir cor base para a cor atual
Set the selected color to the current color Definir a cor selecionada para a cor atual
Settings… Configurações…
Setup… Configuração…
Shadows and Glows Sombras e Brilhos
Shape Forma
@@ -2483,6 +2482,9 @@
Unshift Desfazer Mudança de Cor
Update Link Atualizar Link
Update Links Atualizar Links
Update Changed Links Atualizar Links alterados
{count} linked image(s) changed on disk. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) no disco. Mostrar as novas versões?
@msg Updated {_1} linked file(s) changed on disk {_1} arquivo(s) vinculado(s) alterado(s) no disco atualizado(s)
Update Modified Links Atualizar Links Modificados
Use Use
Use Artboards Usar Mesas de Trabalho
@@ -2528,6 +2530,20 @@
V: V:
Variable Width Profile Perfil de Largura Variável
Variables Variáveis
New Variable… Nova variável…
Unbind Variable Desassociar variável
Delete Variable Excluir variável
New Data Set… Novo conjunto de dados…
Edit Data Set… Editar conjunto de dados…
Delete Data Set Excluir conjunto de dados
Next Data Set Próximo conjunto de dados
Previous Data Set Conjunto de dados anterior
Make Text Dynamic Tornar texto dinâmico
Make Visibility Dynamic Tornar visibilidade dinâmica
Capture Data Set Capturar conjunto de dados
Update Data Set Atualizar conjunto de dados
Rename Data Set… Renomear conjunto de dados…
Variable Options… Opções de variável…
Variation: Variação:
Vector Effects Efeitos Vetoriais
Vector Halftone Meio-tom Vetorial
@@ -2721,3 +2737,20 @@
Video Vídeo
De-Interlace… Desentrelaçar…
NTSC Colors Cores NTSC
RGB: RGB:
CMYK: CMYK:
Texture Textura
Craquelure… Craquelê…
Grain… Granulação…
Mosaic Tiles… Ladrilhos de mosaico…
Patchwork… Colcha de retalhos…
Stained Glass… Vitral…
Texturizer… Texturizador…
System Title Bar Barra de título do sistema
Exit Sair
Distort Distorcer
Diffuse Glow… Brilho difuso…
Glass… Vidro…
Ocean Ripple… Ondulação oceânica…
Rename Data Set Renomear conjunto de dados
Rename Variable Renomear variável
Can't render this file because it contains an unexpected character in line 6 and column 47.
+108
View File
@@ -1192,6 +1192,7 @@
Highlight Alternate Glyphs Выделять альтернативные глифы
Highlight anchors on mouse over Выделять опорные точки при наведении
Highlight Rows Выделять строки
Highlight Variable Подсветить переменную
Highlight Substituted Fonts Выделять заменённые шрифты
Highlight Substituted Glyphs Выделять заменённые глифы
History История
@@ -2999,6 +3000,7 @@
Untitled Без названия
Update Link Обновить связь
Update Links Обновить связи
Update Changed Links Обновить измененные связи
Update Modified Links Обновить изменённые связи
Updated {} image(s) Обновлено изображений: {}
UPPERCASE ПРОПИСНЫЕ
@@ -3050,6 +3052,38 @@
V: В:
Variable Width Profile Профиль переменной ширины
Variables Переменные
New Variable Новая переменная
Delete Variable Удалить переменную
List Variables Перечислить переменные
Bind Variable Привязать переменную
Unbind Variable Отвязать переменную
New Data Set Новый набор данных
Delete Data Set Удалить набор данных
List Data Sets Перечислить наборы данных
Select Data Set Выделить набор данных
Next Data Set Следующий набор данных
Previous Data Set Предыдущий набор данных
Data Sets Наборы данных
New Variable… Новая переменная…
New Data Set… Новый набор данных…
Edit Data Set… Изменить набор данных…
Data Set Набор данных
Kind Тип
Variable Переменная
no document нет документа
no data set to edit нет набора данных для изменения
Make Text Dynamic Сделать текст динамическим
Make Visibility Dynamic Сделать видимость динамической
Capture Data Set Записать набор данных
Update Data Set Обновить набор данных
Rename Data Set… Переименовать набор данных…
Variable Options… Параметры переменной…
Select Bound Object Выбрать связанный объект
Select All Bound Objects Выбрать все связанные объекты
Multiple objects Несколько объектов
New Name Новое имя
no variable to rename нет переменной для переименования
no data set to rename нет набора данных для переименования
Variation: Вариация:
Vector Effects Векторные эффекты
Vector Halftone Векторный полутон
@@ -3134,6 +3168,7 @@
{count} linked file(s) not found Не найдено связанных файлов: {count}
fonts not copied: {fonts} шрифты не скопированы: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии?
{count} linked image(s) changed on disk. Show the new versions? Связанных изображений ({count}) изменились на диске. Показать новые версии?
{count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели.
{effect} is already applied {effect} уже применён
@@ -3784,6 +3819,7 @@ axis Both Оба
@msg {_1} is already used by {_2} {_1} уже используется {_2}
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
@msg Updated {_1} linked file(s) changed on disk Обновлено связанных файлов, измененных на диске: {_1}
@msg {_1}: not saved, so not closed {_1}: не сохранён, поэтому не закрыт
@msg {bpp}-bit bitmaps can't be read растровые изображения с глубиной {bpp} бит не читаются
@msg {command} didn't create an object {command} не создал объект
@@ -3927,3 +3963,75 @@ axis Both Оба
Duplication Дублированием
Interpolation Интерполяцией
@msg Imported library {name} Импортирована библиотека {name}
RGB: RGB:
CMYK: CMYK:
Texture Текстура
Craquelure… Кракелюры…
Grain… Зерно…
Mosaic Tiles… Мозаичные фрагменты…
Patchwork… Цветная плитка…
Stained Glass… Витраж…
Texturizer… Текстуризатор…
Craquelure Кракелюры
Grain Зерно
Mosaic Tiles Мозаичные фрагменты
Patchwork Цветная плитка
Stained Glass Витраж
Texturizer Текстуризатор
Crack Spacing Интервал трещин
Crack Depth Глубина трещин
Crack Brightness Яркость трещин
Intensity Интенсивность
Grain Type Тип зерна
Tile Size Размер фрагментов
Grout Width Ширина швов
Lighten Grout Осветление швов
Square Size Размер квадратов
Relief Рельеф
Border Thickness Толщина границ
Light Intensity Интенсивность света
Light Direction Направление света
Sprinkles Брызги
Clumped Комки
Contrasty Контрастное
Enlarged Укрупнённое
Stippled Пунктир
Speckle Крапинки
Brick Кирпич
Burlap Мешковина
Canvas Холст
Sandstone Песчаник
Bottom Left Снизу слева
Top Left Сверху слева
Top Right Сверху справа
Bottom Right Снизу справа
Edge Край
System Title Bar Системная строка заголовка
Value Axis Ось значений
On Left Side Слева
On Right Side Справа
On Both Sides С обеих сторон
Short Короткие
Full Width Во всю ширину
Distort Искажение
Diffuse Glow… Рассеянное свечение…
Glass… Стекло…
Ocean Ripple… Океанские волны…
Diffuse Glow Рассеянное свечение
Glass Стекло
Ocean Ripple Океанские волны
Graininess Зернистость
Glow Amount Сила свечения
Clear Amount Чистые области
Ripple Size Размер волн
Ripple Magnitude Сила волн
Blocks Блоки
Frosted Матовое
Tiny Lens Мелкие линзы
Rename Data Set Переименовать набор данных
Rename Variable Переименовать переменную
Variable Options Параметры переменной
Edit Data Set Изменить набор данных
Define a variable first Сначала определите переменную
Text Текст
Visibility Видимость
Can't render this file because it contains an unexpected character in line 2 and column 73.
+65 -13
View File
@@ -8,7 +8,11 @@ fn tags_map_to_languages() {
assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN));
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
assert_eq!(lang_from_tag("de_DE"), None);
assert_eq!(lang_from_tag("nl_NL"), None);
// German: Germany, Austria, Switzerland and the rest share the one catalog.
for tag in ["de", "de_DE.UTF-8", "de-AT", "de_CH", "de-LU", "de_DE.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(de()), "{tag}");
}
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Lang::from_code("ja"));
assert_eq!(lang_from_tag("ja"), Lang::from_code("ja"));
// Spanish: every region (and Latin America as a whole) resolves to the one catalog.
@@ -51,7 +55,8 @@ fn candidates_walk_from_specific_to_general() {
#[test]
fn os_language_lists_are_parsed() {
assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH()));
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(Lang::EN));
assert_eq!(first_supported("(\n \"nl-NL\",\n \"en-US\"\n)\n"), Some(Lang::EN));
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(de()));
assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr"));
assert_eq!(first_supported("("), None);
// Windows: `reg query HKCU\Control Panel\International /v LocaleName`.
@@ -199,8 +204,8 @@ fn the_engine_follows_the_interface_language() {
assert_eq!(app.ui_language().code(), "ja");
}
/// VectorCraft › Language (`app.language`) sets the `interfaceLanguage` preference, which is what
/// persists; the checked item follows the preference, and a bad code is an error, not a change.
/// `app.language` sets the `interfaceLanguage` preference, which is what persists; the checked
/// item follows the preference, and a bad code is an error, not a change.
#[test]
fn language_command_sets_the_preference() {
use crate::menus::{checked, run_ui_command};
@@ -218,9 +223,21 @@ fn language_command_sets_the_preference() {
run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "auto"})).unwrap().unwrap();
assert_eq!(app.session.prefs.interface_language, "auto");
assert_eq!(checked(&app, "app.language", &serde_json::json!({"lang": "auto"})), Some(true));
// Every registered language is in the menu, by its own name.
let labels: Vec<String> =
crate::menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("app.language")).map(|e| e.label).collect();
// Every registered language is in the Mac App menu's Language, by its own name (the in-window
// bar has no Language menu).
let layout = crate::native_menu::mac_layout(&app, &crate::native_menu::from_tree(&app, &crate::menus::menu_tree()), Lang::EN);
let app_menu = layout.bar.menus.into_iter().find(|m| m.title == "VectorCraft").expect("App menu");
fn items(nodes: &[crate::native_menu::Node], out: &mut Vec<String>) {
for n in nodes {
match n {
crate::native_menu::Node::Item(it) if it.command == Some("app.language") => out.push(it.label.clone()),
crate::native_menu::Node::Submenu { children, .. } => items(children, out),
_ => {}
}
}
}
let mut labels = vec![];
items(&app_menu.children, &mut labels);
for l in Lang::all() {
assert!(labels.iter().any(|x| x == l.name()), "{} missing from {labels:?}", l.code());
}
@@ -423,9 +440,9 @@ fn cs() -> Lang {
}
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
const KEEPS_MENU_NAMES: [&str; 8] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru", "uk"];
const KEEPS_MENU_NAMES: [&str; 9] = ["cs", "de", "es", "fr", "it", "ja", "pt-br", "ru", "uk"];
/// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
/// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
/// Each language's own name in the Language menu is left alone too.
const MENU_KEEP_AS_IS: &[&str] = &[
@@ -515,7 +532,7 @@ fn toggled_labels() -> Vec<String> {
labels
}
/// Czech, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
/// Czech, German, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs
/// and the canvas context menu included (panels and dialogs not yet).
#[test]
fn menu_catalogs_translate_every_menu_label() {
@@ -553,9 +570,14 @@ fn menu_catalogs_translate_every_menu_label() {
}
assert_eq!(tr(Lang::from_code("pt-br").expect("pt-br"), "File"), "Arquivo");
assert_eq!(tr(es(), "File"), "Archivo");
assert_eq!(tr(de(), "File"), "Datei");
assert_eq!(tr(it(), "Edit"), "Modifica");
}
fn de() -> Lang {
Lang::from_code("de").expect("de registered")
}
fn es() -> Lang {
Lang::from_code("es").expect("es registered")
}
@@ -597,6 +619,36 @@ fn spanish_reads_as_spanish() {
assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas");
}
/// German uses the vector-illustration vocabulary its users know, has two plural forms like
/// English (zero takes the plural), translates the reason inside a message too, and tells the
/// interface theme ("Erscheinungsbild") from the Appearance panel ("Aussehen").
#[test]
fn german_reads_as_german() {
for (en, want) in [
("Artboard Tool", "Zeichenflächen-Werkzeug"),
("Swatches", "Farbfelder"),
("Pathfinder", "Pathfinder"),
("Stroke", "Kontur"),
("Fill", "Fläche"),
("Direct Selection Tool", "Direktauswahl-Werkzeug"),
("Save As…", "Speichern unter…"),
("Undo", "Rückgängig"),
] {
assert_eq!(tr(de(), en), want);
}
assert_eq!(trn(de(), 1, "{n} Layer", "{n} Layers"), "1 Ebene");
assert_eq!(trn(de(), 0, "{n} Layer", "{n} Layers"), "0 Ebenen");
assert_eq!(trn(de(), 3, "{n} Layer", "{n} Layers"), "3 Ebenen");
assert_eq!(tr_ctx(de(), "axis", "Both"), "Beide");
assert_eq!(tr(de(), "Appearance"), "Aussehen");
assert_eq!(tr_ctx(de(), "theme", "Appearance"), "Erscheinungsbild");
let nested = "command `object.group` is not available right now: nothing selected";
assert_eq!(message(de(), nested), "der Befehl `object.group` ist derzeit nicht verfügbar: nichts ausgewählt");
// Text styles are "…format" in German: the kind is joined to it ("Neues Zeichenformat erstellen").
assert_eq!(fmt(tr(de(), "Create New {kind} Style"), &[("kind", tr(de(), "Character"))]), "Neues Zeichenformat erstellen");
assert_eq!(fmt(tr(de(), "Show {kind} Brushes"), &[("kind", tr(de(), "Calligraphic"))]), "Kalligrafiepinsel einblenden");
}
/// French uses the vector-illustration vocabulary its users know, puts zero in the singular (« 0
/// calque »), and reads the same in the menus and in the panels.
#[test]
@@ -757,7 +809,7 @@ fn russian_plurals_have_three_forms() {
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]);
}
/// Czech, Spanish, French and Italian letters (and the punctuation their text uses) come from each family's own first font, not
/// Czech, German, Spanish, French and Italian letters (and the punctuation their text uses) come from each family's own first font, not
/// from a fallback further down the stack. (`has_glyph` can't tell: it counts characters of the
/// face that draws missing glyphs, the first one, as missing.)
#[test]
@@ -772,7 +824,7 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
let first = first.unwrap();
let mut font = fonts.fonts.font(&family);
let chars = font.characters();
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆ\u{a0}".chars()
for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖß\u{a0}".chars()
{
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
}
@@ -872,7 +924,7 @@ fn complete_languages_translate_every_message() {
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ja", "ru", "uk"];
const COMPLETE_MESSAGES: &[&str] = &["de", "es", "fr", "it", "ja", "ru", "uk"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
+108
View File
@@ -1188,6 +1188,7 @@
Highlight Alternate Glyphs Виділяти альтернативні гліфи
Highlight anchors on mouse over Виділяти опорні точки при наведенні
Highlight Rows Виділяти рядки
Highlight Variable Підсвітити змінну
Highlight Substituted Fonts Виділяти замінені шрифти
Highlight Substituted Glyphs Виділяти замінені гліфи
History Історія
@@ -2995,6 +2996,7 @@
Untitled Без назви
Update Link Оновити зв’язок
Update Links Оновити зв’язки
Update Changed Links Оновити змінені зв’язки
Update Modified Links Оновити змінені зв’язки
Updated {} image(s) Оновлено зображень: {}
UPPERCASE ВЕЛИКІ ЛІТЕРИ
@@ -3046,6 +3048,38 @@
V: В:
Variable Width Profile Профіль змінної ширини
Variables Змінні
New Variable Нова змінна
Delete Variable Видалити змінну
List Variables Перерахувати змінні
Bind Variable Прив'язати змінну
Unbind Variable Відв'язати змінну
New Data Set Новий набір даних
Delete Data Set Видалити набір даних
List Data Sets Перерахувати набори даних
Select Data Set Виділити набір даних
Next Data Set Наступний набір даних
Previous Data Set Попередній набір даних
Data Sets Набори даних
New Variable… Нова змінна…
New Data Set… Новий набір даних…
Edit Data Set… Змінити набір даних…
Data Set Набір даних
Kind Тип
Variable Змінна
no document немає документа
no data set to edit немає набору даних для зміни
Make Text Dynamic Зробити текст динамічним
Make Visibility Dynamic Зробити видимість динамічною
Capture Data Set Записати набір даних
Update Data Set Оновити набір даних
Rename Data Set… Перейменувати набір даних…
Variable Options… Параметри змінної…
Select Bound Object Вибрати пов’язаний об’єкт
Select All Bound Objects Вибрати всі пов’язані об’єкти
Multiple objects Кілька об’єктів
New Name Нова назва
no variable to rename немає змінної для перейменування
no data set to rename немає набору даних для перейменування
Variation: Варіація:
Vector Effects Векторні ефекти
Vector Halftone Векторний напівтон
@@ -3130,6 +3164,7 @@
{count} linked file(s) not found не знайдено пов’язаних файлів: {count}
fonts not copied: {fonts} шрифти не скопійовано: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії?
{count} linked image(s) changed on disk. Show the new versions? пов’язані зображення ({count}) змінилися на диску. Показати нові версії?
{count} more rules: Export writes every one. ще правил: {count}: експорт записує все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.
{effect} is already applied {effect} вже застосований
@@ -3771,6 +3806,7 @@ axis Both Обидві
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
@msg Updated {_1} linked file(s) changed on disk Оновлено пов’язаних файлів, змінених на диску: {_1}
@msg {_1}: not saved, so not closed {_1}: не збережено, тому не закрито
@msg {bpp}-bit bitmaps can't be read растрові зображення з глибиною {bpp} біт не читаються
@msg {command} didn't create an object {command} не створив об’єкт
@@ -3912,3 +3948,75 @@ axis Both Обидві
Duplication Дублюванням
Interpolation Інтерполяцією
@msg Imported library {name} Імпортовано бібліотеку {name}
RGB: RGB:
CMYK: CMYK:
Texture Текстура
Craquelure… Кракелюри…
Grain… Зерно…
Mosaic Tiles… Мозаїчні фрагменти…
Patchwork… Клаптикова плитка…
Stained Glass… Вітраж…
Texturizer… Текстуризатор…
Craquelure Кракелюри
Grain Зерно
Mosaic Tiles Мозаїчні фрагменти
Patchwork Клаптикова плитка
Stained Glass Вітраж
Texturizer Текстуризатор
Crack Spacing Інтервал тріщин
Crack Depth Глибина тріщин
Crack Brightness Яскравість тріщин
Intensity Інтенсивність
Grain Type Тип зерна
Tile Size Розмір фрагментів
Grout Width Ширина швів
Lighten Grout Освітлення швів
Square Size Розмір квадратів
Relief Рельєф
Border Thickness Товщина меж
Light Intensity Інтенсивність світла
Light Direction Напрямок світла
Sprinkles Бризки
Clumped Грудки
Contrasty Контрастне
Enlarged Збільшене
Stippled Пунктир
Speckle Цятки
Brick Цегла
Burlap Мішковина
Canvas Полотно
Sandstone Пісковик
Bottom Left Знизу ліворуч
Top Left Вгорі ліворуч
Top Right Вгорі праворуч
Bottom Right Знизу праворуч
Edge Край
System Title Bar Системний рядок заголовка
Value Axis Вісь значень
On Left Side Ліворуч
On Right Side Праворуч
On Both Sides З обох боків
Short Короткі
Full Width На всю ширину
Distort Спотворення
Diffuse Glow… Розсіяне світіння…
Glass… Скло…
Ocean Ripple… Океанські хвилі…
Diffuse Glow Розсіяне світіння
Glass Скло
Ocean Ripple Океанські хвилі
Graininess Зернистість
Glow Amount Сила світіння
Clear Amount Чисті ділянки
Ripple Size Розмір хвиль
Ripple Magnitude Сила хвиль
Blocks Блоки
Frosted Матове
Tiny Lens Дрібні лінзи
Rename Data Set Перейменувати набір даних
Rename Variable Перейменувати змінну
Variable Options Параметри змінної
Edit Data Set Змінити набір даних
Define a variable first Спочатку визначте змінну
Text Текст
Visibility Видимість
Can't render this file because it contains an unexpected character in line 659 and column 40.
+109
View File
@@ -2893,6 +2893,9 @@
Untitled 未命名
Update Link 更新链接
Update Links 更新链接
Update Changed Links 更新已更改的链接
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
Update Modified Links 更新已修改的链接
Updated {} image(s) 已更新 {} 张图像
UPPERCASE 大写
@@ -2944,6 +2947,38 @@
V: V:
Variable Width Profile 变量宽度配置文件
Variables 变量
New Variable 新建变量
Delete Variable 删除变量
List Variables 列出变量
Bind Variable 绑定变量
Unbind Variable 解除变量绑定
New Data Set 新建数据集
Delete Data Set 删除数据集
List Data Sets 列出数据集
Select Data Set 选择数据集
Next Data Set 下一个数据集
Previous Data Set 上一个数据集
Data Sets 数据集
New Variable… 新建变量…
New Data Set… 新建数据集…
Edit Data Set… 编辑数据集…
Data Set 数据集
Kind 类型
Variable 变量
no document 没有文档
no data set to edit 没有可编辑的数据集
Make Text Dynamic 使文本动态化
Make Visibility Dynamic 使可见性动态化
Capture Data Set 捕获数据集
Update Data Set 更新数据集
Rename Data Set… 重命名数据集…
Variable Options… 变量选项…
Select Bound Object 选择绑定的对象
Select All Bound Objects 选择所有绑定的对象
Multiple objects 多个对象
New Name 新名称
no variable to rename 没有可重命名的变量
no data set to rename 没有可重命名的数据集
Variation: 变化:
Vector Effects 矢量效果
Vector Halftone 矢量半色调
@@ -3391,6 +3426,7 @@
Hide Others 隐藏其他
High Performance (discrete) 高性能 (独立显卡)
Highlight Rows 突出显示行
Highlight Variable 突出显示变量
Lock Others 锁定其他
Merge Selected 合并所选图层
Move Rows 移动行
@@ -3559,3 +3595,76 @@ axis Both 两者
Even Fields 偶数场
Duplication 复制
Interpolation 插值
Texture 纹理
Craquelure… 龟裂缝…
Grain… 颗粒…
Mosaic Tiles… 马赛克拼贴…
Patchwork… 拼缀图…
Stained Glass… 染色玻璃…
Texturizer… 纹理化…
Craquelure 龟裂缝
Grain 颗粒
Mosaic Tiles 马赛克拼贴
Patchwork 拼缀图
Stained Glass 染色玻璃
Texturizer 纹理化
Crack Spacing 裂缝间距
Crack Depth 裂缝深度
Crack Brightness 裂缝亮度
Intensity 强度
Grain Type 颗粒类型
Tile Size 拼贴大小
Grout Width 缝隙宽度
Lighten Grout 加亮缝隙
Square Size 方形大小
Relief 凸现
Border Thickness 边框粗细
Light Intensity 光照强度
Light Direction 光照方向
Sprinkles 喷洒
Clumped 结块
Contrasty 强反差
Enlarged 扩大
Stippled 点刻
Speckle 斑点
Brick 砖形
Burlap 粗麻布
Canvas 画布
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft 柔和
effect Horizontal 水平
effect Vertical 垂直
Edge 边缘
System Title Bar 系统标题栏
Value Axis 数值轴
On Left Side 左侧
On Right Side 右侧
On Both Sides 两侧
Short 短
Full Width 全宽
Distort 扭曲
Diffuse Glow… 扩散亮光…
Glass… 玻璃…
Ocean Ripple… 海洋波纹…
Diffuse Glow 扩散亮光
Glass 玻璃
Ocean Ripple 海洋波纹
Graininess 颗粒
Glow Amount 发光量
Clear Amount 清除数量
Ripple Size 波纹大小
Ripple Magnitude 波纹幅度
Blocks 块状
Frosted 磨砂
Tiny Lens 小镜头
Rename Data Set 重命名数据集
Rename Variable 重命名变量
Variable Options 变量选项
Edit Data Set 编辑数据集
Define a variable first 请先定义一个变量
Text 文本
Visibility 可见性
Can't render this file because it contains an unexpected character in line 2 and column 73.
+109
View File
@@ -1170,6 +1170,7 @@
Highlight Alternate Glyphs 醒目提示替代字符
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
Highlight Rows 反白顯示列
Highlight Variable 突出顯示變數
Highlight Substituted Fonts 醒目提示被取代的字體
Highlight Substituted Glyphs 醒目提示被取代的字符
History 步驟記錄
@@ -2984,6 +2985,9 @@
Untitled 未命名
Update Link 更新連結
Update Links 更新連結
Update Changed Links 更新已變更的連結
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
Update Modified Links 更新已修改的連結
Updated {} image(s) 已更新 {} 張影像
UPPERCASE 全部大寫
@@ -3035,6 +3039,38 @@
V: V:
Variable Width Profile 可變寬度描述檔
Variables 變數
New Variable 新增變數
Delete Variable 刪除變數
List Variables 列出變數
Bind Variable 綁定變數
Unbind Variable 解除變數綁定
New Data Set 新增資料集
Delete Data Set 刪除資料集
List Data Sets 列出資料集
Select Data Set 選取資料集
Next Data Set 下一個資料集
Previous Data Set 上一個資料集
Data Sets 資料集
New Variable… 新增變數…
New Data Set… 新增資料集…
Edit Data Set… 編輯資料集…
Data Set 資料集
Kind 類型
Variable 變數
no document 沒有文件
no data set to edit 沒有可編輯的資料集
Make Text Dynamic 使文字動態化
Make Visibility Dynamic 使可見性動態化
Capture Data Set 擷取資料集
Update Data Set 更新資料集
Rename Data Set… 重新命名資料集…
Variable Options… 變數選項…
Select Bound Object 選取繫結的物件
Select All Bound Objects 選取所有繫結的物件
Multiple objects 多個物件
New Name 新名稱
no variable to rename 沒有可重新命名的變數
no data set to rename 沒有可重新命名的資料集
Variation: 變化:
Vector Effects 向量效果
Vector Halftone 向量半色調
@@ -3340,3 +3376,76 @@ axis Both 兩者
Even Fields 偶數圖場
Duplication 複製
Interpolation 內插
RGB: RGB:
CMYK: CMYK:
Texture 紋理
Craquelure… 龜裂紋…
Grain… 顆粒…
Mosaic Tiles… 馬賽克拼貼…
Patchwork… 拼綴圖…
Stained Glass… 彩色玻璃…
Texturizer… 紋理化…
Craquelure 龜裂紋
Grain 顆粒
Mosaic Tiles 馬賽克拼貼
Patchwork 拼綴圖
Stained Glass 彩色玻璃
Texturizer 紋理化
Crack Spacing 裂紋間距
Crack Depth 裂紋深度
Crack Brightness 裂紋亮度
Intensity 強度
Grain Type 顆粒類型
Tile Size 拼貼大小
Grout Width 縫隙寬度
Lighten Grout 加亮縫隙
Square Size 方塊大小
Relief 浮雕
Border Thickness 邊框粗細
Light Intensity 光源強度
Light Direction 光源方向
Sprinkles 噴灑
Clumped 結塊
Contrasty 高反差
Enlarged 放大
Stippled 點刻
Speckle 斑點
Brick 磚塊
Burlap 粗麻布
Canvas 畫布
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft 柔和
Edge 邊緣
System Title Bar 系統標題列
Value Axis 數值軸
On Left Side 左側
On Right Side 右側
On Both Sides 兩側
Short 短
Full Width 全寬
Distort 扭曲
Diffuse Glow… 擴散光暈…
Glass… 玻璃…
Ocean Ripple… 海洋波紋…
Diffuse Glow 擴散光暈
Glass 玻璃
Ocean Ripple 海洋波紋
Graininess 顆粒
Glow Amount 光暈量
Clear Amount 清除量
Ripple Size 波紋大小
Ripple Magnitude 波紋強度
Blocks 塊狀
Frosted 磨砂
Tiny Lens 小鏡頭
Rename Data Set 重新命名資料集
Rename Variable 重新命名變數
Variable Options 變數選項
Edit Data Set 編輯資料集
Define a variable first 請先定義一個變數
Text 文字
Visibility 可見性
Can't render this file because it contains an unexpected character in line 6 and column 47.
+62 -18
View File
@@ -314,8 +314,9 @@ pub struct VectorcraftApp {
pub services: Services,
pub canvas: CanvasCache,
pub perf: Perf,
/// macOS: draw our own title strip under the traffic lights.
pub integrated_titlebar: bool,
/// macOS: the room the app bar keeps at its left for the window buttons over it, in points
/// (0 where the OS draws no buttons over the app bar).
pub titlebar_inset: f32,
/// Last applied effect (Effect → Apply Last Effect).
pub last_effect: Option<(String, serde_json::Value)>,
/// Commands run through [`Self::run`] so far: the native menu bar reads its rows again when it
@@ -352,6 +353,9 @@ pub struct VectorcraftApp {
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
pub custom_titlebar: bool,
/// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`):
/// sent again only when it changes, so idle frames don't spam the backend.
pub(crate) last_window_title: String,
/// The graphics adapter the window renders with ("name (backend)"), as the host reports it:
/// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown.
pub graphics_adapter: Option<String>,
@@ -371,12 +375,6 @@ pub struct VectorcraftApp {
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
/// The window came back to the front: check the active document's linked files next frame
/// ([`dialogs::missing_links::after_focus`]).
pub(crate) links_check: bool,
/// The changed linked files already offered for update (path, size and modification time), so
/// coming back to the window doesn't ask about the same change twice.
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
@@ -446,7 +444,7 @@ impl VectorcraftApp {
cursors: Default::default(),
},
perf: Perf::default(),
integrated_titlebar: false,
titlebar_inset: 0.0,
last_effect: None,
run_count: 0,
clipboard_out: None,
@@ -467,6 +465,7 @@ impl VectorcraftApp {
canvas_rect: None,
hover_doc: None,
custom_titlebar: false,
last_window_title: String::new(),
graphics_adapter: None,
place: Default::default(),
system_paste: false,
@@ -474,8 +473,6 @@ impl VectorcraftApp {
clipboard_probe: None,
picks: picks::Picks::default(),
font_check: None,
links_check: false,
links_asked: Default::default(),
paste_chord: Default::default(),
background: Default::default(),
recovery: Default::default(),
@@ -757,6 +754,54 @@ impl VectorcraftApp {
std::mem::take(&mut self.ime_discard)
}
/// Linked files another app changed while their document is open: every two seconds, and as
/// soon as the window comes back to the front, stamp the active document's linked files on a
/// worker thread and act on the changed ones as Preferences › File Handling › Update Links
/// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an
/// Ask When Modified question never goes unseen.
#[cfg(not(target_arch = "wasm32"))]
fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) {
if let Some(r) = self.session.poll_link_scan() {
match r {
Ok(r) => {
let n = |k: &str| r[k].as_array().map_or(0, Vec::len);
if n("updated") > 0 {
self.status(format!("Updated {} linked file(s) changed on disk", n("updated")));
}
if n("ask") > 0 {
dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default());
}
if n("modified") > 0 {
self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified")));
}
}
Err(e) => self.status(e.to_string()),
}
}
let (last_key, focus_key) = (egui::Id::new("linkWatch.lastScan"), egui::Id::new("linkWatch.focused"));
let focused = ctx.input(|i| i.focused);
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
if self.ui.dialog.is_some() || self.session.active().is_none() {
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
if now - last >= 2.0 || (focused && !was_focused) {
// Walking the document's links costs a pass over it: only when a look is due.
// `start_link_scan` starts none for a document without links, which then doesn't
// wake the app for looks of its own (any frame still looks once one is due).
ctx.data_mut(|d| d.insert_temp(last_key, now));
self.session.start_link_scan();
let links = self.session.link_scan.is_some();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links));
}
if self.session.link_scan.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else if ctx.data(|d| d.get_temp::<bool>(egui::Id::new("linkWatch.links"))).unwrap_or(false) {
ctx.request_repaint_after(std::time::Duration::from_millis(2000));
}
}
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
@@ -889,11 +934,10 @@ impl VectorcraftApp {
self.system_paste = wanted && self.system_clipboard_pasteable();
}
self.poll_font_check(ctx);
if std::mem::take(&mut self.links_check) {
dialogs::missing_links::after_focus(self);
}
picks::poll(self, ctx);
background::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
if !self.background.jobs.is_empty() {
// Keep the status bar's progress moving and pick the result up when it arrives.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
@@ -918,6 +962,9 @@ impl VectorcraftApp {
}
shortcut_editor::sync(&self.ui);
prefs_dialog::apply_runtime(self, ctx);
// The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the
// system title bar this is where the document name lives, as the in-app mark is hidden.
crate::chrome::sync_window_title(self, ctx);
self.drain_control(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
@@ -1025,10 +1072,7 @@ impl VectorcraftApp {
match e {
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
egui::Event::WindowFocused(true) => {
self.refresh_installed_fonts();
self.links_check = true;
}
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
_ => {}
}
}
+79 -37
View File
@@ -13,7 +13,7 @@ use crate::VectorcraftApp;
use crate::io;
use crate::panels::character::Face;
use crate::state::{DockTab, next_zoom};
use crate::theme::{self, Brightness, Tokens};
use crate::theme::{Brightness, Tokens};
use crate::widgets;
#[derive(Clone, Debug)]
@@ -47,6 +47,7 @@ fn sub(label: &'static str, items: Vec<Item>) -> Item {
fn library_placeholders() -> Vec<Item> {
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
}
use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
@@ -1926,8 +1927,10 @@ fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObj
})
}
/// The in-window and native menu tree. `english_names` is Preferences › Type › Show Font Names in
/// English (the Type → Font labels follow it; tests use the default `true`).
/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see
/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`).
/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels
/// follow it; tests use the default `true`).
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
menu_tree_named(true)
}
@@ -1936,24 +1939,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id }));
vec![
(
// The application menu: About and Discord only. The in-window bar never shows it (File
// is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the
// App menu from this entry and the other menus (About from Help, Settings from Edit,
// Quit from File, Language and Appearance synthesized). It stays first: `from_tree`
// gives the first menu the App role.
"VectorCraft",
vec![
c("About VectorCraft", "help.about"),
c("Join Our Discord", "help.discord"),
Sep,
c("Settings…", "edit.preferences"),
sub(
"Language",
std::iter::once(cp("Automatic", "app.language", json!({"lang": "auto"})))
.chain(std::iter::once(Sep))
.chain(crate::i18n::Lang::all().map(|l| cp(l.name(), "app.language", json!({"lang": l.code()}))))
.collect(),
),
Sep,
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
Sep,
c("Quit VectorCraft", "app.quit"),
],
vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")],
),
(
"File",
@@ -2007,6 +1999,8 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
c("File Info…", "file.info"),
Sep,
c("Print…", "file.print"),
Sep,
c("Exit", "app.quit"),
],
),
(
@@ -2555,7 +2549,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
panel("Tabs", "tabs"),
],
),
todo("Variables"),
panel("Variables", crate::panels::variables::ID),
Sep,
sub("Brush Libraries", library_placeholders()),
sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()),
@@ -2754,15 +2748,14 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
}
let mut clicked: Option<(String, Value)> = None;
let tree = menu_tree_named(app.session.prefs.font_names_in_english);
// No application menu in the window (as in Illustrator): the bar starts at File on every
// platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there.
let tree: Vec<(&str, Vec<Item>)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect();
let (end, open) = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 {
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
};
for (title, items) in tree.iter() {
let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text);
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
@@ -3000,6 +2993,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
return;
}
// Variables (data merge): a name and a kind, a name and a row of values.
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::variables::open(app, id, None);
return;
}
// Repeat Options: a dialog with the selected repeat's current values.
if id == "object.repeat.options"
&& p.as_object().is_none_or(|o| o.is_empty())
@@ -3305,8 +3303,14 @@ fn font_items(english_names: bool) -> Vec<Item> {
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
/// vector effects): (submenu, the catalogue's menu path of its effects).
const RASTER_MENUS: [(&str, &[&str]); 4] =
[("Blur", &["Effect", "Blur"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"]), ("Video", &["Effect", "Video"])];
const RASTER_MENUS: [(&str, &[&str]); 6] = [
("Blur", &["Effect", "Blur"]),
("Distort", &["Effect", "Distort"]),
("Pixelate", &["Effect", "Pixelate"]),
("Sharpen", &["Effect", "Sharpen"]),
("Texture", &["Effect", "Texture"]),
("Video", &["Effect", "Video"]),
];
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
fn effect_menu() -> Vec<Item> {
@@ -3519,6 +3523,7 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::theme;
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
/// (rect, id of the title's popup).
@@ -3548,6 +3553,43 @@ mod tests {
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
}
/// File ends with Exit, running the app's own quit flow (unsaved documents are asked about).
#[test]
fn file_ends_with_exit() {
let tree = menu_tree();
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap();
assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}");
}
/// No application menu in the window (as in Illustrator): the bar starts at File.
#[test]
fn the_bar_starts_at_file() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
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(1200.0, 700.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
menu_bar(&mut app, ui);
});
out.textures_delta.clear();
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
for t in titles {
assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}");
}
assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}");
}
#[test]
fn hovering_another_title_switches_the_open_menu() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
@@ -3556,9 +3598,9 @@ mod tests {
theme::apply(&ctx, Default::default());
bar_frame(&mut app, &ctx, vec![]);
let titles = bar_frame(&mut app, &ctx, vec![]);
assert_eq!(titles.len(), menu_tree().len());
// 0 is the app menu, then File, Edit, Object.
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
// No application menu in the window: the bar starts at File on every platform.
assert_eq!(titles.len(), menu_tree().len() - 1);
let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center());
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut t = bar_frame(app, &ctx, events);
for _ in 0..2 {
@@ -3576,17 +3618,17 @@ mod tests {
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
frames(&mut app, vec![button(file, true)]);
let t = frames(&mut app, vec![button(file, false)]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File");
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit");
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
// stays open while the pointer stays still over Object.
egui::Popup::open_id(&ctx, t[1].1);
egui::Popup::open_id(&ctx, t[0].1);
let t = frames(&mut app, vec![]);
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone");
}
#[test]
+52 -7
View File
@@ -7,8 +7,10 @@
//! - an **app menu** named VectorCraft: About; Settings, a submenu with one item per Preferences
//! page (General… ⌘K first); Language; Appearance (the UI brightness); Services; Hide VectorCraft
//! ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All; and Quit ⌘Q, still the app's own
//! `app.quit`, so documents with unsaved changes are asked about. These leave the other menus
//! (Edit › Preferences…, Help › About), and Join Our Discord stays under Help only;
//! `app.quit`, so documents with unsaved changes are asked about. Language and Appearance have
//! no in-window menu (as in PhotoCraft) and are synthesized here; Quit is File › Exit in the
//! window and reads as Quit in the App menu. These leave the other menus (Edit › Preferences…,
//! Help › About), and Join Our Discord stays under Help only;
//! - **Window** gets Minimize ⌃⌘M, Zoom and Bring All to Front, and **Help** the system's search
//! field;
//! - a system key already bound to a command keeps the command's: the system item goes without.
@@ -34,6 +36,7 @@ use serde_json::{Value, json};
use crate::VectorcraftApp;
use crate::i18n::{Lang, tr, tr_ctx};
use crate::menus::{self, Entry};
use crate::theme::Brightness;
pub const APP_NAME: &str = "VectorCraft";
/// Hide VectorCraft: an item of ours (AppKit's always takes ⌘H, which is View › Hide Edges).
@@ -292,7 +295,9 @@ pub struct Layout {
}
/// The Mac menu bar of `bar` (the in-window menus, [`from_tree`]): see the module docs.
pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
/// Language and Appearance have no in-window menu (as in PhotoCraft); when the tree doesn't
/// carry them they are synthesized below, so the App menu keeps them.
pub fn mac_layout(app: &VectorcraftApp, bar: &MenuBar, lang: Lang) -> Layout {
let mut bar = bar.clone();
let mut clashes = Vec::new();
let bound = crate::shortcuts::all_shortcuts();
@@ -316,8 +321,10 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
let about = take_items(&mut bar, "help.about").into_iter().next();
let settings = take_items(&mut bar, "edit.preferences").into_iter().next();
let quit = take_items(&mut bar, "app.quit").into_iter().next();
let language = take_submenu(&mut bar, "app.language");
let appearance = take_submenu(&mut bar, "window.brightness");
// Language and UI Brightness live only in the App menu: taken from the tree when it carries
// them, else synthesized here (the in-window bar has neither).
let language = take_submenu(&mut bar, "app.language").or_else(|| Some(language_node(app, lang)));
let appearance = take_submenu(&mut bar, "window.brightness").or_else(|| Some(appearance_node(app, lang)));
// Its other items (Join Our Discord) stay where the in-window bar also has them: Help.
bar.menus.retain(|m| m.role != MenuRole::App);
@@ -333,7 +340,11 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
}));
app.extend([Node::Separator, Node::Standard(Standard::Services), Node::Separator]);
app.extend([Node::Item(hide), Node::Item(hide_others), Node::Standard(Standard::ShowAll), Node::Separator]);
app.extend(quit.map(Node::Item));
// Quit is File › Exit in the window; in the App menu it reads as a Mac Quit item.
app.extend(quit.map(|mut q| {
q.label = tr(lang, "Quit VectorCraft").into();
Node::Item(q)
}));
bar.menus.insert(0, Menu { title: APP_NAME.into(), role: MenuRole::App, children: app });
if let Some(window) = bar.menus.iter_mut().find(|m| m.role == MenuRole::Window) {
@@ -350,6 +361,40 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
Layout { bar, clashes }
}
/// Language in the app menu, after Settings: Automatic, then every UI language in its own name,
/// with the `interfaceLanguage` preference checked.
fn language_node(app: &VectorcraftApp, lang: Lang) -> Node {
let mut children = vec![Node::Item(native_item(app, "Automatic", "app.language", json!({"lang": "auto"}))), Node::Separator];
children.extend(Lang::all().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()})))));
Node::Submenu { label: tr(lang, "Language").into(), children }
}
/// Appearance in the app menu (the UI-brightness theme, as Mac apps call it).
fn appearance_node(app: &VectorcraftApp, lang: Lang) -> Node {
let children =
Brightness::ALL.iter().map(|b| Node::Item(native_item(app, b.label(), "window.brightness", json!({"brightness": b.id()})))).collect();
Node::Submenu { label: tr_ctx(lang, "theme", "Appearance").into(), children }
}
/// A native item running `command`, built like [`from_tree`] builds it (translated label,
/// shortcut, enabled and checked as they read now).
fn native_item(app: &VectorcraftApp, label: &'static str, command: &'static str, params: Value) -> Item {
let item = menus::Item::Cmd(label, command, params);
match menus::entry(app, &item) {
Some(Entry::Item(row)) => Item {
command: row.command.map(|(id, _)| id),
params: row.command.map_or(Value::Null, |(_, p)| p.clone()),
source: row.source,
label: row.label.into_owned(),
shortcut: row.shortcut,
enabled: row.enabled,
checked: row.checked,
},
// The commands always show; the fallback keeps the structure.
_ => Item { command: Some(command), params: Value::Null, source: label, label: label.into(), shortcut: None, enabled: true, checked: None },
}
}
/// Settings ▸: one item per page of the Preferences dialog (its own list), each opening it there;
/// the first (General…) has Preferences' shortcut (⌘K), as in Illustrator.
fn settings_pages(preferences: &Item, lang: Lang) -> Vec<Node> {
@@ -413,7 +458,7 @@ fn take_submenu(bar: &mut MenuBar, command: &str) -> Option<Node> {
/// VectorCraft's menus as a Mac menu bar, as they show now.
pub fn layout(app: &VectorcraftApp) -> Layout {
mac_layout(&from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
}
// ----------------------------------------------------------------------------- the app's side
+4 -1
View File
@@ -42,6 +42,7 @@ pub mod tabs;
pub mod text_styles;
pub mod transform;
pub mod transparency;
pub mod variables;
use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json};
@@ -136,6 +137,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
"tabs" => tabs::show(app, ui),
flattener_preview::ID => flattener_preview::show(app, ui),
"attributes" => attributes::show(app, ui),
variables::ID => variables::show(app, ui),
"colorThemes" => color_themes::show(app, ui),
links::ID => links::show(app, ui),
asset_export::ID => asset_export::show(app, ui),
@@ -187,6 +189,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
links::ID => links::menu(app, ui),
asset_export::ID => asset_export::menu(app, ui),
css_properties::ID => css_properties::menu(app, ui),
variables::ID => variables::menu(app, ui),
_ => return false,
}
true
@@ -424,7 +427,7 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
/// command feeds); returns the one clicked, for the caller to apply.
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, "Recent Colors");
crate::widgets::subheader(ui, tl!("Recent Colors"));
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let mut chosen = None;
+3 -3
View File
@@ -50,7 +50,7 @@ fn plates_section(app: &mut VectorcraftApp, ui: &mut Ui) {
run(app, "view.separationsPreview", json!({"plates": plates.iter().map(|p| p.name.clone()).collect::<Vec<_>>()}));
}
});
ui.label(egui::RichText::new("CMYK").size(12.0).color(if on { t.text } else { t.text_dim }));
ui.label(egui::RichText::new(tl!("CMYK")).size(12.0).color(if on { t.text } else { t.text_dim }));
});
for p in &plates {
ui.horizontal(|ui| {
@@ -86,13 +86,13 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = cms::active_settings();
widgets::section_header(ui, tl!("Color Settings"));
ui.horizontal(|ui| {
ui.label("RGB:");
ui.label(tl!("RGB:"));
if let Some(n) = profile_dropdown(ui, "cms-rgb", &st.rgb, ProfileKind::Rgb, None) {
run(app, "edit.colorSettings", json!({"rgb": n}));
}
});
ui.horizontal(|ui| {
ui.label("CMYK:");
ui.label(tl!("CMYK:"));
if let Some(n) = profile_dropdown(ui, "cms-cmyk", &st.cmyk, ProfileKind::Cmyk, None) {
run(app, "edit.colorSettings", json!({"cmyk": n}));
}
+468
View File
@@ -0,0 +1,468 @@
//! Variables panel: the document's variables (what they drive, which objects) and its
//! datasets. Clicking a dataset applies it; clicking a variable selects what it binds; the
//! arrows step to the next or previous one. Everything the reference panel does here runs a
//! `variable.*` / `dataset.*` command, so the panel and an agent drive the document the same
//! way — the commands are also in Window › Variables, the palette and over MCP.
//!
//! A row's name goes in italics when an edit has moved the art away from what the row says:
//! the document then no longer matches the active dataset, and "Update Data Set" writes the
//! art back into it.
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind, VariableKind};
use vectorcraft_engine::DocState;
use crate::VectorcraftApp;
use crate::widgets;
/// The panel id, as the dock, the Window menu and `window.panel` name it.
pub const ID: &str = "variables";
/// One variable row: its kind's icon, its name, and the object it drives.
struct VarRow {
name: String,
kind: VariableKind,
bound: Vec<u64>,
/// The name of the object it drives, or "Multiple objects" when it drives more than one.
object: String,
}
/// One dataset row: its name and how many values it carries.
struct SetRow {
name: String,
values: usize,
active: bool,
/// False once an edit has moved the art away from what this row says.
matches: bool,
}
/// The row the panel acts on: Delete, Options… and Select Bound Object all mean it. A name
/// the document no longer defines highlights nothing (deleted from the palette, undone).
fn highlighted_row(st: &DocState) -> Option<String> {
st.variables_highlight.clone().filter(|h| st.doc.variables.variable(h).is_some())
}
/// What a variable drives, as object ids: what Select Bound Object selects.
fn bound_objects(st: &DocState, name: &str) -> Vec<u64> {
st.doc.variables.objects_of(name).iter().map(|i| i.0).collect()
}
/// A row click: highlight the row, and select what it drives (its bindings are left alone).
pub(crate) fn click_row(app: &mut VectorcraftApp, name: &str, bound: &[u64]) {
app.run("variable.highlight", json!({"variable": name})).ok();
if !bound.is_empty() {
app.run("select.set", json!({"ids": bound})).ok();
}
}
/// Select Bound Object: select what the highlighted variable drives; its bindings stay.
pub(crate) fn select_bound_object(app: &mut VectorcraftApp) {
let ids = app.session.active().map(|st| highlighted_row(st).map(|h| bound_objects(st, &h))).unwrap_or_default().unwrap_or_default();
if !ids.is_empty() {
app.run("select.set", json!({"ids": ids})).ok();
}
}
/// What the selection allows the bottom bar's and the panel menu's commands to do.
struct Picked {
selected: Vec<u64>,
has_selection: bool,
/// Unbind needs a bound object under the selection, not just any selection.
selection_bound: bool,
/// Make Text Dynamic needs exactly one type object: a text variable only drives type.
single_type: bool,
}
fn picked(st: &vectorcraft_engine::DocState) -> Picked {
let selected: Vec<u64> = st.selection.objects.iter().map(|i| i.0).collect();
let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id)));
let single_type = matches!(selected.as_slice(), [one] if st.doc.node(NodeId(*one)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))));
Picked { has_selection: !selected.is_empty(), selected, selection_bound, single_type }
}
/// Delete Variable: the highlighted variable only (the command's default); the rest stay.
pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) {
app.run("variable.delete", json!({})).ok();
}
/// Delete Data Set: the current one only (the command's default); the rest of the rows stay.
pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) {
app.run("dataset.delete", json!({})).ok();
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else {
widgets::dim_label(ui, tl!("No document"));
return;
};
// Owned snapshots: the rows below run commands, which borrow the app mutably.
let vars: Vec<VarRow> = st
.doc
.variables
.variables
.iter()
.map(|v| {
let bound: Vec<u64> = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect();
let object = match bound.as_slice() {
[] => String::new(),
[one] => st.doc.node(NodeId(*one)).map(|n| n.display_name()).unwrap_or_default(),
_ => tl!("Multiple objects").to_string(),
};
VarRow { name: v.name.clone(), kind: v.kind, bound, object }
})
.collect();
// What the art holds now, read once for every row's drift check.
let current = st.doc.captured_values();
let sets: Vec<SetRow> = st
.doc
.variables
.datasets
.iter()
.map(|d| SetRow {
name: d.name.clone(),
values: d.values.len(),
active: st.doc.variables.active_dataset.as_deref() == Some(d.name.as_str()),
matches: d.matches(&current),
})
.collect();
let active = sets.iter().find(|s| s.active);
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
let highlighted = highlighted_row(st);
// The current data set across the top: its name, and the arrows that step through them.
ui.horizontal(|ui| {
widgets::field_label(ui, tl!("Data Set"));
let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect();
let current = active.map(|s| s.name.as_str()).unwrap_or("");
let picked = widgets::dropdown_names(ui, "variables-active", current, &names, 160.0);
if let Some(name) = picked.and_then(|i| names.get(i))
&& *name != current
{
app.run("dataset.select", json!({"name": name})).ok();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let has = !sets.is_empty();
if widgets::icon_button_enabled(ui, "chevron-left", tl!("Previous Data Set"), false, has, 24.0).clicked() {
app.run("dataset.prev", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "chevron-right", tl!("Next Data Set"), false, has, 24.0).clicked() {
app.run("dataset.next", json!({})).ok();
}
});
});
widgets::subheader(ui, tl!("Variables"));
if vars.is_empty() {
widgets::dim_label(ui, tl!("None"));
}
for row in &vars {
ui.horizontal(|ui| {
// The kind's own icon, so a text variable and a visibility one read apart.
let icon = match row.kind {
VariableKind::Text => "type",
VariableKind::Visibility => "eye",
};
ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0)));
// The object a variable drives is named beside it, as the reference panel does.
let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) };
let r = ui.selectable_label(highlighted.as_deref() == Some(row.name.as_str()), text);
// A click highlights the row and selects what it drives; a double click opens
// Variable Options for it.
if r.clicked() {
click_row(app, &row.name, &row.bound);
}
if r.double_clicked() {
crate::dialogs::variables::open(app, "variable.rename", Some(&row.name));
}
});
}
widgets::subheader(ui, tl!("Data Sets"));
if sets.is_empty() {
widgets::dim_label(ui, tl!("None"));
}
for row in &sets {
// The active row is ticked and selected; a row the art has drifted from is in
// italics, so an edit that left the document not matching it is visible.
let label = format!("{}{} · {}", if row.active { "● " } else { "" }, row.name, row.values);
let mut text = egui::RichText::new(label);
if !row.matches {
text = text.italics();
}
if ui.selectable_label(row.active, text).clicked() && !row.active {
app.run("dataset.select", json!({"name": row.name})).ok();
}
}
// The bottom bar: make the selection dynamic, and define and delete what the lists show.
ui.separator();
ui.horizontal(|ui| {
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type, 24.0).clicked() {
app.run("variable.makeTextDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() {
app.run("variable.makeVisibilityDynamic", json!({})).ok();
}
if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() {
crate::menus::invoke(app, "variable.define", json!({}));
}
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
app.run("variable.unbind", json!({"ids": selected})).ok();
}
// The bin deletes the highlighted variable only, as the reference panel's does.
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some(), 24.0).clicked() {
delete_highlighted(app);
}
});
ui.horizontal(|ui| {
// Capture records what the art holds now as a new row (paste: take it in); Update
// writes the art back into the row that is active (save: write it out).
if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() {
app.run("dataset.capture", json!({})).ok();
}
let can_update = active.is_some();
if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() {
app.run("dataset.update", json!({})).ok();
}
});
}
/// The panel's (≡) menu: the same commands as its bars and lists, for the ones that act on one
/// row rather than on the selection.
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let vars: Vec<String> = st.doc.variables.variables.iter().map(|v| v.name.clone()).collect();
let sets: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
let active = st.doc.variables.active_dataset.clone();
let bound: Vec<u64> = st.doc.variables.bindings.keys().map(|i| i.0).collect();
let highlighted = highlighted_row(st);
let highlighted_bound: Vec<u64> = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default();
let Picked { selected, has_selection, selection_bound, single_type } = picked(st);
if widgets::menu_item(ui, tl!("New Variable…"), true, false) {
crate::menus::invoke(app, "variable.define", json!({}));
}
// Variable Options renames the highlighted variable, else the first.
if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) {
let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default();
crate::dialogs::variables::open(app, "variable.rename", Some(&name));
}
if widgets::menu_item(ui, tl!("Make Text Dynamic"), single_type, false) {
app.run("variable.makeTextDynamic", json!({})).ok();
}
if widgets::menu_item(ui, tl!("Make Visibility Dynamic"), has_selection, false) {
app.run("variable.makeVisibilityDynamic", json!({})).ok();
}
if widgets::menu_item(ui, tl!("Unbind Variable"), selection_bound, false) {
app.run("variable.unbind", json!({"ids": selected})).ok();
}
if widgets::menu_item(ui, tl!("Delete Variable"), highlighted.is_some(), false) {
delete_highlighted(app);
}
ui.separator();
if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) {
app.run("dataset.capture", json!({})).ok();
}
if widgets::menu_item(ui, tl!("Update Data Set"), active.is_some(), false) {
app.run("dataset.update", json!({})).ok();
}
if widgets::menu_item(ui, tl!("New Data Set…"), true, false) {
crate::menus::invoke(app, "dataset.new", json!({}));
}
if widgets::menu_item(ui, tl!("Edit Data Set…"), active.is_some(), false) {
crate::menus::invoke(app, "dataset.set", json!({}));
}
if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) {
let name = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default();
crate::dialogs::variables::open(app, "dataset.rename", Some(&name));
}
// Only the current data set goes: the rest of the rows stay.
if widgets::menu_item(ui, tl!("Delete Data Set"), active.is_some(), false) {
delete_active_dataset(app);
}
ui.separator();
// What the highlighted variable drives, selected — its bindings are left alone.
if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) {
select_bound_object(app);
}
if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) {
app.run("select.set", json!({"ids": bound})).ok();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app_with_data() -> VectorcraftApp {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let t = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
app.session.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
app.session.execute("variable.bind", &json!({"variable": "Name", "ids": [t]})).unwrap();
app.session.execute("dataset.capture", &json!({})).unwrap();
app
}
#[test]
fn draws_headless() {
let mut app = app_with_data();
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
out.textures_delta.clear();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}");
}
#[test]
fn a_row_the_art_left_behind_is_in_italics() {
let mut app = app_with_data();
let t = app.session.doc().unwrap().doc.variables.objects_of("Name")[0];
// The document no longer matches the row once the art is edited.
app.session.execute("text.editRange", &json!({"id": t.0, "insert": "Bob"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("Data Set 1"), "{text}");
// …and the row's drift is what `dataset.list` reports, so an agent sees it too.
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"][0]["matches"], json!(false));
// Updating writes the art back into the row.
app.run("dataset.update", json!({})).unwrap();
assert_eq!(app.run("dataset.list", json!({})).unwrap()["datasets"][0]["matches"], json!(true));
}
#[test]
fn the_panel_menu_lists_the_row_commands() {
let mut app = app_with_data();
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(&mut app, ui));
out.textures_delta.clear();
let text = crate::tests_labels::painted_text(&mut app, menu);
for item in [
"New Variable…",
"Variable Options…",
"Capture Data Set",
"Update Data Set",
"Rename Data Set…",
"Delete Data Set",
"Select Bound Object",
"Select All Bound Objects",
] {
assert!(text.contains(item), "{item} missing from the panel menu:\n{text}");
}
}
/// What the panel acts on is the highlight, and only while it names a variable: a stale one
/// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled.
#[test]
fn a_highlight_of_a_deleted_variable_highlights_nothing() {
let mut app = app_with_data();
app.session.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
assert_eq!(highlighted_row(app.session.doc().unwrap()).as_deref(), Some("Name"));
assert_eq!(bound_objects(app.session.doc().unwrap(), "Name").len(), 1);
app.session.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
assert!(highlighted_row(app.session.doc().unwrap()).is_none());
}
/// The reviewer's regression case, through the calls the panel and its menu make: highlight
/// a variable, Select Bound Object, then delete one variable and the current dataset. The
/// selection lands on what the row drives with the binding intact, and the others remain.
#[test]
fn selecting_and_deleting_act_on_the_highlighted_row_only() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let a = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
let b = app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Bob"})).unwrap()["id"].as_u64().unwrap();
for (name, id) in [("Name", a), ("Other", b)] {
app.session.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
app.session.execute("variable.bind", &json!({"variable": name, "ids": [id]})).unwrap();
}
app.session.execute("dataset.capture", &json!({})).unwrap();
app.session.execute("dataset.capture", &json!({})).unwrap();
app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap();
// A row click highlights; Select Bound Object selects what it drives, binding intact.
click_row(&mut app, "Name", &[a]);
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
select_bound_object(&mut app);
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
// Delete Variable deletes the highlighted one; Delete Data Set the current one.
delete_highlighted(&mut app);
delete_active_dataset(&mut app);
let vars = app.run("variable.list", json!({})).unwrap();
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
assert_eq!(vars["variables"][0]["name"], "Other");
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
assert_eq!(sets["datasets"][0]["name"], "Data Set 2");
// The panel and its menu still draw with the highlight gone stale.
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("Other") && text.contains("Data Set 2"), "{text}");
let text = crate::tests_labels::painted_text(&mut app, menu);
assert!(text.contains("Select Bound Object"), "{text}");
}
#[test]
fn the_panel_is_registered_with_its_own_icon() {
let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column");
assert_eq!(row.1, "Variables");
assert_ne!(row.2, "dc-list-view", "Document Info's icon, which the panel used to share");
assert!(crate::icons::exists(row.2), "{} is not a bundled icon", row.2);
}
/// The collapsed column draws `ICON_PANEL_GROUPS`, not `ICON_PANELS`: a panel registered
/// but in no group opens from the Window menu and then has nowhere to collapse to. Both
/// lists need an entry, and this is the one that was missing.
#[test]
fn the_panel_reaches_the_collapsed_icon_column() {
let in_column = crate::state::ICON_PANEL_GROUPS.iter().any(|g| g.contains(&ID));
assert!(in_column, "{ID} is in ICON_PANELS but in no ICON_PANEL_GROUPS row, so the collapsed column never draws it");
// Every id in a group names a panel that is registered with a label and an icon.
for group in crate::state::ICON_PANEL_GROUPS {
for id in *group {
let row = crate::state::ICON_PANELS.iter().find(|p| p.0 == *id).unwrap_or_else(|| panic!("{id} is in a group but not registered"));
assert!(!row.1.is_empty() && crate::icons::exists(row.2), "{id}: {} / {}", row.1, row.2);
}
}
}
/// Window › Variables opens the panel, as Window's other panel items do; its commands are in
/// the panel, its menu and the command palette.
#[test]
fn window_variables_opens_the_panel() {
let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
let entries = crate::menus::menu_entries(&app);
let panel = entries
.iter()
.find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(serde_json::Value::as_str) == Some(ID))
.expect("the Variables panel is listed");
assert_eq!((panel.path.clone(), panel.label.as_str()), (vec!["Window".to_string()], "Variables"));
assert!(!entries.iter().any(|e| e.path == ["Window", "Variables"]), "no Variables submenu");
}
/// A menu item that needs a name opens the dialog rather than running the command with
/// empty params, which would only say "missing `name`".
#[test]
fn the_menu_dialogs_open_instead_of_failing() {
let mut app = app_with_data();
for (cmd, field) in [
("variable.define", "kind"),
("variable.rename", "newName"),
("variable.bind", "variable"),
("dataset.new", "name"),
("dataset.rename", "newName"),
("dataset.select", "name"),
] {
app.ui.dialog = None;
crate::menus::invoke(&mut app, cmd, json!({}));
let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{cmd} opened no dialog"));
assert_eq!(d.kind, crate::dialogs::variables::KIND, "{cmd}");
assert_eq!(d.str("__command"), cmd);
assert!(d.fields.contains_key(field), "{cmd}: no `{field}` field");
}
}
}
+39
View File
@@ -261,6 +261,14 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
PrefKind::Bool if sp.key == "animatedZoom" => {
ui.add_enabled_ui(d.bool("gpuPerformance"), |ui| bool_row(ui, d, sp.key, sp.label));
}
// Windows and Linux only: macOS always has the system's title bar, the web has none.
PrefKind::Bool if sp.key == "systemTitleBar" => {
if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
bool_row(ui, d, sp.key, sp.label);
// The window's decorations are chosen once, when the app starts.
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
}
}
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
PrefKind::Num { min, max, unit } => {
labeled(ui, sp.label, |ui| {
@@ -530,6 +538,37 @@ mod tests {
assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance"));
}
/// User Interface › System Title Bar: shown with its next-launch note, applied by OK and
/// saved with the UI state under the key the desktop app reads before the window opens.
#[test]
fn system_title_bar_preference_shows_and_persists() {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut a = app();
open(&mut a, Some("User Interface"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
// The window sizes itself on the first frame and draws on the second.
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}");
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true));
confirm(&mut a).unwrap();
assert!(a.session.prefs.system_title_bar);
let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap();
assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true));
}
/// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it
/// changes, and its checkbox is dimmed unless a unit is Picas.
#[test]
+6
View File
@@ -138,9 +138,14 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
(crate::panels::links::ID, "Links", "link"),
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
(crate::panels::css_properties::ID, "CSS Properties", "globe"),
(crate::panels::variables::ID, "Variables", "chart-column"),
];
/// Groups of icon panels separated by dividers in the collapsed column.
///
/// This, not [`ICON_PANELS`], is what the collapsed column draws: a panel listed there but in
/// no group can be opened from the Window menu and `window.panel`, but never appears as an
/// icon. A panel that belongs in the column needs a row here as well as its entry above.
pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
&["color", "colorGuide"],
&["swatches", "brushes", "symbols", "patternOptions"],
@@ -150,6 +155,7 @@ pub const ICON_PANEL_GROUPS: &[&[&str]] = &[
&["transform", "align", "pathfinder"],
&["character", "paragraph", "glyphs"],
&["history", "actions", "info", "navigator"],
&["variables"],
];
#[derive(Clone, Debug, Serialize, Deserialize)]
+32
View File
@@ -78,6 +78,38 @@ fn effect_menu_lists_the_video_filters_last() {
assert!(at("Sharpen") < at("Video"));
}
/// Effect › Distort, between Blur and Pixelate: each filter opens its dialog.
#[test]
fn effect_menu_lists_the_distort_filters_between_blur_and_pixelate() {
let app = app();
let entries = menus::menu_entries(&app);
let distort: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Distort"]).collect();
let labels: Vec<&str> = distort.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Diffuse Glow…", "Glass…", "Ocean Ripple…"]);
assert!(distort.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
let raster: Vec<&str> =
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
assert!(at("Blur") < at("Distort") && at("Distort") < at("Pixelate"));
// The vector Distort & Transform stays its own submenu.
assert!(raster.contains(&"Distort & Transform"));
}
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
#[test]
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
let app = app();
let entries = menus::menu_entries(&app);
let texture: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Texture"]).collect();
let labels: Vec<&str> = texture.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Craquelure…", "Grain…", "Mosaic Tiles…", "Patchwork…", "Stained Glass…", "Texturizer…"]);
assert!(texture.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
let raster: Vec<&str> =
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
assert!(at("Sharpen") < at("Texture") && at("Texture") < at("Video"));
}
#[test]
fn library_submenus_are_disabled_placeholders() {
let app = app();
+5 -3
View File
@@ -25,7 +25,7 @@ fn app(doc: bool) -> VectorcraftApp {
}
fn bar(app: &VectorcraftApp) -> MenuBar {
native_menu::mac_layout(&native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
native_menu::mac_layout(app, &native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
}
/// A level's nodes as short names: an item's command (`---` a separator, `[label]` a submenu,
@@ -182,8 +182,10 @@ fn every_in_window_item_is_in_the_mac_menu_bar_once() {
assert_eq!(m, want, "{key:?} is listed {m} times on the Mac, {n} in the window");
}
let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE];
// Language and Appearance have no in-window menu (synthesized in the App menu, as in PhotoCraft).
let synthesized = ["app.language", "window.brightness"];
for key in b.keys() {
assert!(a.contains_key(key) || system.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
assert!(a.contains_key(key) || system.contains(&key.0.as_str()) || synthesized.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
}
let help = ids(menu(&mac, "Help"));
assert!(help.contains(&"help.discord".to_string()) && !help.contains(&"help.about".to_string()), "{help:?}");
@@ -211,7 +213,7 @@ fn a_command_keeps_a_system_key_it_is_bound_to() {
let mut app = app(true);
app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into());
crate::shortcut_editor::sync(&app.ui);
let layout = native_menu::mac_layout(&native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
let layout = native_menu::mac_layout(&app, &native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
app.ui.shortcut_overrides.clear();
crate::shortcut_editor::sync(&app.ui);
assert_eq!(find(&layout.bar, native_menu::HIDE).shortcut, None);
+8 -6
View File
@@ -248,7 +248,7 @@ mod tests {
.layers()
.filter(|(l, _)| l.order != Order::Foreground)
.flat_map(|(_, ws)| ws.iter())
.filter(|w| w.sense.senses_click() && w.rect.top() < 44.0 && !skip.contains(&w.id))
.filter(|w| w.sense.senses_click() && w.rect.top() < 32.0 && !skip.contains(&w.id))
.map(|w| w.rect)
.collect()
})
@@ -263,20 +263,22 @@ mod tests {
let out = w.frame(vec![]);
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
assert!(close.height() >= 42.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
assert!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
let widgets = w.bar_widgets();
assert!(widgets.len() >= 12, "home, nine menus, workspace switcher, search: {widgets:?}");
assert!(widgets.len() >= 11, "home, eight menus, workspace switcher, search: {widgets:?}");
for (i, a) in widgets.iter().enumerate() {
assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons at {width}");
for b in &widgets[i + 1..] {
assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} overlaps {b:?} at {width}");
}
}
// Wide windows show the whole group; narrow ones drop Discord and shorten the search.
// Wide windows show the whole group; narrow ones drop Discord, then the search icon.
let texts: Vec<String> = texts(&out).into_iter().map(|(t, _)| t).collect();
let has = |s: &str| texts.iter().any(|t| t == s);
assert_eq!((has("Discord"), has("Search commands and tools")), (width > 1000.0, width > 1000.0), "{width}: {texts:?}");
assert_eq!(has("Discord"), width > 1000.0, "{width}: {texts:?}");
// Search is an icon only (as in PhotoCraft): its label lives in the tooltip.
assert!(!has("Search commands and tools"), "{width}: {texts:?}");
}
}
@@ -314,7 +316,7 @@ mod tests {
fn empty_bar_space_drags_and_double_click_maximizes() {
let mut w = Win::new(1440.0);
w.frame(vec![]);
let empty = pos2(700.0, 22.0);
let empty = pos2(700.0, 16.0);
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
let cmds = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
+5 -5
View File
@@ -598,10 +598,10 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
None
};
ProxyClicks {
fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(),
stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(),
swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
default: def.on_hover_text("Default Fill and Stroke (D)").clicked(),
fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(),
stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(),
swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(),
default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(),
pick,
}
}
@@ -1668,7 +1668,7 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim)));
if let (Some(b), Some([h, s, _])) = (base, base_hsb)
&& let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x)))
{
+4 -2
View File
@@ -208,8 +208,10 @@ Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`)
Window title bar: on Windows and Linux the window has no OS decorations and the app bar is the title bar. Its
caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, 46 pt each; they are window chrome,
not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for
modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web
build are unchanged.
modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User
Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back
instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar
drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged.
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
+8 -5
View File
@@ -57,6 +57,8 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (
- **Automatic on Linux and the BSDs** is the GPU that drives the panel or a monitor, then the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502).
- 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`.
- **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking.
- **If the window's first frame never reaches the screen** within 20 seconds of the app being created (the files named at the start opened), the adapter counts as hanging and the app starts again without it in the same way (#964: High Performance on a hybrid laptop left the app running in the background with no window and nothing in the log). A window minimized at the start isn't counted. When the processor that hung was chosen in Preferences, the status bar points to Automatic.
- **To recover from a Graphics Processor choice that doesn't work** without editing files: start once with `WGPU_POWER_PREF=low` (or `WGPU_ADAPTER_NAME=…`), which overrides the preference for that start while settings still save, and set Graphics Processor back to Automatic. `VECTORCRAFT_NO_PREFS=1` also starts without it, but saves nothing.
- **Environment variables** for when the choice still goes wrong, all read at startup and stronger than the preference:
- `WGPU_POWER_PREF`: `low`, `high` or `none` (the system's order).
- `WGPU_ADAPTER_NAME`: part of an adapter's name, any case (`WGPU_ADAPTER_NAME=nvidia`, `=radv`, `=intel`); the names are in the log.
@@ -86,9 +88,9 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
## macOS: the menu bar
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search box and the workspace switcher next to the traffic lights). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search icon and the workspace switcher next to the traffic lights, centred on the bar by `apps/vectorcraft/src/mac_window.rs`). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. The in-window bar itself never shows the VectorCraft menu (it starts at File, as in Illustrator, on every platform): Language and Appearance have no in-window menu and are synthesized in `mac_layout`, and Quit is File › Exit in the window, relabelled to Quit in the App menu. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus.
- **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type).
- Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`.
@@ -166,7 +168,8 @@ always use the English ids and labels, so agents and scripts never see translate
Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, complete, in the vocabulary used
in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Simplified Chinese (`zh-hans`,
complete, in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve to it,
so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complete, in neutral
so the two scripts never mix), Japanese (`ja`, complete), German (`de`, complete; `de-DE`, `de-AT`, `de-CH` and every
other `de-*` locale resolve to it), Spanish (`es`, complete, in neutral
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), French (`fr`,
complete; `fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Italian (`it`,
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*`
@@ -182,8 +185,8 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, com
- The shared widgets (`widgets::check`, `dropdown`, `menu_item`, the buttons, `label_row`, tooltips of
`icon_button`…), the menus, the dock, the toolbar, the dialog frame and `panels::empty_state` translate
the text they are given, so a panel mostly needs its literals wrapped in `tl!` to be covered by the tests.
- The language is VectorCraft › Language (the `app.language` UI command, `{lang: auto|<code>}`) or Edit ›
Preferences › User Interface › Language; both set the `interfaceLanguage` preference (`auto` or a language
- The language is Edit › Preferences › User Interface › Language or, on macOS, VectorCraft › Language (the
`app.language` UI command, `{lang: auto|<code>}`); both set the `interfaceLanguage` preference (`auto` or a language
code; `auto` follows the system locale: `VECTORCRAFT_LOCALE`, then `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages, the Windows user locale; on the web, `?lang=<code>` in the address, then
the browser's `navigator.languages`). The Preferences dialog previews the chosen language before OK.
+6 -5
View File
@@ -282,7 +282,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |
@@ -521,9 +521,10 @@ CMYK too.
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type,
area type and type on a path), and what can't be is left out with a warning; so is non-native art (the content of a
placed PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data); without editing data
it says it can't be opened.
area type and type on a path), and what can't be is left out with a warning. Non-native art (the content of a placed
PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data) is drawn by the PDF importer,
as a group named "Non-native art" fitted to the object's box; one whose PDF can't be read is left out with a warning.
Without editing data it says it can't be opened.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
@@ -2600,7 +2601,7 @@ without ideographs uses its em box. One undo step.
## New type in a Japanese interface
While the interface is in Japanese (VectorCraft › Language, or `auto` on a Japanese system), new type starts with em
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
box top-to-top leading (`leadingModel: "emBoxTop"`) and em box centre character alignment (`charAlign: "emBoxCenter"`);
in other languages it starts on the Roman baseline (#432). This covers the Type tools,
`text.create` and `text.createInPath`, which also take `leadingModel` and `charAlign` to choose, and `paraStyle.new` /
+2
View File
@@ -75,6 +75,8 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
&["text", "effects"],
&["render", "eps"],
&["metafile", "eps"],
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
&["pdf", "eps"],
&["plugins", "effects"],
];