Compare commits

..
Author SHA1 Message Date
bflatastic c3a21a6ab3 Merge fix/765 into batch 17 2026-10-18 12:36:19 +07:00
bflatastic 976bbb84ef Merge fix/783 into batch 17 2026-10-18 12:36:18 +07:00
bflatastic 78fd30ab5f Merge fix/772 into batch 17 2026-10-18 12:36:18 +07:00
bflatastic 4458eb8802 Merge fix/776 into batch 17 2026-10-18 12:36:17 +07:00
bflatastic d1619f57bd Merge fix/771 into batch 17 2026-10-18 12:36:16 +07:00
bflatastic eac260f160 Merge fix/770 into batch 17 2026-10-18 12:36:15 +07:00
bflatastic a819e0a188 #776: Edit ▸ Delete from the menu bar removes the open interaction too (the guard moves to menus::invoke, which the Delete binding also reaches); has_selected_interaction goes 2026-10-18 12:28:27 +07:00
BFlat 3a9610d480 Merge pull request #785 from storytold/community/batch-16
Community batch 16: canvas label rename, View toggles kept, missing fonts, macOS glyphs (4 PRs) + rename selects the name
2026-10-18 12:19:46 +07:00
bflatastic 104b80cd58 Merge fix/758 into batch 16 (#758's latest head) 2026-10-18 12:06:14 +07:00
bflatastic 55a0712b11 #758: leave the progress docs to the maintainer 2026-10-18 12:06:04 +07:00
bflatastic bec13a4d07 Merge #758's latest head (window only after a file opens; waits for the font scan)
# Conflicts:
#	crates/ui-egui/src/missing_fonts.rs
2026-10-18 11:57:47 +07:00
bflatastic f90becadab #765: search results use Home's own Open files rows and Recent files section (the full list stays known, so pictures and the selection survive a search); search_grid, SearchHit, prepare_search and search_file_card go; the field is widgets::text_input and Escape clears it 2026-10-18 11:52:32 +07:00
bflatastic 6a334fa354 #765: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:37:58 +07:00
bflatastic 2a3ea27605 Merge main into #765 2026-10-18 11:37:55 +07:00
bflatastic d654a14720 #783: put the new French rows in their sorted places 2026-10-18 11:37:21 +07:00
bflatastic d828e6379e #783: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:36:06 +07:00
bflatastic e46dda1036 Merge main into #783 2026-10-18 11:35:53 +07:00
bflatastic 1768be2965 #772: leave the presentation backdrop as the file sets it (owner decision) 2026-10-18 11:35:38 +07:00
bflatastic 0f8e29bc9f #772: drop the progress-doc edits (maintainer adds them) 2026-10-18 11:35:34 +07:00
bflatastic 47c2b228a5 Merge main into #772 2026-10-18 11:35:19 +07:00
bflatastic d712f2ffd1 #776: collapse the nested if in select.none (clippy) 2026-10-18 11:34:43 +07:00
bflatastic 9b489a052e #776: drop the evidence log 2026-10-18 11:34:42 +07:00
bflatastic c8e93ddaaa Merge main into #776 2026-10-18 11:34:21 +07:00
bflatastic 52e3fe2967 Merge main into #771 2026-10-18 11:34:09 +07:00
bflatastic 78222e35d2 Merge main into #770 2026-10-18 11:34:04 +07:00
bflatastic 04e04f3275 Merge fix/rename-selects-text into batch 16
# Conflicts:
#	docs/ui-parity.md
2026-10-18 11:17:02 +07:00
bflatastic 67610bbba4 Merge fix/762 into batch 16 2026-10-18 11:16:44 +07:00
bflatastic 401af64bba Merge fix/758 into batch 16 2026-10-18 11:16:43 +07:00
bflatastic 27fedf0aa8 Merge fix/385 into batch 16 2026-10-18 11:16:42 +07:00
bflatastic abd03d9f51 Merge fix/766 into batch 16 2026-10-18 11:16:41 +07:00
bflatastic bcfaa3daef Merge #385's rebased head (the branch carries the reviewed resolution) 2026-10-18 11:15:20 +07:00
RealtaNuaandClaude Opus 5.5 db43dc5a4a M7: missing fonts window only after a file opens; wait for the font scan (#752)
From review:
- The check ran after any file.open/openRecent/import result, so a
  pasted or dropped image, reopening a file that was already open, or
  Clear recent brought the window back. It now runs only for results
  with a `summary` (a file opened, or pages imported), and never in the
  browser.
- Text > Missing fonts... before the startup scan finished listed
  installed fonts as missing. It now says it is checking the installed
  fonts and lists them once that is done
  (`figcraft_text::installed_fonts_known`, also true when system fonts
  are opted out, where checks used to wait forever).
- ROADMAP progress log and file-format-parity status line.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 11:14:39 +07:00
RealtaNuaandClaude Opus 5.5 cf6bd4eeb2 M7: list the fonts a file uses that aren't installed (#752)
Text in a family that isn't installed was drawn in a fallback font
without a word. Now:

- `text.missingFonts` lists the families a document names that no
  installed or built-in font provides: text layers and their styled
  ranges, text styles, and string variables bound to a font family
  (every mode, through aliases), with the number of text layers.
- The desktop app shows a Missing fonts window after the user opens a
  file that has any: once installed fonts are indexed (before that every
  one of them would count as missing) and after any import report is
  closed. Files named on the command line count. Programmatic opens and
  the browser build never open it by themselves. Text > Missing fonts...
  shows it for the file in front; Escape closes it.
- `figcraft-cli info` prints `missing font` lines (`missingFonts` with
  --json).
- file-format-parity: the row claimed REST import warnings that no code
  produced.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 11:14:11 +07:00
sanches e9be17427f M9: French catalog at 100% of the strings (still a draft)
The 91 UI strings, 12 command labels and 2 menu wordings the strings added since the French catalog was drafted left in English are translated in the catalog's conventions (NBSP before : ! and inside guillemets, one/other plurals, 0 singular): starter templates and the Templates tab, the home screen's recents table with its sorting and dates in the French way, dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width commands. Appended to each section; still requires native review.
2026-10-18 05:51:39 +03:00
Brandon ThomasandClaude Opus 5.5 47438dd4a2 Release 0.7.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:43:28 -04:00
NathanaelandClaude Opus 5.5 84a86e1d84 fix(ui): Rotation gets its own icon, not Corner radius's (#256) (#769)
* fix(ui): Rotation gets its own icon, not Corner radius's (#256)

The Rotation field showed the corner radius icon, so the two fields
looked alike. Rotation now shows an angle: two rays from a vertex with
the arc between them, drawn in code like the rest of the icon set.

Refs #256 ("The icon for rotating objects is the same as for rounding
corners").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(ui): the angle icon is drawn unhinted and centred; its test sits with the icons (#256)

From review: the rays and the arc are both unhinted, so the arc meets
the rays at the 14 px field size; the glyph moves up half a unit to
centre it like the other leads; the test moves from a11y_tests.rs to
the icon tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:35:47 -06:00
NathanaelandClaude Opus 5.5 c846f157dc fix(ui): SVG and PDF export rows offer no size (#256) (#768)
* fix(ui): SVG and PDF export rows offer no size (#256)

The size picker (1x, 2x, 512w…) showed on every export row, but SVG
and PDF are vector and their export ignores it (export_bytes_with). It
now shows for PNG, JPG and WebP only, and the suffix field takes its
width.

Refs #256 ("exporting to SVG is offered in different resolutions,
although this should not be the case").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(ui): export size field as an Option; test WebP too (#256)

From review: the size field's width is an Option instead of a negative
width, and the test checks WebP as well as JPG.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:34:38 -06:00
Yarila 1fe78aec66 M7: respect theme in presenter controls and backdrop 2026-10-18 03:14:53 +05:00
Yarila 1f0d9a9107 M7: show file previews in Home search 2026-10-18 01:49:22 +05:00
Yarila dd980c884f M7: search known files and templates from Home 2026-10-18 01:49:21 +05:00
skylar taylor-barrick 3f5f673ebc Merge pull request #777 from skylartaylor/document-link-schemes
Open only web links and local libraries from a document
2026-10-17 14:34:58 -06:00
skylar taylor-barrick c7acd1a81d chore: clear the rustc 1.96 clippy lints that fail xtask ci
Unrelated to the security changes in this branch; `main` already failed
`clippy --workspace --all-targets -- -D warnings` without them. Eight
clippy::nonminimal_bool sites across seven files, each taking clippy's own
suggested rewrite.

Every rewrite is pure boolean algebra with the negation kept outside, so the
float comparisons and their NaN behaviour are untouched: !A && !B becomes
!(A || B), and !opt.is_some_and(f) becomes opt.is_none_or(!f). Two sites needed
explicit parentheses the suggestion omitted, where the rewritten term is the left
operand of an && and || binds looser.

designcraft cleared the same lint in bc840130; this is figcraft's equivalent.
2026-10-17 13:35:44 -06:00
skylar taylor-barrick f7188de93d Drop a needless borrow in the dev-resource link gate
clippy::needless_borrow; `url` is already a reference. Missed on review because a
workspace clippy run aborts at the first failing crate, which was figcraft-format.
2026-10-17 13:35:44 -06:00
skylar taylor-barrick 37419afe14 Open only web links and local libraries from a document
A document carries things the app acts on for whoever opens it. Three of them were
taken as given.

Prototype Open link and Dev Mode dev resources both reached the platform's opener
with any scheme. In the web build the opener is window.open, so a saved document
could run script in the app's own origin; on the desktop any scheme went to the
system's handler. Both now pass through the new engine module `doc_urls`, which
allows http, https and mailto and nothing else. It is an allow-list on purpose: a
list of known-bad schemes is outgrown by every handler a platform adds.

A figcraft:// invite link joined the session at once. The system hands the app
whatever such link was followed, which may come from a page or a message, so the
Join dialog now opens with the link filled in and the person confirms.

A library link stored in a document was loaded from wherever it pointed. Only a
path on this computer is followed now, so opening someone else's file cannot make
the app contact their server (which on Windows would also send credentials).

Tests: each change has one that fails without it, verified by reverting. The
prototype test fires through an afterDelay trigger, since no click is needed, and
keeps a positive case so a refusal cannot pass vacuously.
2026-10-17 13:19:13 -06:00
BFlat d574eaac1d Merge pull request #775 from storytold/community/batch-15
Batch 15: .fig distributed alignment (#552), REST grid tracks, macOS CJK fonts, pen Esc
2026-10-18 02:18:23 +07:00
bflatastic 154baa4467 M7: a rename selects the whole name, so typing replaces it 2026-10-18 01:49:02 +07:00
Natnael-Dev 1e7983a550 docs(evidence): record verification evidence for #767 fix 2026-10-17 21:47:40 +03:00
Natnael-Dev bc5a2b8fa7 fix(proto): delete prototype interaction without deleting component (#767)
When an interaction noodle or interaction editor was selected in the
Prototype tab, pressing Delete or Backspace (or executing edit.delete)
targeted the selected layer/component instead of deleting the prototype
connection.

- Intercept Backspace and Delete in shortcuts::key_event and run_binding
  when an interaction is selected in the Prototype tab to remove the
  interaction using proto.interaction.remove rather than deleting the layer.
- Ensure clicking on empty canvas or outside noodles/handles deselects the
  active interaction noodle/editor.
- Close the interaction editor when navigating away from the Prototype tab.
- Add regression tests in proto_editor_tests verifying interaction deletion
  with Delete/Backspace keeps the component intact, and Escape closes the
  interaction editor before deselecting the component.
2026-10-17 21:45:51 +03:00
bflatastic e25c48cd56 Merge main into batch 15
# Conflicts:
#	docs/ui-parity.md
2026-10-18 01:42:03 +07:00
BFlat 42242e3c8b M1: IME composition in text layers (#747) (#755)
- Text editing on the canvas now tells the platform it wants the IME on **every** frame of the
  edit. Before, it only did so on frames that brought input events.
- The candidate window opens at the caret. Before, it opened at the canvas corner: the desktop
  integration places it by `IMEOutput.rect`, which was the whole canvas.
- Keys that reach the editor while a composition is in progress belong to the IME. Enter, Escape,
  Backspace, arrows and shortcuts don't act on the text then, so Enter picking a candidate doesn't
  add a newline.
- New test `crates/ui-egui/src/ime_tests.rs`.
- Docs: the IME rows in `docs/ui-parity.md` and `ROADMAP.md` now say it hasn't been checked on a Mac.

Fixes #747.
2026-10-18 01:41:15 +07:00
NetterSilverandClaude Haiku 5.5 89c5d3dfe6 Keep View toggles such as Rulers in the preferences
Fixes #379

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 18:25:28 +00:00
bflatastic 7fd936c8ba #762: one system fallback helper for scripts and shortcut glyphs, a face added once; the glyph test skips when this machine's fonts lack some 2026-10-18 00:36:36 +07:00
bflatastic cbf6d2df41 #762: drop the progress-doc edit (the maintainer adds those) 2026-10-18 00:33:32 +07:00
bflatastic cc12b8a3ec Merge main into #762 2026-10-18 00:33:13 +07:00
bflatastic 7ee2772da8 #758: ask whether the installed fonts are indexed only while a list waits for them 2026-10-18 00:22:21 +07:00
bflatastic 694c3114e8 #758: drop the progress-doc edits (the maintainer adds those) 2026-10-18 00:22:13 +07:00
bflatastic a7352c1860 Merge main into #758 2026-10-18 00:22:03 +07:00
bflatastic 913634bec8 #385: outline modes and soft-proof stay with their file; one toggle table for the menu's check marks 2026-10-18 00:13:49 +07:00
bflatastic 4a038a5f63 Merge main into #385
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-18 00:10:16 +07:00
RealtaNuaandClaude Opus 5.5 7b9ebaacbb fix(ui): fitting Intensity translations; cut values end in "…", cut labels show on hover (#256)
From review:
- ru and pt-BR "Intensity" were too long for the glass field and ended
  in "…"; they are now "Яркость" and "Brilho", which fit.
- A value too long for its field ends in "…" instead of being clipped,
  so "100000" never shows as "10".
- A label cut short shows whole on hover.
- The every-language test now checks the Intensity label fits whole in
  each catalog (it fails with the old ru translation); the panel test
  checks the label isn't cut and that the field is the 98.5 points the
  language test measures against.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:50:52 +07:00
bflatastic 76f0edce08 #766: port the engine test, a section and click-elsewhere UI case, and the control-protocol note 2026-10-17 23:44:41 +07:00
bflatastic a7da760f9c #766: drop the progress-doc edits (the maintainer adds those) 2026-10-17 23:44:41 +07:00
bflatastic f33ffb4367 #766: keep the label rename in layers::Rename like the layers panel 2026-10-17 23:43:58 +07:00
bflatastic e03c2a65f7 #766: check a label before a widget's clickable part on a double-click 2026-10-17 23:43:32 +07:00
bflatastic 43bc7a3032 Merge main into #766 2026-10-17 23:42:55 +07:00
bflatastic dc184083ef Merge fix/pen-esc-same-layer into batch 15 2026-10-17 23:31:00 +07:00
RealtaNuaandClaude Opus 5.5 1c16aeae09 fix(canvas): the Pencil previews its stroke in its own colour and weight (#256)
While drawing, the Pencil previewed its stroke in the theme's text
colour, 2 points wide: white in dark theme, so over a white shape
nothing showed until the mouse was let go. The preview now uses the
colour and weight the Pencil draws with (one constant, PENCIL_STROKE,
black at 2 pt), scaled by the zoom, at least 1 pt. It doesn't show pen
pressure or a brush, which only the finished vector has.

Refs #256 ("The 'draw' tool is not visible when you are drawing. You
can see it after you release the mouse.").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:25:51 +07:00
RealtaNuaandClaude Opus 5.5 a672a6650f fix(ui): glass settings: label the light intensity, keep labels in their fields (#256)
The glass effect's light intensity field had no label, and "Refraction
80%" and "Dispersion 50%" ran into the edge of their fields. The field
is labelled "Intensity" in every catalog. Text labels of number fields
take their measured width instead of 7 points per character, and a
label too long for its field ends in "…" before the value, which is
clipped to its own area. This changes every text-labelled number field:
Latin labels sit a little tighter, CJK labels get their real width.

Refs #256 ("There is one parameter that is not signed in the glass
effect editing options. The inscriptions also go beyond their
borders.").

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:23:25 +07:00
bflatastic 9265d554af Merge fix/macos-cjk-fonts into batch 15 2026-10-17 23:17:38 +07:00
bflatastic c8b611a802 Merge fix/rest-grid-tracks into batch 15 2026-10-17 23:17:37 +07:00
bflatastic 1d928a189c Merge fix/552 into batch 15 2026-10-17 23:17:37 +07:00
bflatastic 03bb164b5c M4: Esc after a pen path stays on the same vector layer (#760)
Escape or Enter while the pen draws ends the path and keeps its vector in
edit mode with the pen, so the next path joins the same layer; another
Escape leaves edit mode. Entering edit mode on a layer that already is a
vector no longer records an empty undo step.
2026-10-17 23:05:03 +07:00
bflatastic 91c80a19cc M7: find macOS's downloadable CJK system fonts (#406)
Current macOS keeps PingFang and other on-demand fonts in its asset
store, which the font scan never walked, so Chinese text showed as
boxes. The scan now also walks the com_apple_MobileAsset_Font* folders
of /System/Library/AssetsV2 (and the older /System/Library/Assets) with
the same depth, size and file checks, and Hiragino Sans GB and Heiti SC
(preinstalled) join the Chinese UI faces and the text fallbacks.
Tested with a folder laid out the same way; not run on a Mac.
2026-10-17 22:53:54 +07:00
bflatastic 68b19422ad M1: REST import reads grid track sizes (#465)
gridColumnsSizing and gridRowsSizing are CSS grid-template strings: px
tracks are fixed, fr tracks fill, auto/min-content/max-content hug,
minmax(_, Nfr) fills and repeat(n, ...) expands; other tracks stay equal
(1fr). Tracks past the axis's own count are left out. Files from .fig
keep reading the tracks from their source.
2026-10-17 22:44:09 +07:00
bflatastic 9a69f79861 #552: write and read space evenly as SPACE_EVENLY_CSS
The format's SPACE_EVENLY is its older name for equal gaps without edge
space, so files keep reading it as space between; true space evenly is
SPACE_EVENLY_CSS, which the export now writes.
2026-10-17 22:39:10 +07:00
BFlat 0a01188c31 M7: translate the .fig import notes (#761)
- The import report translates an importer note when the catalog has it, but 30 notes added by the
  `.fig` import series (#626–#639) had no catalog entries, so they showed in English: connector
  labels and snapshots, grid track sizing, image filters, slots and their content, vector segment
  styles, and text on a path.
- All 30 are now in `en.toml` and in every full catalog: es, de, fr, pt-BR, ru, ja, ko, zh-Hans, cs,
  id, sv and tr (30 each). `es-419` overrides only the 3 notes about settings (configuración rather
  than Spain's ajustes), as the overlay does elsewhere. The wording follows each catalog's existing
  terms for connector, layer, frame, stroke, grid, slot and text on path. The translations are drafts,
  like the catalogs, and wait for native speakers' review.
- `every_key_in_the_code_is_in_the_english_catalog_and_no_other` now counts each importer note (the
  `warn("…")` literals in `crates/format/src/fig` and `crates/format/src/rest`) as a required key, not
  only as an allowed one. A new note that isn't in `en.toml` fails the check, and because Spanish is
  in `COMPLETE` it has to be translated there too, so the report can't fall behind again.
  `docs/translating.md` says so.
- The importers' texts are unchanged. Notes built with `format!` (layer counts, type names, field
  names) still show in English, since the report matches whole notes.
2026-10-17 22:38:10 +07:00
bflatastic 6afcfc2641 Merge main into #552 2026-10-17 22:36:20 +07:00
RealtaNua cae69a7ae8 Merge remote-tracking branch 'upstream/main' into feat/752-report-missing-fonts
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
2026-10-17 22:21:25 +07:00
Filip Strajnar 6ce46b3532 M1: double-click a frame's name on the canvas to rename it (#757)
A frame's name above its top edge (or a section's pill) only selected and
dragged it; a double-click there did nothing, since the label lies outside
the frame and hits no layer. With the Move or Scale tool, a double-click on
a label now selects that frame and asks the UI (`canvas.renameLabel`) for a
field over the label with the name selected, as the layers panel and pages
list do. Enter or a click elsewhere renames it (`object.rename`, one undo
step) and Escape leaves the name as it was. Rotated labels work too.

Tests drive the canvas: a double-click on a label opens the field, typing
and Enter rename the frame without moving it, undo restores the name, and
Escape cancels on a rotated frame.
2026-10-17 17:09:51 +02:00
RealtaNuaandClaude Opus 5.5 a3c5025186 M7: missing fonts window only after a file opens; wait for the font scan (#752)
From review:
- The check ran after any file.open/openRecent/import result, so a
  pasted or dropped image, reopening a file that was already open, or
  Clear recent brought the window back. It now runs only for results
  with a `summary` (a file opened, or pages imported), and never in the
  browser.
- Text > Missing fonts... before the startup scan finished listed
  installed fonts as missing. It now says it is checking the installed
  fonts and lists them once that is done
  (`figcraft_text::installed_fonts_known`, also true when system fonts
  are opted out, where checks used to wait forever).
- ROADMAP progress log and file-format-parity status line.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 22:06:03 +07:00
JimKnoxxandClaude Opus 5.5 b84ee179d4 M8: draw the macOS shortcut glyphs the UI font lacks
Menus and tooltips showed boxes for ⌃ ⌥ ↵ ⌫ ⇥ ⇞ ⇟ (e.g. Present ⌥⌘↵):
egui's default face has ⌘ and ⇧ but not these. The desktop app now adds
a system face that has them after the UI fonts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:05:49 +02:00
BFlat c6d6433473 Docs: parity rows for the 2026-10-17 landings (#756)
Brings the progress docs up to date with what landed on 2026-10-17: community batches #727, #728, #733, #735, #736 and #753, plus #729, #745, #746 and #751. Docs only.
2026-10-17 21:56:26 +07:00
RealtaNuaandClaude Opus 5.5 825cb1e7af M7: list the fonts a file uses that aren't installed (#752)
Text in a family that isn't installed was drawn in a fallback font
without a word. Now:

- `text.missingFonts` lists the families a document names that no
  installed or built-in font provides: text layers and their styled
  ranges, text styles, and string variables bound to a font family
  (every mode, through aliases), with the number of text layers.
- The desktop app shows a Missing fonts window after the user opens a
  file that has any: once installed fonts are indexed (before that every
  one of them would count as missing) and after any import report is
  closed. Files named on the command line count. Programmatic opens and
  the browser build never open it by themselves. Text > Missing fonts...
  shows it for the file in front; Escape closes it.
- `figcraft-cli info` prints `missing font` lines (`missingFonts` with
  --json).
- file-format-parity: the row claimed REST import warnings that no code
  produced.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 21:49:32 +07:00
BFlat bb8b809d46 Merge pull request #753 from storytold/community/batch-14
Community batch 14: selection colors, spacing, presenter tab, Wayland controls and more (10 PRs)
2026-10-17 21:32:13 +07:00
bflatastic 5962b630a1 Merge fix/all-f6 into batch 14 2026-10-17 21:04:46 +07:00
bflatastic df66ddd3a4 Merge fix/750
# Conflicts:
#	crates/ui-egui/src/paste_image_tests.rs
2026-10-17 20:48:47 +07:00
bflatastic e39d24a32e Merge fix/749 2026-10-17 20:48:35 +07:00
bflatastic c532053396 #749: clear the shortcuts panel after the home screen draws, so it never shows for a frame 2026-10-17 20:43:37 +07:00
BFlat b8f97a5f4f M8: the control channel answers while the window is minimized (#720) (#751)
- A minimised or covered window now answers every control request that acts on the app's state
  (`engine.execute`, `document.inspect`, `node.get`, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`,
  `ui.set`, `ui.menu.invoke`, `ui.render`, `window.list`, `window.focus`, `app.quit`), in that window's
  file, from the app's `logic` (no frame drawn). Before, only six methods were answered there.
- What only a drawn frame can answer (`ui.screenshot`, `ui.input`, `window.new`) is an error at once
  for a hidden window: `window 0 is minimised or hidden: `ui.screenshot` needs it on screen
  (`ui.render` draws the document without it)`. Before, these waited up to 60 s.
- `crates/ui-egui/src/control.rs`: the `NO_FRAME` allow-list becomes a short `NEEDS_FRAME` list;
  `drain_hidden` answers the rest and fails the frame-only ones at once.
- Docs: `docs/control-protocol.md` (hidden windows, `ui.screenshot`) and `docs/mcp.md` (attached
  mode while the window is minimised). ROADMAP progress log and revision history.

Part of #720.
2026-10-17 20:29:32 +07:00
bflatastic 2c350ca7fd Merge fix/592 2026-10-17 20:23:43 +07:00
bflatastic 8b3beac6bf Merge fix/537 2026-10-17 20:23:42 +07:00
bflatastic ba433cc7b0 Merge fix/595
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/lib.rs
2026-10-17 20:23:42 +07:00
bflatastic 84ffdfc786 Merge fix/575 2026-10-17 20:23:25 +07:00
bflatastic 137425ab25 Merge fix/369 2026-10-17 20:23:25 +07:00
bflatastic 468bc5734d Merge fix/359 2026-10-17 20:23:25 +07:00
bflatastic 8e52c8cac2 Merge fix/347 2026-10-17 20:23:24 +07:00
bflatastic 8495309ea2 Merge fix/603 2026-10-17 20:23:24 +07:00
bflatastic 420ef78842 #592: drop the README section (the module docs in linux_window.rs cover it) 2026-10-17 20:23:06 +07:00
bflatastic d254d5a6ca Merge main into #592
# Conflicts:
#	ROADMAP.md
2026-10-17 20:23:05 +07:00
bflatastic acfc0352c3 #537: frames scroll on phones only as their overflow allows; tablets and laptops keep their device body 2026-10-17 20:22:44 +07:00
bflatastic 725e2c6913 #537: presenter strings in every catalog 2026-10-17 20:17:14 +07:00
bflatastic e77a02993e #537: remove the two screenshots nothing references 2026-10-17 20:17:04 +07:00
bflatastic 88925c2f82 #537: drop the progress-doc edits 2026-10-17 20:17:02 +07:00
bflatastic 89174280d1 Merge main into #537 2026-10-17 20:16:50 +07:00
Ivan Shishkanov cc10c0de55 M7: use native macOS paste modifiers in tests 2026-10-17 16:13:47 +03:00
bflatastic 6b52bddc5b #595: arrange.spacing label in the Swedish catalog 2026-10-17 20:10:33 +07:00
bflatastic 8aa5fd1a3b #595: drop the progress-doc edits 2026-10-17 20:10:32 +07:00
bflatastic f84616702d Merge main into #595
# Conflicts:
#	ROADMAP.md
2026-10-17 20:10:26 +07:00
bflatastic af27ff7d2a #575: Selection colors strings and command labels in every catalog 2026-10-17 20:03:54 +07:00
bflatastic 17758458ef #575: drop the progress-doc edit 2026-10-17 20:03:45 +07:00
bflatastic 2efdeb593b Merge main into #575
# Conflicts:
#	ROADMAP.md
2026-10-17 20:03:35 +07:00
MarekSpodBiedry 96091494a5 M8: Keyboard shortcuts panel closes on the home screen (#548) 2026-10-17 15:03:28 +02:00
BFlat ac4b98dbd7 M8: confine MCP file access with --automation-read-root/--automation-write-root (#715) (#746)
- `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, `--flag=DIR` works too). When either is given, every file an agent's tool call reads must lie inside a read root or a write root, and every file it writes inside a write root. Anything else is an ordinary tool error (`automation path rejected: … is outside the folders that may be written: …`); nothing is written and the server keeps serving. Without the flags nothing changes.
- The desktop app takes the same flags (`figcraft --control PORT --automation-read-root DIR …`) and confines every control request and every frame of every window to them, so `figcraft-cli mcp --attach` drives a confined app. `--attach` together with the flags is a usage error, because the app runs the commands and only the app can confine them.
- New engine module `crates/engine/src/file_access.rs`: `Roots` (resolution and comparison), a thread-scoped `confined(roots, f)`, and the file primitives (`read`, `read_capped`, `open`, `metadata`, `read_dir`, `write` (atomic), `create_dir_all`, `exists`, `is_dir`). Every engine file access now goes through it. `crates/engine/clippy.toml` denies `std::fs::*`, `File::open/create`, `OpenOptions::open`, `Path::exists/is_file/is_dir/metadata/read_dir/canonicalize` and `figcraft_format::{write_file_atomic, save_file, load_file}` everywhere else in the engine, so a command added later is confined without a check of its own.
- `ui.screenshot` and `ui.render` return the PNG as base64 `data` when no `path` is given (before, `ui.screenshot` wrote `figcraft-screenshot.png` in the working directory and `ui.render` required a path). The attached MCP backend uses that, so it no longer writes and deletes temporary PNGs, and a confined app needs no exemption for them.
- `initialize`'s `instructions` name the folders the agent may read and write.
- `figcraft-cli` options may be given more than once (`Args::values`); single-valued options keep the last value, as before.
- Docs: `docs/mcp.md` › Confining file access (options, semantics, coverage, resolution, limits), `docs/control-protocol.md` › Confining file access and the optional `path` of `ui.screenshot` / `ui.render`, `docs/cli.md`, `figcraft-cli --help`, and the Automation line plus a revision row in `docs/target-app-parity.md`.

Fixes #715, fixes #481.
2026-10-17 19:56:20 +07:00
bflatastic ab1ae981f3 #359: drop the progress-doc edit 2026-10-17 19:51:27 +07:00
bflatastic 9417d13b26 Merge main into #369 2026-10-17 19:51:19 +07:00
bflatastic c542af4efe Merge main into #359
# Conflicts:
#	ROADMAP.md
2026-10-17 19:51:17 +07:00
bflatastic 23fbd8f935 Merge main into #347 2026-10-17 19:50:54 +07:00
bflatastic adde034a7c Merge main into #603 2026-10-17 19:40:01 +07:00
bflatastic 64423c58bc M3: .fig and REST imports keep Hug on auto layout and text (#732)
An auto layout frame a .fig records no primary-axis sizing for hugs on
that axis (auto layout sized it by its content before that axis could be
fixed) instead of turning fixed; an instance that records no auto layout
takes its main's; REST frames whose sizing modes are left out hug (the
documented default); text hugs by its resize mode (auto width both axes,
auto height its height) instead of coming in fixed.
2026-10-17 19:27:35 +07:00
bflatastic 6cdfe2ab8e M3: layers inside instances keep their resizing overrides (#731)
A layer inside an instance can now override its resizing (hug, fill or
fixed, with min and max sizes; override group `resizing`) and its size.
They were refused, and imported ones were dropped at the first relayout.
Kept through rebuilds, undo, save, .fig and REST import, .fig export and
detach; reset per group and with Reset all changes. Position, constraints
and absolute or grid placement stay the main's.
2026-10-17 19:27:35 +07:00
Yarila 2e3949f0ba Merge branch 'main' into feat/figcraft-canvas-fonts-presenter 2026-10-17 17:24:35 +05:00
BFlat fdd4eb5cad Docs: roadmap rows for the community fixes from #524 up (#727, #728, #733, #735, #736, #729) (#744)
Adds the progress rows for the community PRs landed today: batches #727, #728, #733, #735 and #736, plus #729. That's 55 PRs, and the batch-13 boolean-op crash guard.

- `ROADMAP.md` and `docs/roadmap.md`: the header's "Last updated/Change" line and one revision-history row each.
- Headline numbers are unchanged. The per-area rows for the `.fig` import and component overrides come with the #731/#732 PR.
2026-10-17 19:16:10 +07:00
BFlat 18b8bc11e9 Merge pull request #729 from Jorrevdb/submenu-aim
UI: submenu hover fixes (stale state, safe triangle, hover delay)
2026-10-17 19:05:02 +07:00
Yarila b64ec66244 M7: reconcile presenter with current main 2026-10-17 16:54:16 +05:00
bflatastic 82ae9b1f59 Merge #729's latest head (its merge of main) 2026-10-17 18:53:53 +07:00
Ivan Shishkanov 1336d449d3 M2: merge current main into PR #595
Preserve feature and upstream progress/catalog updates. Use native Command modifiers in macOS paste tests; Command+Control is an unbound chord.
2026-10-17 14:46:29 +03:00
Ivan Shishkanov 98e47487f4 M3: merge current main into PR #575
Preserve feature and upstream progress/catalog updates. Use native Command modifiers in macOS paste tests; Command+Control is an unbound chord.
2026-10-17 14:41:08 +03:00
Jorrevdb 28a23a9d1d Merge branch 'main' into submenu-aim 2026-10-17 13:35:34 +02:00
bflatastic 2ebc496d07 Merge main into #729
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-17 18:32:36 +07:00
BFlat 6a76d4fa85 Merge pull request #736 from storytold/community/batch-13
Community batch 13: colour picker, text strokes and selection, pages divider, variant reorder (8 PRs)
2026-10-17 18:30:04 +07:00
bflatastic c448b85779 Merge fix/621 into batch 13 (sweep panic guard) 2026-10-17 18:11:28 +07:00
bflatastic 41abce53b7 #621: a panic inside the boolean-op sweep is bad input, not a crash (text strokes clip to glyph outlines) 2026-10-17 18:11:27 +07:00
BFlat 05ecbe8d49 Merge pull request #735 from storytold/community/batch-12
Community batch 12: engine checks, server limits, web join links, menus and more (13 PRs)
2026-10-17 17:52:50 +07:00
bflatastic bacca0a0f0 Merge fix/652 into batch 13 2026-10-17 17:19:56 +07:00
bflatastic e8fc6d410e Merge fix/672 into batch 13 2026-10-17 17:19:55 +07:00
bflatastic 05d4f1903b Merge fix/679 into batch 13 2026-10-17 17:19:54 +07:00
bflatastic feeb9f71db Merge fix/671 into batch 13 2026-10-17 17:19:52 +07:00
bflatastic 7482410753 Merge fix/667 into batch 13 2026-10-17 17:19:51 +07:00
bflatastic 3365ae0370 Merge fix/668 into batch 13 2026-10-17 17:19:49 +07:00
bflatastic 0fe7e1e7db Merge fix/621 into batch 13 2026-10-17 17:19:48 +07:00
bflatastic 450faf7269 Merge fix/624 into batch 13
# Conflicts:
#	contributors/people.toml
2026-10-17 17:19:38 +07:00
bflatastic b67b71732f Merge fix/680 into batch 12 (#680's rebased head; batch already has its content) 2026-10-17 17:17:55 +07:00
bflatastic cbbe735db0 Merge #680's rebased head (the batch carries the reviewed resolution) 2026-10-17 17:17:54 +07:00
bflatastic 00b0b08680 #668: a short pages list drags from the rows it shows, not the room it was given 2026-10-17 17:02:34 +07:00
bflatastic 99d097636e Merge fix/all-f5 into batch 12 2026-10-17 16:59:57 +07:00
BFlat e17f58ba85 Merge pull request #733 from storytold/community/batch-11
Community batch 11: editor fixes and the Actions palette (9 PRs)
2026-10-17 16:54:01 +07:00
bflatastic e912a2efe8 #655: fill.set / stroke.set read index with params::opt_index (#675) 2026-10-17 16:38:35 +07:00
bflatastic 1ede34074e Merge fix/633 2026-10-17 16:37:44 +07:00
bflatastic e86cae4fe0 Merge fix/641 2026-10-17 16:37:43 +07:00
bflatastic 59d3d7e0f8 Merge fix/616 2026-10-17 16:37:41 +07:00
bflatastic 2945b3042c Merge fix/599 2026-10-17 16:37:40 +07:00
bflatastic 98fcf2472b Merge fix/699 2026-10-17 16:37:38 +07:00
bflatastic 6f2fb6cce8 Merge fix/712 2026-10-17 16:37:37 +07:00
bflatastic 74c2088292 Merge fix/705 2026-10-17 16:37:36 +07:00
bflatastic 432a3e4016 Merge fix/687 2026-10-17 16:37:34 +07:00
bflatastic c7cba0d626 Merge fix/670 2026-10-17 16:37:33 +07:00
bflatastic b96e007a2c Merge fix/675 2026-10-17 16:37:32 +07:00
bflatastic a2e9b5ade2 Merge fix/673 2026-10-17 16:37:28 +07:00
bflatastic 6f8c2a657e Merge fix/680 2026-10-17 16:37:27 +07:00
bflatastic e2d9e7b493 Merge fix/625 2026-10-17 16:37:25 +07:00
bflatastic 3b66750a5a Merge fix/655 2026-10-17 16:37:23 +07:00
bflatastic 0232031bbe Merge main into #633 2026-10-17 16:37:09 +07:00
bflatastic 734f8efedd Merge main into #641 2026-10-17 16:37:02 +07:00
bflatastic 8a66e7bdc5 Merge main into #616 2026-10-17 16:36:58 +07:00
bflatastic fec54d9361 #599: leave the progress docs to the maintainer 2026-10-17 16:36:54 +07:00
bflatastic f5112a3d52 Merge main into #599 2026-10-17 16:36:40 +07:00
NetterSilverandClaude Haiku 5.5 234339a786 Refuse pages in edit.copy and edit.cut
Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 11:36:32 +02:00
bflatastic 7f7921260c #699: menu input dialog and language picker use the shared widgets; File > Rename edits the file name in place 2026-10-17 16:36:24 +07:00
bflatastic b6d98e28dc #699: keep en.toml's end of file as on main 2026-10-17 16:36:24 +07:00
bflatastic fba23e9ddb Merge main into #699 2026-10-17 16:36:20 +07:00
bflatastic 02b231db6d #712: read the invite link from the URL fragment (#join=), never sent to the web host; ignore overlong links 2026-10-17 16:18:50 +07:00
bflatastic e8260b1b7a Merge main into #712 2026-10-17 16:12:05 +07:00
bflatastic 3c73045088 #705: cap what a client buffers from a server at 1 GiB; warn the operator about files over 64 MiB when open and over what the app opens 2026-10-17 16:11:59 +07:00
bflatastic 7da7ed226e Merge main into #705 2026-10-17 16:08:08 +07:00
bflatastic e095ef1fe1 #652: note that a file stamped in the future is re-read until the clock passes it 2026-10-17 16:08:01 +07:00
bflatastic 7d67d13b62 Merge main into #687 2026-10-17 16:06:44 +07:00
bflatastic 93fc6fd082 Merge main into #670 2026-10-17 16:06:30 +07:00
bflatastic 1503f0743c #675: cap the offending value echoed in id/ids errors 2026-10-17 16:06:26 +07:00
bflatastic b377fab0a5 Merge main into #652 2026-10-17 16:05:22 +07:00
bflatastic 7bf814f6b4 Merge main into #675 2026-10-17 16:03:45 +07:00
bflatastic 6f0e14a271 #673: read the failed step with get() instead of indexing 2026-10-17 16:03:41 +07:00
bflatastic 990e621ec4 Merge main into #672 2026-10-17 16:02:41 +07:00
bflatastic 5e2913db34 #679: leave the progress docs to the maintainer 2026-10-17 15:59:52 +07:00
bflatastic 415722439a Merge main into #679 2026-10-17 15:59:40 +07:00
bflatastic b3a2891c2e Merge main into #673 2026-10-17 15:58:31 +07:00
bflatastic 54bb194531 Merge main into #671 2026-10-17 15:58:30 +07:00
bflatastic 5ea5590673 #680: move the page clipboard test to its own module, check the error id 2026-10-17 15:58:24 +07:00
bflatastic 01ce23ca76 #680: page refusal names the calling command (edit.copy / edit.cut) 2026-10-17 15:58:23 +07:00
bflatastic 5b963dce08 Merge main into #667 2026-10-17 15:57:01 +07:00
bflatastic abfc0071f1 Merge main into #680 2026-10-17 15:55:55 +07:00
bflatastic 7de7dbc991 Merge main into #625 2026-10-17 15:53:28 +07:00
bflatastic da76c806cc Merge main into #668 2026-10-17 15:51:37 +07:00
bflatastic ff418eaaac Merge main into #621 2026-10-17 15:49:16 +07:00
Jorrevdb b044038857 UI: submenu hover fixes (stale state, safe triangle, hover delay) 2026-10-17 10:49:01 +02:00
bflatastic 32266dcd93 #624: open the picker only for a shared paint list (mixed-selection guard) 2026-10-17 15:49:01 +07:00
bflatastic cd8a88733f Merge main into #655 2026-10-17 15:48:00 +07:00
bflatastic efe4911eb0 Merge main into #624 2026-10-17 15:42:48 +07:00
sushi-killer a9bcc48df7 feat(web): join a live session from ?join= in the page URL 2026-10-17 01:45:08 +03:00
sushi-killer 1b4db71957 fix(collab-net): keep the client's message cap in range on 32-bit targets 2026-10-17 00:37:55 +03:00
sushi-killer b0f56ed169 fix(server): let message limits follow max_file_mb so files above 64 MiB publish and open 2026-10-16 23:19:32 +03:00
Yarila 8c90ef962a M7: open inputs for parameterized menu actions 2026-10-16 23:36:23 +05:00
Barzabel 554821afef M9: a damaged generation field no longer drops a log's flushed edits (#408)
A log whose generation isn't its snapshot's was skipped as a leftover of a
compaction cut short, but neither generation is under a checksum: one flipped
bit made the server serve the older document, report nothing and overwrite the
log. Replay such a log over the snapshot instead: replay skips what the
snapshot already holds, so a genuine leftover changes nothing, while edits the
snapshot lacks are recovered and the damage is reported.
2026-10-16 17:31:11 +03:00
NetterSilverandClaude Haiku 5.5 0e25cf4c26 Refuse pages in edit.copy and edit.cut
Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 13:47:07 +00:00
Marco Arnulfo 3065cd6da9 M3: drag variant properties to reorder them in the Properties section 2026-10-16 15:05:00 +02:00
Yarila ac1a192a3a M7: keep mobile presenter fixed and confirm window close 2026-10-16 17:25:13 +05:00
phonedu 9daf39e24c M1: a param that names a layer says so when it isn't one
`node.create` read its `parent` as `id(p, "parent").unwrap_or(page)`, so a `parent` that isn't a
layer id — a `{"$step": n}` nothing had resolved, a name, a negative number — quietly meant "no
parent" and the layer landed on the page, with the call reporting success. The same shape sat on
`image.place` and `instance.create`, on `index` (a string index appended where it was asked to
insert), and on the `ids` of every command that targets layers (an entry that wasn't an id was
dropped and the rest ran). A number that names nothing was reported; anything that isn't a number at
all was not.

`params::opt_id` and `params::opt_index` now say what the commands mean: missing and `null` are
"none", and anything else has to be a layer id (or a whole, non-negative number) and is named when it
isn't. `req_targets` refuses an `ids` entry that isn't an id, before falling back to the selection. A
number that names no layer still reports `no such node`, and a boolean is still a number to `f64_of`,
as it is for `x`, `width` and the rest.

This turns a silently wrong result into an error on purpose, so a caller that passed something that
isn't an id and relied on the fallback now hears about it.
2026-10-16 19:53:43 +08:00
phonedu 0f4c79b629 M0.9: a script runs as one undo step and resolves $step, like command.batch
`figcraft-cli run` ran each step of a script on its own, so a script's `{"$step": n}` reached
`parent` unresolved and the layer landed on the page while every step reported success — the same
commands through `command.batch` (the MCP `batch` tool, the control channel, plugins) put it inside
the frame. One `edit.undo` took back one step of a script rather than the script, and a failing step
left the earlier ones applied.

`Session::batch_step` is now the one path both take — one step of a batch, its `$step` references
resolved against the results before it — and `run_script` wraps its steps in `Session::grouped`: the
whole script is one undo step, and a step that fails takes the script back. The result lines come out
once the script is through, so none of them reports work a later failure took back; a failure prints
one line with `rolledBack: true` and exits 1. `docs/cli.md` documents the reference and the output.
2026-10-16 19:33:59 +08:00
Hamza 805a121e15 Hold the duplicate step for a dragged and a nudged copy (#567)
Duplicate repeats the last duplicate's offset, but of the ways to move the copy
before the second Cmd+D only `node.set` and Alt-drag were covered. Dragging it
with the mouse is how almost everyone does it, and is what #567 describes; an
arrow-key nudge is how a small offset is usually made exact. Both work, because
the step is measured from where the copy ended up rather than recorded from the
gesture, but nothing held them there.

The drag test also asserts the drag moved the copy rather than making another
one, left it selected, and left the original in place: the ways the feature
could break without the step arithmetic breaking.

Both fail if the step lookup in `edit.duplicate` is stubbed out.
2026-10-16 16:30:58 +05:00
Ahmed5Emad e1d9f24312 Merge remote-tracking branch 'origin/main' into fix/layout-add-keeps-frame-size 2026-10-16 14:27:19 +03:00
Hamza c5a8420089 Make the control server's connection-limit test pass on macOS
`cargo xtask ci` was red on a clean main on macOS: the test's last shutdown
runs after the server has answered an invalid request and closed the
connection, and `shutdown(2)` on a socket whose peer has already closed is
ENOTCONN on macOS and the BSDs where Linux returns success.

The test is right about what it checks; it checked a return value that is not
portable. Closing the client is what the worker thread waits for, and dropping
the socket does that whether or not shutdown reported anything. The file's
other six shutdowns run while the peer is still open, which is well defined on
both platforms, so they stay checked.

With no CI running the tests on pull requests, the local gate is the only
signal a contributor gets, and it could not be green on macOS.
2026-10-16 16:27:05 +05:00
Marco Arnulfo 7d7a724764 Text: selection highlights after a line break start at the line's start, and the empty last line shows (fixes #665) 2026-10-16 13:27:01 +02:00
Andrii VolkovandClaude Opus 5.5 52eb736241 perf(render): look each layer up once per render
Node lookups in the document's B-tree were still a third of a render on a
900 000-layer file: a small subtree was walked for its bounds, again to
see whether it could be splatted, and again to splat it, and each child
of a drawn layer was looked up by paint_runs, by the mask check and by
draw_node.

- The render's table also keeps the layers it looked up, keyed with a
  multiply hash instead of SipHash (ids are counters), and so do the
  bounds it keeps.
- draw_children looks each child up once, works out the paint runs from
  those (Document::paint_runs_of) and draws the child it has.
- Whether a layer can be splatted is kept like its bounds: layers with
  children only, answers the nesting cap decided aren't kept, and kept
  answers are read only where their levels fit under the cap.

Ctx gets a second lifetime, for the document the kept layers come from.

Single-threaded cycles per 1440x900 render fitted to the page: 0.39 ->
0.34 G on a page of 246 204 layers, 0.26 -> 0.22 G on one of 118 358.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 13:20:53 +03:00
Yarila be3912f986 M7: Reflow presenter device screens and simplify tablet and desktop previews 2026-10-16 15:05:13 +05:00
Andrii VolkovandClaude Opus 5.5 4484c2d1b0 perf(render): work out paint bounds once per render
Culling a layer asked for its paint bounds, which walk its whole subtree,
and then did the same for each child: every layer's subtree was walked
again at each level above it, and once more in each parallel band. The
bounds worked out during a render are now kept for it (layers with
children only; bounds the 64-level cap cut short are not kept, and kept
ones are read only where their levels still fit under the cap, so culling
is exactly as before).

Every render also looked through every layer of the document for the
largest background blur, to size the overlap of its bands: 900 000 layers
on one large file, on the main thread before the bands start. The
document now works it out when first asked after a change, like whether
it has masks.

A 1440x900 render on a 900 000-layer file, page of 118 358 layers: at
100% 13.0 -> 0.6 ms, at 25% 16.5 -> 3.4 ms, fitted to the page 56 -> 31 ms;
page of 246 204 layers: 12.6 -> 2.5 ms, 12.1 -> 2.0 ms, 69 -> 62 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 12:31:55 +03:00
Yarila 6d3e7aad20 M7: isolate presenter tab and device controls 2026-10-16 14:16:44 +05:00
NetterSilverandClaude Haiku 5.5 a911ab9614 Reject negative or fractional index in fill.set and stroke.set
Found while testing crates/engine/src/cmd/edit.rs:1180

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 08:29:55 +00:00
Andrii VolkovandClaude Opus 5.5 afdb21bad6 fix(ui): drag the line between pages and layers to size the pages list
The pages list took a fixed 40% of the left panel (#272). The line under
it now drags to make the list taller or shorter: at least three rows, and
always leaving the layers their header and three rows. A double-click puts
back the default. The main window keeps the height in the preferences, as
it does the panel widths, saved once the drag is over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 11:24:39 +03:00
Skyfuller1303 a4156d2910 M3: re-read a library file whose stamp is as recent as the read
A library file is cached by its length and modification time. File systems keep the time in coarse ticks, so a same-length rewrite right after a read can keep the stamp, and library.updates then reports no change. A stamp now proves the file unchanged only once its modification time lies at least two seconds before the read; otherwise the file is read again.
2026-10-16 09:37:07 +02:00
Ivan Shishkanov 5be9a8caef M1: cache smart-selection geometry and skip unchanged drag edits 2026-10-16 08:06:26 +03:00
Ivan Shishkanov 1ac0e475c1 M3: adapt Selection colors to current main 2026-10-16 07:58:58 +03:00
Patrik LermonandClaude Opus 5.5 107aa27d0a docs: Swedish now covers the command labels and menu wordings
ROADMAP.md, docs/localization-parity.md and docs/translating.md said
Swedish had 120 of the 588 command labels and menu wordings; with the
previous commit it has all of them. Worded without counts, so the docs
stay right as commands are added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:20:40 +02:00
Zaxaerith 167e06f695 docs: correct example plugin count (#392) 2026-10-16 12:08:26 +08:00
Dylan M. Taylor cc26c29217 ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:57:45 -04:00
Patrik LermonandClaude Opus 5.5 a00b2e0583 i18n(sv): translate the command and menu labels
The [commands] and [menu] rows held their command id as the text
("align.bottom" = "align.bottom"), which the menus and the command
palette would show as is. They now translate the English labels the
code gives those ids (spec! and m! in crates/engine), e.g.
"align.bottom" = "Justera nederkant". Also the interface strings that
are new on upstream main (23bdebd).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:42:54 +02:00
Patrik Lermon ba3455cc6e Merge upstream/main into swedish-ui-2026-10-15 2026-10-16 05:41:29 +02:00
NetterSilverandClaude Haiku 5.5 8538dd740f Refuse edits that leave a layer with non-finite geometry
Found while testing crates/engine/src/cmd/arrange.rs:132

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 03:38:36 +02:00
krinry b525fde8d1 M1: pop up colour picker when adding a fill or stroke (#404)
Clicking the + button to add a fill or stroke in the Design panel now
immediately opens the colour picker dialog for the newly added paint,
matching Figma's behaviour.
2026-10-16 06:45:40 +05:30
vxMacDev 9c1d65d924 fix(render): text strokes follow their alignment (#615)
The canvas renderer and PDF export stroked text with the alignment
forced to centre, so Inside and Outside on text did nothing (SVG export
already honoured them). Glyph outlines are closed, so text strokes now
go through stroke_area like other shapes' strokes, with the same fill
rule: inside clips to the glyphs, outside to around them.
2026-10-15 19:36:39 -05:00
Marco Arnulfo 5d1c24d061 Contributors: add my name for the About window 2026-10-16 02:19:42 +02:00
Edivan TeixeiraandClaude Opus 5.5 f05eecdaa0 node.set: test that a refused call changes nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:46:04 -03:00
Edivan TeixeiraandClaude Opus 5.5 8cccc48516 node.set: apply auto layout keys to the frames and name the rest; resolve fontStyle like text does
- Several ids: auto layout keys go to the frame-like layers, the others get the remaining keys
  and are named in `warnings`. Refused only when no target can have auto layout.
- fontStyle is never refused: it draws in the nearest face, as a text layer resolves it.
  `warnings` says so when that face's weight or slant differs from what the name asks for.
- set_properties passes `warnings` on to the agent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:43:52 -03:00
Daniel Miguel c6a3f25224 M0.9: sync reviewed GNOME controls fix with upstream 2026-10-16 00:13:38 +02:00
Daniel Miguel b83a0de7a8 M0.9: scope Wayland decorations and avoid bundled title fonts 2026-10-16 00:01:55 +02:00
Edivan TeixeiraandClaude Opus 5.5 c8ba7b4a4b node.set: refuse auto layout keys off frames, and font styles the family lacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:26:08 -03:00
Ivan Shishkanov ce6726aad3 M1: merge current main and resolve PR #595 conflicts
Preserve the upstream behaviour and regression tests alongside this feature. Align the control-server connection-limit test with main closing invalid JSON connections: assert EOF instead of shutting down an already closed socket on macOS.
2026-10-15 23:12:40 +03:00
Ivan Shishkanov 692d52c514 M3: merge current main and resolve PR #575 conflicts
Preserve the upstream behaviour and regression tests alongside this feature. Align the control-server connection-limit test with main closing invalid JSON connections: assert EOF instead of shutting down an already closed socket on macOS.
2026-10-15 23:08:41 +03:00
Patrik LermonandClaude Opus 5.5 0a5eceedf1 i18n(sv): translate 480 new strings
Brings the Swedish catalog up to date with upstream main (eb741a3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:05:47 +02:00
Skyfuller1303 fd24a6b883 M7: resolve canvas snapping conflicts with current main 2026-10-15 21:29:33 +02:00
Skyfuller1303 35005a571d M7: isolate canvas pixel snapping and resolve review feedback 2026-10-15 21:09:19 +02:00
Ivan Shishkanov 97d09369d1 M1: edit smart-selection spacing from fields and canvas (#402) 2026-10-15 22:07:53 +03:00
VanTan 69154a03f1 M3: defer Selection colors discovery during gestures 2026-10-15 21:57:53 +03:00
Daniel Miguel 9809caaa30 fix(linux): restore Adwaita window controls on GNOME Wayland 2026-10-15 20:50:46 +02:00
Ivan Shishkanov 7a5885cde9 M3: add Selection colors for mixed selections (#403) 2026-10-15 19:05:24 +03:00
IanHollow d0c1c12969 M7: test credits column sizing and horizontal scrolling
Restore the original cell spacing and check the table geometry across text sizes instead of requiring every font to fit without scrolling.

Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-15 08:10:19 -07:00
NetterSilverandClaude Haiku 5.5 1e0641a0bd Keep space-around and space-evenly alignment on FIG import
Fixes #460

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 15:06:36 +00:00
Ahmed5Emad 48b62a8b95 M1: only empty frames keep their size when adding auto layout 2026-10-15 14:46:20 +03:00
NetterSilverandClaude Haiku 5.5 3d90d8edfe Keep View toggles such as Rulers in the preferences
Fixes #379

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:33:28 +02:00
Ahmed5Emad 93a75a4720 M1: adding auto layout to an existing frame keeps its size instead of hugging 2026-10-15 02:02:00 +03:00
Skyfuller1303 39d0f2d872 M7: show windowed text and sidebar sharpness close-ups 2026-10-15 00:15:01 +02:00
Skyfuller1303 7a8e5373b1 M7: keep Windows windowed UI and canvas text sharp 2026-10-15 00:02:41 +02:00
IanHollow 1ddc2c338b Fit contributor columns inside the About window
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-14 13:12:53 -07:00
206 changed files with 11058 additions and 827 deletions

No files matched your search

+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
Generated
+72 -26
View File
@@ -856,6 +856,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1408,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"eframe",
@@ -1422,17 +1427,20 @@ dependencies = [
"fmv-macos-events",
"image",
"log",
"raw-window-handle",
"rfd",
"serde_json",
"wat",
"wayland-client",
"webbrowser",
"windows",
"winit",
"winresource",
]
[[package]]
name = "figcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1446,7 +1454,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1461,7 +1469,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1484,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1493,7 +1501,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1505,9 +1513,10 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1532,7 +1541,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1547,7 +1556,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1555,7 +1564,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1563,7 +1572,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1579,7 +1588,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1588,7 +1597,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1600,12 +1609,12 @@ dependencies = [
"krilla",
"log",
"thiserror 2.0.21",
"tiny-skia",
"tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-plugin"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1618,7 +1627,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1631,12 +1640,12 @@ dependencies = [
"moxcms",
"png",
"rayon",
"tiny-skia",
"tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-server"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1660,7 +1669,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1676,7 +1685,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1690,7 +1699,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1715,7 +1724,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -2570,7 +2579,7 @@ dependencies = [
"skrifa 0.42.1",
"smallvec",
"subsetter",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
@@ -4018,6 +4027,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia 0.11.4",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -4477,6 +4497,20 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
@@ -4489,7 +4523,18 @@ dependencies = [
"cfg-if",
"log",
"png",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
@@ -4778,7 +4823,7 @@ dependencies = [
"skrifa 0.44.0",
"strict-num",
"svgtypes",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
"unicode-bidi",
"unicode-script",
"unicode-vo",
@@ -5731,6 +5776,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -5888,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-engine",
"serde_json",
+4 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
+31
View File
@@ -20,3 +20,34 @@ licence texts.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
similar permissive licenses); see each crate's metadata.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
+17 -13
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
@@ -99,25 +99,25 @@ native speakers' reviews of the six language drafts need people.
| Language | Code | Strings | Status |
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| French | fr | 100% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
| Indonesian | id | 0% | none |
| Japanese | ja | 100% | draft (needs native review) |
| German | de | 100% | draft (needs native review) |
| Korean | ko | 100% | draft (needs native review) |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
@@ -315,6 +316,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
+18 -8
View File
@@ -1,5 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options, and boolean `--flag`s. Unknown options are usage errors.
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors.
use std::collections::BTreeMap;
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec<String>,
values: BTreeMap<String, String>,
values: BTreeMap<String, Vec<String>>,
flags: Vec<String>,
}
@@ -34,7 +35,7 @@ impl Args {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
a.values.insert(name.to_string(), v);
a.values.entry(name.to_string()).or_default().push(v);
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
@@ -45,7 +46,12 @@ impl Args {
}
pub fn value(&self, k: &str) -> Option<String> {
self.values.get(k).cloned()
self.values.get(k).and_then(|v| v.last()).cloned()
}
/// Every value of an option that may be given more than once, in order.
pub fn values(&self, k: &str) -> &[String] {
self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
@@ -62,9 +68,9 @@ impl Args {
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
let bleed = match self.values.get("bleed") {
let bleed = match self.value("bleed") {
None => 0.0,
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
@@ -72,7 +78,7 @@ impl Args {
}
pub fn scale(&self) -> CliResult<f64> {
match self.values.get("scale") {
match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
@@ -85,7 +91,7 @@ impl Args {
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
match self.values.get(k) {
match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
@@ -110,6 +116,10 @@ mod tests {
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
assert_eq!(a.values("root"), ["a", "b"]);
assert_eq!(a.value("root").as_deref(), Some("b"));
assert!(a.values("none").is_empty());
}
#[test]
+89 -10
View File
@@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
})
.collect();
let missing = figcraft_engine::fonts::missing(&doc);
let v = json!({
"file": path,
"name": doc.name,
@@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
}
}
for m in &missing {
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
}
Ok(ExitCode::SUCCESS)
}
@@ -333,17 +339,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
}
None => Session::new(),
};
for (i, (cmd, params)) in steps.iter().enumerate() {
let r = s.execute(cmd, params);
s.ui_requests.clear();
match r {
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
Err(e) => {
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
return Ok(ExitCode::from(1));
}
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
@@ -422,4 +443,62 @@ mod tests {
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
dir
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
#[test]
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
let dir = temp_dir("run-step");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let page = s.page().unwrap();
let top = doc.node(page).unwrap().children.clone();
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
let inner = doc.node(top[0]).unwrap().children.clone();
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
let dir = temp_dir("run-rollback");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
}
+24 -5
View File
@@ -23,7 +23,7 @@ USAGE:
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT]
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
@@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
writes only inside a write root, everything else refused. A running app confines itself: start it
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
fn main() -> ExitCode {
@@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach"], &[])?;
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
mcp(a.value("attach").as_deref())
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
(true, _) => None,
// The app runs the commands: only its own roots can confine them.
(false, Some(_)) => {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
};
mcp(a.value("attach").as_deref(), roots)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
@@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
Some(p) => {
let addr = if p.contains(':') {
@@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
None => Box::new(figcraft_mcp::Headless::new()),
};
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
let mut server = figcraft_mcp::Server::new(backend);
if let Some(r) = &roots {
eprintln!("file access confined to {}", r.to_json());
}
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
let stdin = std::io::stdin();
let stdout = std::io::stdout();
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
+46
View File
@@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() {
&["render", "a", "-o"],
&["perf", "--shapes", "lots"],
&["mcp", "--attach", "notaport"],
// An attached app confines itself: roots go to `figcraft --control`.
&["mcp", "--attach", "7979", "--automation-read-root", "."],
&["mcp", "--automation-write-root"],
&["convert", "a"],
] {
let out = bin().args(args).output().unwrap();
@@ -120,6 +123,49 @@ fn mcp_over_stdio() {
assert_eq!(lines[2]["result"]["isError"], false);
}
#[test]
fn mcp_over_stdio_confined_to_automation_roots() {
let d = tmp("roots");
for sub in ["in", "out", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
// A root that isn't a folder stops the server before it serves.
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
let mut child = bin()
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
let call = |id: u32, tool: &str, args: serde_json::Value| {
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
};
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
}
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 6);
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
let error = |i: usize| lines[i]["result"]["isError"] == true;
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn commands_json_lists_everything() {
let out = bin().args(["commands", "--json"]).output().unwrap();
+22
View File
@@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
}
#[test]
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
let text =
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
let out = cli().arg("info").arg(&file).output().unwrap();
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
+30 -12
View File
@@ -135,9 +135,23 @@ impl Config {
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
let client_max = NetLimits::default().max_server_message >> 20;
if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
out.push(format!(
"max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
self.file_mb(),
client_max - 1
));
}
if !self.open_publish {
return out;
}
if self.file_mb() > 64 {
out.push(format!(
"open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
self.file_mb()
));
}
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
@@ -168,7 +182,7 @@ impl Config {
return Err("`idle_days` must be from 0 to 3650".into());
}
let terms = self.terms_url.trim();
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
return Err("`terms_url` must be a web address (https://…)".into());
}
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
@@ -253,8 +267,9 @@ impl Config {
}
}
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
/// and a margin, not the core's 64 MiB.
/// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
/// public this is also the cap (strangers' files stay small); a private server never goes below
/// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
fn open_message(&self) -> usize {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
@@ -265,9 +280,9 @@ impl Config {
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
if !self.open_publish {
return base;
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message().min(base.max_message_bytes);
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
}
@@ -279,21 +294,24 @@ impl Config {
pub fn net(&self) -> NetLimits {
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
if !self.open_publish {
return base;
let max = base.max_client_message.max(self.open_message());
return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
}
let max = self.open_message().min(base.max_client_message);
let max = self.open_message();
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
// eighth of each, and its biggest consumers are dropped first.
let (queued, reading) = (256usize << 20, 192usize << 20);
// eighth of each, and its biggest consumers are dropped first. Each still fits one whole
// message, or a file as large as `max_file_mb` could never arrive.
let one = max.saturating_add(1 << 20);
let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
NetLimits {
max_per_address: self.max_connections_per_ip,
max_client_message: max,
max_queued_bytes: max.saturating_add(1 << 20),
max_queued_bytes: one,
max_queued_block: queued / 8,
max_queued_total: queued,
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
max_reading_block: one.min(reading / 8),
max_reading_total: reading,
max_in_flight: 128 << 20,
max_in_flight: (128usize << 20).max(one),
reserve_for_sessions: self.max_connections / 8,
request_idle: std::time::Duration::from_secs(20),
..base
+4 -2
View File
@@ -8,8 +8,10 @@
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
//!
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
//! first damaged record (a write cut short).
use std::fs::{self, File};
use std::io::{self, Read, Write};
+32 -1
View File
@@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
assert_eq!(open.net().max_client_message, 17 << 20);
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
let w = open.warnings().join("\n");
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
let set = Config { operator_contact: "[email protected]".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
@@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() {
assert!(!format!("{info:?}").contains(&k));
server.stop();
}
#[test]
fn files_above_64_mib_fit_every_limit() {
// `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
// follow it, or the upload is dropped by the transport before the server can say why.
for open_publish in [false, true] {
let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
}
// A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
let warns = |open_publish, mb| {
Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
"
",
)
};
assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
}
+27 -3
View File
@@ -440,7 +440,7 @@ impl Store {
&& let Ok(log) = disk::read_log(&bytes)
&& log.generation != generation
{
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
}
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
@@ -1126,8 +1126,32 @@ impl Store {
fresh_log = true;
}
}
// From before a compaction that was cut short: the snapshot has it all.
Ok(_) => {}
// From before a compaction that was cut short, the snapshot has it all; or a generation
// field damaged, which no checksum covers. Replaying tells them apart without trusting
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
Ok(log) => {
let before = server.seq();
let mut stopped = None;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
stopped = Some(e);
break;
}
}
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
if recovered > 0 {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
{recovered} changes the snapshot didn't; they were recovered"
));
}
if let Some(e) = stopped {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
making sense after {recovered} newer changes ({e}); the rest was dropped"
));
}
}
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
@@ -0,0 +1,139 @@
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::Document;
use figcraft_server::config::Config;
use figcraft_server::disk::{self, LOG, SNAPSHOT};
use figcraft_server::store::{FILES, Store};
use std::fs;
use std::path::{Path, PathBuf};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn page_name(server: &Server) -> String {
let doc = server.document();
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
}
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
fn renamed_and_stopped(data: &Path) -> (String, Server) {
let mut store = Store::open(data, Config::default().store()).unwrap();
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
let id = info.id;
let mut server = store.open(&id).unwrap();
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
let mut client = Client::join(welcome).unwrap();
store.changed(&id, &mut server);
let mut edited = client.document().clone();
let page = edited.first_page().unwrap();
edited.node_mut(page).unwrap().name = "Kept".into();
let change = client.edit(&edited).unwrap().unwrap();
assert!(!server.receive(client.id(), change).is_empty());
store.changed(&id, &mut server);
assert!(store.flush().is_empty(), "the edit must reach disk");
assert_eq!(page_name(&server), "Kept");
let dir = data.join(FILES).join(&id);
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
drop(store);
(id, server)
}
/// Reopen the data directory: the file's first page name and the problems reported.
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
let mut store = Store::open(data, Config::default().store()).unwrap();
let server = store.open(id).unwrap();
(page_name(&server), store.problems().to_vec())
}
fn flip(path: &Path, at: usize) {
let mut bytes = fs::read(path).unwrap();
bytes[at] ^= 1;
fs::write(path, bytes).unwrap();
}
/// The generation field's first byte in both headers (after the magic and the format byte).
const GENERATION_AT: usize = 5;
#[test]
fn an_intact_log_restores_its_edits_without_a_report() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept");
assert!(problems.is_empty(), "{problems:?}");
}
#[test]
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let log = fixture.0.join(FILES).join(&id).join(LOG);
flip(&log, GENERATION_AT);
// The records themselves still read: only the header's generation changed.
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
assert!(read.damaged.is_none() && !read.entries.is_empty());
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept", "the flushed rename must survive");
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
// And it stays: the recovered edit is now in the file's snapshot.
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
}
#[test]
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
flip(&snapshot, GENERATION_AT);
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept", "the flushed rename must survive");
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
}
#[test]
fn a_damaged_log_format_is_still_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
let (_, problems) = reopen(&fixture.0, &id);
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
}
#[test]
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
let fixture = Fixture::new();
let (id, server) = renamed_and_stopped(&fixture.0);
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept");
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
}
+9
View File
@@ -14,7 +14,16 @@
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
//! bar and the browser history keep it, so the URL is as private as the link itself.
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(target_arch = "wasm32")]
mod web;
+15
View File
@@ -0,0 +1,15 @@
//! The page's URL: the invite link its fragment names.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
/// opened with.
pub fn join_link(fragment: &str) -> Option<&str> {
// An invite link is at most 2 KiB; every byte of it escaped is three.
const MAX: usize = 3 * 2048;
let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
+29
View File
@@ -0,0 +1,29 @@
//! `#join=` in the page's URL.
use crate::query::join_link;
#[test]
fn join_takes_the_rest_of_the_fragment() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
}
#[test]
fn no_join_without_the_entry() {
assert_eq!(join_link(""), None);
assert_eq!(join_link("#"), None);
assert_eq!(join_link("#join="), None);
assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
assert_eq!(join_link("#xjoin=x&a=1"), None);
assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
}
#[test]
fn an_overlong_link_is_ignored() {
let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
+7 -1
View File
@@ -40,7 +40,13 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
figcraft_ui_egui::live::open_invite(&mut fig, &link);
}
Ok(Box::new(WebShell { fig }))
}),
)
+6
View File
@@ -43,3 +43,9 @@ wat = { workspace = true }
[build-dependencies]
winresource = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
# Use vector window controls without a title-font renderer or embedded Cantarell.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
raw-window-handle = "0.6"
wayland-client = "0.31"
+16 -2
View File
@@ -1,4 +1,5 @@
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
@@ -11,6 +12,11 @@ pub struct Args {
pub files: Vec<String>,
/// The last invite link given.
pub join: Option<String>,
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
/// which is refused at startup.
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--control" {
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
let roots = match flag {
"--automation-read-root" => Some(&mut out.read_roots),
"--automation-write-root" => Some(&mut out.write_roots),
_ => None,
};
if let Some(roots) = roots {
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
} else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if is_invite(a) {
out.join = Some(a.clone());
+11 -1
View File
@@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec<String> {
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
#[test]
fn automation_roots_repeat_and_are_never_files() {
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
assert_eq!(a.read_roots, args(&["in", "more"]));
assert_eq!(a.write_roots, args(&["out"]));
assert_eq!(a.files, args(&["a.figcraft"]));
// A flag without its folder is kept as an empty one, which startup refuses.
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
}
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
+5 -1
View File
@@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
client.shutdown(Shutdown::Both).unwrap();
// The server closes this connection after answering an invalid request, and shutting down
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
// the other shutdowns in this file run while the peer is still open and stay checked.
let _ = client.shutdown(Shutdown::Both);
workers.pop().unwrap().join().unwrap();
}
+112
View File
@@ -0,0 +1,112 @@
//! Request winit's frame only on Wayland compositors without xdg-decoration.
//! Desktop/session names cannot identify the backend of an individual window.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
/// Never consume, duplicate or query that inherited connection: there may be no
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
pub fn display_socket() -> Option<PathBuf> {
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
return None;
}
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
let path = PathBuf::from(display);
if path.is_absolute() {
Some(path)
} else {
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
}
}
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
if !wayland {
log::debug!("window controls: non-Wayland window, retaining custom controls");
return false;
}
let server_decorations = socket.and_then(probe);
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
native_frame(wayland, server_decorations)
}
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
wayland && server_decorations == Some(false)
}
fn probe(socket: PathBuf) -> Option<bool> {
// The compositor must not be able to block startup on a registry roundtrip.
// On failure/timeout retain FigCraft's own working title bar and buttons.
let (send, receive) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("wayland-decorations".into())
.spawn(move || {
let _ = send.send(registry_has_decorations(socket));
})
.ok()?;
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
}
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
// A fresh connection leaves winit's event queue and inherited descriptors alone.
let stream = UnixStream::connect(socket).ok()?;
let connection = Connection::from_socket(stream).ok()?;
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
}
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_queue: &QueueHandle<Self>,
) {
}
}
#[cfg(test)]
mod tests {
#[test]
fn only_wayland_without_server_decorations_uses_the_frame() {
for (wayland, server_decorations, expected) in [
(false, None, false),
(false, Some(false), false),
(false, Some(true), false),
(true, Some(true), false),
(true, Some(false), true),
(true, None, false),
] {
assert_eq!(super::native_frame(wayland, server_decorations), expected);
}
}
#[test]
fn an_unavailable_display_keeps_the_custom_controls() {
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
}
#[test]
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
let start = std::time::Instant::now();
assert_eq!(super::probe(socket.clone()), None);
assert!(start.elapsed() < std::time::Duration::from_secs(2));
drop(listener);
let _ = std::fs::remove_file(socket);
Ok(())
}
}
+58 -9
View File
@@ -6,6 +6,11 @@
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
//!
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
//! the command line are not. A root that isn't an existing folder stops the app at startup.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@@ -15,6 +20,8 @@ mod args;
mod control_server;
mod gamepad;
mod graphics;
#[cfg(target_os = "linux")]
mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -119,6 +126,17 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
let roots = match (
args.read_roots.is_empty() && args.write_roots.is_empty(),
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
) {
(true, _) => None,
(false, Ok(r)) => Some(Arc::new(r)),
(false, Err(e)) => {
eprintln!("FigCraft: {e}");
std::process::exit(2);
}
};
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
@@ -143,6 +161,7 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
// The preferences first: they hold the online files a recovered document may be one of.
@@ -174,7 +193,10 @@ fn main() -> eframe::Result {
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
fig.ui.font_checks = opened;
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
@@ -192,21 +214,18 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
// window is frameless and FigCraft draws the buttons and resize edges.
if cfg!(target_os = "macos") {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
} else {
viewport = viewport.with_decorations(false);
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
}
// Start frameless on Linux; the app creator checks the actual display backend
// before enabling a client-side frame. Other windows inherit that decision.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
#[cfg(target_os = "linux")]
let linux_display = linux_window::display_socket();
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -217,6 +236,11 @@ fn main() -> eframe::Result {
"FigCraft",
options,
Box::new(move |_cc| {
#[cfg(target_os = "linux")]
if linux_window::needs_native_frame(_cc, linux_display) {
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
}
Ok(Box::new(App {
queue,
ctx_slot,
@@ -233,3 +257,28 @@ fn main() -> eframe::Result {
}
result
}
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
use figcraft_ui_egui::TitleBar;
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
}
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
use figcraft_ui_egui::TitleBar;
match title_bar {
TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
TitleBar::Native => viewport.with_decorations(true),
TitleBar::Custom => viewport.with_decorations(false),
}
}
#[cfg(all(test, target_os = "linux"))]
mod linux_window_tests {
#[test]
fn linux_starts_with_the_existing_frameless_title_bar() {
let bar = super::desktop_title_bar();
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(false));
}
}
+12
View File
@@ -44,3 +44,15 @@ sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.krinry]
display_name = "krinry"
sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
+1 -1
View File
@@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
fn with_port(authority: &str, default: u16) -> String {
let has_port = match authority.rsplit_once(':') {
// `[v6]` alone has colons inside the brackets only.
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
None => false,
};
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
+4 -2
View File
@@ -12,7 +12,9 @@ pub struct NetLimits {
/// before: its hello.
pub max_client_message: usize,
pub max_hello: usize,
/// Largest message a client accepts from the host (a welcome carries the whole document).
/// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB,
/// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server
/// (any link can name one) can't make the app or a browser tab buffer more than that.
pub max_server_message: usize,
/// Messages a client may send per second on average, and in a burst; beyond that it is
/// disconnected.
@@ -61,7 +63,7 @@ impl Default for NetLimits {
max_connections: 64,
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
max_hello: 64 << 10,
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
max_server_message: 1 << 30,
rate: 100.0,
burst: 400.0,
hello_timeout: Duration::from_secs(10),
+19 -6
View File
@@ -77,17 +77,19 @@ pub enum DocError {
pub type DocResult<T> = Result<T, DocError>;
/// The set of touched node ids, and whether any layer is a mask (worked out when first asked
/// after a change: hit testing skips its mask checks without one). Never part of equality or
/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks
/// without one) and the largest background blur radius (renders keep that much overlap between
/// their bands), both worked out when first asked after a change. Never part of equality or
/// serialisation.
#[derive(Clone, Debug, Default)]
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>);
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>, std::sync::OnceLock<f64>);
impl Dirty {
/// Record node `id` as touched (edited, added or removed).
fn touch(&mut self, id: NodeId) {
self.0.insert(id);
self.1 = std::sync::OnceLock::new();
self.2 = std::sync::OnceLock::new();
}
}
@@ -254,6 +256,11 @@ impl Document {
*self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask))
}
/// The largest background blur or glass radius of any layer's effects (0 without any).
pub fn max_backdrop_radius(&self) -> f64 {
*self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max))
}
/// Node ids touched so far (without clearing).
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
&self.dirty.0
@@ -1117,8 +1124,14 @@ impl Document {
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
let kids = &n.children;
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
let kids: Vec<Option<&Node>> = n.children.iter().map(|k| self.node(*k)).collect();
Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z))
}
/// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run
/// first when `reverse_z`.
pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec<std::ops::Range<usize>> {
let node = |i: usize| kids.get(i).copied().flatten();
let mut runs = Vec::new();
let mut i = 0;
while i < kids.len() {
@@ -1131,7 +1144,7 @@ impl Document {
runs.push(i..j);
i = j;
}
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
if reverse_z {
runs.reverse();
}
runs
+92 -1
View File
@@ -860,7 +860,35 @@ pub struct Device {
impl PrototypeSettings {
/// The devices to pick from ("None" presents the frame at its own size).
pub const DEVICES: [Device; 6] = [
pub const DEVICES: [Device; 19] = [
Device {
name: "iPhone 17",
width: 402.0,
height: 874.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)],
},
Device {
name: "iPhone 17 Pro",
width: 402.0,
height: 874.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 17 Pro Max",
width: 440.0,
height: 956.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone Air",
width: 420.0,
height: 912.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 16",
width: 393.0,
@@ -874,6 +902,13 @@ impl PrototypeSettings {
c("Blue", 0x72, 0x84, 0xD4),
],
},
Device {
name: "iPhone 16 Pro",
width: 402.0,
height: 874.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)],
},
Device {
name: "iPhone 16 Pro Max",
width: 440.0,
@@ -881,6 +916,55 @@ impl PrototypeSettings {
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)],
},
Device {
name: "iPhone 16 Plus",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
},
Device {
name: "iPhone 15 Pro",
width: 393.0,
height: 852.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 15 Pro Max",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 15 Plus",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
},
Device {
name: "iPhone 14 Pro",
width: 393.0,
height: 852.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 14 Pro Max",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 14",
width: 390.0,
height: 844.0,
rotates: true,
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
},
Device {
name: "Android Compact",
width: 412.0,
@@ -888,6 +972,13 @@ impl PrototypeSettings {
rotates: true,
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)],
},
Device {
name: "Android Medium",
width: 700.0,
height: 840.0,
rotates: true,
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)],
},
Device {
name: "iPad mini",
width: 744.0,
+1
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+39
View File
@@ -0,0 +1,39 @@
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
# the automation roots (#715), so every command is confined without a check of its own.
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-methods = [
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
]
+17
View File
@@ -180,6 +180,23 @@ fn vertical_wrap_columns_take_arrows_and_drops() {
assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0]));
}
#[test]
fn adding_auto_layout_to_a_frame_keeps_its_size() {
// An existing frame with content hugs it (padding inferred, so the size is preserved);
// an empty one keeps its size instead of collapsing to its padding.
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
rect(&mut s, f, 40.0, 20.0, "#FF0000");
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
assert_eq!(size(&s, f), (300.0, 200.0));
// An empty frame keeps its size too.
let e = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
s.execute("layout.add", &json!({"ids": [e]})).unwrap();
assert_eq!(sizing(&s, e), (Sizing::Fixed, Sizing::Fixed));
assert_eq!(size(&s, e), (300.0, 200.0));
}
#[test]
fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
// Documented: the last child is on top by default; "first on top" flips which one is drawn
+3 -1
View File
@@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"),
m!("Text", SEP, ""),
m!("Text", "Missing fonts…", "text.missingFonts"),
// Arrange
m!("Arrange", "Align left", "align.left"),
m!("Arrange", "Align horizontal centers", "align.hcenter"),
@@ -287,7 +289,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+57
View File
@@ -19,6 +19,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
spec!(
"arrange.spacing",
"Spacing",
"{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)",
true,
spacing
),
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
@@ -546,3 +553,53 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
ok()
}
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.spacing", s, p)?;
// A scrub is absolute from the interaction snapshot, even if tracks overlap or cross.
let ds = s.ds()?;
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let layout = crate::selection_spacing::selection_spacing(&base, &ids);
let mut offsets = std::collections::BTreeMap::<NodeId, (Option<f64>, Option<f64>)>::new();
let mut any = false;
for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] {
if get(p, key).is_none() {
continue;
}
let value = req_num("arrange.spacing", p, key)?;
if value.abs() > 1_000_000.0 {
return Err(bad("arrange.spacing", "spacing out of range"));
}
let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?;
any = true;
for (id, delta) in axis.offsets(value) {
if !delta.is_finite() {
return Err(bad("arrange.spacing", "non-finite spacing result"));
}
let pair = offsets.entry(id).or_default();
if vertical {
pair.1 = Some(delta);
} else {
pair.0 = Some(delta);
}
}
}
if !any {
return Err(bad("arrange.spacing", "pass horizontal or vertical spacing"));
}
let doc = s.doc_mut()?;
let mut moves = Vec::new();
for (id, (dx, dy)) in offsets {
let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue };
let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0));
if !dx.is_finite() || !dy.is_finite() {
return Err(bad("arrange.spacing", "non-finite spacing result"));
}
moves.push((id, dx, dy));
}
for (id, dx, dy) in moves {
move_world(doc, id, dx, dy)?;
}
ok()
}
+4 -4
View File
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
let cmd = "instance.create";
let c = req_id(cmd, p, "component")?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let doc = s.doc_mut()?;
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
let (x, y) = match (num(p, "x"), num(p, "y")) {
@@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
if !doc.contains(component) {
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
}
// Copy every overridden layer's values to its source in the main (geometry, swaps, property
// values and slot content stay with the instance).
// Copy every overridden layer's values to its source in the main (geometry, resizing, swaps,
// property values and slot content stay with the instance).
let mut pushed = 0;
for d in doc.subtree(inst) {
let Some(n) = doc.node(d).cloned() else { continue };
@@ -691,7 +691,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
let groups: BTreeSet<String> = n
.overrides
.iter()
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
.filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name"))
.cloned()
.collect();
for g in &groups {
+6 -6
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
use crate::{Result, Session, bad, spec};
/// Frame presets shown by the Frame tool (category, name, width, height).
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
@@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let doc = s.doc()?;
let pn = doc.get(parent)?;
if !pn.kind.is_container() {
@@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
node.width = tw;
node.height = th;
}
let index = num(p, "index").map(|i| i.max(0.0) as usize);
let index = crate::params::opt_index(cmd, p, "index")?;
doc.insert(node, parent, index)?;
if let Some(Value::Object(props)) = get(p, "props") {
super::props::apply(cmd, doc, nid, props)?;
@@ -313,12 +313,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::read(path)
}
#[cfg(target_arch = "wasm32")]
fn read_path(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// Place an image as a rectangle with an image fill (Figma's image layer).
@@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
_ => ImageScaleMode::Fill,
};
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
+77 -9
View File
@@ -503,9 +503,14 @@ fn bump_name(n: &str) -> String {
}
/// The copied form of the selection.
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
let ds = s.ds()?;
let (nodes, images, bounds) = copied(&ds.doc, ids);
// Pages are not layers: they can't be pasted anywhere.
let layers: Vec<NodeId> = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect();
if layers.is_empty() && !ids.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
}
let (nodes, images, bounds) = copied(&ds.doc, &layers);
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
@@ -598,14 +603,16 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, &ids)?;
copy_to_clipboard(s, "edit.copy", &ids)?;
Ok(json!({"copied": s.clipboard.nodes.len()}))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, &ids)?;
delete(s, &json!({"ids": ids}))
copy_to_clipboard(s, "edit.cut", &ids)?;
let doc = s.doc()?;
let layers: Vec<NodeId> = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect();
delete(s, &json!({"ids": layers}))
}
/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords).
@@ -1149,9 +1156,34 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
.collect(),
None => Vec::new(),
};
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
// the first refuses them (`props::layout_mut`).
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
let mut without_layout = props.clone();
let mut skipped = BTreeSet::new();
if !layout_keys.is_empty() {
let doc = &s.ds()?.doc;
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
if ids.iter().any(frame_like) {
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
}
if !skipped.is_empty() {
let names: Vec<String> =
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: only frames, components and instances have auto layout",
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
}
}
let doc = s.doc_mut()?;
for i in ids {
super::props::apply("node.set", doc, i, &props)?;
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
}
if let Some(o) = pivot {
for (i, q) in pinned {
@@ -1161,7 +1193,43 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
ok()
if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) }
}
/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that
/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is
/// Bold), say which face it is. Families that aren't installed, and names that match a style or a
/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing.
fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map<String, Value>) -> Vec<String> {
let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() };
let mut families: Vec<String> = match props.get("fontFamily").and_then(Value::as_str) {
Some(f) => vec![f.to_string()],
None => ids
.iter()
.filter_map(|i| doc.node(*i))
.filter_map(|n| n.kind.text())
.flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::<Vec<_>>())
.collect(),
};
families.sort();
families.dedup();
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic"));
families
.into_iter()
.filter_map(|family| {
let styles = figcraft_text::family_styles(&family);
if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) {
return None;
}
let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?;
let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32);
if face.instance(style).is_some() || (w == want_w && face.italic == want_i) {
return None;
}
Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", ")))
})
.collect()
}
/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances
@@ -1240,7 +1308,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Valu
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.set" } else { "stroke.set" };
let ids = req_targets(cmd, s, p)?;
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
let idx = opt_index(cmd, p, "index")?;
let new_paint = match get(p, "paint") {
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
None => None,
@@ -1288,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "fill.pattern";
let src = req_id(CMD, p, "source")?;
let doc = s.doc()?;
if !doc.node(src).is_some_and(|n| !n.is_page()) {
if doc.node(src).is_none_or(|n| n.is_page()) {
return Err(bad(CMD, "`source` must be a layer"));
}
if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) {
+24 -24
View File
@@ -435,7 +435,7 @@ pub(crate) fn recovered(path: &str) -> Result<DocState> {
#[cfg(not(target_arch = "wasm32"))]
fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::read(path)
}
#[cfg(target_arch = "wasm32")]
@@ -445,11 +445,7 @@ fn read(path: &str) -> Result<Vec<u8>> {
/// The bytes of file `path`, refused when it's larger than `max`.
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
}
read(path)
crate::file_access::read_capped(path, max)
}
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
@@ -457,7 +453,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
/// truncates `path`, and no file or symlink at a staging name is ever written through.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::write(path, bytes)
}
#[cfg(target_arch = "wasm32")]
@@ -577,11 +573,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
return None;
}
let dir = dir.as_ref()?;
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
let p = dir.join(format!("{r}{e}"));
let meta = std::fs::metadata(&p).ok()?;
if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
})
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"]
.iter()
.find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok())
}
}
@@ -604,11 +598,7 @@ fn sibling_variables(path: &str) -> Option<Value> {
let file = std::path::Path::new(path);
let dir = file.parent()?;
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
let meta = std::fs::metadata(&c).ok()?;
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
return None;
}
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?;
figcraft_format::rest::is_variables_json(&v).then_some(v)
})
}
@@ -646,20 +636,29 @@ fn remember(s: &mut Session, path: &str) {
}
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
use crate::file_access::{Access, allow, allowed};
const CMD: &str = "file.openRecent";
// Confined to automation roots, the recent files outside them are never listed, opened, removed
// or named: `index` counts the files listed.
let listed = |r: &String| allowed(r, Access::Read);
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
s.prefs.recent_files.retain(|r| !listed(r));
}
if let Some(gone) = string(p, "remove") {
s.prefs.recent_files.retain(|r| *r != gone);
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
(Some(path), _) => {
allow(&path, Access::Read)?;
Some(path)
}
(None, Some(i)) => {
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
}
_ => None,
};
if let Some(path) = path {
if !std::path::Path::new(&path).exists() {
if !crate::file_access::exists(&path) {
s.prefs.recent_files.retain(|r| *r != path);
return Err(bad(CMD, format!("{path} no longer exists")));
}
@@ -669,9 +668,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
.prefs
.recent_files
.iter()
.filter(|r| listed(r))
.map(|r| {
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
})
.collect();
Ok(json!({"files": files}))
@@ -1183,7 +1183,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let path = std::path::Path::new(d).join(&job.file);
// A layer name with slashes exports into folders.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
let _ = crate::file_access::create_dir_all(parent);
}
let path = path.to_string_lossy().to_string();
write(&path, &bytes)?;
+2
View File
@@ -71,6 +71,7 @@ fn registry() -> &'static Registry {
list.extend(create::specs());
list.extend(edit::specs());
list.extend(lists::specs());
list.extend(crate::selection_colors::specs());
list.extend(arrange::specs());
list.extend(structure::specs());
list.extend(section::specs());
@@ -79,6 +80,7 @@ fn registry() -> &'static Registry {
list.extend(page::specs());
list.extend(view::specs());
list.extend(text::specs());
list.extend(crate::fonts::specs());
list.extend(styles::specs());
list.extend(image::specs());
list.extend(variables::specs());
+71 -18
View File
@@ -265,6 +265,56 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
}
/// The keys that set a frame's auto layout (through [`layout_mut`]).
pub const LAYOUT_KEYS: &[&str] = &[
"gap",
"itemSpacing",
"columnGap",
"crossGap",
"counterAxisSpacing",
"rowGap",
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"paddingHorizontal",
"paddingVertical",
"mainAlign",
"primaryAxisAlignItems",
"crossAlign",
"counterAxisAlignItems",
"contentAlign",
"counterAxisAlignContent",
"wrap",
"gridColumns",
"gridRows",
"strokesIncluded",
"reverseZ",
"columnTracks",
"gridColumnTracks",
"rowTracks",
"gridRowTracks",
];
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
/// (`node.set` on several layers skips them instead, see `edit::set`).
fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> {
if !n.kind.is_frame_like() {
return Err(bad(
cmd,
format!(
"`{k}` is an auto layout property, and layer {} “{}” is a {} (only frames, components and instances have auto layout)",
n.id,
n.name,
n.kind.type_name()
),
));
}
Ok(ensure_layout(n))
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
@@ -452,56 +502,56 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
"gap" | "itemSpacing" | "columnGap" => {
ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"crossGap" | "counterAxisSpacing" | "rowGap" => {
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"padding" => {
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
*slot = val;
}
}
"paddingHorizontal" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
l.padding[1] = val;
l.padding[3] = val;
}
"paddingVertical" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
l.padding[0] = val;
l.padding[2] = val;
}
"mainAlign" | "primaryAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).main_align =
layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
}
"crossAlign" | "counterAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"contentAlign" | "counterAxisAlignContent" => {
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
// Tracks past the new count go away (new tracks are 1fr).
let (n, tracks) =
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
*n = count;
tracks.truncate(count as usize);
}
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
"sizingH" | "layoutSizingHorizontal" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, true)?;
@@ -533,7 +583,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
let tracks = a.iter().map(track_of).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
if k.contains("olumn") {
l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
l.column_tracks = tracks;
@@ -783,12 +833,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
if !unknown.is_empty() {
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
}
// Inside an instance, record which property groups were overridden (an instance's own export
// settings and interactions are its own, not overrides).
// Inside an instance, record which property groups were overridden (an instance's own
// resizing, export settings and interactions are its own, not overrides).
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
let n = doc.get_mut(id)?;
if n.source.is_some() {
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
for g in
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
{
n.overrides.insert(g.to_string());
}
}
@@ -810,7 +862,8 @@ pub fn override_group(k: &str) -> &'static str {
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
| "hangingPunctuation" | "hangingList" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
"width" | "height" | "w" | "h" => "size",
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
+7 -2
View File
@@ -544,11 +544,16 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
let n = doc.get_mut(*f)?;
n.children = kids;
n.layout = Some(al);
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
if !all_frames || !empty {
// A frame wrapped around the selection hugs it; an existing frame with no flow
// children keeps its size instead of collapsing to its padding.
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
}
}
s.set_selection(targets.clone());
Ok(json!({"ids": targets}))
+65 -37
View File
@@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul
Ok(json!({"on": *slot}))
}
/// One of a view's on/off settings.
type Slot = fn(&mut ViewState) -> &mut bool;
/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and
/// whether the preferences keep each: files opened or made later start with the kept ones. Outline
/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one.
const TOGGLES: &[(&str, Slot, bool)] = &[
("view.pixelGrid", |v| &mut v.pixel_grid, true),
("view.layoutGrids", |v| &mut v.layout_grids, true),
("view.rulers", |v| &mut v.rulers, true),
("view.slices", |v| &mut v.show_slices, true),
("view.comments", |v| &mut v.comments, true),
("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true),
("view.outlines", |v| &mut v.outlines, false),
("view.outlinesSelection", |v| &mut v.outlines_selection, false),
("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true),
("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true),
("view.maskOutlines", |v| &mut v.mask_outlines, true),
("view.frameOutlines", |v| &mut v.frame_outlines, true),
("view.proofSrgb", |v| &mut v.proof_srgb, false),
("view.snapGeometry", |v| &mut v.snap_geometry, true),
("view.snapObjects", |v| &mut v.snap_objects, true),
("view.snapPixel", |v| &mut v.snap_pixel, true),
];
/// View toggle `id`: the file's view changes, and the preferences remember a kept one.
fn toggle(s: &mut Session, p: &Value, id: &str) -> Result<Value> {
let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) };
let r = flag(s, p, f)?;
if keep && let Some(on) = r.get("on").and_then(Value::as_bool) {
s.prefs.view.insert(id.to_string(), on);
}
Ok(r)
}
/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are).
pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) {
for (id, f, _) in TOGGLES.iter().filter(|t| t.2) {
if let Some(on) = prefs.view.get(*id) {
*f(v) = *on;
}
}
}
/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command
/// that isn't one.
pub fn checked(s: &Session, id: &str) -> Option<bool> {
let v = &s.ds().ok()?.view;
Some(match id {
"view.pixelGrid" => v.pixel_grid,
"view.layoutGrids" => v.layout_grids,
"view.rulers" => v.rulers,
"view.slices" => v.show_slices,
"view.comments" => v.comments,
"view.multiplayerCursors" => v.multiplayer_cursors,
"view.outlines" => v.outlines,
"view.outlinesSelection" => v.outlines_selection,
"view.outlinesHiddenLayers" => v.outline_hidden,
"view.outlinesObjectBounds" => v.outline_bounds,
"view.maskOutlines" => v.mask_outlines,
"view.pixelPreview" => v.pixel_preview,
"view.frameOutlines" => v.frame_outlines,
"view.proofSrgb" => v.proof_srgb,
"view.snapGeometry" => v.snap_geometry,
"view.snapObjects" => v.snap_objects,
"view.snapPixel" => v.snap_pixel,
_ => return None,
})
let mut v = s.ds().ok()?.view.clone();
if id == "view.pixelPreview" {
return Some(v.pixel_preview);
}
TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v))
}
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
@@ -235,46 +263,46 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")),
spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")),
spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")),
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
flag(s, p, |v| &mut v.multiplayer_cursors)
toggle(s, p, "view.multiplayerCursors")
}),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
flag(s, p, |v| &mut v.outlines_selection)
toggle(s, p, "view.outlinesSelection")
}),
spec!(
"view.outlinesHiddenLayers",
"Include hidden layers in outlines",
"{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there",
false,
|s, p| { flag(s, p, |v| &mut v.outline_hidden) }
|s, p| { toggle(s, p, "view.outlinesHiddenLayers") }
),
spec!(
"view.outlinesObjectBounds",
"Include object bounds in outlines",
"{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer",
false,
|s, p| { flag(s, p, |v| &mut v.outline_bounds) }
|s, p| { toggle(s, p, "view.outlinesObjectBounds") }
),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")),
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")),
spec!(
"view.proofSrgb",
"Soft-proof sRGB",
"{on?} mark the colours of a Display P3 file that sRGB screens and files can't show",
false,
|s, p| { flag(s, p, |v| &mut v.proof_srgb) }
|s, p| { toggle(s, p, "view.proofSrgb") }
),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
+55 -24
View File
@@ -11,12 +11,12 @@
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
//! settings and interactions are its own, like its place. `size` and `position` overrides of
//! layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
//! main; the instance itself resizes.
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
//! export settings, interactions and resizing are its own, like its place. `position`
//! overrides of layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
//!
//! Switching variants keeps an override where the layer's name path matches and the two variants
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
@@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
"exports",
"interactions",
"size",
"resizing",
"position",
"component",
"props",
@@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
"size" => {
to.width = from.width;
to.height = from.height;
to.child_layout = from.child_layout.clone();
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.auto_resize = a.auto_resize;
}
}
"resizing" => {
let (a, b) = (&from.child_layout, &mut to.child_layout);
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
}
"position" => to.transform = from.transform,
"component" => {
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
@@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
for s in &src {
let Some(nid) = map.get(s).copied() else { continue };
let Some(o) = old.get(&nid) else { continue };
// A frame that kept the size its own resizing gave it: its layers (copied at their main
// layers' places) follow their constraints from its main layer's size.
if o.overrides.contains("resizing")
&& !o.overrides.contains("size")
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
{
figcraft_layout::apply_constraints(doc, nid, mw, mh);
}
if !o.overrides.contains("size") && !o.overrides.contains("position") {
continue;
}
@@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
out.push("size");
}
if resizing(a) != resizing(b) {
out.push("resizing");
}
if a.transform != b.transform {
out.push("position");
}
@@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
out
}
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
let c = &n.child_layout;
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
}
/// Whether two texts differ in anything but their characters (the `textStyle` group).
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
x.style != y.style
@@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
}
let raw = diff_groups(b, n);
let mut groups = raw.clone();
// An instance's own place, export settings and interactions are its own, not overrides.
// An instance's own place, resizing, export settings and interactions are its own, not
// overrides.
if top_root {
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
}
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
@@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
let Some(p) = doc.node(parent) else { return false };
let constrains = p.layout.is_none() || absolute;
let resized = p.overrides.contains("size")
let resized = sized_own(p)
|| match &p.kind {
NodeKind::Instance { component, .. } => {
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
@@ -1475,7 +1498,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo
let gs: Vec<&'static str> = groups
.iter()
.copied()
.filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
.collect();
if gs.is_empty() {
continue;
@@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
chain.windows(2).all(|w| {
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
let constrains = a.layout.is_none() || below.child_layout.absolute;
if a.overrides.contains("size") && constrains {
if sized_own(a) && constrains {
return false;
}
match &a.kind {
@@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
})
}
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
/// layer's size, so the layers in it can't take their main layers' places as they are.
fn sized_own(n: &Node) -> bool {
n.overrides.contains("size") || n.overrides.contains("resizing")
}
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
/// outwards), each outermost instance once, inner components first.
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
@@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
/// instance itself resizes, and its layers follow; text whose box follows its content still
/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
/// library update).
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
/// when the main changed the same way in this edit (a library update).
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
let (n, b) = (after.node(id)?, before.node(id)?);
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
@@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
l
})
};
let size = |x: &Node| (x.width, x.height);
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
// Child layout settings other than the resizing ones.
let placing = |x: &Node| {
let mut c = x.child_layout.clone();
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
c
};
if mode(n) != mode(b) && mode(ma) == mode(mb) {
return Some("The auto layout direction");
}
@@ -1698,14 +1730,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
return Some("A layer's position");
}
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
return Some("A layer's size");
}
if n.constraints != b.constraints && ma.constraints == mb.constraints {
return Some("A layer's constraints");
}
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
return Some("A layer's resizing");
if placing(n) != placing(b) && placing(ma) == placing(mb) {
return Some("A layer's place in auto layout");
}
None
}
@@ -1923,6 +1952,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.filter(|g| placed_source.is_some() || g != "resizing")
.collect();
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
@@ -1946,7 +1976,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
}
if let Some(n) = doc.node_mut(inst) {
n.source = root_base;
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
n.overrides.extend(root_groups);
}
}
@@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() {
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
(
"resize a layer",
"node.set",
json!({"id": "$", "props": {"width": 50}}),
Layer::Bg,
"not among the overridable properties; the instance resizes",
),
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
(
"change constraints",
"node.set",
@@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() {
Layer::Bg,
"\"any constraints applied to the layers\"",
),
(
"make a layer fill its auto layout",
"node.set",
json!({"id": "$", "props": {"sizingH": "fill"}}),
Layer::Chip,
"resizing comes with the layer's size",
),
(
"take a layer out of auto layout",
"node.set",
-2
View File
@@ -67,8 +67,6 @@ fn overrides_survive_main_edits() {
ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}}));
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"}));
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}}));
// A layer inside an instance keeps its main's size (the instance itself resizes).
assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err());
let ov = node(&s, ibg).overrides;
for g in ["fills", "strokes", "effects", "name"] {
assert!(ov.contains(g), "{g} in {ov:?}");
+6 -5
View File
@@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
let before = s.doc().unwrap().world_bounds(inner).unwrap();
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
ex(&mut s, "select.set", json!({"ids": [inner]}));
// Resizing it by its right edge: only the main component can.
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow");
// Resizing it by its right edge: a size of its own (an override).
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone");
// Moving it: refused too.
let before = s.doc().unwrap().world_bounds(inner).unwrap();
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
// Moving it: only the main component can.
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
+48
View File
@@ -0,0 +1,48 @@
//! Which URLs stored in a document may be handed to the browser or the system opener.
//!
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
//! up at the platform's opener, so a document that someone else made chooses what that opener is
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
//!
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
//! the app's own origin, so a saved document becomes stored XSS.
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
//! schemes to whichever application registered them.
//!
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
//! dangerous schemes is outgrown by every new handler a platform adds.
/// The schemes a URL stored in a document may use.
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
/// Whether `url`, stored in a document, may be opened.
///
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
/// refused: the opener would resolve it against the app's own page on the web. Control characters
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
pub fn openable(url: &str) -> bool {
let url = url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = url.split_once(':') else { return false };
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return false;
}
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
return false;
}
if scheme.eq_ignore_ascii_case("mailto") {
// `mailto:` names an address.
return !rest.is_empty();
}
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
// and `http:///p` has no host.
let Some(after) = rest.strip_prefix("//") else { return false };
!after.is_empty() && !after.starts_with('/')
}
+87
View File
@@ -0,0 +1,87 @@
//! A URL stored in a document reaches the browser or the system opener, so only the schemes a
//! browser follows on a plain link are allowed ([`crate::doc_urls`]).
use crate::doc_urls::openable;
#[test]
fn web_links_are_opened() {
for url in [
"http://example.com",
"https://example.com",
"https://example.com/a/b?c=d#e",
"HTTPS://EXAMPLE.COM",
"HtTp://example.com",
"mailto:[email protected]",
"mailto:[email protected]?subject=hi",
" https://example.com ",
"https://user:[email protected]:8443/p",
] {
assert!(openable(url), "{url:?} should open");
}
}
#[test]
fn script_and_handler_schemes_are_refused() {
for url in [
// Runs in the app's own origin on the web build: a saved document would be stored XSS.
"javascript:alert(1)",
"JavaScript:alert(1)",
"jAvAsCrIpT:alert(document.cookie)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
"blob:https://example.com/x",
// Reach the file system; a UNC host also sends Windows credentials.
"file:///etc/passwd",
"file://attacker.example/share/x",
"smb://attacker.example/share/x",
"webdav://attacker.example/x",
// Windows application handlers.
"ms-msdt:/id PCWDiagnostic",
"ms-officecmd:x",
"search-ms:query=x",
"shell:startup",
// Anything else a platform may have registered.
"tel:+1234",
"sms:+1234",
"ssh://host",
"ftp://host/x",
"figcraft://join?host=127.0.0.1",
"zoommtg://zoom.us/join?x",
"view-source:https://example.com",
"jar:https://example.com/x!/y",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
#[test]
fn a_scheme_is_required_and_control_characters_are_refused() {
for url in [
// No scheme: the web opener would resolve these against the app's own page.
"",
" ",
"example.com",
"/etc/passwd",
"//example.com",
"\\\\host\\share\\x",
"about",
// Browsers ignore some control characters inside a scheme, so a smuggled one must not
// leave `javascript` looking like an unknown scheme that we then refuse by luck.
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"java\rscript:alert(1)",
"javascript\u{0}:alert(1)",
"https://example.com/\u{0}",
// A colon in ordinary text is not a scheme.
"see page 2: the diagram",
"1https://example.com",
// The allowed schemes still need their own part.
"mailto:",
"http:",
"http:example.com",
"http:///p",
"https://",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
@@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() {
cmd_d(&mut s);
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
}
/// Drag `from` by `by` with no modifier: moving the selection, not copying it.
fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
s.pointer(&ev(PointerKind::Down, from)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap();
}
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
}
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
/// and the step is measured from where the copy ended up, whichever way it got there.
#[test]
fn duplicate_repeats_the_step_set_by_dragging_the_copy() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
drag_move(&mut s, (50.0, 50.0), (150.0, 20.0));
assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one");
assert_eq!(s.selection(), vec![c1], "and left it selected");
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
let c2 = cmd_d(&mut s);
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
let c3 = cmd_d(&mut s);
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
}
/// And a step set with the arrow keys, which is how a small offset is usually made exact.
#[test]
fn duplicate_repeats_a_nudged_step() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0}));
assert_eq!(at(&s, c1), (40.0, 0.0));
let c2 = cmd_d(&mut s);
assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step");
}
+293
View File
@@ -0,0 +1,293 @@
//! Automation roots (#715): the folders the files an agent's commands read and write must lie in.
//!
//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app
//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on
//! the thread, every file the engine reads must lie inside a read or a write root and every file it
//! writes inside a write root; anything else is an ordinary error. The engine reaches the file
//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends
//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes.
//!
//! A path is made absolute against the working directory and resolved before it is compared: its
//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
//! checked is what is opened.
#![allow(clippy::disallowed_methods)]
use std::cell::RefCell;
use std::ffi::{OsStr, OsString};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use crate::{EngineError, Result};
/// What a command does with a file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders automation may read (read and write roots) and write (write roots), resolved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Roots {
read: Vec<PathBuf>,
write: Vec<PathBuf>,
}
impl Roots {
/// Roots from the folders given (relative ones against the working directory). Each must be an
/// existing folder.
pub fn new(read: &[String], write: &[String]) -> std::result::Result<Self, String> {
let resolve = |flag: &str, dirs: &[String]| {
dirs.iter()
.map(|d| match std::fs::canonicalize(d) {
Ok(p) if p.is_dir() => Ok(p),
Ok(_) => Err(format!("{flag} {d}: not a folder")),
Err(e) => Err(format!("{flag} {d}: {e}")),
})
.collect::<std::result::Result<Vec<_>, String>>()
};
Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? })
}
/// The resolved path to use for `access` to `path`, or why it is refused.
pub fn check(&self, path: &Path, access: Access) -> std::result::Result<PathBuf, String> {
let asked = path.display();
let (roots, what) = match access {
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
Access::Write => (self.write.clone(), "written"),
};
match resolve(path) {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
// Why a path can't be resolved says what exists there: only told for paths inside a root.
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
}
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
}
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
pub fn to_json(&self) -> serde_json::Value {
let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::<Vec<_>>();
serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)})
}
}
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
lexical.pop();
}
Component::CurDir => {}
c => lexical.push(c),
}
}
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
}
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
/// false when either can't be resolved.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
}
/// `p` without Windows' `\\?\` prefix, for messages.
fn shown(p: &Path) -> String {
let s = p.to_string_lossy();
match s.strip_prefix(r"\\?\UNC\") {
Some(unc) => format!(r"\\{unc}"),
None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(),
}
}
/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its
/// nearest existing folder resolved with the rest appended (plain names only).
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("not a file path".into());
}
let abs = std::path::absolute(path).map_err(|e| e.to_string())?;
let mut rest: Vec<OsString> = Vec::new();
let mut at = abs.as_path();
loop {
match std::fs::canonicalize(at) {
Ok(mut base) => {
base.extend(rest.iter().rev());
return Ok(base);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Writing through a link that leads nowhere would create its target, wherever it is.
if std::fs::symlink_metadata(at).is_ok() {
return Err("a link that leads nowhere".into());
}
match at.components().next_back() {
Some(Component::Normal(name)) => {
plain_name(name)?;
rest.push(name.to_os_string());
}
Some(Component::CurDir) => {}
_ => return Err("`..` (or a drive) below a folder that doesn't exist".into()),
}
at = at.parent().ok_or("no existing folder")?;
}
Err(e) => return Err(e.to_string()),
}
}
}
/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…)
/// and no alternate data stream (`a:b`).
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
if cfg!(windows) {
let n = name.to_string_lossy();
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.chars().count() == 4
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
if device || n.contains(':') {
return Err(format!("`{n}` is a device or stream name, not a file"));
}
}
Ok(())
}
thread_local! {
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
}
/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`).
pub fn confined<R>(roots: Option<&Arc<Roots>>, f: impl FnOnce() -> R) -> R {
match roots {
Some(roots) => scoped(Some(roots.clone()), f),
None => f(),
}
}
/// Run `f` unconfined: only for the app's own files, never for a path a command was given.
// Only plugins read from disk use it, and the browser has no disk.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn app_owned<R>(f: impl FnOnce() -> R) -> R {
scoped(None, f)
}
fn scoped<R>(roots: Option<Arc<Roots>>, f: impl FnOnce() -> R) -> R {
/// Puts the outer scope's roots back, also when `f` panics.
struct Restore(Option<Arc<Roots>>);
impl Drop for Restore {
fn drop(&mut self) {
let outer = self.0.take();
ROOTS.with(|r| *r.borrow_mut() = outer);
}
}
let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots)));
f()
}
/// The roots this thread's file access is confined to now, if any.
pub fn current() -> Option<Arc<Roots>> {
ROOTS.with(|r| r.borrow().clone())
}
/// The path to use for `access` to `path`: resolved when confined, else as given.
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
let path = path.as_ref();
match current() {
Some(roots) => roots.check(path, access).map_err(EngineError::Other),
None => Ok(path.to_path_buf()),
}
}
/// Whether `path` may be accessed so now.
pub fn allowed(path: impl AsRef<Path>, access: Access) -> bool {
allow(path, access).is_ok()
}
fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
move |e| EngineError::Other(format!("{}: {e}", path.display()))
}
/// The bytes of file `path`.
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
}
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
let path = path.as_ref();
let p = allow(path, Access::Read)?;
let meta = std::fs::metadata(&p).map_err(io(path))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
}
std::fs::read(p).map_err(io(path))
}
/// File `path` opened for reading.
pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
let path = path.as_ref();
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
}
/// What the file system says about `path` (links followed).
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
let path = path.as_ref();
std::fs::metadata(allow(path, Access::Read)?).map_err(io(path))
}
/// Whether `path` is a folder (false when it may not be read).
pub fn is_dir(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok_and(|m| m.is_dir())
}
/// Whether `path` exists (false when it may not be read).
pub fn exists(path: impl AsRef<Path>) -> bool {
metadata(path).is_ok()
}
/// The paths of the entries of folder `path`.
pub fn read_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
let path = path.as_ref();
let dir = allow(path, Access::Read)?;
Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect())
}
/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see
/// [`figcraft_format::write_file_atomic`]).
pub fn write(path: impl AsRef<Path>, bytes: &[u8]) -> Result<()> {
let path = path.as_ref();
write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path))
}
#[cfg(not(target_arch = "wasm32"))]
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
figcraft_format::write_file_atomic(path, bytes)
}
#[cfg(target_arch = "wasm32")]
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser"))
}
/// Create folder `path` and the folders above it that are missing.
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path))
}
#[cfg(test)]
#[path = "file_access_tests.rs"]
mod tests;
+371
View File
@@ -0,0 +1,371 @@
//! Automation roots: how paths are resolved and compared, and that commands run confined can't
//! reach past them.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::json;
use super::{Access, Roots, allow, confined, current};
use crate::Session;
/// A fresh folder for one test: `<tmp>/figcraft-roots-<name>-<pid>`, holding `root/`, `root2/`
/// (a sibling whose name starts like the root's) and `outside/`.
struct Fixture(PathBuf);
impl Fixture {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for sub in ["root/sub", "root2", "outside"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
std::fs::write(d.join("root/in.txt"), b"in").unwrap();
std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap();
Self(d)
}
fn p(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().into_owned()
}
/// The root as both read and write root.
fn rw(&self) -> Roots {
Roots::new(&[], &[self.p("root")]).unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(unix)]
fn symlink_dir(target: &Path, link: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
fn symlink_dir(target: &Path, link: &Path) -> bool {
std::os::windows::fs::symlink_dir(target, link).is_ok()
}
#[cfg(unix)]
fn symlink_file(target: &Path, link: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
fn symlink_file(target: &Path, link: &Path) -> bool {
std::os::windows::fs::symlink_file(target, link).is_ok()
}
#[test]
fn inside_is_allowed_and_resolved_outside_is_refused() {
let f = Fixture::new("inside");
let r = f.rw();
let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap();
assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path");
assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok());
let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}");
assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err());
// The root itself is inside.
assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok());
}
#[test]
fn a_sibling_whose_name_starts_like_the_root_is_outside() {
let f = Fixture::new("prefix");
let r = f.rw();
for p in ["root2/x.png", "root2", "root.png"] {
assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}");
assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}");
}
}
#[test]
fn traversal_is_resolved_before_comparing() {
let f = Fixture::new("dots");
let r = f.rw();
assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err());
assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err());
assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok());
assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok());
// `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically,
// as the system does, and stays inside).
let deep = f.p("root/missing/../../outside/x.txt");
assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}");
}
#[test]
fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() {
let f = Fixture::new("missing");
let r = f.rw();
let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap();
assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png"));
assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err());
}
#[test]
fn relative_paths_are_resolved_against_the_working_directory() {
// `cargo test` runs in the crate's folder, which holds Cargo.toml.
let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned();
let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap();
assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap());
assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok());
let f = Fixture::new("relative");
assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err());
// Relative roots too.
assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here);
}
#[test]
fn read_and_write_authority_are_separate() {
let f = Fixture::new("modes");
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err();
assert!(e.contains("no folder may be written"), "{e}");
// A write root may be read too; a read root elsewhere doesn't make it writable.
let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap();
assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok());
assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err());
// Several roots of each kind.
let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap();
assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok());
assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err());
}
#[test]
fn roots_must_be_existing_folders() {
let f = Fixture::new("bad-roots");
assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root"));
assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder"));
assert!(Roots::new(&[], &[String::new()]).is_err());
assert!(f.rw().check(Path::new(""), Access::Read).is_err());
}
#[test]
fn links_that_lead_out_of_a_root_are_outside() {
let f = Fixture::new("links");
let r = f.rw();
if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err());
assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err());
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err());
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err());
// A link that leads nowhere: writing through it would create its target.
assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")));
let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
// A link inside the root to the root is fine.
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
}
#[cfg(windows)]
#[test]
fn windows_case_devices_and_streams() {
let f = Fixture::new("windows");
let r = f.rw();
// Case is taken from the disk: another spelling of the root is the root.
let upper = f.0.join("ROOT").join("IN.TXT");
assert!(r.check(&upper, Access::Read).is_ok());
for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] {
assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}");
}
assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok());
// The `\\?\` form of a path inside is inside; of a path outside, outside.
let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png");
assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
assert!(r.check(&verbatim, Access::Write).is_ok());
assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err());
// Messages don't show the prefix.
let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err();
assert!(!e.contains(r"\\?\"), "{e}");
}
#[test]
fn a_scope_confines_the_thread_and_ends_with_it() {
let f = Fixture::new("scope");
let roots = Arc::new(f.rw());
assert!(current().is_none());
confined(Some(&roots), || {
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
assert!(super::read(f.p("outside/secret.txt")).is_err());
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
// Not confined: the app's own files.
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
assert!(current().is_some(), "back in the scope after app_owned");
// Another thread isn't confined by this one's scope.
let p = f.p("outside/secret.txt");
assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap());
});
assert!(current().is_none());
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok());
// A panic inside the scope still ends it.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom"))));
assert!(r.is_err());
assert!(current().is_none());
// `None` changes nothing.
assert!(confined(None, || current().is_none()));
}
#[test]
fn commands_run_confined_read_and_write_only_inside() {
let f = Fixture::new("commands");
std::fs::create_dir_all(f.0.join("outside/readable")).unwrap();
let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap());
// Unconfined, the outside is writable: the bait files a confined command must not reach.
let bait = f.p("outside/bait.figcraft");
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap();
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")];
confined(Some(&roots), || {
let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string();
assert!(e.contains("automation path rejected"), "{e}");
assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok());
assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err());
assert!(s.execute("file.open", &json!({"path": bait})).is_err());
assert!(s.execute("file.import", &json!({"path": bait})).is_err());
assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err());
assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read");
assert!(s.execute("file.save", &json!({})).is_err(), "but not written");
assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok());
// Recent files outside the roots aren't listed.
let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string();
assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}");
assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err());
});
assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists());
}
/// Every command, given paths outside the roots in its usual path parameters, writes nothing
/// there and opens nothing from there: they all reach files through `file_access`.
#[test]
fn no_command_reaches_outside_the_roots() {
let f = Fixture::new("sweep");
let roots = Arc::new(f.rw());
let outside = f.0.join("outside");
// Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a
// name that must never show up in a document or a result.
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap();
s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap();
s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap();
std::fs::write(
outside.join("bait.svg"),
r#"<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><rect id="BAIT-LAYER" width="9" height="9"/></svg>"#,
)
.unwrap();
let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
// The bait is what an unconfined command finds.
for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] {
let mut s = Session::new();
s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap();
assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}");
}
let mut s = Session::new();
s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap();
assert!(!s.doc().unwrap().images.is_empty());
for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] {
let path = f.p(&format!("outside/{bait}"));
let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true});
for c in crate::all_commands() {
let mut s = Session::new();
let r = confined(Some(&roots), || s.execute(c.id, &params));
let shown = format!("{r:?}");
assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id);
let docs = crate::inspect::document(&s, 64, true).to_string();
assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id);
assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id);
let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id);
}
}
}
#[test]
fn recent_files_outside_the_roots_are_never_opened_removed_or_named() {
let f = Fixture::new("recents");
let roots = Arc::new(f.rw());
let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft"));
let mut s = Session::new();
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
s.execute("file.saveAs", &json!({"path": ok})).unwrap();
let mut s = Session::new();
let recents = vec![bait.clone(), gone.clone(), ok.clone()];
s.prefs.recent_files = recents.clone();
let quiet = |r: &crate::Result<serde_json::Value>| {
let shown = format!("{r:?}");
assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}");
};
confined(Some(&roots), || {
let listing = s.execute("file.openRecent", &json!({}));
quiet(&listing);
assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1);
// `index` counts the files listed: 0 is the first one inside the roots.
let opened = s.execute("file.openRecent", &json!({"index": 0}));
quiet(&opened);
assert!(opened.is_ok(), "{opened:?}");
assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str()));
for i in [1, 2] {
let r = s.execute("file.openRecent", &json!({"index": i}));
quiet(&r);
assert!(r.is_err());
}
// A path outside is refused like any outside path, and never dropped from the list.
let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string();
assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}");
quiet(&s.execute("file.openRecent", &json!({"remove": bait})));
assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone));
// Clear clears what is listed.
quiet(&s.execute("file.openRecent", &json!({"clear": true})));
});
assert_eq!(s.prefs.recent_files, vec![bait, gone]);
// Unconfined, a recent file that is gone is still dropped.
s.execute("file.openRecent", &json!({"index": 1})).unwrap_err();
assert_eq!(s.prefs.recent_files.len(), 1);
}
#[test]
fn outside_paths_are_refused_alike_whatever_exists_there() {
let f = Fixture::new("oracle");
let r = f.rw();
let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err();
let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display());
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
outside.push(f.p("outside/dangling.txt"));
}
for p in &outside {
let e = refusal(p);
assert!(e.starts_with(&standard(p)), "{p}: {e}");
}
// Inside a root the reason is given.
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere"));
}
}
#[test]
fn only_resolved_paths_count_as_the_apps_own_folder() {
let f = Fixture::new("own");
let install = f.0.join("root");
assert!(super::within_app_folder(&install.join("in.txt"), &install));
assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it");
assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another");
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
}
@@ -0,0 +1,40 @@
//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be
//! reopened (JSON writes NaN and infinity as null).
use serde_json::json;
use crate::Session;
fn rect(s: &mut Session) -> crate::NodeId {
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap();
crate::params::id_of(&r["id"]).unwrap()
}
fn assert_roundtrips(s: &Session) {
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens");
}
#[test]
fn huge_moves_are_refused_and_the_file_still_reopens() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok();
let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308}));
assert!(second.is_err(), "a move past the f64 range is refused");
let n = s.doc().unwrap().node(r).unwrap();
assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform);
assert_roundtrips(&s);
}
#[test]
fn huge_scale_is_refused_and_the_file_still_reopens() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok();
let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300}));
assert!(second.is_err(), "a size past the f64 range is refused");
let n = s.doc().unwrap().node(r).unwrap();
assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height);
assert_roundtrips(&s);
}
+100
View File
@@ -0,0 +1,100 @@
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
//! ranges, text styles and the string variables bound to a font family. Text in such a family
//! is drawn in a fallback font, so the user is told which ones are missing.
use std::collections::BTreeMap;
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::{Result, Session, spec};
/// A family the document names that no installed or built-in font provides.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Missing {
/// As the document spells it.
pub family: String,
/// Text layers that use it (0: only a text style or a variable names it).
pub layers: usize,
}
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
/// installed fonts are indexed in the background: ask once that's done
/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing.
pub fn missing(doc: &Document) -> Vec<Missing> {
// Lower-case family → (spelling, layers).
let mut used: BTreeMap<String, (String, usize)> = BTreeMap::new();
let mut add = |family: &str, layer: bool| {
let family = family.trim();
if family.is_empty() {
return;
}
let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0));
e.1 += usize::from(layer);
};
for n in doc.nodes() {
if let NodeKind::Text(t) = &n.kind {
let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect();
families.push(&t.style.font_family);
families.sort_by_key(|f| f.trim().to_lowercase());
families.dedup_by_key(|f| f.trim().to_lowercase());
for f in families {
add(f, true);
}
}
if let Some(var) = n.bound.get("fontFamily") {
for f in string_values(&doc.variables, var) {
add(&f, false);
}
}
}
for st in &doc.styles {
if let StyleKind::Text { style } = &st.kind {
add(&style.font_family, false);
}
if let Some(var) = st.bound.get("fontFamily") {
for f in string_values(&doc.variables, var) {
add(&f, false);
}
}
}
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
}
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut todo = vec![(id.to_string(), 0)];
while let Some((id, depth)) = todo.pop() {
let Some(var) = vars.get(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
_ => {}
}
}
}
out
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
)]
}
#[cfg(test)]
#[path = "fonts_tests.rs"]
mod tests;
+126
View File
@@ -0,0 +1,126 @@
use serde_json::json;
use super::*;
use crate::params::id_of;
use figcraft_doc::{Style, StyleRun, TextStyle};
fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId {
let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
t.style.font_family = family.into();
}
id
}
fn families(s: &mut Session) -> Vec<(String, u64)> {
let r = s.execute("text.missingFonts", &json!({})).unwrap();
r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect()
}
/// An installed family (built in, or found by the system scan), if this machine has any.
fn installed() -> Option<String> {
figcraft_text::scan_system_fonts();
figcraft_text::family_names().into_iter().next()
}
#[test]
fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
let mut s = Session::new();
text(&mut s, "FigCraft Missing A");
let b = text(&mut s, "figcraft missing a");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind {
let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() };
t.runs.push(StyleRun { start: 0, end: 3, style });
}
let doc = s.doc_mut().unwrap();
doc.styles.push(Style {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap();
s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap();
let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone();
s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var);
assert_eq!(
families(&mut s),
vec![
("FigCraft Missing A".to_string(), 2),
("FigCraft Missing B".to_string(), 1),
("FigCraft Missing C".to_string(), 0),
("FigCraft Missing D".to_string(), 0),
("FigCraft Missing H".to_string(), 0),
]
);
assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts"));
}
#[test]
fn installed_families_are_not_listed() {
let Some(family) = installed() else {
eprintln!("skipped: no fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
text(&mut s, &family.to_uppercase());
text(&mut s, "FigCraft Missing E");
assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]);
}
#[test]
fn a_family_found_only_by_the_system_scan_is_not_missing() {
figcraft_text::scan_system_fonts();
let embedded: Vec<String> = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect();
let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else {
eprintln!("skipped: no system fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
assert!(families(&mut s).is_empty(), "{family} is installed");
}
#[test]
fn blank_families_and_aliases_are_handled() {
let mut s = Session::new();
text(&mut s, " ");
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap();
let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone();
let t = text(&mut s, "FigCraft Missing G");
let doc = s.doc_mut().unwrap();
// A variable that only aliases another, and one aliasing itself.
let mut alias = doc.variables.get(&base).unwrap().clone();
alias.id = "V:alias".into();
alias.name = "Alias".into();
for v in alias.values.values_mut() {
*v = VariableValue::Alias(base.clone());
}
let mut cycle = alias.clone();
cycle.id = "V:cycle".into();
cycle.name = "Cycle".into();
for v in cycle.values.values_mut() {
*v = VariableValue::Alias("V:cycle".into());
}
doc.variables.variables.push(alias);
doc.variables.variables.push(cycle);
doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into());
let other = text(&mut s, "FigCraft Missing G");
s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into());
// The bound layer takes the value its alias resolves to (bindings are applied after the next
// command); the cycle resolves to nothing.
let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() };
assert_eq!(bound.style.font_family, "FigCraft Missing F");
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
+23 -1
View File
@@ -1,6 +1,7 @@
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
//! hidden, deleted or other pages' frames do nothing.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
let flat = [Point::new(1.0, 1.0); 4];
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
}
#[test]
fn double_clicking_a_label_asks_to_rename_its_frame() {
let (mut s, f, q) = setup();
let c = center(&q);
let before = s.doc().unwrap().world_bounds(f).unwrap();
let undo = s.ds().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
s.pointer(&ev(kind, c)).unwrap();
}
assert_eq!(s.selection(), vec![f]);
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
// Other tools ignore the label.
s.ui_requests.clear();
s.set_tool(crate::tools::ToolId::Hand);
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
}
@@ -0,0 +1,161 @@
//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than
//! quietly meaning "none".
//!
//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id —
//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the
//! layer landed on the page while the call reported success. The same shape sat on `image.place`
//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets
//! layers (an entry that wasn't an id was dropped and the rest ran).
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::*;
use serde_json::{Value, json};
/// The page's children, by name (bottom first).
fn top(s: &Session) -> Vec<String> {
let doc = s.doc().unwrap();
let page = s.page().unwrap();
doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect()
}
/// The message of a failure, or `None` when the call went through.
fn failed(r: std::result::Result<Value, EngineError>) -> Option<String> {
match r {
Ok(_) => None,
Err(e) => Some(e.to_string()),
}
}
fn rect(parent: Value) -> Value {
json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10})
}
fn card(s: &mut Session) -> NodeId {
let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
id_of(&v["id"]).unwrap()
}
#[test]
fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() {
let mut s = Session::new();
let card = card(&mut s);
for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] {
let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused"));
assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}");
}
assert_eq!(top(&s), ["Card"], "nothing landed on the page");
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame");
}
#[test]
fn leaving_the_parent_out_or_null_still_means_the_page() {
let mut s = Session::new();
let card = card(&mut s);
for parent in [None, Some(json!(null))] {
let mut p = rect(json!(0));
match parent {
None => _ = p.as_object_mut().unwrap().remove("parent"),
Some(v) => p["parent"] = v,
}
s.execute("node.create", &p).unwrap();
}
assert_eq!(top(&s), ["Card", "child", "child"]);
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty());
}
#[test]
fn a_parent_id_that_names_nothing_is_still_reported() {
let mut s = Session::new();
card(&mut s);
let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail"));
assert!(msg.contains("no such node 99999"), "{msg}");
}
#[test]
fn an_index_that_is_not_a_whole_non_negative_number_is_refused() {
let mut s = Session::new();
let card = card(&mut s);
// (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every
// other number, so it is deliberately not in this list.)
for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] {
let mut p = rect(json!(card));
p["index"] = index.clone();
let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused"));
assert!(msg.contains("`index`"), "{index}: {msg}");
}
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted");
}
#[test]
fn a_whole_index_still_inserts_where_it_says() {
let mut s = Session::new();
let card = card(&mut s);
for name in ["a", "b"] {
s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
}
s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10}))
.unwrap();
let doc = s.doc().unwrap();
let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect();
assert_eq!(names, ["a", "mid", "b"]);
}
#[test]
fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() {
let mut s = Session::new();
let card = card(&mut s);
// The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame.
for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] {
let msg =
failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused"));
assert!(msg.contains("`ids`"), "{ids}: {msg}");
}
let doc = s.doc().unwrap();
assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed");
}
#[test]
fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() {
let mut s = Session::new();
let card = card(&mut s);
let msg =
failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail"));
assert!(msg.contains("`ids` must be an array"), "{msg}");
// An explicit null says the same as leaving it out: the selection.
s.execute("select.set", &json!({"ids": [card]})).unwrap();
s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected");
}
#[test]
fn a_single_id_that_is_not_an_id_is_refused() {
let mut s = Session::new();
card(&mut s);
let msg =
failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail"));
assert!(msg.contains("`id` must be a node id"), "{msg}");
}
#[test]
fn an_image_and_an_instance_parent_are_checked_the_same_way() {
let mut s = Session::new();
card(&mut s);
let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}})));
assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place");
let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}})));
assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent");
}
#[test]
fn a_huge_bad_value_is_cut_short_in_the_error() {
let mut s = Session::new();
card(&mut s);
let junk = "é".repeat(10_000);
let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail"));
assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}");
assert!(msg.chars().count() < 200, "{} chars", msg.chars().count());
}
@@ -0,0 +1,190 @@
//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or
//! keep a fixed size of its own, and that override is kept through relayout, edits of the main
//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import,
//! detach and reset.
use base64::Engine;
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{copy_of, ex, healthy, idv, node};
use figcraft_doc::*;
/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding
/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of
/// it. Returns (component, atom, dot, instance).
fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"})));
ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}}));
let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"})));
ex(
s,
"node.set",
json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}),
);
let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}})));
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100})));
(c, atom, dot, i)
}
fn sizing_h(s: &Session, id: NodeId) -> Sizing {
node(s, id).child_layout.sizing_h
}
/// The layer named `name` below instance `inst`.
fn named(s: &Session, inst: NodeId, name: &str) -> NodeId {
let doc = s.doc().unwrap();
doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap()
}
/// The first instance in the document.
fn instance(s: &Session) -> NodeId {
s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id
}
fn open(s: &mut Session, bytes: &[u8], name: &str) {
ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name}));
}
#[test]
fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() {
let mut s = Session::new();
let (c, atom, dot, i) = field(&mut s);
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide");
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance");
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
// Edits of the main relay the instance out: the override stays, other properties follow.
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}}));
assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0));
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}}));
assert_eq!(node(&s, atom).width, 120.0);
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0)));
assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main");
// The main fixing its own width is the same property: the instance keeps filling.
ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}}));
assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0));
// A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves.
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0));
healthy(&s);
// Back to the main's resizing: no override left.
ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width));
assert!(!node(&s, ia).overrides.contains("resizing"));
healthy(&s);
}
#[test]
fn size_and_resizing_overrides_undo_reset_and_detach() {
let mut s = Session::new();
let (_, atom, dot, i) = field(&mut s);
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0));
assert!(node(&s, ia).overrides.is_empty());
// A fixed size of its own: kept when the main resizes the layer.
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}}));
assert!(node(&s, idot).overrides.contains("size"));
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0));
// A min width of its own.
ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}}));
assert_eq!(node(&s, ia).width, 150.0);
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}}));
assert_eq!(node(&s, ia).width, 150.0);
healthy(&s);
// Reset all changes: the main's values again.
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0));
assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty());
// Detaching keeps what the instance showed.
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
ex(&mut s, "component.detach", json!({"ids": [i]}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None));
healthy(&s);
}
#[test]
fn layers_in_instances_resize_but_keep_their_place() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
let it = copy_of(&s, i, title);
ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}}));
assert_eq!(node(&s, it).width, 150.0, "a text box resizes");
assert!(node(&s, it).overrides.contains("size"));
// Its place, rotation and constraints are still the main's.
for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] {
assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused");
}
// The instance's own resizing is its own, not an override.
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}}));
assert!(!node(&s, i).overrides.contains("resizing"));
healthy(&s);
}
#[test]
fn resizing_overrides_survive_save_and_reopen() {
let mut s = Session::new();
let (_, atom, dot, i) = field(&mut s);
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}}));
let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] {
let mut s = Session::new();
open(&mut s, &bytes, name);
let i = instance(&s);
let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot"));
assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides);
assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides);
// An edit of the main relays the instance out.
let main = |s: &Session, id: NodeId| node(s, id).source.unwrap();
let (atom, dot) = (main(&s, ia), main(&s, idot));
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}}));
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}");
healthy(&s);
}
}
#[test]
fn imported_resizing_overrides_survive_relayout() {
let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}});
let atom = |id: &str, dot_id: &str, w: f64, more: Value| {
let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20},
"layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]});
a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
a
};
let field = |id: &str, ty: &str, y: f64, kids: Value| {
json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL",
"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids})
};
let mut inst =
field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))]));
inst["componentId"] = json!("1:1");
inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]);
let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]}
]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}});
let mut s = Session::new();
open(&mut s, file.to_string().as_bytes(), "field.json");
let i = instance(&s);
let ia = named(&s, i, "Atom");
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
// The main's first edit relays every instance out from it.
let main_atom = node(&s, ia).source.unwrap();
ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
healthy(&s);
}
+79 -3
View File
@@ -6,6 +6,8 @@
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
//! edits and are journaled the same way.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml).
#![cfg_attr(test, allow(clippy::disallowed_methods))]
pub mod al_keys;
pub mod annotations;
@@ -24,7 +26,10 @@ pub mod dev_resources;
pub mod devcompare;
pub mod devinspect;
pub mod distance;
pub mod doc_urls;
pub mod drop;
pub mod file_access;
pub mod fonts;
pub mod guard;
pub mod guides;
pub mod inspect;
@@ -43,6 +48,8 @@ pub mod plugins;
pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_colors;
pub mod selection_spacing;
pub mod shape_handles;
pub mod shapebuilder;
pub mod snap;
@@ -369,6 +376,8 @@ pub struct DocState {
pub guide: Option<(NodeId, usize)>,
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
/// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]).
view_from_prefs: bool,
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
@@ -430,6 +439,7 @@ impl DocState {
focus: None,
guide: None,
rotation_origin: None,
view_from_prefs: false,
last_duplicate: None,
};
st.zoom_to_fit();
@@ -609,6 +619,9 @@ pub struct Prefs {
/// The widths of the main window's left and right panels (the interface keeps them within its
/// limits; other windows have their own while they are open).
pub panel_widths: (f32, f32),
/// The height asked for the main window's pages list, dragged at its lower edge (`None`: a
/// share of the panel; the interface keeps it within its limits).
pub pages_height: Option<f32>,
/// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser
/// language). Languages without a catalog show English.
pub language: String,
@@ -655,6 +668,10 @@ pub struct Prefs {
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
/// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id:
/// files opened or made later start with them. Missing: the toggle's default.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub view: BTreeMap<String, bool>,
/// Recently run actions (the Actions palette's Recents), newest first, at most
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
/// `widget:<plugin>`.
@@ -677,6 +694,7 @@ impl Default for Prefs {
show_dimensions: true,
theme: "system".into(),
panel_widths: (240.0, 240.0),
pages_height: None,
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
@@ -692,6 +710,7 @@ impl Default for Prefs {
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
view: BTreeMap::new(),
recent_actions: Vec::new(),
}
}
@@ -844,6 +863,14 @@ impl Session {
s
}
/// Files new to the session start with the View toggles the preferences keep.
pub fn apply_view_prefs(&mut self) {
for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) {
cmd::view::apply_kept(&self.prefs, &mut ds.view);
ds.view_from_prefs = true;
}
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
@@ -885,6 +912,7 @@ impl Session {
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
}
branches::sync(self);
self.apply_view_prefs();
if textpreview::COMMANDS.contains(&id) {
// A live preview is no edit: no undo step, normalisation or rollback here.
let r = guard::run(id, || (spec.run)(self, params));
@@ -905,10 +933,12 @@ impl Session {
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
match &result {
Ok(_) => {
self.after_command(spec, before);
self.apply_view_prefs();
if spec.history && id != "edit.repeat" {
self.last_command = Some((id.to_string(), params.clone()));
}
@@ -947,6 +977,22 @@ impl Session {
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
}
/// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file
/// holding them saves but can't be reopened. Refuse such an edit (it is rolled back).
fn check_finite_geometry(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
if Arc::ptr_eq(&ds.doc, bdoc) {
return Ok(());
}
let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite());
let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b));
match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) {
Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))),
None => Ok(()),
}
}
/// A live session's document you may only view (or that is on its way to a server) is
/// read-only: refuse an edit (it is rolled back).
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@@ -1166,13 +1212,22 @@ impl Session {
self.grouped(label, |s| {
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
for (step, (id, p)) in cmds.iter().enumerate() {
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
results.push(s.execute(id, &p)?);
let v = s.batch_step(&results, step, id, p)?;
results.push(v);
}
Ok(results)
})
}
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
/// resolve `$step` the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
self.execute(cmd, &p)
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
@@ -1541,6 +1596,8 @@ mod devfocus_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod doc_urls_tests;
#[cfg(test)]
mod double_click_tests;
#[cfg(test)]
mod draw_tools_tests;
@@ -1561,6 +1618,8 @@ mod fig_export_tests;
#[cfg(test)]
mod fig_import_tests;
#[cfg(test)]
mod finite_geometry_tests;
#[cfg(test)]
mod frame_guides_tests;
#[cfg(test)]
mod frame_label_tests;
@@ -1583,18 +1642,24 @@ mod html_codegen_tests;
#[cfg(test)]
mod huge_stroke_tests;
#[cfg(test)]
mod id_params_behaviour_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod instance_resizing_tests;
#[cfg(test)]
mod interaction_rollback_tests;
#[cfg(test)]
mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_path_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
@@ -1621,16 +1686,22 @@ mod menu_gaps_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_clipboard_tests;
#[cfg(test)]
mod page_copy_navigation_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_index_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
@@ -1639,6 +1710,8 @@ mod panels_behaviour_tests;
#[cfg(test)]
mod pdf_print_tests;
#[cfg(test)]
mod pen_end_tests;
#[cfg(test)]
mod pen_overlay_tests;
#[cfg(test)]
mod pixel_preview_tests;
@@ -1758,3 +1831,6 @@ mod version_tests;
mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod selection_spacing_tests;
+34 -5
View File
@@ -449,9 +449,27 @@ struct Stamp {
#[derive(Clone, Debug)]
struct Entry {
stamp: Option<Stamp>,
/// When the file was read: a stamp only proves the file unchanged once its modification time
/// is safely older than that, since file systems keep the time in coarse ticks.
read_at: Option<std::time::SystemTime>,
result: std::result::Result<Arc<PublishedLibrary>, String>,
}
/// How far a file's modification time must lie before its read for the stamp to be trusted.
const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2);
impl Entry {
/// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on
/// every load until the clock passes it.
fn still_valid(&self, now: &Stamp) -> bool {
let settled = match (now.modified, self.read_at) {
(Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at),
_ => false,
};
settled && self.stamp.as_ref() == Some(now)
}
}
/// Library files read by a session, by path (re-read when they change on disk).
#[derive(Clone, Debug, Default)]
pub struct Cache {
@@ -481,10 +499,11 @@ impl Cache {
return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024)));
}
if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp)
&& e.stamp.as_ref() == Some(st)
&& e.still_valid(st)
{
return e.result.clone().map_err(EngineError::Other);
}
let read_at = now();
let result = match &stamp {
Ok(_) => read_file(path).map(Arc::new),
Err(e) => Err(e.clone()),
@@ -492,7 +511,7 @@ impl Cache {
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
self.entries.clear();
}
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() });
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() });
result.map_err(EngineError::Other)
}
@@ -506,13 +525,23 @@ impl Cache {
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
self.entries.clear();
}
self.entries.insert(path.to_string(), Entry { stamp: None, result });
self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result });
}
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> Option<std::time::SystemTime> {
Some(std::time::SystemTime::now())
}
#[cfg(target_arch = "wasm32")]
fn now() -> Option<std::time::SystemTime> {
None
}
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
}
@@ -526,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
match figcraft_format::read_library(std::io::BufReader::new(f)) {
Ok(Some(lib)) => Ok(lib),
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
+30
View File
@@ -0,0 +1,30 @@
//! A library link is stored in the document, so it is followed only on this computer
//! ([`crate::library_server`]): a path on another server would make opening someone else's file
//! contact that server, and on Windows send the credentials of whoever opened it.
use crate::Session;
#[test]
fn a_library_is_opened_only_from_this_computer() {
let mut s = Session::new();
for path in [
r"\\attacker.example\share\lib.figcraft",
"//attacker.example/share/lib.figcraft",
"file://attacker.example/share/lib.figcraft",
r"\\?\UNC\attacker.example\share\lib.figcraft",
r"\\.\pipe\lib",
"https://attacker.example/lib.figcraft",
] {
let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused"));
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
}
}
#[test]
fn a_local_library_path_is_still_followed() {
let mut s = Session::new();
// Refused for being absent, not for where it is: the gate let it through.
let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft");
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
}
+7
View File
@@ -129,6 +129,13 @@ impl Session {
/// document uses the library.
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
if !is_server_link(path) {
// A library link is stored in the document, so opening someone else's file would
// otherwise reach whatever path it names. Only a path on this computer is followed: a
// network share would make opening a document contact that server, and on Windows
// send the credentials of whoever opened it.
if !craft_docpath::is_local(path) {
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
}
return self.libraries.load(path);
}
let have = match self.libraries.cached(path) {
+18
View File
@@ -445,3 +445,21 @@ fn library_survives_saving_with_versions() {
healthy(&user);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
use std::time::{Duration, SystemTime};
let read_at = SystemTime::now();
let entry = |modified: SystemTime| Entry {
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
read_at: Some(read_at),
result: Err("unused".into()),
};
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
let fresh = entry(read_at);
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
// Written well before the read: an unchanged stamp means an unchanged file.
let old = read_at - Duration::from_secs(10);
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
}
@@ -0,0 +1,95 @@
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
//! `fontStyle` the family lacks says which face draws instead.
use serde_json::{Value, json};
use crate::{EngineError, Session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> crate::NodeId {
crate::params::id_of(v).unwrap()
}
fn bad_msg(r: crate::Result<Value>) -> String {
match r {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
let mut s = Session::new();
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]);
for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] {
let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}})));
assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}");
assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}");
}
// Frames still take them, with or without a layout mode first.
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
assert!(out.get("warnings").is_none(), "{out}");
}
#[test]
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
let mut s = Session::new();
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
let doc = s.doc().unwrap();
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
let w = out["warnings"][0].as_str().unwrap();
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
// One undo step for all of it.
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
}
#[test]
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
figcraft_text::scan_system_fonts();
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
let st = figcraft_text::family_styles(&f);
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
});
let Some((family, styles)) = pick else { return };
let mut s = Session::new();
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
let out = set(&mut s, &style);
assert!(out.get("warnings").is_none(), "{style}: {out}");
}
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
let out = set(&mut s, "Black");
let w = out["warnings"][0].as_str().unwrap();
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
assert!(out.get("warnings").is_none(), "{out}");
}
#[test]
fn a_refused_call_changes_nothing() {
let mut s = Session::new();
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
let before = s.doc().unwrap().node(t).unwrap().fills.clone();
// `fill` comes before `gap` (keys apply in order), so this checks the rollback.
assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err());
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before);
}
+36
View File
@@ -0,0 +1,36 @@
//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a
//! selection of only pages under their own command id.
use serde_json::json;
use crate::{EngineError, Session};
#[test]
fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() {
for cmd in ["edit.copy", "edit.cut"] {
let mut s = Session::new();
let page = s.page().unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
match s.execute(cmd, &json!({"ids": [page.0]})) {
Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd),
r => panic!("{cmd} on a page: {r:?}"),
}
let _ = s.execute("edit.paste", &json!({}));
let doc = s.doc().unwrap();
assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate());
assert!(doc.node(page).is_some(), "{cmd} removed the page");
}
}
#[test]
fn cut_of_a_page_and_a_layer_moves_only_the_layer() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let rect = crate::params::id_of(&r["id"]).unwrap();
s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap();
assert!(s.doc().unwrap().node(page).is_some());
assert!(s.doc().unwrap().node(rect).is_none());
s.execute("edit.paste", &json!({})).unwrap();
assert!(s.doc().unwrap().validate().is_empty());
}
+39
View File
@@ -0,0 +1,39 @@
//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of
//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`).
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn paints(s: &Session, id: NodeId) -> String {
let n = s.doc().unwrap().node(id).unwrap();
format!("{:?} {:?}", n.fills, n.strokes)
}
#[test]
fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() {
let mut s = Session::new();
let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
for add in ["fill.add", "stroke.add"] {
for c in ["#0000FF", "#FFFF00"] {
ex(&mut s, add, json!({"ids": [r], "color": c}));
}
}
let before = paints(&s, r);
for cmd in ["fill.set", "stroke.set"] {
for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] {
let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"}));
assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}");
assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document");
}
ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"}));
assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(paints(&s, r), before);
}
}
+48 -1
View File
@@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
}
/// The offending value for an error message, cut to a few dozen characters so a huge param
/// doesn't end up in the error whole.
fn shown(v: &Value) -> String {
const MAX: usize = 40;
let text = v.to_string();
match text.char_indices().nth(MAX) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text,
}
}
/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a
/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" —
/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't
/// resolved yet silently becomes "no parent" and the layer lands on the page.
pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result<Option<NodeId>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))),
}
}
/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none".
pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result<Option<usize>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(_) => req_index(cmd, p, key).map(Some),
}
}
/// `ids` (array), `id` (single), or the current selection.
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
if let Some(Value::Array(a)) = get(p, "ids") {
@@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
s.selection()
}
/// Like [`targets`] but an empty result is an error.
/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an
/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit
/// `null` says the same as leaving the key out.
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match get(p, "ids") {
Some(Value::Array(a)) => {
if let Some(v) = a.iter().find(|v| id_of(v).is_none()) {
return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v))));
}
}
Some(Value::Null) | None => {
if let Some(v) = get(p, "id").filter(|v| !v.is_null())
&& id_of(v).is_none()
{
return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v))));
}
}
Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))),
}
let t = targets(s, p);
if t.is_empty() {
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
+72
View File
@@ -0,0 +1,72 @@
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::vectoredit::VTool;
use figcraft_doc::NodeKind;
fn click(s: &mut Session, x: f64, y: f64) {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
}
}
fn layers(s: &Session) -> usize {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
}
/// (points, segments) of vector `id`.
fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) {
match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
k => panic!("not a vector: {k:?}"),
}
}
/// A two-point pen path, ended with `end`.
fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
click(&mut s, 200.0, 100.0);
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
let undo = s.undo_depth().0;
end(&mut s);
assert!(s.tool.pen.is_none(), "the path ended");
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector");
assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end");
(s, id)
}
#[test]
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
// Escape, and Enter (its key runs `pen_end`).
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
for end in ends {
let (mut s, id) = drawn(end);
// The next path goes into the same layer, unconnected to the first.
click(&mut s, 100.0, 300.0);
click(&mut s, 200.0, 300.0);
assert_eq!(layers(&s), 1, "no new layer");
assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network");
// Escape ends that path, the next one leaves edit mode.
s.escape().unwrap();
assert!(s.ds().unwrap().vector_edit.is_some());
s.escape().unwrap();
assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode");
assert_eq!(s.ds().unwrap().selection, vec![id]);
}
}
#[test]
fn escape_before_a_segment_leaves_nothing_to_edit() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
s.escape().unwrap();
assert!(s.tool.pen.is_none());
assert!(s.ds().unwrap().vector_edit.is_none());
assert_eq!(layers(&s), 0);
}
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(path) = string(p, "path") {
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
let png = crate::cmd::file::export_bytes(&copy, &[inst], ExportFormat::Png, scale)?;
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::file_access::write(&path, &png)?;
out["path"] = json!(path);
}
Ok(out)
+11 -4
View File
@@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
// The install folder is the app's own (set by the desktop app, never by a command), so automation
// roots don't apply to it.
#[allow(clippy::disallowed_methods)]
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
#[cfg(not(target_arch = "wasm32"))]
@@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option
}
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
#[allow(clippy::disallowed_methods)]
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
@@ -208,7 +212,7 @@ pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
use crate::plugins::{module_file, read_file};
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
if crate::file_access::is_dir(&file) {
file.push(MANIFEST_FILE);
}
let text =
@@ -217,9 +221,12 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
let wasm = module_file(&file, &m)?;
let mut assets = Vec::new();
let dir = file.parent().map(|d| d.join(ASSETS));
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> =
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> = entries
.into_iter()
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
.collect();
names.sort();
if names.len() > MAX_ASSETS {
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
+20 -9
View File
@@ -251,12 +251,11 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
/// Read a file of at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
let shown = path.display();
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let meta = crate::file_access::metadata(path)?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
crate::file_access::read(path)
}
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
@@ -264,7 +263,7 @@ pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
if crate::file_access::is_dir(&file) {
file.push(MANIFEST_FILE);
}
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
@@ -295,10 +294,22 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
let Some(path) = &p.path else {
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
};
#[cfg(not(target_arch = "wasm32"))]
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
#[cfg(target_arch = "wasm32")]
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
module_from_disk(s, p, path)
}
#[cfg(not(target_arch = "wasm32"))]
fn module_from_disk(s: &Session, p: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
let file = std::path::Path::new(path);
let read = || module_file(file, &p.manifest).map(Arc::new);
// A plugin installed from a directory lives in the app's own install folder, not among the
// files automation roots confine.
let own = p.source.is_some() && s.plugins.install_dir.as_deref().is_some_and(|d| crate::file_access::within_app_folder(file, d));
if own { crate::file_access::app_owned(read) } else { read() }
}
#[cfg(target_arch = "wasm32")]
fn module_from_disk(_: &Session, _: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
Err(EngineError::Other(format!("{path}: there are no files in the browser")))
}
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
+245
View File
@@ -0,0 +1,245 @@
//! Paints used by the selection, including descendants and effective text-range fills.
//! Targets name paint slots rather than colors: a live edit must not capture other slots when
//! its color happens to become equal to another selection color.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding};
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PaintTarget {
pub id: NodeId,
pub fills: bool,
pub index: usize,
/// Character offsets, not byte offsets. None means the node's fill or stroke list.
pub range: Option<(usize, usize)>,
}
impl PaintTarget {
pub fn paints(&self, doc: &Document) -> Option<Vec<Paint>> {
let n = doc.node(self.id)?;
if let Some((start, end)) = self.range {
let t = n.kind.text()?;
if !self.fills || start > end || end > t.char_count() || (start == end && end != 0) {
return None;
}
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
} else {
Some(if self.fills { n.fills.clone() } else { n.strokes.clone() })
}
}
pub fn paint(&self, doc: &Document) -> Option<Paint> {
self.paints(doc)?.get(self.index).cloned()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct SelectionColor {
pub paint: Paint,
pub style: Option<String>,
pub variable: Option<String>,
pub targets: Vec<PaintTarget>,
}
/// Deterministic order, one target per slot even when a parent and child are both selected.
/// Images and patterns are assets rather than selection colors. Hidden paints are omitted.
pub fn collect(doc: &Document, ids: &[NodeId]) -> Vec<SelectionColor> {
let mut groups: Vec<SelectionColor> = Vec::new();
let mut seen = BTreeSet::new();
let mut by_paint: BTreeMap<(Option<String>, Option<String>, String), usize> = BTreeMap::new();
let mut pending: Vec<_> = ids.iter().rev().copied().collect();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let Some(n) = doc.node(id) else { continue };
if n.is_mask {
continue;
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
pending.extend(n.children.iter().rev().copied());
}
let mut add = |paints: &[Paint], fills: bool, range: Option<(usize, usize)>| {
for (index, paint) in paints.iter().enumerate() {
if !paint.visible || !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
continue;
}
let target = PaintTarget { id, fills, index, range };
let inherited = range.is_none_or(|(start, _)| n.kind.text().is_some_and(|t| t.style_at(start).fills.is_none()));
let style = inherited.then(|| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten();
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let variable = inherited
.then(|| {
n.bound.iter().find_map(|(key, var)| {
list_binding(key).filter(|(l, i, field)| *l == list && *i == index && *field == "color").map(|_| var.clone())
})
})
.flatten();
let Ok(value) = serde_json::to_string(paint) else { continue };
let key = (style.clone(), variable.clone(), value);
if let Some(index) = by_paint.get(&key).copied() {
if let Some(g) = groups.get_mut(index) {
g.targets.push(target);
}
} else {
by_paint.insert(key, groups.len());
groups.push(SelectionColor { paint: paint.clone(), style, variable, targets: vec![target] });
}
}
};
if let NodeKind::Text(t) = &n.kind {
let spans = t.spans();
if spans.iter().all(|(_, _, style)| style.fills.is_none()) {
add(&n.fills, true, None);
} else {
for (start, end, style) in spans {
add(style.fills.as_deref().unwrap_or(&n.fills), true, Some((start, end)));
}
}
} else {
add(&n.fills, true, None);
}
add(&n.strokes, false, None);
}
groups
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"selection.colors",
"Selection colors",
"{ids?} → {colors: [{paint, targets}]}; includes descendants and text ranges",
false,
|s, p| { Ok(json!({"colors": collect(s.doc()?, &targets(s, p))})) }
),
spec!("selection.setPaint", "Change selection color", "{targets: [{id, fills, index, range: [start,end] | null}], paint}", true, set_paint),
spec!(
"selection.applyColorStyle",
"Apply selection color style",
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
true,
apply_style
),
spec!(
"selection.selectColor",
"Select layers with color",
"{targets} → {ids}; selects the layers owning these paint slots",
false,
select_color
),
]
}
fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>> {
let ts: Vec<PaintTarget> = parse(cmd, p, "targets")?.ok_or_else(|| bad(cmd, "`targets` is required"))?;
if ts.is_empty() {
return Err(bad(cmd, "`targets` must not be empty"));
}
for t in &ts {
if t.paint(s.doc()?).is_none() {
return Err(bad(cmd, "a selection color target no longer exists"));
}
}
Ok(ts)
}
/// Detach variables only where the explicit edit changed their value. In particular, an
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
// Range fills override the node's paints; the node binding still belongs to other chars.
if t.range.is_some() {
return;
}
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
n.bound.retain(|key, _| {
let Some((l, index, field)) = list_binding(key) else { return true };
if l != list || index != t.index {
return true;
}
match field {
"color" => match (&old.kind, &new.kind) {
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
_ => false,
},
"opacity" => old.opacity == new.opacity,
_ => {
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
match (stop, &old.kind, &new.kind) {
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
}
}
}
});
}
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.setPaint";
let targets = checked_targets(s, p, CMD)?;
let paint = get(p, "paint").and_then(paint_of).ok_or_else(|| bad(CMD, "`paint` must be a paint"))?;
for target in &targets {
let mut paints = target.paints(s.doc()?).ok_or_else(|| bad(CMD, "invalid target"))?;
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
if *slot == paint {
continue;
}
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
unbind_changed(n, target, &old, &paint);
if let Some((start, end)) = target.range {
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
}
} else if target.fills {
n.fills = paints;
n.styles.fill = None;
} else {
n.strokes = paints;
n.styles.stroke = None;
}
}
Ok(json!({"ok": true}))
}
fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.applyColorStyle";
let targets = checked_targets(s, p, CMD)?;
let name = req_string(CMD, p, "style")?;
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(CMD, "a color style is required"));
}
for target in &targets {
if let Some((start, end)) = target.range {
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
// values just as an explicit range fill does; never replace its font or size.
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
let n = s.doc_mut()?.get_mut(target.id)?;
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
}
} else {
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
}
}
Ok(json!({"ok": true}))
}
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
let ts = checked_targets(s, p, "selection.selectColor")?;
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
crate::cmd::edit::select_and_page(s, ids.clone())?;
Ok(json!({"ids": ids}))
}
+132
View File
@@ -0,0 +1,132 @@
//! Smart-selection spacing in page coordinates. Tracks contain aligned siblings (columns for
//! horizontal spacing, rows for vertical spacing); moving a track preserves its internal layout.
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::Rect;
const EPS: f64 = 0.01;
#[derive(Clone, Debug)]
pub struct Track {
pub ids: Vec<NodeId>,
pub bounds: Rect,
anchor: Rect,
}
#[derive(Clone, Debug)]
pub struct Axis {
pub tracks: Vec<Track>,
pub vertical: bool,
/// None means the existing distances are mixed; setting a value makes them uniform.
pub value: Option<f64>,
}
impl Axis {
pub fn start(&self, b: Rect) -> f64 {
if self.vertical { b.y0 } else { b.x0 }
}
pub fn end(&self, b: Rect) -> f64 {
if self.vertical { b.y1 } else { b.x1 }
}
pub fn gap(&self, a: &Track, b: &Track) -> f64 {
self.start(b.bounds) - self.end(a.bounds)
}
/// Desired page-space translations, keeping the first track fixed.
pub fn offsets(&self, value: f64) -> Vec<(NodeId, f64)> {
let mut pos = self.tracks.first().map_or(0.0, |t| self.start(t.bounds));
let mut out = Vec::new();
for t in &self.tracks {
let delta = pos - self.start(t.bounds);
out.extend(t.ids.iter().map(|id| (*id, delta)));
pos += self.end(t.bounds) - self.start(t.bounds) + value;
}
out
}
}
#[derive(Clone, Debug, Default)]
pub struct SelectionSpacing {
pub horizontal: Option<Axis>,
pub vertical: Option<Axis>,
}
/// Available only for movable, visible siblings outside normal auto-layout flow. Parent/child
/// selections, instance internals and locked layers must not be moved indirectly by a gap edit.
pub fn selection_spacing(doc: &Document, ids: &[NodeId]) -> SelectionSpacing {
let Some(first) = ids.first().and_then(|id| doc.node(*id)) else { return SelectionSpacing::default() };
if ids.len() < 2 || ids.len() > 2000 {
return SelectionSpacing::default();
}
let mut items = Vec::new();
for id in ids {
let Some(n) = doc.node(*id) else { return SelectionSpacing::default() };
if n.parent != first.parent
|| !n.visible
|| doc.ancestors(*id).into_iter().any(|a| doc.node(a).is_some_and(|n| !n.visible))
|| crate::tools::locked_in(doc, *id)
|| crate::components::inside_instance(doc, *id)
|| matches!(n.kind, NodeKind::Page { .. })
|| n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && !n.child_layout.absolute)
{
return SelectionSpacing::default();
}
let determinant = n.parent.map(|p| doc.world_transform(p).determinant()).unwrap_or(1.0);
if !determinant.is_finite() || determinant.abs() < 1e-12 {
return SelectionSpacing::default();
}
let Some(b) = doc.world_bounds(*id) else { return SelectionSpacing::default() };
if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= EPS || b.height() <= EPS {
return SelectionSpacing::default();
}
if !items.iter().any(|(i, _)| i == id) {
items.push((*id, b));
}
}
SelectionSpacing { horizontal: axis(&items, false), vertical: axis(&items, true) }
}
fn axis(items: &[(NodeId, Rect)], vertical: bool) -> Option<Axis> {
let edges = |b: Rect| if vertical { (b.y0, b.y1) } else { (b.x0, b.x1) };
let mut items = items.to_vec();
items.sort_by(|a, b| edges(a.1).0.total_cmp(&edges(b.1).0).then(a.0.cmp(&b.0)));
let mut tracks: Vec<Track> = Vec::new();
for (id, b) in items {
let (lo, hi) = edges(b);
let aligned = tracks.iter_mut().find(|t| {
let (a, z) = edges(t.anchor);
(lo - a).abs() <= EPS || (hi - z).abs() <= EPS || ((lo + hi - a - z) * 0.5).abs() <= EPS
});
if let Some(t) = aligned {
t.ids.push(id);
t.bounds = t.bounds.union(b);
} else {
tracks.push(Track { ids: vec![id], bounds: b, anchor: b });
}
}
if tracks.len() < 2 {
return None;
}
tracks.sort_by(|a, b| edges(a.bounds).0.total_cmp(&edges(b.bounds).0));
// Diagonally scattered layers aren't a row/column/grid selection.
let cross = |b: Rect| if vertical { (b.x0, b.x1) } else { (b.y0, b.y1) };
let low = tracks.iter().map(|t| cross(t.bounds).0).fold(f64::NEG_INFINITY, f64::max);
let high = tracks.iter().map(|t| cross(t.bounds).1).fold(f64::INFINITY, f64::min);
if high - low <= EPS {
return None;
}
let mut result = Axis { tracks, vertical, value: None };
let gaps: Vec<f64> = result.tracks.windows(2).filter_map(|p| p.first().zip(p.get(1)).map(|(a, b)| result.gap(a, b))).collect();
result.value = gaps.first().copied().filter(|v| gaps.iter().all(|g| (g - v).abs() <= EPS));
Some(result)
}
/// A spacing gesture never changes selection. Roll it back in its owning document, preserving
/// a subsequent tab/page/selection change and leaving another document's gesture alone.
pub fn cancel_gesture(session: &mut crate::Session, uid: u64) {
if let Some(ds) = session.docs.iter_mut().find(|d| d.uid == uid)
&& let Some(it) = ds.interaction.take()
{
ds.doc = it.doc;
ds.revision += 1;
ds.clean_selection();
}
}
@@ -0,0 +1,123 @@
use crate::{Session, selection_spacing::selection_spacing};
use figcraft_doc::NodeId;
use serde_json::json;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
}
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
#[test]
fn rows_columns_and_grids_keep_track_contents_and_sizes() {
let mut s = Session::new();
// Unequal widths, left-aligned columns. The column widths are 50 and 80; gaps 30 and 20.
let a = rect(&mut s, 100., 100., 50., 40., None);
let b = rect(&mut s, 180., 100., 80., 40., None);
let c = rect(&mut s, 100., 160., 30., 60., None);
let d = rect(&mut s, 180., 160., 40., 60., None);
let ids = [a, b, c, d];
let layout = selection_spacing(s.doc().unwrap(), &ids);
assert_eq!(layout.horizontal.unwrap().value, Some(30.));
assert_eq!(layout.vertical.unwrap().value, Some(20.));
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":12,"vertical":25})).unwrap();
assert_eq!(xy(&s, a), (100., 100.));
assert_eq!(xy(&s, b), (162., 100.));
assert_eq!(xy(&s, c), (100., 165.));
assert_eq!(xy(&s, d), (162., 165.));
assert_eq!(s.doc().unwrap().node(d).unwrap().width, 40.);
s.undo().unwrap();
assert_eq!(xy(&s, d), (180., 160.));
s.redo().unwrap();
assert_eq!(xy(&s, d), (162., 165.));
s.begin_interaction("Spacing").unwrap();
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":22,"fromGesture":true})).unwrap();
s.execute("arrange.spacing", &json!({"ids":ids,"vertical":35,"fromGesture":true})).unwrap();
assert_eq!(xy(&s, d), (172., 175.), "the unrequested axis keeps the earlier edit");
s.commit_interaction().unwrap();
}
#[test]
fn mixed_gaps_normalize_and_fractional_negative_gaps_are_supported() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 160., 0., 50., 40., None);
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, None);
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":-5.5})).unwrap();
assert_eq!(xy(&s, b), (44.5, 0.));
assert_eq!(xy(&s, c), (89., 0.));
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, Some(-5.5));
}
#[test]
fn a_scrub_uses_the_original_tracks_after_crossing_and_makes_one_undo_step() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 140., 0., 50., 40., None);
let before = s.undo_depth().0;
s.begin_interaction("Spacing").unwrap();
for v in [-100., 0., 30.] {
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":v,"fromGesture":true})).unwrap();
}
assert_eq!(xy(&s, b), (80., 0.));
assert_eq!(xy(&s, c), (160., 0.));
s.commit_interaction().unwrap();
assert_eq!(s.undo_depth().0, before + 1);
s.undo().unwrap();
assert_eq!(xy(&s, c), (140., 0.));
}
#[test]
fn unsafe_selections_and_invalid_axes_are_rejected_without_changes() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
for props in [json!({"locked":true}), json!({"visible":false})] {
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
assert!(s.execute("arrange.spacing", &json!({"ids":[a,b],"horizontal":5})).is_err());
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
}
for args in [
json!({"ids":[a,b],"vertical":5}),
json!({"ids":[a,b],"horizontal":5,"vertical":5}),
json!({"ids":[a,b],"horizontal":"bad"}),
json!({"ids":[a,b],"horizontal":1e300}),
] {
assert!(s.execute("arrange.spacing", &args).is_err());
assert_eq!(xy(&s, b), (70., 0.));
}
let f = NodeId(
s.execute("frame.create", &json!({"x":0,"y":0,"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap(),
);
let x = rect(&mut s, 0., 0., 50., 40., Some(f));
let y = rect(&mut s, 70., 0., 50., 40., Some(f));
assert!(selection_spacing(s.doc().unwrap(), &[x, y]).horizontal.is_none());
assert!(selection_spacing(s.doc().unwrap(), &[a, x]).horizontal.is_none());
}
#[test]
fn rotated_parents_use_page_space_and_cancellation_stays_with_its_document() {
let mut s = Session::new();
let f = NodeId(
s.execute("frame.create", &json!({"x":100,"y":100,"width":300,"height":200,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
);
let a = rect(&mut s, 0., 0., 50., 40., Some(f));
let b = rect(&mut s, 70., 0., 50., 40., Some(f));
let before = xy(&s, b);
let uid = s.ds().unwrap().uid;
s.begin_interaction("Spacing").unwrap();
s.execute("arrange.spacing", &json!({"ids":[a,b],"vertical":40,"fromGesture":true})).unwrap();
assert!(((xy(&s, b).1 - xy(&s, a).1).abs() - 90.0).abs() < 1e-7, "50-high rotated rectangles must have a 40-point gap");
s.execute("file.new", &json!({})).unwrap();
s.begin_interaction("Unrelated").unwrap();
crate::selection_spacing::cancel_gesture(&mut s, uid);
assert!(s.ds().unwrap().interaction.is_some());
s.active = 0;
assert_eq!(xy(&s, b), before);
assert!(s.ds().unwrap().interaction.is_none());
}
+3
View File
@@ -415,6 +415,9 @@ fn every_command_survives_junk_params() {
// Online files: ids, servers, refreshes, actions and names as junk.
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
+45 -11
View File
@@ -661,6 +661,9 @@ fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool
/// handles: the canvas draws just its outline (the handles still answer just outside it).
pub const SMALL_BOX_PX: f64 = 16.0;
/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same).
pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0);
/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl).
pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) {
let [a, b, _, d] = corners;
@@ -1859,6 +1862,20 @@ impl Session {
if self.tool.tool == ToolId::Pen {
return self.pen_finish();
}
// A frame's or section's name label: rename it there (the canvas puts a field over it).
// Labels come before widgets, as on a press; the second press began a move: undo it.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
&& let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p())
{
self.tool.gesture = Gesture::None;
self.cancel_interaction()?;
if sel != [id] {
self.set_selection(vec![id]);
}
self.request_ui("canvas.renameLabel", serde_json::json!({"id": id}));
return Ok(());
}
// Quick clicks on a widget's clickable part are clicks, one by one.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
@@ -2051,13 +2068,7 @@ impl Session {
if self.tool.pen.is_none()
&& let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6))
{
self.set_tool(ToolId::Move);
crate::vectoredit::begin(self, id)?;
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
return crate::vectoredit::set_state(self, |e| {
e.anchor = Some(v);
e.vertices = vec![v];
});
return self.pen_edit(id, Some(v));
}
let pen = self.tool.pen.get_or_insert_with(PenPath::default);
// Clicking the first anchor closes the path.
@@ -2175,7 +2186,30 @@ impl Session {
Ok(())
}
/// Finish the pen path (Enter/Escape/double-click).
/// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting
/// a new path in the same layer at the next click).
fn pen_edit(&mut self, id: NodeId, anchor: Option<usize>) -> Result<()> {
self.set_tool(ToolId::Move);
crate::vectoredit::begin(self, id)?;
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
crate::vectoredit::set_state(self, |e| {
e.anchor = anchor;
e.vertices = anchor.into_iter().collect();
})
}
/// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the
/// pen, so the next path joins the same vector (another Escape leaves edit mode).
pub fn pen_end(&mut self) -> Result<()> {
let id = self.tool.pen.as_ref().and_then(|p| p.id);
self.pen_finish()?;
match id {
Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None),
_ => Ok(()),
}
}
/// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch).
pub fn pen_finish(&mut self) -> Result<()> {
let pen = self.tool.pen.take();
self.tool.overlay.pen.clear();
@@ -2210,8 +2244,8 @@ impl Session {
let mut local = fitted.clone();
local.apply_affine(pw.inverse());
let _ = d;
let mut props =
json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
let (color, weight) = PENCIL_STROKE;
let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
// Pen pressure becomes the stroke's width profile (brushes follow it too).
if let Some(points) = pressure_profile(points, pressure) {
props["widthProfile"] = json!(points);
@@ -2320,7 +2354,7 @@ impl Session {
return Ok(());
}
if self.tool.pen.is_some() {
return self.pen_finish();
return self.pen_end();
}
if self.tool.busy() {
self.tool.gesture = Gesture::None;
+9 -10
View File
@@ -508,16 +508,15 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> {
let before = s.ds()?.doc.clone();
let mut edited = Vec::new();
let mut first_err = None;
{
let doc = s.doc_mut()?;
for id in ids.iter().take(MAX_EDITED) {
if edited.contains(id) {
continue;
}
match ensure_vector(doc, *id) {
Ok(()) => edited.push(*id),
Err(e) => first_err = first_err.or(Some(e)),
}
for id in ids.iter().take(MAX_EDITED) {
if edited.contains(id) {
continue;
}
// Vectors are left untouched, so editing only them records no undo step.
let is_vector = before.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Vector { .. }));
match if is_vector { Ok(()) } else { ensure_vector(s.doc_mut()?, *id) } {
Ok(()) => edited.push(*id),
Err(e) => first_err = first_err.or(Some(e)),
}
}
let Some((&id, rest)) = edited.split_first() else {
+29
View File
@@ -742,3 +742,32 @@ fn alt_measures_between_layers() {
assert_eq!(measured(&mut s, p, m), want, "{why}");
}
}
#[test]
fn view_toggles_are_kept_for_the_next_launch() {
let mut s = Session::new();
// (command, turned to, ViewState field, derivation)
let rows: Vec<(&str, bool, fn(&ViewState) -> bool, &str)> = vec![
("view.rulers", true, |v| v.rulers, "rulers shown stay shown"),
("view.pixelGrid", false, |v| v.pixel_grid, "a hidden pixel grid stays hidden"),
("view.layoutGrids", false, |v| v.layout_grids, "hidden layout grids stay hidden"),
("view.snapPixel", false, |v| v.snap_pixel, "snapping to the pixel grid off stays off"),
];
for (cmd, on, _, _) in &rows {
ex(&mut s, cmd, json!({"on": on}));
}
// Modes stay with their file: a new one never opens in outline mode or soft-proofing.
for cmd in ["view.outlines", "view.outlinesSelection", "view.proofSrgb"] {
ex(&mut s, cmd, json!({"on": true}));
}
// The app writes its preferences as JSON and reads them back at the next launch.
let saved = serde_json::to_value(&s.prefs).unwrap();
let mut next = Session::empty();
next.prefs = serde_json::from_value(saved).unwrap();
ex(&mut next, "file.new", json!({}));
let view = &next.ds().unwrap().view;
for (cmd, on, get, why) in rows {
assert_eq!(get(view), on, "{cmd}: {why}");
}
assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}");
}
@@ -1,5 +1,5 @@
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use figcraft_doc::{NodeId, StyleKind};
use figcraft_engine::Session;
+274
View File
@@ -0,0 +1,274 @@
#![allow(clippy::unwrap_used)]
use figcraft_color::Paint;
use figcraft_doc::NodeId;
use figcraft_engine::{
Session,
selection_colors::{SelectionColor, collect},
};
use serde_json::{Value, json};
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn rectangle(s: &mut Session, color: &str) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"width":40,"height":40,"props":{"fill":color}}))["id"].as_u64().unwrap())
}
fn group(s: &Session, ids: &[NodeId], hex: &str) -> SelectionColor {
collect(s.doc().unwrap(), ids).into_iter().find(|g| g.paint.solid_color().is_some_and(|c| c.to_hex() == hex)).unwrap()
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
#[test]
fn aggregate_descendants_deduplicate_roots_and_ignore_images_and_hidden_paints() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#00ff00");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let parent = NodeId(run(&mut s, "object.group", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
let colors = collect(s.doc().unwrap(), &[parent, a]);
assert_eq!(colors.len(), 2);
assert_eq!(group(&s, &[parent, a], "FF0000").targets.len(), 2);
run(&mut s, "fill.add", json!({"id":a,"paint":Paint::image("synthetic",figcraft_color::ImageScaleMode::Fill)}));
run(&mut s, "fill.add", json!({"id":a,"paint":{"type":"solid","color":"#0000ff","visible":false}}));
assert_eq!(collect(s.doc().unwrap(), &[parent]).len(), 2);
run(&mut s, "select.set", json!({"ids":[parent]}));
assert_eq!(run(&mut s, "selection.colors", json!({}))["colors"].as_array().unwrap().len(), 2);
}
#[test]
fn editing_only_matching_slots_is_one_undo_step_and_keeps_selection() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
let c = rectangle(&mut s, "#00ff00");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
run(&mut s, "select.set", json!({"ids":[a,b]}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#ffff00"}));
assert_eq!(fill(&s, a), "FFFF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(fill(&s, c), "00FF00");
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "FFFF00");
assert_eq!(s.selection(), vec![a, b]);
assert_eq!(s.undo_depth().0, before.0 + 1);
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
s.redo().unwrap();
assert_eq!(fill(&s, a), "FFFF00");
}
#[test]
fn live_picker_targets_do_not_expand_when_colors_merge() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
s.begin_interaction("Color").unwrap();
for hex in ["#0000ff", "#00ff00"] {
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":hex}));
}
s.commit_interaction().unwrap();
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.undo_depth().0, before.0 + 1);
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
}
#[test]
fn text_range_colors_use_character_offsets_and_preserve_typography() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"Яab","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]}));
let blue = group(&s, &[id], "0000FF");
assert_eq!(blue.targets[0].range, Some((1, 2)));
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
let n = s.doc().unwrap().node(id).unwrap();
let t = n.kind.text().unwrap();
assert_eq!(t.characters, "Яab");
assert_eq!(t.style_at(1).font_size, 30.0);
assert_eq!(t.style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "00FF00");
assert_eq!(t.style_at(0).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(t.style_at(2).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn selecting_color_selects_its_unique_owners_without_document_history() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.selectColor", json!({"targets":red.targets}));
assert_eq!(s.selection(), vec![a]);
assert_eq!(s.undo_depth(), before);
}
#[test]
fn applying_color_styles_links_matching_fill_and_stroke_lists_only() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let red = group(&s, &[a, b], "FF0000");
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone();
let before = s.undo_depth();
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), style.as_str());
assert_eq!(s.doc().unwrap().node(b).unwrap().styles.stroke.as_deref(), style.as_str());
assert_eq!(s.undo_depth().0, before.0 + 1);
run(&mut s, "style.update", json!({"style":style,"value":"#ffff00"}));
assert_eq!(fill(&s, a), "FFFF00");
assert_eq!(fill(&s, b), "0000FF");
}
#[test]
fn explicit_color_detaches_its_binding_but_keeps_other_bindings() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
let red = group(&s, &[a], "FF0000");
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#0000ff"}));
assert_eq!(fill(&s, a), "0000FF");
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
assert!(!s.doc().unwrap().node(a).unwrap().bound.is_empty());
}
#[test]
fn invalid_or_stale_targets_roll_back_atomically() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let mut red = group(&s, &[a], "FF0000");
let mut bad = red.targets[0].clone();
bad.index = 999;
red.targets.push(bad);
let before = s.undo_depth();
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] {
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
assert!(s.execute(command, &params).is_err());
}
}
}
#[test]
fn equal_resolved_colors_from_different_styles_or_variables_stay_separate() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let c = rectangle(&mut s, "#ff0000");
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone();
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let variable = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":variable}));
let colors = collect(s.doc().unwrap(), &[a, b, c]);
assert_eq!(colors.len(), 3);
assert_eq!(colors[0].style.as_deref(), style.as_str());
assert_eq!(colors[1].variable.as_deref(), variable.as_str());
assert!(colors[2].style.is_none() && colors[2].variable.is_none());
}
#[test]
fn gradients_and_multiple_paints_are_edited_without_replacing_other_slots() {
use figcraft_color::{Color, Gradient, GradientKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
run(&mut s, "fill.add", json!({"id":a,"paint":paint}));
let colors = collect(s.doc().unwrap(), &[a]);
assert_eq!(colors.len(), 2);
let gradient = colors.iter().find(|g| g.paint == paint).unwrap();
run(&mut s, "selection.setPaint", json!({"targets":gradient.targets,"paint":"#00ff00"}));
let paints = &s.doc().unwrap().node(a).unwrap().fills;
assert_eq!(paints.len(), 2);
assert_eq!(paints[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(paints[1].solid_color().unwrap().to_hex(), "00FF00");
}
#[test]
fn masks_and_boolean_operands_are_not_selection_colors() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#00ff00");
let boolean = NodeId(run(&mut s, "bool.union", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
run(&mut s, "fill.set", json!({"id":boolean,"color":"#0000ff"}));
let colors = collect(s.doc().unwrap(), &[boolean]);
assert_eq!(colors.len(), 1);
assert_eq!(colors[0].paint.solid_color().unwrap().to_hex(), "0000FF");
s.doc_mut().unwrap().get_mut(boolean).unwrap().is_mask = true;
assert!(collect(s.doc().unwrap(), &[boolean]).is_empty());
}
#[test]
fn range_edit_keeps_a_node_binding_that_other_characters_inherit() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v}));
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"textFills":["#0000ff"]}));
let blue = group(&s, &[id], "0000FF");
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
assert!(!s.doc().unwrap().node(id).unwrap().bound.is_empty());
assert_eq!(fill(&s, id), "FF0000");
assert_eq!(
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(),
"00FF00"
);
}
#[test]
fn selection_color_edits_respect_read_only_version_previews() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "file.saveVersion", json!({"name":"Before"}));
run(&mut s, "version.preview", json!({"index":0}));
let red = group(&s, &[a], "FF0000");
let before = s.undo_depth();
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
}
#[test]
fn opacity_edits_keep_the_color_variable_binding() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
let mut red = group(&s, &[a], "FF0000");
red.paint.opacity = 0.5;
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":red.paint}));
let n = s.doc().unwrap().node(a).unwrap();
assert_eq!(n.fills[0].opacity, 0.5);
assert!(!n.bound.is_empty());
}
#[test]
fn whole_text_fill_keeps_a_color_style_link() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"Text","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
let red = group(&s, &[id], "FF0000");
assert_eq!(red.targets[0].range, None);
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}))["id"].clone();
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
assert_eq!(s.doc().unwrap().node(id).unwrap().styles.fill.as_deref(), style.as_str());
run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"}));
assert_eq!(fill(&s, id), "00FF00");
}
@@ -0,0 +1,218 @@
//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in
//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and
//! text that hugs by its resize mode. Also how distributed main-axis alignment reads.
use figcraft_doc::{Document, MainAlign, Sizing};
use serde_json::{Value, json};
use super::kiwi::{Kv, Schema};
const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE
enum StackMode NONE HORIZONTAL VERTICAL
enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE
enum StackJustify MIN CENTER MAX SPACE_EVENLY SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY_CSS
enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT
struct GUID uint sessionID, uint localID
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextData string characters
message SymbolData GUID symbolID
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, StackJustify stackPrimaryAlignItems, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData
message Message NodeChange[] nodeChanges
";
type Fields = Vec<(&'static str, Kv<'static>)>;
fn o(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn g(id: u64) -> Kv<'static> {
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
}
fn xy(x: f64, y: f64) -> Kv<'static> {
o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))])
}
fn at(x: f64, y: f64) -> Kv<'static> {
let f = |v: f64| Kv::Float(v as f32);
o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
}
fn node(id: u64, parent: u64, pos: &str, ty: &'static str, name: &str, mut more: Fields) -> Kv<'static> {
let mut v = vec![("guid", g(id)), ("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(pos.into()))]))];
v.extend([("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]);
v.append(&mut more);
o(v)
}
/// An auto layout frame's fields: its direction and the sizing it records (`None`: none).
fn stack(mode: &'static str, primary: Option<&'static str>, counter: Option<&'static str>) -> Fields {
let mut f = vec![("stackMode", Kv::Enum(mode))];
f.extend(primary.map(|p| ("stackPrimarySizing", Kv::Enum(p))));
f.extend(counter.map(|c| ("stackCounterSizing", Kv::Enum(c))));
f
}
/// A text layer "<name> text" in `parent`, with resize mode `resize` (`None`: none recorded).
fn label(id: u64, parent: u64, name: &str, resize: Option<&'static str>) -> Kv<'static> {
let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 0.0)), ("fontSize", Kv::Float(12.0))];
f.push(("textData", o(vec![("characters", Kv::Str(name.into()))])));
f.extend(resize.map(|r| ("textAutoResize", Kv::Enum(r))));
node(id, parent, "!", "TEXT", &format!("{name} text"), f)
}
/// A component at `x` holding a text layer, with `fields` of its own.
fn component(id: u64, x: f64, name: &str, fields: Fields, resize: Option<&'static str>) -> Vec<Kv<'static>> {
let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(x, 0.0))];
f.extend(fields);
vec![node(id, 1, &format!("{id}"), "SYMBOL", name, f), label(id + 1, id, name, resize)]
}
fn import(nodes: Vec<Kv<'static>>) -> Document {
let mut all = vec![
o(vec![
("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])),
("type", Kv::Enum("DOCUMENT")),
("name", Kv::Str("D".into())),
]),
o(vec![
("guid", g(1)),
("parentIndex", o(vec![("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), ("position", Kv::Str("!".into()))])),
("type", Kv::Enum("CANVAS")),
("name", Kv::Str("Page".into())),
]),
];
all.extend(nodes);
let schema = Schema::parse(SCHEMA).unwrap();
let bytes = super::tests::canvas(&schema, &o(vec![("nodeChanges", Kv::Array(all))]));
super::import(&bytes, "sizing").unwrap().0
}
fn sizing(doc: &Document, name: &str) -> (Sizing, Sizing) {
let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
(n.child_layout.sizing_h, n.child_layout.sizing_v)
}
#[test]
fn auto_layout_frames_hug_as_recorded_and_by_default_on_their_primary_axis() {
use Sizing::{Fixed, Hug};
let fit = Some("RESIZE_TO_FIT");
// (component, its auto layout fields, expected (horizontal, vertical), derivation)
let rows: Vec<(&str, Fields, (Sizing, Sizing), &str)> = vec![
("Button", stack("HORIZONTAL", fit, fit), (Hug, Hug), "both axes hug, as recorded"),
("Implicit", stack("HORIZONTAL", Some("RESIZE_TO_FIT_WITH_IMPLICIT_SIZE"), fit), (Hug, Hug), "hugging with an implicit size"),
("Fixed", stack("HORIZONTAL", Some("FIXED"), Some("FIXED")), (Fixed, Fixed), "both axes fixed, as recorded"),
("Legacy", stack("HORIZONTAL", None, fit), (Hug, Hug), "no primary sizing: the primary axis sizes by its content"),
("Legacy column", stack("VERTICAL", None, None), (Fixed, Hug), "no sizing: the primary (vertical) axis hugs, the counter axis is fixed"),
];
let mut nodes = Vec::new();
for (k, (name, fields, _, _)) in rows.iter().enumerate() {
nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, fields.clone(), Some("WIDTH_AND_HEIGHT")));
}
let doc = import(nodes);
for (name, _, want, why) in rows {
assert_eq!(sizing(&doc, name), want, "{name}: {why}");
}
}
#[test]
fn text_hugs_by_its_resize_mode() {
use Sizing::{Fixed, Hug};
let fit = Some("RESIZE_TO_FIT");
let mut nodes = Vec::new();
for (k, (name, resize)) in
[("Auto width", Some("WIDTH_AND_HEIGHT")), ("Auto height", Some("HEIGHT")), ("Fixed box", Some("NONE")), ("Unset", None)]
.into_iter()
.enumerate()
{
nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, stack("HORIZONTAL", fit, fit), resize));
}
// An instance of the auto width one: it hugs, and so does its copy of the text.
nodes.push(node(
90,
1,
"~",
"INSTANCE",
"Auto width instance",
[vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(10))]))], stack("HORIZONTAL", fit, fit)]
.concat(),
));
// One that records nothing of its auto layout: it has its main's.
nodes.push(node(
91,
1,
"~~",
"INSTANCE",
"Bare instance",
vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 150.0)), ("symbolData", o(vec![("symbolID", g(10))]))],
));
let doc = import(nodes);
for (name, want) in
[("Auto width text", (Hug, Hug)), ("Auto height text", (Fixed, Hug)), ("Fixed box text", (Fixed, Fixed)), ("Unset text", (Fixed, Fixed))]
{
assert_eq!(sizing(&doc, name), want, "{name}");
}
let inst = doc.nodes().find(|n| n.name == "Auto width instance").unwrap();
assert_eq!((inst.child_layout.sizing_h, inst.child_layout.sizing_v), (Hug, Hug));
let copy = inst.children.first().and_then(|c| doc.node(*c)).unwrap();
assert_eq!((copy.child_layout.sizing_h, copy.child_layout.sizing_v), (Hug, Hug));
let bare = doc.nodes().find(|n| n.name == "Bare instance").unwrap();
assert_eq!(bare.layout.as_ref().map(|l| l.mode), Some(figcraft_doc::LayoutMode::Horizontal));
assert_eq!((bare.child_layout.sizing_h, bare.child_layout.sizing_v), (Hug, Hug));
}
#[test]
fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() {
use Sizing::{Fixed, Hug};
let at = json!([[1, 0, 0], [0, 1, 0]]);
let text = |id: &str, name: &str, more: Value| {
let mut t = json!({"id": id, "name": name, "type": "TEXT", "relativeTransform": at, "size": {"x": 40, "y": 16}, "characters": name,
"style": {"fontFamily": "Inter", "fontSize": 12, "textAutoResize": "WIDTH_AND_HEIGHT"}});
t.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
t
};
let frame = |id: &str, name: &str, more: Value, kids: Value| {
let mut f = json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at, "size": {"x": 40, "y": 16},
"layoutMode": "HORIZONTAL", "children": kids});
f.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
f
};
let file = json!({"name": "Sizing", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
frame("1:1", "Defaults", json!({}), json!([text("1:2", "Label", json!({}))])),
frame("2:1", "Fixed", json!({"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED"}),
json!([text("2:2", "Fixed label", json!({"layoutSizingHorizontal": "FIXED"}))])),
]}]}});
let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
// The REST API leaves out the default sizing modes (AUTO); explicit per-axis sizing wins.
for (name, want) in [("Defaults", (Hug, Hug)), ("Label", (Hug, Hug)), ("Fixed", (Fixed, Fixed)), ("Fixed label", (Fixed, Hug))] {
assert_eq!(sizing(&doc, name), want, "{name}");
}
}
#[test]
fn distributed_alignment_reads_the_older_evenly_name_as_space_between() {
let rows = [
("Between", "SPACE_BETWEEN", MainAlign::SpaceBetween),
("Older evenly", "SPACE_EVENLY", MainAlign::SpaceBetween),
("Around", "SPACE_AROUND", MainAlign::SpaceAround),
("Evenly", "SPACE_EVENLY_CSS", MainAlign::SpaceEvenly),
];
let nodes = rows
.iter()
.enumerate()
.flat_map(|(i, (name, align, _))| {
let mut fields = stack("HORIZONTAL", None, None);
fields.push(("stackPrimaryAlignItems", Kv::Enum(align)));
component(10 + 2 * i as u64, 50.0 * i as f64, name, fields, None)
})
.collect();
let doc = import(nodes);
for (name, _, want) in rows {
let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
assert_eq!(n.layout.as_ref().map(|l| l.main_align), Some(want), "{name}");
}
}
+17 -15
View File
@@ -73,7 +73,7 @@ enum StrokeJoin MITER=0 BEVEL=1 ROUND=2
enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4
enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3
enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 SPACE_EVENLY_CSS=6
enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5
enum StackSize FIXED=0 RESIZE_TO_FIT=1
enum StackWrap NO_WRAP=0 WRAP=1
@@ -1371,7 +1371,7 @@ impl<'d> Exporter<'d> {
MainAlign::Max => "MAX",
MainAlign::SpaceBetween => "SPACE_BETWEEN",
MainAlign::SpaceAround => "SPACE_AROUND",
MainAlign::SpaceEvenly => "SPACE_EVENLY",
MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS",
};
let cross = match l.cross_align {
CrossAlign::Min => "MIN",
@@ -1736,7 +1736,7 @@ impl<'d> Exporter<'d> {
let sp = &n.stroke;
let (w, h, weight) = (n.width, n.height, sp.weight);
let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none();
if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
return;
}
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9);
@@ -1858,12 +1858,13 @@ impl<'d> Exporter<'d> {
let named = set.is_none_or(|s| s.name != n.name);
let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect();
// Each layer below: its id path, the fields that differ from the main's layer, that layer's
// fields, and the component a nested instance was swapped for.
// fields, whether its size is its own (a nested instance, a size override) and the
// component a nested instance was swapped for.
struct Row {
path: Kv<'static>,
changed: Fields,
theirs: Fields,
nested: bool,
sized: bool,
swap: Option<Kv<'static>>,
}
let mut rows: Vec<Row> = Vec::new();
@@ -1891,8 +1892,8 @@ impl<'d> Exporter<'d> {
(NodeKind::Instance { component, .. }, _) => self.guid_of(*component),
_ => None,
};
let nested = matches!(d.kind, NodeKind::Instance { .. });
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, swap });
let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size");
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap });
}
// An instance scaled as a whole (the Scale tool) is `factor` times its main in every length:
// sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights.
@@ -1927,8 +1928,8 @@ impl<'d> Exporter<'d> {
let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]);
for (k, v) in r.changed {
let follows = follows(k, &v, &r.theirs);
// A nested instance's size is its own to set.
if follows && r.nested && k == "size" {
// A nested instance's size is its own to set, as is a size the layer overrides.
if follows && r.sized && k == "size" {
of.push((k, v.clone()));
}
if follows { &mut df } else { &mut of }.push((k, v));
@@ -2216,12 +2217,13 @@ fn outlined(n: &Node) -> Option<(Node, String)> {
NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle
);
let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible);
if !shape
|| sp.brush.is_some()
|| !sp.profile().is_some_and(one_sided)
|| !(sp.weight.is_finite() && sp.weight > 0.0)
|| !shown(&n.strokes)
|| shown(&n.fills)
if !(shape
&& sp.brush.is_none()
&& sp.profile().is_some_and(one_sided)
&& sp.weight.is_finite()
&& sp.weight > 0.0
&& shown(&n.strokes)
&& !shown(&n.fills))
{
return None;
}
+11 -3
View File
@@ -595,6 +595,17 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
n.source = None;
n.overrides.clear();
n.constrain_proportions = false;
// A text's resize mode is its sizing (the editor keeps them together; generated documents
// don't): auto width hugs both axes, auto height its height.
if let NodeKind::Text(t) = &n.kind {
let cl = &mut n.child_layout;
let hug = |s: &mut Sizing| *s = if *s == Sizing::Fixed { Sizing::Hug } else { *s };
match t.auto_resize {
TextAutoResize::WidthAndHeight => [&mut cl.sizing_h, &mut cl.sizing_v].into_iter().for_each(hug),
TextAutoResize::Height => hug(&mut cl.sizing_v),
_ => {}
}
}
let fold = |ps: &mut Vec<Paint>| {
for p in ps.iter_mut() {
if let PaintKind::Solid { color } = &mut p.kind {
@@ -619,9 +630,6 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
}
}
if let Some(l) = &mut n.layout {
if matches!(l.main_align, MainAlign::SpaceAround | MainAlign::SpaceEvenly) {
l.main_align = MainAlign::SpaceBetween;
}
// Track counts only matter (and are only written) for grids.
if l.mode != LayoutMode::Grid {
let d = AutoLayout::default();
+21 -4
View File
@@ -860,6 +860,8 @@ impl<'a> Mapper<'a> {
"wireFrame": own == "FRAME",
"wireInstance": own == "INSTANCE",
"slotContent": inst.is_some_and(|i| i.slot_content),
// A size the instance gives this layer itself (kept when the instance relays out).
"sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()),
}),
);
o.insert("type".into(), json!(ty));
@@ -922,7 +924,16 @@ impl<'a> Mapper<'a> {
} else if ty == "INSTANCE" {
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
let main = self.nodes.get(&sym).map(|m| View::of(m));
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
// An instance that records no auto layout of its own has its main's.
let view = match main {
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
self.auto_layout(&mut o, &layered);
layered
}
_ => view.clone(),
};
for c in self.children(&sym) {
// The main's branch can live outside the cached context being mapped.
kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None));
@@ -1393,7 +1404,10 @@ impl<'a> Mapper<'a> {
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
Some("CENTER") => "CENTER",
Some("MAX") => "MAX",
Some("SPACE_BETWEEN" | "SPACE_EVENLY" | "SPACE_AROUND" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN",
Some("SPACE_AROUND") => "SPACE_AROUND",
// `SPACE_EVENLY` is the format's older name for equal gaps without edge space.
Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY",
Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "SPACE_BETWEEN",
_ => "MIN",
};
o.insert("primaryAxisAlignItems".into(), json!(main));
@@ -1405,7 +1419,10 @@ impl<'a> Mapper<'a> {
};
o.insert("counterAxisAlignItems".into(), json!(cross));
let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" };
o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing")));
// A flow that records no sizing for its primary axis sizes it by its content: auto layout
// did before that axis could be fixed.
let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") };
o.insert("primaryAxisSizingMode".into(), json!(primary));
o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing")));
if let Some(b) = v.flag("bordersTakeSpace") {
o.insert("strokesIncludedInLayout".into(), json!(b));
@@ -2031,7 +2048,7 @@ impl<'a> Mapper<'a> {
self.warn("slot content assignment uses the empty GUID sentinel and was skipped");
continue;
}
if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) {
if self.nodes.get(&target).is_none_or(|n| n.get("isSlotContent").and_then(Kv::bool) != Some(true)) {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
+2
View File
@@ -13,6 +13,8 @@
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
//! with a warning; malformed input never panics.
#[cfg(test)]
mod auto_layout_sizing_tests;
#[cfg(test)]
mod connector_label_tests;
mod export;
@@ -0,0 +1,42 @@
//! REST import of grid track sizes (`gridColumnsSizing`, `gridRowsSizing`: CSS
//! `grid-template-columns`/`-rows` strings). Fixtures are synthetic, written by hand.
use super::*;
use figcraft_doc::GridTrack;
use serde_json::json;
fn tracks(columns: Value, rows: Value) -> (Vec<GridTrack>, Vec<GridTrack>) {
let file = json!({"name": "Fixture", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1", "name": "Grid", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 400, "y": 200},
"layoutMode": "GRID", "gridColumnCount": 7, "gridRowCount": 2, "gridColumnsSizing": columns, "gridRowsSizing": rows}
]}
]}});
let (doc, _) = import(&file, &no_images).unwrap();
let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap();
(layout.column_tracks, layout.row_tracks)
}
#[test]
fn css_track_strings_set_the_track_sizes() {
let (cols, rows) = tracks(json!("120px 2fr auto"), json!("repeat(2, minmax(0, 1fr))"));
assert_eq!(cols, [GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]);
assert_eq!(rows, [GridTrack::fill(1.0); 2]);
let (cols, rows) = tracks(json!(" repeat(2, 40px 1.5fr) minmax(64px, 64px) "), json!("min-content max-content"));
assert_eq!(cols, [GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(64.0)]);
assert_eq!(rows, [GridTrack::hug(); 2]);
}
#[test]
fn unsupported_or_junk_tracks_are_equal_tracks() {
// Ranges the grid can't express, units it doesn't have and junk keep their place as 1fr.
let (cols, rows) = tracks(json!("minmax(10px, 80px) 5em -3px 0fr NaNpx repeat(x, 1px) 10px"), json!(7));
assert_eq!(cols, [[GridTrack::default(); 6].as_slice(), &[GridTrack::fixed(10.0)]].concat());
assert!(rows.is_empty(), "a non-string is ignored");
// Tracks past the grid's own count are left out; huge repeats and deep nesting stay bounded.
let (cols, rows) = tracks(json!(format!("repeat(999999999999, 1px) {}", "minmax(".repeat(10_000))), json!("1px 2px 3px"));
assert_eq!(cols, [GridTrack::fixed(1.0); 7]);
assert_eq!(rows, [GridTrack::fixed(1.0), GridTrack::fixed(2.0)]);
let (cols, rows) = tracks(json!("1e9px"), json!("( ) )) repeat(2,)"));
assert_eq!((cols, rows.len()), (vec![GridTrack::default()], 2));
}
+17 -3
View File
@@ -29,6 +29,8 @@ mod fig_sources;
#[cfg(test)]
mod gamepad_tests;
#[cfg(test)]
mod grid_track_tests;
#[cfg(test)]
mod import_regression_tests;
pub(crate) mod props;
pub(crate) mod proto;
@@ -481,9 +483,11 @@ impl<'a> Ctx<'a> {
let id = self.doc.alloc_id();
let label = kind.label();
let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true)
{
n.overrides.insert("slot".into());
// Overrides the .fig names explicitly (the rest are found by comparing with the main).
for (flag, group) in [("slotOverride", "slot"), ("sizeOverride", "size")] {
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get(flag)).and_then(Value::as_bool) == Some(true) {
n.overrides.insert(group.into());
}
}
n.transform = local(space.world, world);
self.common(&mut n, j, parent_mode);
@@ -1152,6 +1156,16 @@ fn override_group(field: &str) -> Option<&'static str> {
| "textDecorationSkipInk"
| "textDecorationColor" => "textStyle",
"width" | "height" | "size" => "size",
"layoutSizingHorizontal"
| "layoutSizingVertical"
| "layoutGrow"
| "layoutAlign"
| "primaryAxisSizingMode"
| "counterAxisSizingMode"
| "minWidth"
| "maxWidth"
| "minHeight"
| "maxHeight" => "resizing",
"paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding",
"itemSpacing" | "counterAxisSpacing" => "gap",
"primaryAxisAlignItems" | "counterAxisAlignItems" => "alignment",
+84 -5
View File
@@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
let pad = |k: &str| len(v, k).unwrap_or(0.0);
let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
let grid = mode == LayoutMode::Grid;
let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2));
Some(AutoLayout {
mode,
wrap: text(v, "layoutWrap") == Some("WRAP"),
@@ -448,15 +449,84 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween,
_ => ContentAlign::Auto,
},
grid_columns: count("gridColumnCount", 2),
grid_rows: count("gridRowCount", 2),
grid_columns: columns,
grid_rows: rows,
strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() },
row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() },
column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns),
row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows),
})
}
/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS
/// `grid-template-*` string (at most `count` tracks, the axis's own).
fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec<GridTrack> {
match (grid, fig_tracks) {
(false, _) => Vec::new(),
(true, true) => fig_grid_tracks(v, fig_axis),
(true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(),
}
}
/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto`
/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one.
fn css_grid_tracks(css: &str, max: usize) -> Vec<GridTrack> {
let mut out = Vec::new();
for item in css_split(css, char::is_whitespace) {
let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| {
let (count, tracks) = r.split_once(',')?;
Some((count.trim().parse::<usize>().ok()?, css_split(tracks, char::is_whitespace)))
});
let (count, tracks) = repeated.unwrap_or((1, vec![item]));
for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) {
if out.len() >= max {
return out;
}
out.push(css_track(t));
}
}
out
}
fn css_track(t: &str) -> GridTrack {
let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() };
// A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree.
match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::<Vec<_>>()[..] {
[_, Some(max)] if max.sizing == Sizing::Fill => max,
[Some(min), Some(max)] if min == max => min,
_ => GridTrack::default(),
}
}
fn css_size(t: &str) -> Option<GridTrack> {
let n = |s: &str| s.parse::<f64>().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
if let Some(px) = t.strip_suffix("px").and_then(n) {
Some(GridTrack::fixed(px))
} else if let Some(fr) = t.strip_suffix("fr").and_then(n) {
(fr > 0.0).then(|| GridTrack::fill(fr))
} else {
matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug)
}
}
/// `s` split at the `sep` characters outside parentheses, empty parts dropped.
fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> {
let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0);
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
c if depth == 0 && sep(c) => {
parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty()));
start = i + c.len_utf8();
}
_ => {}
}
}
parts.extend(s.get(start..).filter(|p| !p.trim().is_empty()));
parts
}
fn fig_grid_tracks(v: &Value, axis: &str) -> Vec<GridTrack> {
v.get(crate::rest::FIG_SOURCE_FIELD)
.and_then(|source| source.get("gridTracks"))
@@ -484,8 +554,17 @@ pub(super) fn child_layout(v: &Value, own: Option<LayoutMode>, parent_mode: Opti
let mut cl = ChildLayout::default();
let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical;
// Legacy fields first, the explicit per-axis sizing wins.
// A text's resize mode is its sizing: auto width hugs both axes, auto height its height.
if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") {
match v.get("style").and_then(|s| text(s, "textAutoResize")) {
Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug),
Some("HEIGHT") => cl.sizing_v = Sizing::Hug,
_ => {}
}
}
if let Some(m) = own {
let hug = |k: &str| text(v, k) == Some("AUTO");
// Both default to AUTO (hug) when the file leaves them out.
let hug = |k: &str| text(v, k) != Some("FIXED");
let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode"));
let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) };
if h {
@@ -0,0 +1,24 @@
//! FIG export + import keeps all three distributed main-axis alignments distinct.
use figcraft_doc::{AutoLayout, Document, MainAlign, NodeKind};
use figcraft_format::fig;
#[test]
fn distributed_alignment_round_trip() {
let mut results = Vec::new();
for expected in [MainAlign::SpaceBetween, MainAlign::SpaceAround, MainAlign::SpaceEvenly] {
let mut doc = Document::new("Alignment");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 40.0).unwrap();
doc.node_mut(frame).unwrap().layout = Some(AutoLayout { main_align: expected, ..Default::default() });
let (native, _) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
assert_eq!(native.node(frame).unwrap().layout.as_ref().unwrap().main_align, expected);
let (bytes, export_warnings) = fig::export(&doc, None).unwrap();
let (imported, import_warnings) = fig::import(&bytes, "Alignment").unwrap();
let actual = imported.nodes().find(|n| n.name == "Frame").unwrap().layout.as_ref().unwrap().main_align;
assert!(export_warnings.is_empty());
assert!(import_warnings.is_empty());
results.push((expected, actual));
}
assert!(results.iter().all(|(expected, actual)| expected == actual), "{results:?}");
}
+7 -21
View File
@@ -22,10 +22,9 @@ pub trait Backend {
fn describe(&self) -> String;
/// PNG bytes of the document: `ids` (in page coordinates, their union) or, when empty, the
/// current page's content. The default goes through `ui.render` and a temporary file.
/// current page's content. The default goes through `ui.render`, the PNG coming back as data.
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
let path = temp_png("render");
let mut params = json!({"path": path, "scale": scale});
let mut params = json!({"scale": scale});
if !ids.is_empty() {
params["ids"] = json!(ids);
} else {
@@ -36,8 +35,7 @@ pub trait Backend {
}
params["ids"] = json!(kids);
}
self.call("ui.render", params)?;
read_and_remove(&path)
decode_data(&self.call("ui.render", params)?)
}
/// PNG bytes of what the user sees: the app window when attached, the current viewport when
@@ -52,11 +50,10 @@ pub trait Backend {
}
}
fn capture<B: Backend + ?Sized>(b: &mut B, mut params: Value) -> Result<Vec<u8>, String> {
let path = temp_png("screenshot");
params["path"] = json!(path);
b.call("ui.screenshot", params)?;
read_and_remove(&path)
/// A window capture, the PNG coming back as data (no file: an app confined to automation roots
/// writes none outside them).
fn capture<B: Backend + ?Sized>(b: &mut B, params: Value) -> Result<Vec<u8>, String> {
decode_data(&b.call("ui.screenshot", params)?)
}
/// The ids of the current page's top-level layers.
@@ -72,17 +69,6 @@ pub fn page_children<B: Backend + ?Sized>(b: &mut B) -> Result<Vec<u64>, String>
.unwrap_or_default())
}
fn temp_png(what: &str) -> String {
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
std::env::temp_dir().join(format!("figcraft-mcp-{what}-{}-{n}.png", std::process::id())).to_string_lossy().into_owned()
}
fn read_and_remove(path: &str) -> Result<Vec<u8>, String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let _ = std::fs::remove_file(path);
Ok(bytes)
}
/// Decode a `data` field (base64, optionally a data: URL) from an export result.
pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
let d = v.get("data").and_then(Value::as_str).ok_or("the export returned no data")?;
+3 -3
View File
@@ -160,7 +160,7 @@ impl Headless {
self.execute("text.edit.begin", &json!({"id": self.single_text()}))?;
}
"select.children" if self.session.tool.pen.is_some() => {
let _ = self.session.pen_finish();
let _ = self.session.pen_end();
}
// Enter on shapes edits them as vectors (several at once).
"select.children" if self.vector_targets().is_some() => {
@@ -215,7 +215,7 @@ impl Headless {
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
match p.get("path").and_then(Value::as_str) {
Some(path) => {
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": w, "height": h, "bytes": bytes.len()}))
}
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
@@ -278,7 +278,7 @@ impl Backend for Headless {
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
match p.get("path").and_then(Value::as_str) {
Some(path) => {
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": w, "height": h}))
}
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
+26 -5
View File
@@ -1,6 +1,9 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
use std::io::{BufRead, Read, Write};
use std::sync::Arc;
use figcraft_engine::file_access::{Roots, confined};
use serde_json::{Value, json};
@@ -42,6 +45,9 @@ Resources: figcraft://document, figcraft://node/{id}, figcraft://command/{id}. p
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
/// The automation roots every request's file access is confined to (`--automation-read-root`,
/// `--automation-write-root`), if any.
roots: Option<Arc<Roots>>,
}
fn response(id: Value, result: Value) -> Value {
@@ -58,7 +64,14 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false }
Self { backend, initialized: false, roots: None }
}
/// Confine the files every request reads and writes to `roots` (see
/// [`figcraft_engine::file_access`]).
pub fn with_roots(mut self, roots: Option<Roots>) -> Self {
self.roots = roots.map(Arc::new);
self
}
/// A server over a fresh headless session.
@@ -150,15 +163,23 @@ impl Server {
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
}
// A bug that panics fails this request only; the server keeps serving.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.request(method, &params))).unwrap_or_else(|p| {
Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref()))))
});
let roots = self.roots.clone();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(roots.as_ref(), || self.request(method, &params)))).unwrap_or_else(
|p| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref())))),
);
Some(match r {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
/// What `initialize` says about the automation roots.
fn roots_note(&self) -> String {
let Some(r) = &self.roots else { return String::new() };
let j = r.to_json();
format!(" Files: only these folders may be read: {}; written: {} (any other path is refused).", j["read"], j["write"])
}
fn notification(&mut self, method: &str) {
match method {
"notifications/initialized" => self.initialized = true,
@@ -180,7 +201,7 @@ impl Server {
"prompts": {"listChanged": false},
},
"serverInfo": {"name": "figcraft", "title": "FigCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
"instructions": format!("{INSTRUCTIONS} Backend: {}.{}", self.backend.describe(), self.roots_note()),
}))
}
"ping" => Ok(json!({})),
+7 -3
View File
@@ -163,7 +163,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"set_properties",
"Set properties",
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
obj(
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
&["props"],
@@ -544,7 +544,7 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
}
params["ids"] = json!(ids);
}
exec(b, "node.set", params)?;
let warnings = exec(b, "node.set", params)?.get("warnings").cloned();
let ids = match ids {
Some(i) => i,
None => b
@@ -555,7 +555,11 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
.unwrap_or_default(),
};
let nodes: Vec<Value> = ids.iter().take(20).filter_map(|i| b.call("node.get", json!({"id": i, "depth": 0})).ok()).collect();
Ok(ToolResult::json(&json!({"ok": true, "nodes": nodes})))
let mut out = json!({"ok": true, "nodes": nodes});
if let Some(w) = warnings {
out["warnings"] = w;
}
Ok(ToolResult::json(&out))
}
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
+126
View File
@@ -0,0 +1,126 @@
//! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only
//! inside the read and write roots and writes only inside the write roots.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use std::path::PathBuf;
use figcraft_engine::file_access::Roots;
use figcraft_mcp::Server;
use serde_json::{Value, json};
/// `<tmp>/figcraft-mcp-roots-<name>-<pid>` with `in/` (read root), `out/` (write root),
/// `outside/` and `out2/` (named like the write root, outside it).
struct Dirs(PathBuf);
impl Dirs {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("figcraft-mcp-roots-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out", "outside", "out2"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
Self(d)
}
fn p(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().into_owned()
}
fn server(&self) -> Server {
Server::headless().with_roots(Some(Roots::new(&[self.p("in")], &[self.p("out")]).unwrap()))
}
}
impl Drop for Dirs {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn call(s: &mut Server, tool: &str, args: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string();
let r: Value = serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap();
r["result"].clone()
}
fn text(r: &Value) -> String {
r["content"][0]["text"].as_str().unwrap_or_default().to_string()
}
#[test]
fn writes_go_only_into_the_write_root() {
let d = Dirs::new("write");
let mut s = d.server();
assert_eq!(call(&mut s, "create_node", json!({"type": "frame", "width": 40, "height": 20}))["isError"], false);
assert_eq!(call(&mut s, "run_command", json!({"command": "export.add"}))["isError"], false);
for (tool, args) in [
("save_file", json!({"path": d.p("outside/a.figcraft")})),
("save_file", json!({"path": d.p("out2/a.figcraft")})),
("save_file", json!({"path": d.p("out/../outside/a.figcraft")})),
("save_file", json!({"path": d.p("in/a.figcraft")})),
("export", json!({"format": "png", "path": d.p("outside/a.png")})),
("run_command", json!({"command": "file.export", "params": {"format": "svg", "path": d.p("outside/a.svg")}})),
("run_command", json!({"command": "file.exportLayers", "params": {"dir": d.p("outside")}})),
("batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": d.p("outside/b.figcraft")}}]})),
] {
let r = call(&mut s, tool, args.clone());
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
}
for sub in ["outside", "out2", "in"] {
assert_eq!(std::fs::read_dir(d.0.join(sub)).unwrap().count(), 0, "nothing written in {sub}");
}
assert_eq!(call(&mut s, "save_file", json!({"path": d.p("out/a.figcraft")}))["isError"], false);
assert_eq!(call(&mut s, "export", json!({"format": "png", "path": d.p("out/a.png")}))["isError"], false);
assert!(d.0.join("out/a.figcraft").is_file() && d.0.join("out/a.png").is_file());
// Without a path the bytes come back, written nowhere.
let r = call(&mut s, "export", json!({"format": "png"}));
assert_eq!(r["content"][0]["type"], "image", "{r}");
}
#[test]
fn reads_go_only_into_the_read_and_write_roots() {
let d = Dirs::new("read");
// Files made without roots: one in each folder.
let mut free = Server::headless();
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 40, "height": 20, "name": "Made"}))["isError"], false);
for f in ["in/a.figcraft", "out/b.figcraft", "outside/c.figcraft"] {
assert_eq!(call(&mut free, "export", json!({"format": "svg", "path": d.p(&f.replace(".figcraft", ".svg"))}))["isError"], false);
assert_eq!(call(&mut free, "run_command", json!({"command": "file.saveAs", "params": {"path": d.p(f)}}))["isError"], false);
}
let mut s = d.server();
for (tool, args) in [
("open_file", json!({"path": d.p("outside/c.figcraft")})),
("open_file", json!({"path": d.p("in/../outside/c.figcraft")})),
("open_file", json!({"path": d.p("in/a.figcraft"), "variables": d.p("outside/c.svg")})),
("run_command", json!({"command": "file.import", "params": {"path": d.p("outside/c.svg")}})),
("run_command", json!({"command": "file.placeImage", "params": {"path": d.p("outside/c.svg")}})),
("run_command", json!({"command": "library.add", "params": {"path": d.p("outside/c.figcraft")}})),
] {
let r = call(&mut s, tool, args.clone());
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
}
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("in/a.figcraft")}))["isError"], false);
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("out/b.figcraft")}))["isError"], false, "a write root may be read");
assert_eq!(call(&mut s, "run_command", json!({"command": "file.import", "params": {"path": d.p("in/a.svg")}}))["isError"], false);
}
#[test]
fn initialize_names_the_roots_and_no_roots_changes_nothing() {
let d = Dirs::new("init");
let mut s = d.server();
let r: Value =
serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap())
.unwrap();
let ins = r["result"]["instructions"].as_str().unwrap();
assert!(ins.contains("only these folders may be read") && ins.contains("figcraft-mcp-roots-init"), "{ins}");
let mut free = Server::headless();
let r: Value = serde_json::from_str(
&free.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap(),
)
.unwrap();
assert!(!r["result"]["instructions"].as_str().unwrap().contains("only these folders"));
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 4, "height": 4}))["isError"], false);
assert_eq!(call(&mut free, "export", json!({"format": "png", "path": d.p("outside/free.png")}))["isError"], false);
}
+11
View File
@@ -208,3 +208,14 @@ fn serve_accepts_a_limit_sized_frame_and_a_final_unterminated_frame() {
assert_eq!(lines[0]["result"], json!({}));
assert_eq!(lines[1]["id"], 2);
}
#[test]
fn set_properties_passes_on_what_it_skipped() {
let mut c = Client::new();
let f = c.ok("create_node", json!({"type": "frame"}))["id"].clone();
let t = c.ok("create_node", json!({"type": "text", "text": "Hi"}))["id"].clone();
let r = c.ok("set_properties", json!({"ids": [f, t], "props": {"gap": 8}}));
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`gap`") && w.contains("TEXT")), "{r}");
assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none());
assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout"));
}
+21 -2
View File
@@ -59,6 +59,13 @@ pub fn closed(path: &BezPath) -> BezPath {
/// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input).
pub use figcraft_geom::measure::finite_path as finite;
/// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal
/// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash.
// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there.
fn sweep<T>(f: impl FnOnce() -> Result<T, PathOpsError>) -> Result<T, PathOpsError> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput))
}
fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
if a.elements().is_empty() && b.elements().is_empty() {
return Ok(BezPath::new());
@@ -66,7 +73,7 @@ fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath
if !finite(a) || !finite(b) {
return Err(PathOpsError::BadInput);
}
let contours = linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput)?;
let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput))?;
let mut out = BezPath::new();
for c in contours.contours() {
out.extend(c.path.elements().iter().copied());
@@ -192,9 +199,14 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result<Vec<ArrangementFace>, PathOpsE
let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b));
let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0);
let eps = (m * f64::EPSILON * 64.0).max(1e-6);
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
sweep(|| arrange(&closed_paths, &even_odd, eps))
}
/// [`faces`] of the closed `paths`, swept with tolerance `eps`.
fn arrange(closed_paths: &[BezPath], even_odd: &[bool], eps: f64) -> Result<Vec<ArrangementFace>, PathOpsError> {
let topo = linesweeper::topology::Topology::<Windings>::from_paths(closed_paths.iter().enumerate().map(|(i, p)| (p, i)), eps)
.map_err(|_| PathOpsError::BadInput)?;
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
let cover_of = |w: &Windings| -> Vec<usize> {
w.0.iter()
.enumerate()
@@ -453,6 +465,13 @@ mod tests {
p.area().abs()
}
#[test]
fn a_panic_in_the_sweep_is_bad_input() {
let r: Result<(), PathOpsError> = sweep(|| panic!("sweep bug"));
assert!(matches!(r, Err(PathOpsError::BadInput)));
assert!(matches!(sweep(|| Ok(1)), Ok(1)));
}
#[test]
fn union_intersect_subtract_exclude() {
let a = sq(0.0, 0.0, 10.0);
+1 -1
View File
@@ -116,7 +116,7 @@ pub(crate) fn rule(r: figcraft_render::FillRule) -> FillRule {
}
/// The renderer's stroke fill rule: even-odd ring paths for aligned strokes, else non-zero.
fn stroke_rule(n: &Node, closed: bool) -> FillRule {
pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) {
FillRule::EvenOdd
} else {
+5 -5
View File
@@ -5,7 +5,7 @@ use std::collections::BTreeSet;
use std::ops::Range;
use std::sync::Arc;
use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeProps};
use figcraft_doc::{Node, NodeKind};
use figcraft_geom::{Affine, BezPath};
use figcraft_text::fontdb::FontBytes;
use figcraft_text::{Face, Glyph, TextLayout};
@@ -16,7 +16,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph};
use figcraft_color::{GradientKind, Paint, PaintKind};
use crate::draw::{Exporter, above, affine_ok, blend, to_xf, vector_paint};
use crate::draw::{Exporter, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
use crate::raster::Raster;
/// Glyphs of one face and size on one line, written as one text run.
@@ -84,11 +84,11 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
}
}
ex.place_inner(s, inner);
let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
// Glyph outlines are closed: inside and outside strokes clip to them, as on the canvas (#615).
for outlines in &stroked {
let area = figcraft_pathops::stroke_center(outlines, props.weight, &props);
let area = figcraft_render::draw::stroke_area(n, outlines, true);
for p in &n.strokes {
ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf);
ex.fill(s, &area, stroke_rule(n, true), p, w, h, gxf);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Paint bounds worked out once per render: the bounds kept for a layer are the ones worked out
//! without keeping any, and the largest background blur follows the document's edits.
use super::*;
use figcraft_doc::{Effect, EffectKind};
#[test]
fn kept_paint_bounds_are_the_bounds_worked_out_afresh() {
// Unclipped frames nested past the 64-level walk, a rectangle at the bottom: bounds asked for
// deep first and then from the top, and the other way round, are those worked out alone.
let mut d = Document::new("deep");
let p = d.first_page().unwrap();
let mut chain = Vec::new();
let mut parent = p;
for k in 0..80 {
let f = d.create(parent, NodeKind::Frame, &format!("F{k}"), 1.0, 1.0, 10.0, 10.0).unwrap();
d.node_mut(f).unwrap().clip = false;
chain.push(f);
parent = f;
}
d.create(parent, NodeKind::Rectangle, "R", 500.0, 0.0, 10.0, 10.0).unwrap();
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
let memo = draw::Bounds::default();
for id in &order {
let n = d.node(*id).unwrap();
for depth in [0, 20] {
let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0;
assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name);
}
}
}
}
#[test]
fn the_largest_background_blur_follows_edits() {
let mut d = Document::new("blur");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
assert_eq!(max_backdrop_blur(&d), 0.0);
let mut blur = Effect::background_blur();
if let EffectKind::BackgroundBlur(b) = &mut blur.kind {
b.radius = 12.0;
}
d.node_mut(r).unwrap().effects.push(blur);
assert_eq!(max_backdrop_blur(&d), 12.0);
// A copy of the document keeps it, and an edit of the copy changes only the copy's.
let mut copy = d.clone();
if let Some(EffectKind::BackgroundBlur(b)) = copy.node_mut(r).unwrap().effects.first_mut().map(|e| &mut e.kind) {
b.radius = 30.0;
}
assert_eq!(max_backdrop_blur(&copy), 30.0);
assert_eq!(max_backdrop_blur(&d), 12.0);
copy.node_mut(r).unwrap().effects.clear();
assert_eq!(max_backdrop_blur(&copy), 0.0);
// A deleted layer's blur no longer counts.
d.remove(r).unwrap();
assert_eq!(max_backdrop_blur(&d), 0.0);
}
#[test]
fn kept_splat_answers_are_the_answers_worked_out_afresh() {
use figcraft_color::{ImageScaleMode, Paint};
// Groups nested past the 64-level walk, with an image-filled rectangle at the bottom of one
// branch and a plain one at the bottom of the other: answers asked for deep-first and
// top-first, at depths 0 and 20, are those worked out alone.
let mut d = Document::new("deep");
let p = d.first_page().unwrap();
let mut chain = Vec::new();
for image in [false, true] {
let mut parent = p;
for k in 0..80 {
let g = d.create(parent, NodeKind::Frame, &format!("{image} {k}"), 0.0, 0.0, 10.0, 10.0).unwrap();
d.node_mut(g).unwrap().clip = false;
chain.push(g);
parent = g;
}
let r = d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
if image {
d.node_mut(r).unwrap().fills = vec![Paint::image("missing", ImageScaleMode::Fill)];
}
}
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
let memo = draw::Bounds::default();
for id in &order {
let n = d.node(*id).unwrap();
for depth in [0, 20] {
let kept = lod::splattable_within(&d, n, depth, Some(&memo)).0;
assert_eq!(kept, lod::splattable(&d, n, depth), "{} at depth {depth}", n.name);
}
}
}
}
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more