Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 4b7e0afd19 Desktop: refuse unknown command-line options instead of ignoring them (#836)
A mistyped or unsupported flag (e.g. --automation-write-roots) used to be
dropped silently, so the app started unconfined. It now exits 2 with
'unknown option', like a bad automation root. A bare -- ends the options.

Adds a desktop control-channel test that file.save / file.saveAs outside the
write root are refused and create nothing, while saves inside succeed, and
documents that the root flags arrived in 0.7.0 (0.6.0 ignores them).
2026-10-18 14:02:25 -06:00
Dylan M. Taylor 7fcb193f2a ci: configure Dependabot for Cargo and GitHub Actions (#825)
* ci: configure Dependabot for Cargo and GitHub Actions

* ci: add Cargo update entry for /plugins workspace in Dependabot

* ci: consolidate Cargo Dependabot directories in figcraft
2026-10-18 15:10:42 -04:00
Dylan M. Taylorandbflatastic d8ff1441b1 docs: drop references to competing commercial software (#786)
* docs: drop references to competing commercial software

* docs: clarify parity checklist coverage in status callout

* #786: say interface design once in the tagline

---------

Co-authored-by: bflatastic <[email protected]>
2026-10-18 19:34:53 +07:00
BFlat 018b8166b2 M3: a bound colour variable's alpha applies once, not squared (#802) (#804)
Fixes #802.

Fixes #802.
2026-10-18 18:50:19 +07:00
BFlat 86e91d0b71 Merge pull request #801 from phonedu/fix/silent-document-errors
M1: vector.create honours width/height by scaling the path
2026-10-18 18:37:38 +07:00
bflatastic d1dad2e1c0 Merge #801's latest commit (negative sizes: covered by the size check above)
# Conflicts:
#	crates/engine/src/cmd/create.rs
#	crates/engine/src/vector_behaviour_tests.rs
#	docs/mcp.md
2026-10-18 18:18:51 +07:00
phonedu fec902b7d5 M1: refuse negative vector sizes
A negative width or height was clamped to zero by the scale helper, producing a layer smaller than asked for; vector.create now says so. Regression test covers width -10 with height 8.
2026-10-18 19:00:25 +08:00
bflatastic 057bf9a289 #801: create_node and mcp.md say width/height scale a vector's path 2026-10-18 17:54:25 +07:00
bflatastic f1d336249a #801: vector.create accepts 0 on an axis the geometry has none of and refuses a flattening size; points scaling tests 2026-10-18 17:54:24 +07:00
bflatastic 55aa0165fd #801: keep a component set's outline as its real stroke (reverts the export change) 2026-10-18 17:41:11 +07:00
bflatastic 836d6f2fa6 Merge main into #801 2026-10-18 17:40:54 +07:00
BFlat 1c3e175f66 Merge pull request #798 from ashsajal1/m9-branch-merge-lists-item-by-item
M9: the other item lists merge one by item when a branch merges (gap 17)
2026-10-18 17:35:09 +07:00
phonedu aff3c4f18b M1: gate the outline migration on a marker and refuse impossible vector sizes
Review follow-up. A file written by this version carries editorOutlines, so a stroke someone chose is kept even when it matches the old decoration; only files without the marker get the one-time legacy cleanup. vector.create now errors when a requested side cannot scale the geometry or when both axes would collapse, instead of creating an invisible layer.
2026-10-18 18:22:48 +08:00
bflatastic adb23f0ff1 #798: branch.review docs name the lists that merge item by item 2026-10-18 17:01:03 +07:00
bflatastic 53c8308b66 #798: test layout grids, code links and interactions (add, change, delete, conflicts) 2026-10-18 17:01:03 +07:00
bflatastic a4c7e259ab #798: layout grids merge item by item, code links merge whole (keyed by language); fix stale doc comments 2026-10-18 17:01:02 +07:00
bflatastic de2915908c #798: drop progress-doc edits (maintainer-owned) 2026-10-18 17:01:02 +07:00
bflatastic 34b08ac8bf Merge main into #798 2026-10-18 16:57:23 +07:00
BFlat e9565813ac Merge pull request #800 from storytold/community/batch-18
Community batch 18: recovery lock wait (#334), Finnish catalog (2 PRs)
2026-10-18 16:51:37 +07:00
phonedu c87baa37cc docs: record the component set outline and vector size behaviour 2026-10-18 17:48:51 +08:00
phonedu 911831fb89 M1: vector.create scales its path to width/height
A path's intrinsic box replaced the width and height asked for, so an icon imported at 24 stayed on its source grid. Scale the geometry to the requested size; with one side the other keeps its proportion. A path or point set that draws nothing is an error now, not an invisible 0x0 layer.
2026-10-18 17:24:50 +08:00
phonedu be47f99534 M1: component sets keep the dashed canvas outline out of the document
Node::new gave every component set a purple dashed stroke, so export and screenshot drew the editor frame into the image. Sets now start with no paint; the canvas draws the dashed frame itself, and files written by earlier versions lose exactly that stored decoration when opened. A stroke someone chose is kept.
2026-10-18 17:24:49 +08:00
bflatastic 0b08ea1127 Merge fix/794 2026-10-18 16:21:45 +07:00
bflatastic 61ccfe18a8 Merge fix/795 2026-10-18 16:21:45 +07:00
bflatastic d1125130f6 #794: translate the slot-layers import note added on main 2026-10-18 16:14:45 +07:00
bflatastic ddd42b36bd #794: list Finnish with the other catalogs in translating.md instead of a status paragraph 2026-10-18 16:14:27 +07:00
bflatastic 6743c68203 #794: drop the Finnish-only tests that pin wordings (the generic catalog tests cover fi) 2026-10-18 16:14:27 +07:00
bflatastic caa3c5afce Merge main into #794 2026-10-18 16:13:48 +07:00
bflatastic 8d3a3e6171 #795: drop the hardware-parity revision row (test-only effect; the maintainer records it) 2026-10-18 16:13:41 +07:00
bflatastic d7a27c593e #795: wait once at startup for held recovery folders, only on Unix
Collect the folders whose lock is held, wait 100 ms once and retry only those,
instead of up to 100 ms per held folder. Recovery::start takes its folder without
waiting again (a held name already falls through to another). Windows doesn't
pass the lock to child processes, so the wait and its test are Unix-only; the
test holds the lock 10 ms against the 100 ms wait.
2026-10-18 16:13:34 +07:00
bflatastic 7d51d0515d Merge main into #795 2026-10-18 15:59:04 +07:00
Ashsajal 13fdae3d44 M9: the other item lists merge one by item when a branch merges (gap 17)
Fills, strokes and effects already merged item by item, so both sides
changing different items of one of those lists was no conflict. Every other
list conflicted as a whole: both sides editing different export settings, or
different guides, or different prototype interactions, or different
accessibility annotations or Dev Mode links, each had to be resolved by hand
for the whole list, though the items stand on their own.

Export settings, guides, prototype interactions, accessibility annotations and
the Dev Mode code links and resources now merge item by item, with the same
rule: the same item changed both ways, an item removed on one side, or
different items added on both sides still conflict.

Properties that are keyed maps rather than item lists (bound, varModes,
overrides, propRefs, pluginData) are deliberately left out: their items line up
by key, not by position. Text runs are left whole too: they live in the node's
text data, so merging them item by item means splitting a property the change
format keeps whole.

Replaces the test that documented the gap (other_lists_still_conflict_as_a_whole)
with one that checks each new list merges, and adds one for the same item
changed both ways still conflicting.
2026-10-18 14:55:12 +06:00
BFlat ef535e49b0 M1: REST import keeps slot layers and instances' slot content (#792) (#796)
Fixes #792.

Fixes #792.
2026-10-18 15:52:30 +07:00
Ashsajal e35f8eb1b7 M8: wait for a recovery folder lock that frees itself (#334)
A child process forked before its exec inherits the run's open descriptors,
and a flock lasts while any descriptor for the file is open. A crashed run
whose folder lock was still held by such a child read as a run that was still
going, so recover_all skipped it and its unsaved documents were not opened at
the next start. The desktop app's tests failed intermittently for the same
reason whenever another test spawned a process in parallel (the reduced-motion
read spawns gsettings on Linux and the BSDs): recovery_tests failed in 23 of
60 runs of the whole binary and never on its own.

Taking a folder's lock now retries try_lock briefly, so a lock that frees
itself is no longer read as a live run while a run that really is running
still keeps its folder.

Regression test: a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going
holds the crashed run's lock on a second descriptor, the way a forked child
does, and frees it shortly after; the copies come back. The recovery tests
that collide with a spawning test went from 8 failures in 10 runs to 0 in 10.
2026-10-18 14:15:56 +06:00
Esa Komulainen 55fbcd0d7e M10: add Finnish (fi) interface catalog 2026-10-18 11:03:36 +03:00
BFlat 8629a6f586 M3: variable.import reads variables outside meta and never imports nothing silently (#790) (#793)
Fixes #790.

Fixes #790.
2026-10-18 14:59:14 +07:00
BFlat 99e5db399b M7: quitting on macOS ends the process after saving, avoiding the Touch Bar teardown crash (#781) (#791)
- **Quit (macOS):** `on_exit` now calls `services::shutdown` and then, on macOS only,
  `std::process::exit(0)`. This happens before eframe tears the windows down, which is where the
  crash in #781 is thrown (same approach as the sibling apps, tracked in craft-libs#3).
- **`services::shutdown(app, dir, recovery)`** replaces `save_state`. It runs on every platform and
  everything in it is finished before it returns:
  1. the preferences (`state.json`) are written atomically, as before;
  2. this run's recovery copies are settled by the new `Recovery::quit(session, asked)`:
     - if the unsaved-changes question was asked (`ui.quit`: last window closed through the
       question, or `app.quit`), or nothing is unsaved, it works as before: `finish()` removes
       them;
     - otherwise the unsaved documents are copied (waiting for any copy still being written)
       and the copies stay for the next start, as after a crash. This is the macOS Quit menu
       (Cmd+Q): winit's menu calls `terminate:`, which reaches `on_exit` without asking. Until
       now that path removed the recovery copies of files that had unsaved changes;
  3. live sessions end explicitly: a session this app hosts tells everyone and stops; a joined
     session is left. Before, this only happened in the app's thread-local destructor at
     process exit, if at all.
- If the app can't be reached in `on_exit` (it's busy), the recovery copies are left on disk for
  the next start rather than removed.

Quit paths checked:
- **Cmd+Q / the menu bar's Quit (macOS):** `terminate:` → winit `applicationWillTerminate` →
  eframe `exiting` → `on_exit`. Covered.
- **Last window closed** (close button, title bar Close, Dock/Finder quit Apple event → `Close`):
  `on_close_request` → `begin` sets `ui.quit` → root `Close` → `on_exit`. Covered.
- **`app.quit` (control channel / MCP):** sets `ui.quit` and closes the root window → `on_exit`.
  Covered. Its reply goes to the connection thread before the window closes. The process now ends
  sooner after that, so a client that reads very slowly could in theory see EOF instead of the
  reply (not seen).
- **The frame guard (panic):** isn't a quit. It writes recovery copies and the app keeps running,
  so it doesn't need this. A panic outside the guard aborts as before.
- **eframe error mid-run:** eframe also calls `on_exit` before `run_native` returns `Err`. On macOS
  that error now only reaches the log (eframe logs it), not the "window couldn't open" box. Startup
  failures still show the box: `on_exit` only runs once the app exists.

Part of #781.
2026-10-18 14:09:51 +07:00
BFlat 247530b60e Merge pull request #787 from pechnikov2026-star/community/dutch-catalog
M9: Dutch catalog, machine-drafted (nl.toml)
2026-10-18 13:58:06 +07:00
bflatastic ae89635e00 #787: translate the 11 strings main gained since the catalog was drafted 2026-10-18 13:25:03 +07:00
bflatastic 633455c380 #787: leave the progress docs to the maintainer 2026-10-18 13:25:02 +07:00
bflatastic 846a9f7214 Merge main into #787 2026-10-18 13:16:03 +07:00
BFlat 1c158ed81b Merge pull request #788 from storytold/community/batch-17
Community batch 17: glass labels, pencil preview, prototype delete fix, presenter menus, French catalog, Home search (6 PRs)
2026-10-18 13:14:30 +07:00
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
sanches ac606a2d43 M9: Dutch catalog, machine-drafted (nl.toml) 2026-10-18 08:17:12 +03: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 532b0f7321 Batch 11: Paste here puts copied design layers at the point 2026-10-17 16:18:57 +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 313386c1ac Merge main into batch 11
# Conflicts:
#	apps/figcraft/src/services.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/menus.rs
2026-10-17 16:15:12 +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
BFlat 753d073f1b Merge pull request #728 from storytold/community/batch-10
Community batch 10: .fig import and file handling (12 PRs)
2026-10-17 16:09:50 +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 84dc9c4f22 Merge fix/524 into batch 10 (#524's rebased head; batch already has its content) 2026-10-17 15:52:39 +07:00
bflatastic f6bfd6953f Merge #524's rebased head (the batch carries the reviewed resolution) 2026-10-17 15:52:18 +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 a6b35f32e0 Merge fix/all-f3 (9 editor PRs) 2026-10-17 15:47:01 +07:00
bflatastic efe4911eb0 Merge main into #624 2026-10-17 15:42:48 +07:00
Andrii VolkovandClaude Opus 5.5 58d891f63d fix(format): one kiwi value per compressed canvas byte
One value per two bytes of the compressed canvas was too tight for a
public design system: a 39.5 MB canvas holding 29.7 million values got
a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75
values per compressed byte; the budget is now one per byte, at least the
old 20 million and at most 128 million. A small file still gets main's
20 million, however far it unpacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
Andrii VolkovandClaude Opus 5.5 62ade9b9ad fix(format): reopen large documents once saved
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
Andrii VolkovandClaude Opus 5.5 330f80fcf5 fix(format): open large .fig files
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 11:29:41 +03:00
bflatastic da5fe1359b Merge fix/524 into batch 10 2026-10-17 15:26:46 +07:00
bflatastic 546c09b3ba Merge fix/all-f2 into batch 10 2026-10-17 15:26:45 +07:00
bflatastic 2fe28bf0a0 Merge fix/707 2026-10-17 15:20:40 +07:00
bflatastic d7da6555f1 Merge fix/654 2026-10-17 15:20:38 +07:00
bflatastic c6ad0bd55d Merge fix/657 2026-10-17 15:20:37 +07:00
bflatastic 2446ed8ddb Merge fix/713 2026-10-17 15:20:35 +07:00
bflatastic 8d97b16e90 Merge fix/691 2026-10-17 15:20:34 +07:00
bflatastic e4d06b2f63 Merge fix/717 2026-10-17 15:20:33 +07:00
bflatastic 6b88e2ea64 Merge fix/718 2026-10-17 15:20:31 +07:00
bflatastic 62308e247e Merge fix/666 2026-10-17 15:20:30 +07:00
bflatastic 3a8e6e2860 #707: search every command, ranked; a highlight for Enter; recents from the palette only
- A search covers every command again (the engine's and the UI's own), grouped by
  menu where one lists it and under Other otherwise, best matches first (prefix,
  then shortest), at most 60. Without a query the menus' items show as before.
- Up and Down move a visible highlight (the pointer moves it too); Enter runs
  the highlighted row, not an invisible first one.
- Only runs from the palette are kept as Recents; menus, shortcuts and context
  menus no longer write the preferences. Recents match the translated label.
- The Plugins menu's items (run last, manage, import) show with the plugins.
- The search keeps its hint, so every catalog keeps its translation.
- The panel sits above the toolbar pill's own rect (toolbar::pill_rect); one
  set_palette/toggle_palette opens it from every entry point.
- Clippy: needless borrows in the toolbar.
2026-10-17 15:20:08 +07:00
bflatastic 45b154e40b #707: drop the unrelated .gitignore line and the parity doc edit 2026-10-17 15:20:08 +07:00
bflatastic 5fcf82abfc Merge #524's latest commit (same budget as the fix above)
# Conflicts:
#	crates/format/src/fig/kiwi.rs
#	crates/format/src/fig/tests.rs
2026-10-17 15:19:59 +07:00
bflatastic 58923a22e8 Merge fix/647 2026-10-17 15:11:24 +07:00
bflatastic 426217327b #647: text copied in another app after a design paste pastes as text again 2026-10-17 15:11:23 +07:00
bflatastic 946004f0d6 Merge main into #707 2026-10-17 15:08:01 +07:00
bflatastic bbfc408ab8 #654: leave the roadmap to the maintainers' docs PR 2026-10-17 15:06:47 +07:00
bflatastic 00e6fc1d59 Merge main into #654
# Conflicts:
#	ROADMAP.md
2026-10-17 15:06:46 +07:00
bflatastic 58de7d4dab #657: a drop that would leave no segment only moves the point
Dropping one end of a 2-point line on the other left a 1-point vector.
2026-10-17 15:06:37 +07:00
BFlat c1347d28c5 Merge pull request #727 from storytold/community/batch-9
Community batch 9: the .fig import series (11 PRs by @Skyfuller1303)
2026-10-17 15:06:06 +07:00
bflatastic 22a821c742 #657: leave the roadmap to the maintainers' docs PR 2026-10-17 15:03:02 +07:00
bflatastic 45e1c38d60 Merge main into #657
# Conflicts:
#	ROADMAP.md
2026-10-17 15:03:02 +07:00
bflatastic 5c0b4e3325 Merge main into #713 2026-10-17 15:00:51 +07:00
bflatastic ef684c5df4 #691: leave the parity docs to the maintainers' docs PR 2026-10-17 14:58:06 +07:00
bflatastic e21becfa85 Merge main into #691 2026-10-17 14:57:55 +07:00
bflatastic c1e86b7f02 Merge main and the #693 fixes into #717 2026-10-17 14:55:43 +07:00
bflatastic 25e9be3936 #693: clipboard reads on Linux and the BSDs give up after 2 s
A Wayland clipboard read waits on the app that copied with no timeout of its
own. The read now runs on a helper thread with its own clipboard handle, so a
stuck app can't hang the window.
2026-10-17 14:49:48 +07:00
bflatastic 22ac265435 Merge main into #693 2026-10-17 14:46:14 +07:00
bflatastic 9e16cddda8 Merge main into #718 2026-10-17 14:43:10 +07:00
bflatastic 6020d94d55 Merge fix/647 2026-10-17 14:41:03 +07:00
bflatastic a3e8c86331 Merge fix/620 2026-10-17 14:40:55 +07:00
bflatastic 9f01b21a54 Merge fix/619 2026-10-17 14:40:49 +07:00
bflatastic 5633453f5b Merge fix/524 2026-10-17 14:40:43 +07:00
bflatastic d8df6f6895 Merge main into #666 2026-10-17 14:36:15 +07:00
bflatastic 460985672c Merge fix/656 2026-10-17 14:36:06 +07:00
bflatastic aa90259f1c Merge fix/617 2026-10-17 14:36:06 +07:00
bflatastic b5cba7c885 Merge fix/711 2026-10-17 14:35:20 +07:00
bflatastic 4c1f2b978b Merge fix/697 2026-10-17 14:35:17 +07:00
bflatastic 1328092a7e Merge fix/612 2026-10-17 14:35:15 +07:00
bflatastic 45c68177d0 Merge fix/695 2026-10-17 14:35:14 +07:00
bflatastic 090266ad40 Merge fix/610 2026-10-17 14:35:12 +07:00
bflatastic d07b55104d #647: paste takes the design path only when the HTML holds readable design data; neutral wording in every catalog 2026-10-17 14:34:58 +07:00
bflatastic 3353d2d5d3 #647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64 2026-10-17 14:34:57 +07:00
bflatastic 946006f100 #647: leave progress docs to the maintainer's docs PR 2026-10-17 14:34:56 +07:00
bflatastic f7fb03f25d Merge #632 2026-10-17 14:34:20 +07:00
bflatastic 9415ef6579 Merge #631 2026-10-17 14:34:20 +07:00
bflatastic 2e32a1659c Merge the .fig import stack (#626-#639) fixed for landing 2026-10-17 14:34:19 +07:00
bflatastic edc2845324 Merge #632's reviewed head (its changes are folded in above) 2026-10-17 14:34:17 +07:00
bflatastic 8f9be76fac Merge #631's reviewed head (its changes are folded in above) 2026-10-17 14:34:15 +07:00
bflatastic 92714667f6 Merge main into #647 2026-10-17 14:27:33 +07:00
bflatastic 02c4d7d825 #620: test the summary and report of a file opened from data 2026-10-17 14:27:08 +07:00
bflatastic fd98f572d2 Merge fix/635 2026-10-17 14:26:04 +07:00
bflatastic 4a572572db #635: no negative zero from the flipped tint 2026-10-17 14:25:56 +07:00
bflatastic 01c3f0bca6 #620: the import report and its notes in every catalog; #617's notes added, stale and unshipped ones dropped 2026-10-17 14:21:29 +07:00
bflatastic d40728d0b8 #620: import notes come from the importers' own warn() texts, translated once when the report opens 2026-10-17 14:21:29 +07:00
bflatastic f757044d8c fix/all: rustfmt the merged module list 2026-10-17 14:08:05 +07:00
bflatastic d0539b83e9 #631: follow the decoder rename in #639's connector check
#639 lands first and reads networks with decode_styled; once #631 drops the count it calls
network::decode. Needed whenever #631 is rebased onto a main that has #639.
2026-10-17 14:06:43 +07:00
bflatastic 7cb0b68d73 Merge fix/635 2026-10-17 14:05:09 +07:00
bflatastic 3881cce291 Merge fix/632 2026-10-17 14:05:08 +07:00
bflatastic 3f36adfa90 Merge fix/638 2026-10-17 14:05:05 +07:00
bflatastic add7806197 Merge fix/639 2026-10-17 14:05:02 +07:00
bflatastic de28945484 Merge fix/636 2026-10-17 14:04:59 +07:00
bflatastic 313a38c0a8 Merge fix/630 2026-10-17 14:04:55 +07:00
bflatastic 1e85d54952 #635: cap the .fig import warnings like the REST ones
Image filter warnings name fields of the file, so a crafted schema could add any number of
distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as
the REST importer does.
2026-10-17 14:04:00 +07:00
BFlat 9e28e11954 M3: number variables in the text content picker (#629) (#725)
The Typography section's variable picker also lists number variables for a text layer's content (shown as the number), as the engine already accepted; other text fields keep their own types (#629).
2026-10-17 14:03:59 +07:00
bflatastic 48d74df226 #635: one tint sign for REST file JSON and .fig
Both carry the same image filter value, which the public docs describe as green when lower and
magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path
did, the REST path didn't). The .fig test message no longer names the product.
2026-10-17 14:03:57 +07:00
bflatastic 601ff4218b Merge #651 into #620: translate the vector style import notes 2026-10-17 14:02:15 +07:00
bflatastic 4771eb45fa Merge #617 into #620: its import notes join the report 2026-10-17 14:02:13 +07:00
bflatastic baa58b21fa #620: file.open with base64 data returns the summary too 2026-10-17 13:59:03 +07:00
bflatastic 24eec5d6bb Merge main into #620 2026-10-17 13:59:02 +07:00
bflatastic 2ac4ce3a73 #635: rebase onto #628: the filter tests add their paint fields themselves
The test schema extended the image scale-mode field that only #634 adds; it now adds the
filter containers to the paint message itself, so it works with and without #634.
2026-10-17 13:58:41 +07:00
Skyfuller1303 d84aa7f1e1 M1: import image adjustments from .fig files
Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once.
2026-10-17 13:58:04 +07:00
bflatastic ca94b5fe6f #632: leave the progress docs to the maintainer 2026-10-17 13:54:27 +07:00
bflatastic 3fadbf4ba9 #632: read the network with the decoder #631 left 2026-10-17 13:54:25 +07:00
bflatastic 6baeb53b20 #619: the Save as messages in every catalog 2026-10-17 13:51:01 +07:00
bflatastic a96222012b #619: leave progress docs to the maintainer's docs PR 2026-10-17 13:51:00 +07:00
Skyfuller1303 6233b90ba7 M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-17 13:49:58 +07:00
bflatastic c605bd5781 #631: drop the styled-segment count nobody reads 2026-10-17 13:49:46 +07:00
bflatastic 3134849b81 Merge main into #619 2026-10-17 13:49:02 +07:00
bflatastic 5fba7e5dba #524: one kiwi value per compressed canvas byte (up to 128M), lower value and document.json ceilings on 32-bit targets 2026-10-17 13:48:56 +07:00
bflatastic ec325c648c Merge main into #631
# Conflicts:
#	crates/format/src/fig/network.rs
2026-10-17 13:46:35 +07:00
bflatastic cca1058b4a Merge main into #524 2026-10-17 13:46:29 +07:00
bflatastic 99dc199bb8 #656: borrow embedded image bytes and copy only the ones a paint uses 2026-10-17 13:46:22 +07:00
bflatastic 22bdad22fe #656: leave progress docs to the maintainer's docs PR 2026-10-17 13:46:21 +07:00
Skyfuller1303 d9e79d604b M1: distinguish unresolved vector segment style metadata in .fig import
The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model.
2026-10-17 13:45:55 +07:00
bflatastic b31ef2273a Merge main into #656 2026-10-17 13:45:26 +07:00
bflatastic 7fe0770b9d #617: point joins equal to the layer's no longer count as point styles (SVG keeps a plain stroke) 2026-10-17 13:45:16 +07:00
bflatastic 6023d3547a #617: leave progress docs to the maintainer's docs PR 2026-10-17 13:45:15 +07:00
bflatastic 15533338e8 Merge main into #638 2026-10-17 13:44:45 +07:00
bflatastic ee3e3f0bd4 #637: skip ink only counts under an underline
The public docs don't give a default for skip ink; they say the value is unset when the text
isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text
without an underline and the real value under one (that's what the canvas draws), and the import
leaves a value on text without an underline out of the decoration look, so plain text keeps the
default look and round-trips unchanged. The native default stays off, so existing documents
draw as before.
2026-10-17 13:44:34 +07:00
bflatastic 9de5b333ac Merge main into #617 2026-10-17 13:41:04 +07:00
bflatastic 9b4e45aa0b #711: move the vector.offset tests into their own module 2026-10-17 13:40:55 +07:00
bflatastic 0dac325868 Merge main into #637 2026-10-17 13:40:41 +07:00
bflatastic 0ef8966844 #639: drop the ignored test that needs a fixture outside the repo 2026-10-17 13:40:30 +07:00
bflatastic e4f2c7fc4c Merge main into #711 2026-10-17 13:38:42 +07:00
bflatastic f44d7ed598 Merge main into #639 2026-10-17 13:36:31 +07:00
bflatastic 102f553f2c Merge main into #697 2026-10-17 13:35:58 +07:00
BFlat 69806c9edd Plugins: refuse file and network access through path params and collab (#723)
Plugins can no longer reach the disk or network through path parameters (variable.export/codeConnect.export/component.playground writes, codeConnect.import/variable.import reads) or through collab.* and window.*; found and fixed by @lucasmartins-ai, split out of #696.
2026-10-17 13:35:00 +07:00
bflatastic c42207fdee Merge main into #636 2026-10-17 13:33:43 +07:00
bflatastic fbe953e9b0 Merge main into #612 2026-10-17 13:33:11 +07:00
bflatastic b6820d6710 Merge main into #630 2026-10-17 13:31:18 +07:00
bflatastic ab6cf4fd58 Merge main into #628 2026-10-17 13:27:46 +07:00
Andrii VolkovandClaude Opus 5.5 7dcd7fd05b fix(format): one kiwi value per compressed canvas byte
One value per two bytes of the compressed canvas was too tight for a
public design system: a 39.5 MB canvas holding 29.7 million values got
a budget of 19.7 million (reported in #701). Real files hold 0.37 to 0.75
values per compressed byte; the budget is now one per byte, at least the
old 20 million and at most 128 million. A small file still gets main's
20 million, however far it unpacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-17 09:27:42 +03:00
bflatastic 51e02a6b1a #695: leave progress docs to the maintainer's docs PR 2026-10-17 13:27:28 +07:00
bflatastic b4d58a362e #627: leave the progress docs to the maintainer 2026-10-17 13:27:13 +07:00
bflatastic 6e15e30790 #627: remove the dead network::decode shim 2026-10-17 13:27:12 +07:00
bflatastic b9d587e80e Merge main into #695 2026-10-17 13:27:11 +07:00
bflatastic fa4821bcea #627: settle bound paints once per open, not also in the native loader
DocState::new already settles every opened document (native files, branches and live joins
included); the .fig/REST path keeps its settlement in link_instances for file.import into an
open document.
2026-10-17 13:27:11 +07:00
bflatastic de06c1ed5f Merge main into #610 2026-10-17 13:25:09 +07:00
bflatastic b0e9e87129 Merge main into #627 2026-10-17 13:21:48 +07:00
bflatastic 52df73c44c Merge main into #626 2026-10-17 13:10:59 +07:00
BFlat 40c744da10 M1: convert frames to sections and sections to frames (#719) (#721)
Frames on the page or in a section convert to sections (object.convertToSection) and sections back to frames (object.convertToFrame), in place, one undo step each, from the canvas context menu; nested frames, instances, main components and sections holding sections are refused.
2026-10-17 13:03:49 +07:00
justinjohn0306andClaude Opus 5.5 df674d0d53 #710: Resources and Shift+I open the Assets panel from a collapsed sidebar or the version history
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:17:28 +05:30
RealtaNuaandClaude Opus 5.5 18a7a063fe fix(ui): paste chords read modifiers from modifier changes only (#716)
Every shell reports a modifier change before the key it goes with, so key
events needn't update the held modifiers too. The old test that pressed
Shift+Cmd+V and Alt+Cmd+V expecting "nothing" now covers plain V only:
those chords run their commands, covered by their own tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 09:00:56 +07:00
RealtaNuaandClaude Opus 5.5 9f52819fe9 fix(ui): Shift+Cmd/Ctrl+V pastes over the selection and Alt+Cmd/Ctrl+V pastes properties (#716)
egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and
sends a paste event only for text. Shift+Cmd+V and Alt+Cmd+V became a
plain paste (text) or did nothing (image). The keymap now decides what a
swallowed chord means, from the modifiers tracked in the raw events: its
paste event or its release runs paste over or paste properties instead.
In the browser the key press runs the command, and the paste event no
longer pastes its text as well.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 08:35:03 +07:00
Ismael Trejo ce472a251c fix(doc): thin strokes take a double-click a few pixels away (#646)
A 1 px Line or stroke-only path only counted as hit within 2 screen
pixels, so a double-click slightly off it cleared the selection and
never entered vector edit mode, and Cmd/Ctrl could not bend it.
Strokes with nothing filled now reach 5 screen pixels; filled shapes
keep the plain tolerance.
2026-10-16 16:40:27 -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
NetterSilverandClaude Haiku 5.5 9e2596e975 Skip layers with children in vector.offset
Found while testing crates/engine/src/cmd/structure.rs:575

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:09:53 +00: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
Ahmed5Emad 3b5c5d563b Palette follows the bar: toggle, dismiss, no invented icons
The Actions button toggles (re-click and Cmd+K close), reads on in
accent blue with a white icon while open, and a press anywhere outside
closes the panel in one click. The opening press is spared once (the
spare expires on press-free frames); presses on the button itself never
dismiss, so the release toggles cleanly shut instead of reopening.

Rows drop the per-family icons (labels + right-side hints only);
plugins and widgets keep their letter tiles. The panel anchors to the
canvas centre at the toolbar pill's exact width (toolbar::main_width),
at a fixed size, with inset hover pills and larger section titles.

Tests: one-click dismiss pinned (palette_tests).
2026-10-16 23:27:45 +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
Ahmed5Emad dc97118c6f Actions palette rework: Figma-shaped panel over the toolbar
Tabs (All, Assets, Plugins & widgets), Recents kept in the preferences
(newest first, capped at 5), commands grouped by top-level menu, this
file's components (placed at the view centre) and styles (applied to
the selection), installed plugins and widgets with letter tiles. Rows
carry right-side shortcut/kind hints; Enter runs the first visible row.

The panel floats 8 px above the toolbar pill with its exact width and
centre (toolbar::main_width, canvas-centred), at a fixed size so tab
switches never resize it. Tabs keep their frames when idle so the
active pill never moves its neighbours.

Engine: Prefs.recent_actions (persisted, skipped when empty). UI:
invoke/menu/plugin picks record recents; gone entries drop out.
Locales: new keys in en + es, retired the old search hint everywhere.
Docs: ui-parity row describes the new surface (palette_tests).
2026-10-16 22:58:36 +03:00
Yarila 8c90ef962a M7: open inputs for parameterized menu actions 2026-10-16 23:36:23 +05:00
RealtaNuaandClaude Opus 5.5 d27f45e063 fix(format): write a list's start on every line; report it in get_node (#686)
The .fig export now writes a numbered list's start on each of its lines,
so a reader that takes it per line or per list counts the same. Bullets
no longer keep a start. The field is named start_offset (it holds the
offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc
lists what's still not read from .fig lists.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:58:20 +07:00
NetterSilverandClaude Haiku 5.5 9aeb9fb752 Cap stroke width in the outliner so huge weights don't panic
Found while testing crates/engine/src/cmd/props.rs:361 (strokeWeight is only clamped with .max(0.0)); the panic is raised when the renderer draws the stroke (render crate / tiny-skia stroker)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 19:58:00 +02:00
RealtaNuaandClaude Opus 5.5 4adbd6cee9 fix(ui): a V held while the window was away doesn't paste on release (#663)
winit sends no press for a key held across a focus change (X11's
synthetic presses are dropped by egui-winit), so letting go of V after
coming back looked like a swallowed Cmd+V. After a focus gain, a V
release only pastes with Cmd still held.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:35:46 +07:00
RealtaNuaandClaude Opus 5.5 60fa813eb2 fix(format): numbered lists from .fig files keep their start number (#686)
Figma stores where a numbered list starts counting on each text line
(listStartOffset; a list shown as "2." has offset 1). The import dropped
it, so every list counted from 1.

A paragraph's list settings now carry a start number (written only when
set, so older files open and save unchanged). Numbering counts on from
it where a list starts; items that go on counting ignore it. The .fig
import reads it, the .fig export writes it on a list's first line, and
plain paragraphs drop it. Toggling a list off ignores differing starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:27:34 +07:00
RealtaNuaandClaude Opus 5.5 569a3b3669 fix(engine): a Shift-click on a selected widget doesn't also click it (#498)
With Shift let go before the button, the release took the layer out of
the selection and also ran the widget's click. The take-out now skips
the widget click, as a Shift-click always has.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:11:00 +07:00
RealtaNuaandClaude Opus 5.5 40955aae7c fix(ui): Cmd/Ctrl+V pastes an image copied in another app (#663)
egui-winit swallows the Cmd/Ctrl+V press and sends a paste event only
when the clipboard holds text, so with an image on the clipboard only
the key's release arrives and the image fallback, which waited for the
press, never ran. Paste from a V release whose press never came and
brought no paste event; the press and paste event are tracked from the
raw events at the start of each frame.

On Wayland arboard read the X11 clipboard, which the compositor does
not fill for a native Wayland window, so every system paste found it
empty. Enable arboard's wayland-data-control: it uses the Wayland
clipboard when WAYLAND_DISPLAY is set and falls back to X11 otherwise.

Right-click > Paste here now follows the same newest-wins rule, so a
newer image beats copied layers there too, and lands at the point.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:09:59 +07:00
Dylan M. Taylor d1d8c3f652 ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action (#640)
* ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action

* ui: widen About window so contributor table fits with Inter font
2026-10-16 10:08:58 -06:00
RealtaNuaandClaude Opus 5.5 8303cfa56f fix(engine): Shift-drag on a selected layer moves the selection (#498)
Shift on a selected layer took it out of the selection on press, so the
drag that followed had nothing to move, and with several layers selected
the grabbed one stayed behind. It now leaves the selection on release,
and only when the press didn't become a drag. Where no move can start
(a version being viewed), it still leaves on press.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:52:41 +07: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
Ahmed5Emad 1aa499c84f Merge remote-tracking branch 'origin/main' into feat/font-hover-preview
# Conflicts:
#	crates/ui-egui/src/style_picker.rs
#	crates/ui-egui/src/typography.rs
2026-10-16 14:34:02 +03: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
Ahmed5Emad 2cac36a4e2 Live font preview on hover in font picker (#653)
Hovering a family in the Typography font dropdown previews it on the
canvas via text.preview; closing without picking restores the original,
clicking applies it as one undo step.
2026-10-16 12:31:50 +03:00
Yarila 6d3e7aad20 M7: isolate presenter tab and device controls 2026-10-16 14:16:44 +05:00
AganzeFelicite b962603707 M4: points dropped on a point merge
In vector edit mode a point dragged onto another point of its path
snapped there but stayed a separate point, so a path couldn't be closed
or joined by dragging. A single dragged point dropped within the point
hit radius (6 px) of another point now merges into it, in the same undo
step as the move, and that point stays selected.

Closes #401
2026-10-16 10:46:36 +02:00
loyet 11455e0fe9 M1: .fig import reads images carried in the message (image.dataBlob)
Copies written by plugins and tools carry their image bytes in the
message: a paint's image.dataBlob indexes the message's blobs. A bare
canvas (what the clipboard holds, see #647) has no images/, so these
fills came in as grey placeholders. The importer now falls back to the
embedded bytes, keyed by image hash, after the archive's images/.

Blobs over the image size cap and indexes past the blobs are skipped and
stay placeholders. Tests: a bare canvas carrying its image in a blob, and
a dataBlob past the blobs.
2026-10-16 10:44:29 +02: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
AganzeFelicite e8b181de39 M4: Flatten keeps open paths
Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result
from each layer's fill outline, so open paths, which have none, were
dropped: two pen paths flattened to "nothing to flatten", and next to a
closed shape they vanished. Vectors now bring their own network (open
paths, unfilled loops and each region's winding stay), mapped into the
parent's space; other layers still flatten to their outline.

Closes #377
2026-10-16 10:23:57 +02:00
Andrii VolkovandClaude Opus 5.5 0efddc3894 fix(format): reopen large documents once saved
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.

That saved file reopens in 13 s with a 5.9 GB peak.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 10:46:08 +03:00
Andrii VolkovandClaude Opus 5.5 12d43b1f99 fix(format): open large .fig files
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.

The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
  the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
  import's 200 000 and at most 2 000 000. A small or crafted file can't
  expand its instances into millions of layers, however far it unpacks; the
  REST JSON import keeps 200 000.

Memory:
- The mapper no longer passes the document through `json!`, which serialises
  its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
  importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
  objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
  as soon as the next stage is built.

On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 10:46:08 +03:00
Skyfuller1303 41d465a678 M1: reword the vector segment style import notes 2026-10-16 09:40:06 +02:00
Skyfuller1303 d8e64a73bf M1: reword the vector segment style import notes 2026-10-16 09:40:04 +02:00
Skyfuller1303 771f7e869f M1: reword the unresolved point style import note 2026-10-16 09:40:04 +02: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
Skyfuller1303 657a8f4df6 M1: translate the vector style import notes 2026-10-16 09:16:10 +02:00
JimKnoxxandClaude Opus 5.5 ab10e37d43 M1: paste layers copied in Figma
Figma puts copied layers in the clipboard's HTML as a base64 .fig canvas
between (figma) markers. edit.pasteFromFigma reads it with the .fig import,
turns the layers into the session clipboard and pastes them as edit.paste
does (selected frame, in place or view centre), with the import's warnings.
The desktop app reads the clipboard's HTML (arboard) and Paste prefers a
Figma copy; the browser's paste event carries plain text only.

Tested on copies written by our own .fig export; not yet on real copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:20:49 +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
skylar taylor-barrick 23bdebd973 Merge pull request #176 from dylanmtaylor/use-craft-common-actions
ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check
2026-10-15 21:34:16 -06:00
skylartaylor d9584410af ci: retain FigCraft package and metadata checks 2026-10-16 05:24:23 +02:00
Skyfuller1303 c4536f412b M1: import labels of straight connectors from .fig files
Straight connectors saved with a manual routing snapshot (connector and derived-frame version 1) now come in as an editable vector plus their label text layers, instead of an empty frame. Label placement composes the saved background and text transforms, and label paints and text styles use the existing import path. Connectors of other versions or line styles keep the existing fallback with their compatibility notes. At the layer depth or node limit the saved vector is kept without extra label layers, and skipped labels are reported once. Live endpoint bindings and routing are not retained.
2026-10-16 05:08:37 +02:00
Skyfuller1303 7fd44641f2 M1: keep paragraph options from .fig files
Text layers, text styles and instance overrides in .fig files can carry list spacing, hanging list, hanging punctuation and leading trim. The importer now reads them from the highest layer with a valid value, so an invalid instance override row falls through to the valid value below it, while explicit zero, false and NONE values stay resets. Both text layers and text styles share one mapping. The native text data stores these options once per text node, so differing values on separate runs are not represented, and the experimental .fig exporter keeps its existing note that these settings are left out.
2026-10-16 05:03:38 +02:00
Skyfuller1303 3723fcffbd M1: keep text decoration skip ink from .fig files
Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value.
2026-10-16 05:03:34 +02:00
Skyfuller1303 120b6830ff M1: import editable text on a path from .fig files
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.

The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
2026-10-16 05:03:29 +02:00
Skyfuller1303 a52e2bb6b3 M1: distinguish unresolved vector segment style metadata in .fig import
The vector network decoder now keeps each segment's style id. Import reports a segment style that names a row of the layer's style table as metadata that was not interpreted, and one that names no row as a reference to a missing style entry, each at most once per file. The earlier note claimed that stroke styles were left out, which overstated what is known about the metadata. Segment geometry, paint and width are unchanged, and the style ids are not kept in the native vector model.
2026-10-16 05:02:27 +02:00
Skyfuller1303 8408cc72b8 M1: import component slot content from .fig files
Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main.

Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed.
2026-10-16 04:59:11 +02:00
Skyfuller1303 0b40964202 M1: keep grid track sizes in .fig import
Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata.
2026-10-16 04:58:11 +02:00
Dylan M. Taylor 3489e01a22 ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check 2026-10-15 21:39:02 -04: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
Skyfuller1303 d2606b3f51 M1: let local text values and instance overrides win over a shared text style in .fig import
A text layer that links a shared text style keeps its own, possibly stale, copy
of the typography. The style's typography now beats that copy, a case,
decoration, paragraph spacing or paragraph indent set on the layer itself beats
the style (so "original", "none" and zero clear the style's value), and an
override row of an instance beats both. An explicit "no style" on a text layer
unlinks it even when the older style field still names one.

Letter spacing given as a percentage of the font size stays a percentage. A text
property value on an instance no longer inherits the character styles written
for the main's text, which count other characters.
2026-10-16 03:32:39 +02:00
Skyfuller1303 4b29d8c34d M1: apply instance overrides by key and order, settle bound paints, keep stroke outline styles
Override rows of an instance can name a layer of its main by the key the layer
had before the main was edited. The key now resolves to the layer's current id
when it is unambiguous and the current id does not exist already. Rows of an
enclosing instance win over those of the instances nested in it, whatever order
the wire lists them in, and an explicit "no style" on an override detaches the
style instead of falling back to the older field.

Paints bound to variables take the colours of the active modes when a document
loads, before the first edit, without touching other pending changes.

A stroke-only vector that imports as the outline of its stroke now moves the
stroke's shared style and colour variables to the fill it becomes, and vectors
whose single segments carry their own stroke style say so in a warning.
2026-10-16 03:32:38 +02:00
Skyfuller1303 d331db00aa M1: keep explicit instance identity and cached library components in .fig import
A .fig import now links the layers of an instance to the layers of the
component they came from by the ids the file stores, instead of leaving it to
the engine to guess from names and places. Renamed slots and layers, siblings
that share a name and swapped mains keep their source link and the override
they carry.

Components that live only on the file's hidden internal pages (cached copies of
library components) are kept in the hidden library store when a visible layer
uses them, so their instances stay instances. The read-only guard for library
copies now compares the copies themselves rather than the dirty set, which an
import leaves full until the first edit.

Rebasing an instance (after detaching an outer instance or restoring a main)
follows the source chain inside the right enclosing instance, so contextual
links survive it. The last change of a GUID wins and a removed change deletes
it. Ordinary file JSON never reads the private provenance.

Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target.
2026-10-16 03:32: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
BFlat f40c600e67 Docs: roadmap rows for the community fixes from #478 up (#591, #598, #602) and #589 (#605) 2026-10-16 07:55:52 +07:00
BFlat 9c2d61f6ae Merge pull request #601 from Skyfuller1303/feat/drop-to-open
M7: drop files anywhere to open them
2026-10-16 07:41:02 +07:00
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
skylar taylor-barrick e4fe308cdf Merge pull request #608 from Silvertree2010/fix/h1871-file-close-with-a-negative-index
Reject negative or fractional index in file.close
2026-10-15 18:28:25 -06:00
Marco Arnulfo 5d1c24d061 Contributors: add my name for the About window 2026-10-16 02:19:42 +02:00
Skyfuller1303 01daf92fc3 M1: show an import report after opening a file
Opening or importing a .fig or REST JSON file, or an SVG that has notes,
from the app (File > Open or Import, a recent file, a drop, a paste, the
system's open event) now shows an Import report window with the
document's name, its page, layer and image counts and the compatibility
notes the importer collected, instead of leaving them only in the command
result. Commands sent over the control channel or MCP never open it.

file.open and file.import return the counts as `summary`; ui.inspect
reports the open report as `importReport`. The fixed notes the importers
emit are translated (German and Spanish) and shown with a capital first
letter; other notes, such as SVG's, show as written. The report goes
away with its document. Escape or Close dismisses it.
2026-10-16 02:18:50 +02:00
Skyfuller1303 d4394e87cc M1: save a copy as .fig
Save as now offers .figcraft and .fig as separate file types. A .fig
destination, chosen in the dialog or passed to file.saveAs and file.save,
writes the existing experimental .fig export of the document that is shown
and reports how much was left out. The file's own path, unsaved changes,
versions, branches and undo history are not touched, and a shown earlier
version stays shown while the copy is written or a Save as choice is
pending.

Plain Save no longer writes over a file whose saved path ends in .fig;
earlier builds could write native data under that name. It asks for a
destination instead.
2026-10-16 02:03:46 +02:00
vxMacDev c56a65f58b fix(format): keep vertical trim on .fig import (#466)
Figma's schema has leadingTrim on NodeChange (NONE, CAP_HEIGHT), and
the REST importer already reads style.leadingTrim, but the .fig mapping
never copied it, so text set to "Cap height to baseline" imported
untrimmed in its trimmed box. text_style now copies it with the other
text fields, from the layer or its text style.
2026-10-15 18:45:06 -05:00
Skyfuller1303 924310c124 M1: keep per-point stroke joins on .fig import
Vector points carry their own stroke join in a .fig file. Import now keeps each point's join as the point's own override instead of dropping it for the layer's join.

Joins equal to the layer join no longer force styled stroke runs, and an absent layer join counts as miter. Unresolved point styles, unknown joins, and joins that dashed or variable-width strokes cannot draw each produce a warning.
2026-10-16 01:34:52 +02:00
Skyfuller1303 0404448f74 M1: keep imported file bytes out of the command journal
Pasting or dropping a file runs file.import with the whole file as Base64
data, and every successful command was journaled with its parameters, so
each imported file stayed in memory a second time for the session. Byte
imports, including ones wrapped in command.batch, are no longer journaled.
Imports by path and all other commands are unchanged.
2026-10-16 01:34:29 +02:00
BFlat 4ee413acde Merge pull request #606 from storytold/community/batch-8
Community batch 8: drop files anywhere, duplicate fill tests
2026-10-16 06:18:07 +07: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
Skyfuller1303 1f6010e07c M7: open dropped files that have no file on disk
A home-screen drop that carries only bytes queued file.open with data and name, which file.open rejected because it requires a path. file.open now also takes base64 data with a name and opens it as a new document without a save path.
2026-10-16 00:22:14 +02:00
NetterSilverandClaude Haiku 5.5 90d7521840 Reject negative or fractional index in file.close
Found while testing crates/engine/src/cmd/file.rs:1283

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 00:19:49 +02:00
Daniel Miguel c6a3f25224 M0.9: sync reviewed GNOME controls fix with upstream 2026-10-16 00:13:38 +02:00
bflatastic 665a578957 Merge branch 'fix-601' into batch/t8 2026-10-16 05:04:14 +07:00
bflatastic 44ac32db0c Merge branch 'pr-600' into batch/t8 2026-10-16 05:04:13 +07:00
bflatastic f948e08fbc Merge remote-tracking branch 'origin/main' into fix-601
# Conflicts:
#	ROADMAP.md
2026-10-16 05:04:09 +07:00
bflatastic 65f5771a6e Home: describe the drop target without naming other software 2026-10-16 05:04:00 +07:00
Daniel Miguel b83a0de7a8 M0.9: scope Wayland decorations and avoid bundled title fonts 2026-10-16 00:01:55 +02:00
BFlat 33d59932f9 Merge pull request #602 from storytold/community/batch-7
Community batch 7: Czech plurals and bounded canvas overscan
2026-10-16 04:51:25 +07: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
bflatastic 049f97a4c3 Merge branch 'pr-597' into batch/t7
# Conflicts:
#	ROADMAP.md
2026-10-16 03:46:48 +07:00
bflatastic 62a291fcb8 Merge branch 'pr-596' into batch/t7 2026-10-16 03:46:43 +07:00
BFlat 7459cc7f86 Merge pull request #598 from storytold/community/batch-6
Community batch 6: 8 reviewed PRs
2026-10-16 03:45:01 +07: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
Barzabel d27ae11d15 M1: a duplicate's fill stays its own (#586)
Regression test for #586: with the colour picker open on a red rectangle or
pen path, duplicating it (edit.duplicate, or copy and paste) and recolouring
the copy leaves the original red. The cause was the picker staying bound to
the original after the selection moved to the copy, fixed by #523; without
that fix this test fails. The duplicated paint is already independent data.
2026-10-15 23:06:31 +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 b640069961 M7: drop files anywhere to open them
- Files dragged over the window show a "Drop to open" (home screen) or
  "Drop to import" (canvas) overlay.
- On the home screen each dropped file the app opens becomes a document of its
  own; any other file is turned away with a toast.
- On the canvas dropped files import as before, at the drop point.
- A small card ("Opening as a new file", "Adding to {document}") is on screen
  before a large file blocks the window; the files then open a frame later.
- A canvas import stays with the document and page that received the drop,
  keeps its placement, restores the page and selection chosen meanwhile, and is
  cancelled with a message if that document or page is gone.
- ui.inspect reports pendingOpens; every new string, including the cancel
  messages, goes through tr() with en, de and es entries.
2026-10-15 21:48:25 +02:00
bflatastic 9f935718fe Merge branch 'rb-578' into batch/t6
# Conflicts:
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:37:43 +07:00
bflatastic 1c863aa9f5 Merge branch 'rb-568' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 891f0acb3c Merge branch 'rb-560' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 55c05338a1 Merge branch 'rb-554' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 4e3fd82bd1 Merge branch 'rb-549' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 41b08a2393 Merge branch 'rb-528' into batch/t6 2026-10-16 02:36:54 +07:00
bflatastic 730e377418 Merge branch 'rb-487' into batch/t6 2026-10-16 02:36:53 +07:00
bflatastic 175fdfa428 Merge remote-tracking branch 'origin/main' into rb-478
# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	crates/ui-egui/src/lib.rs
2026-10-16 02:36:34 +07:00
bflatastic d60a27b675 Merge main into #554: one id map for the copy, and #562's flows, prototype and guides follow it
page.duplicate copies every layer with duplicate_all_into (navigation between
copied frames points at the copies) and builds the old-to-new list for flows
from that copy, instead of copying each layer separately.
2026-10-16 02:34:54 +07:00
bflatastic fd6a7e0ba3 Merge main into #549: keep #539's whole URL and add the empty-URL arm 2026-10-16 02:33:05 +07:00
bflatastic db8bf6ba33 Merge main into #560; normalise the replacement where findReplace reads it
Main's case-insensitive replace loop (from an earlier PR) took the place of the
loop this change edited, so the replacement text is normalised once, where
the parameter is read.
2026-10-16 02:31:34 +07:00
Skyfuller1303 fd24a6b883 M7: resolve canvas snapping conflicts with current main 2026-10-15 21:29:33 +02:00
LosminekandClaude Opus 5.5 4d9cde8fee i18n: Czech one/few/other plurals
Czech counts take three integer forms: one (1), few (2-4) and other
(0 and 5+). Since #257 catalogs can supply `few`, so `plural()` gets a
Czech rule and the 38 plural entries in cs.toml now read naturally
("1 vrstva", "3 vrstvy", "5 vrstev") instead of the count-neutral
"Vrstvy: {n}" that #469 used for everything but 1.

Test czech_integer_plurals covers the rule (1; 2-4; 0, 5, 11, 12, 21,
22, 100, 1 000 000), a real key through counted(), and that every Czech
plural entry has one and few. Docs no longer say Czech has only one/other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:28:51 +02:00
Yarila f8458b70a8 Reduce blank canvas strips during panning with bounded overscan 2026-10-16 00:28:10 +05:00
bflatastic 0a84dee777 Merge remote-tracking branch 'origin/main' into rb-578
# Conflicts:
#	crates/text/src/fontdb.rs
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:27:50 +07:00
bflatastic 46316c9895 Merge remote-tracking branch 'origin/main' into rb-568
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:26:29 +07:00
bflatastic 0e1ccb55d2 Merge remote-tracking branch 'origin/main' into rb-487
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-16 02:25:11 +07:00
bflatastic ec8a506353 Merge remote-tracking branch 'origin/main' into rb-528 2026-10-16 02:23:46 +07:00
BFlat eb741a362d Community batch 5: #312's sturdier zoom-preview test; record four rebased heads (#588)
Lands #312's test-only follow-up commit and records #326, #317 and #306's rebased heads (no content change) so they show as merged.
2026-10-16 02:23:45 +07:00
BFlat 97e422226f Merge pull request #591 from storytold/community/batch-5
Community batch 5: 59 reviewed PRs (#482 and up)
2026-10-16 02:15:47 +07:00
BFlat 8ba09001cd M0.9: a control line that isn't JSON ends the connection (#589)
The control server answered a line that wasn't JSON with an error and read
on. A web page can make the browser send an HTTP request to the loopback
port; its request and header lines were skipped that way and the JSON lines
of its body ran as commands. The first line that isn't JSON now closes the
connection before anything after it is read. Clients that send JSON lines
(MCP --attach, scripts) are unaffected.
2026-10-16 02:15:18 +07: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
bflatastic ecad646330 Merge branch 'pr-584' into batch/t5 2026-10-16 01:36:24 +07:00
bflatastic 87cb799783 Merge branch 'pr-583' into batch/t5
# Conflicts:
#	docs/ui-parity.md
2026-10-16 01:36:24 +07:00
bflatastic 466fd249fe Merge branch 'pr-579' into batch/t5
# Conflicts:
#	docs/ui-parity.md
2026-10-16 01:36:18 +07:00
bflatastic a4ba67ac22 Merge branch 'pr-577' into batch/t5 2026-10-16 01:36:13 +07:00
bflatastic 99b6495b7d Merge branch 'pr-576' into batch/t5 2026-10-16 01:36:12 +07:00
bflatastic 6eb9f38487 Merge branch 'pr-566' into batch/t5 2026-10-16 01:36:12 +07:00
bflatastic bf4d9e0f6f Merge branch 'pr-565' into batch/t5 2026-10-16 01:36:11 +07:00
bflatastic 9418e2c8bb Merge branch 'pr-564' into batch/t5 2026-10-16 01:36:10 +07:00
bflatastic c4c88900c4 Merge branch 'pr-563' into batch/t5 2026-10-16 01:36:10 +07:00
bflatastic aadaa2d975 Merge branch 'pr-562' into batch/t5 2026-10-16 01:36:09 +07:00
bflatastic 580eee3572 Merge branch 'pr-561' into batch/t5 2026-10-16 01:36:08 +07:00
bflatastic e359d856f2 Merge branch 'pr-559' into batch/t5 2026-10-16 01:36:07 +07:00
bflatastic e08c1c598f Merge branch 'pr-558' into batch/t5 2026-10-16 01:36:06 +07:00
bflatastic 4df3702dbb Merge branch 'pr-556' into batch/t5 2026-10-16 01:36:05 +07:00
bflatastic b7c692171d Merge branch 'pr-551' into batch/t5 2026-10-16 01:36:03 +07:00
bflatastic d95aef1d0c Merge branch 'pr-547' into batch/t5 2026-10-16 01:36:02 +07:00
bflatastic 887e374c58 Merge branch 'pr-545' into batch/t5 2026-10-16 01:36:00 +07:00
bflatastic 34dcf852d5 Merge branch 'pr-542' into batch/t5 2026-10-16 01:35:59 +07:00
bflatastic 5e7eb51e46 Merge branch 'pr-539' into batch/t5 2026-10-16 01:35:58 +07:00
bflatastic d40fd80be7 Merge branch 'pr-536' into batch/t5 2026-10-16 01:35:58 +07:00
bflatastic 1c5b4d8450 Merge branch 'pr-534' into batch/t5 2026-10-16 01:35:57 +07:00
bflatastic ef69a7f8e9 Merge branch 'pr-533' into batch/t5 2026-10-16 01:35:57 +07:00
bflatastic a1849aae15 Merge branch 'pr-531' into batch/t5 2026-10-16 01:35:56 +07:00
bflatastic 1e25e239c2 Merge branch 'pr-527' into batch/t5 2026-10-16 01:35:55 +07:00
bflatastic 7bda68876b Merge branch 'pr-526' into batch/t5 2026-10-16 01:35:55 +07:00
bflatastic 362bdce73b Merge branch 'pr-525' into batch/t5 2026-10-16 01:35:54 +07:00
bflatastic 5e99e71728 Merge branch 'pr-523' into batch/t5 2026-10-16 01:35:53 +07:00
bflatastic 09e85240ea Merge branch 'pr-522' into batch/t5 2026-10-16 01:35:52 +07:00
bflatastic 7998845515 Merge branch 'pr-521' into batch/t5 2026-10-16 01:35:51 +07:00
bflatastic e8eca728fe Merge branch 'pr-519' into batch/t5 2026-10-16 01:35:50 +07:00
bflatastic 438d468c76 Merge branch 'pr-518' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:50 +07:00
bflatastic 07130947f9 Merge branch 'pr-517' into batch/t5 2026-10-16 01:35:45 +07:00
bflatastic 16e26b69ff Merge branch 'pr-516' into batch/t5 2026-10-16 01:35:44 +07:00
bflatastic cd5c70a244 Merge branch 'pr-515' into batch/t5 2026-10-16 01:35:44 +07:00
bflatastic e6240f6229 Merge branch 'pr-514' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:43 +07:00
bflatastic 1498c0ea52 Merge branch 'pr-513' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 67cf5ecfd5 Merge branch 'pr-512' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 0c3c00db99 Merge branch 'pr-511' into batch/t5 2026-10-16 01:35:38 +07:00
bflatastic 7988e48b5d Merge branch 'pr-509' into batch/t5 2026-10-16 01:35:37 +07:00
bflatastic 0105724108 Merge branch 'pr-508' into batch/t5 2026-10-16 01:35:36 +07:00
bflatastic 2894195ab3 Merge branch 'pr-506' into batch/t5 2026-10-16 01:35:35 +07:00
bflatastic 02156c0edc Merge branch 'pr-505' into batch/t5 2026-10-16 01:35:35 +07:00
bflatastic 06844dc703 Merge branch 'pr-504' into batch/t5 2026-10-16 01:35:34 +07:00
bflatastic 2d1a7c973d Merge branch 'pr-503' into batch/t5 2026-10-16 01:35:33 +07:00
bflatastic 77c9ccd649 Merge branch 'pr-502' into batch/t5 2026-10-16 01:35:32 +07:00
bflatastic 95b32053d5 Merge branch 'pr-499' into batch/t5 2026-10-16 01:35:31 +07:00
bflatastic b1760ff098 Merge branch 'pr-497' into batch/t5 2026-10-16 01:35:30 +07:00
bflatastic b0aa597787 Merge branch 'pr-496' into batch/t5 2026-10-16 01:35:29 +07:00
bflatastic dcb36e376f Merge branch 'pr-495' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:28 +07:00
bflatastic 139195cb0c Merge branch 'pr-494' into batch/t5 2026-10-16 01:35:24 +07:00
bflatastic 991cb604c2 Merge branch 'pr-493' into batch/t5 2026-10-16 01:35:23 +07:00
bflatastic 1ef42e6504 Merge branch 'pr-491' into batch/t5 2026-10-16 01:35:23 +07:00
bflatastic d7de56f046 Merge branch 'pr-490' into batch/t5 2026-10-16 01:35:22 +07:00
bflatastic 4bc63d506c Merge branch 'pr-489' into batch/t5 2026-10-16 01:35:21 +07:00
bflatastic 567110f063 Merge branch 'pr-488' into batch/t5 2026-10-16 01:35:20 +07:00
bflatastic ffde863e92 Merge branch 'pr-486' into batch/t5 2026-10-16 01:35:19 +07:00
bflatastic e2b567d5b3 Merge branch 'pr-485' into batch/t5 2026-10-16 01:35:16 +07:00
bflatastic 77b6f1fc12 Merge branch 'pr-483' into batch/t5 2026-10-16 01:35:14 +07:00
bflatastic 97b10b637d Merge branch 'pr-482' into batch/t5
# Conflicts:
#	docs/localization-parity.md
#	docs/mcp.md
#	docs/translating.md
2026-10-16 01:35:12 +07:00
bflatastic 7da4cf5e31 Export frames to PDF: use the asynchronous save dialog
Main's file dialogs answer asynchronously now (they no longer re-enter the
event loop on macOS); the menu item takes the path in a callback, and the test
draws a frame to let the dialog answer.
2026-10-16 01:34:36 +07:00
bflatastic a8416b27c1 Merge remote-tracking branch 'origin/main' into fix-502 2026-10-16 01:30:26 +07:00
bflatastic 9552e530be Swedish: command labels that only repeated the command id show in English
468 of the 588 command and menu rows had the command id as their text
("align.bottom" = "align.bottom"), which the app would have shown as is.
They are left out, so English shows until they are translated. Swedish
isn't in the complete list (that would make every new UI string need a
Swedish translation in the same change); the docs say what it covers.
2026-10-16 01:30:10 +07:00
bflatastic fe5a22e468 Colour picker: record recent colours by the write's live flag
A text edit holds an interaction too, so treating any open interaction as a
drag dropped the colours clicked while editing text. Recent colours dedupe by
8-bit value: the colour picked and the one the document stores differ by a
rounding fraction.
2026-10-16 01:23:21 +07:00
bflatastic 59fe9b5ea8 Record #326, #317 and #306's rebased heads (same content as landed in #530 and #529) 2026-10-16 01:17:17 +07:00
bflatastic 862d73bf9f Merge #312: test(canvas): grow the slow zoom-preview scene until it crosses the threshold (@tylerjenningsw) 2026-10-16 01:17:16 +07:00
bflatastic 916ee3276c #306: say why the window can't open in the interface language
The message now comes from the UI crate (dialogs::window_failed) through
tr() and trf(), with entries in every catalog, and the app picks the
language from the preferences before the event loop starts, so the box
reads in it even though no frame was ever drawn. The commands to type stay
as written.
2026-10-15 20:24:05 +03:00
Barzabel 97f9c2fc5d M1: say why when the window can't open (#261)
Release builds on Windows have no console, so an eframe::run_native error
(no usable graphics adapter, a device the driver refuses) went to an unseen
standard error and the app quit silently. The error is now logged and shown
in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise
update the driver or try another graphics API (OpenGL on Windows), and
where to report it. With --control the box is skipped so agents aren't
blocked.
2026-10-15 20:24:04 +03:00
bflatastic 4a2d19f0db #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them 2026-10-15 20:23:07 +03:00
bflatastic a7bcc6d6ff #317: compute the backdrop once under a casting layer
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.
2026-10-15 20:23:07 +03:00
Barzabel f4d53a659b M1: memory budgets for decoded images and a file's images (#203, #189) 2026-10-15 20:23:07 +03:00
Barzabel 3074db1cbb M1: drop shadows under a background blur (#255)
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
2026-10-15 20:23:07 +03:00
Marco Arnulfo 4f9023d80f Cmd/Ctrl+= and - zoom the canvas only, not the whole interface (fixes #580) 2026-10-15 19:04:53 +02:00
Mateus Villain 825bb708df M7: fields select their text on focus
A click or Tab into a one-line field selects all of its text, so typing
replaces the value, as in Figma: hex and colour fields, inline fields
(a fill's hex, variable values, renames), names, the font size field,
search boxes and dialog fields (widgets::select_all_on_focus). A drag
from the press still selects only what it covers; multi-line fields
keep the caret where clicked.

The first click into an inline field (a fill's hex) now takes the
keyboard: egui dropped the focus of the editor created by that click,
so it took a second click.
2026-10-15 13:54:04 -03:00
Ivan Shishkanov 55db465d8e M7: scrub typography spacing from leading icons (#373) 2026-10-15 19:28:46 +03:00
bkimandClaude Opus 5.5 e9e2d87405 M1: Hangul falls back to a Korean system font, at the run's weight
Text in the default Inter (or any Latin font) drew the missing-glyph box for every Hangul
syllable on macOS and Windows: no Korean family was on the fallback list, so the Korean
fonts those systems ship were never tried, and there is no default-font setting to work
around it. Apple SD Gothic Neo (macOS), Malgun Gothic (Windows) and Noto Sans CJK KR /
Noto Sans KR (Linux) now come after the Chinese and Japanese entries, so Han characters
resolve as before.

Fallback faces also follow the run's style: each fallback family gives its face nearest the
run's style instead of always its Regular, so Hangul (or kana) in a bold run comes out bold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015MAPjgJo17KasfbzMTbV65
2026-10-16 01:23:06 +09:00
RealtaNuaandClaude Opus 5.5 44128f7e97 fix(render): the rim light fades out at its reach instead of stopping (#256)
Where the 512 px cap held the light short of half a level, it dropped to 0
in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300,
splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap
doesn't bite this moves the light by at most half a level.

Cut tests now also cover rounded corners and a look under the cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:18:45 +07:00
Ivan Shishkanov 7a5885cde9 M3: add Selection colors for mixed selections (#403) 2026-10-15 19:05:24 +03:00
Mateus Villain 782c9757f3 M3: CSS colour names in hex fields (red → FF0000)
Every hex field (fills, strokes, text ranges, effects, layout grids,
colour variables, page and prototype backgrounds, the colour picker)
now takes any of the 148 CSS named colours. Case, spaces, hyphens and
underscores are ignored; hex wins where both could match; names are
opaque and keep the colour's opacity. Color::parse_css (the picker's
CSS field and command colour params) takes the full list too.
2026-10-15 13:02:30 -03:00
IanHollow 5454bdc244 M7: skip special files when scanning system fonts
Require resolved font entries to be regular files before indexing them. Preserve normal font symlinks and cover named pipes in a bounded child-process regression.

Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-15 08:29:11 -07:00
MarekSpodBiedry 843b00b22f text: scripted text stores CRLF, CR, U+2028 and U+2029 as LF like the editor (#427) 2026-10-15 17:20:58 +02:00
MarekSpodBiedry 9fa99f8594 Keep per-character text fills in SVG export (#429) 2026-10-15 17:20:43 +02:00
MarekSpodBiedry 85d97a831c Keep flows, prototype settings and guides when duplicating a page (#417) 2026-10-15 17:19:38 +02:00
MarekSpodBiedry 7d0f9d7518 Colour picker: record only the final colour of a drag in Recent colors (#395) 2026-10-15 17:19:35 +02:00
RealtaNuaandClaude Opus 5.5 4588832c80 test(render): name the glass cut tests for what they check
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:21 +07:00
RealtaNuaandClaude Opus 5.5 bc5b474293 perf(render): sample once where glass doesn't bend
Where nothing bends (past `depth`, or with no refraction) the three
colour channels sample the backdrop at the same point: one sample
instead of three. The rim light's facing and powers are worked out only
where the light reaches. Same bytes: an ignored `glass_digests` test
prints digests of 30 renders (5 looks, square and rounded, 3 scales),
identical before and after this change (and to main).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:00 +07:00
RealtaNuaandClaude Opus 5.5 18e3070d4c fix(render): glass looks the same wherever a render cuts it
The glass light falls off with the distance to the edge, read past
`depth` (up to ~3 × depth at full splay), but the coverage window around
a cut layer was only `depth + 2` wide. With frost and refraction at 0
the backdrop margin is 0 too, so a band or canvas patch edge running
through the pane changed the light near it: up to 12 levels between a
banded and a whole render.

The window now covers the light's reach (where it falls under half a
level, clamped at 0 and capped at 512 px) plus the span the normals are
smoothed over, and past that reach the light is 0 instead of whatever
the window happened to hold.

Tests: a cut 50–80 px inside the pane equals the whole render byte for
byte (fails on main by 12 levels); light_reach edge cases. Adds an
ignored release timing test (glass_bench_tests).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:00 +07:00
MarekSpodBiedry 3062c6c4d6 Keep the selected guide selected when an earlier guide is removed (#418) 2026-10-15 17:18:38 +02:00
MarekSpodBiedry 4376f659ac Docs: FIG export guides no longer say variables and style links are omitted (#398) 2026-10-15 17:18:32 +02:00
MarekSpodBiedry 0ba8e30fbc docs: document perf --instances in the CLI reference (#391) 2026-10-15 17:17:51 +02:00
MarekSpodBiedry fdf397d7dc Refuse to create a variable collection with a name that is already taken (#416) 2026-10-15 17:17:44 +02:00
MarekSpodBiedry d1fcf69b7b Keep Mixed line height when Enter is pressed without typing (#439) 2026-10-15 17:17:30 +02:00
RealtaNuaandClaude Opus 5.5 49010614fd chore: credit Nael-Nathanael by GitHub profile name
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:16:10 +07:00
RealtaNuaandClaude Opus 5.5 778b8c6807 fix(ui): scrubbing the Scale panel's W and H scales live (#256)
W and H ignored scrubs. They now scale live in one undo step. The factor
field applies on release, because it is relative to 1.0 on every frame.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:58 +07:00
RealtaNuaandClaude Opus 5.5 1593b163fb fix(ui): scrubbing a prototype panel number sets it once (#256)
Delays, scroll offsets, overlay offsets and the overlay background alpha
were set on every frame of a scrub, an undo step each. They now set once,
when let go, like the custom size field.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:58 +07:00
RealtaNuaandClaude Opus 5.5 102429e6b5 fix(ui): scrubbing a colour picker field is one undo step (#256)
The RGB/HSL/HSB fields committed every frame of a scrub, so one drag left
about ten undo steps, and the alpha field ignored scrubs. Both now edit
live and commit when let go.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:55 +07:00
NetterSilverandClaude Haiku 5.5 6ce65b24f4 Keep region loops in ring order when adding a point to a reversed edge
Fixes #420

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:12:06 +00: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 a4f4869443 Point duplicated page navigation at the copied frames
Fixes #419

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:10:00 +02: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
NetterSilverandClaude Haiku 5.5 a57976e88f Keep frame min/max size in .fig export without an auto layout parent
Fixes #457

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:00:16 +02:00
NetterSilverandClaude Haiku 5.5 3c28f605d1 Keep cleared range hyperlinks through FIG export and import
Fixes #458

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:55:48 +00:00
NetterSilverandClaude Haiku 5.5 76a67e7ee5 Point library style variable bindings at imported library variables
Fixes #462

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:47:58 +02:00
NetterSilverandClaude Haiku 5.5 330b237c9c Refresh linked layers when a library update changes only a style
Fixes #461

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:45:47 +00:00
NetterSilverandClaude Haiku 5.5 7689af50d6 Keep character-range text styles when publishing and placing components
Fixes #463

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:31:53 +02:00
tylerjenningsw d769320d45 test(canvas): grow the slow zoom-preview scene until it crosses the threshold (M8)
The browser-worker test assumed a fixed 2400x2400 glass scene renders
in at least 12 ms; a faster machine or renderer (the glass speedups)
would make it quick and the test fail. The scene now gets more and
more blurred rectangles until renders from scratch of the views the
test uses take three times the threshold.

Refs #295
2026-10-15 10:04:09 -04:00
Hamza 01c0b38669 fix(rest): keep a text hyperlink's destination whole on import (#459)
A hyperlink longer than 200 characters came back as a different 200-character
URL -- one that still looks like a working link -- and the import reported
nothing. It applied to a text layer's base style and to a character style
override alike.

The destination went through `clip_str`, which exists so that names and labels
from a file stay readable when they are quoted in a warning. Every other caller
is a name, a description, a style id or a layer type; a hyperlink is data, and
clipping it silently rewrote it. Two things say so: the FIG importer already
keeps the URL whole, so the two importers disagreed, and `rest::props` bounds
numbers and list lengths but no string beyond that label helper -- the text
content itself is not clipped either.

The reporter's integration test is included, with its assertions unchanged. Its
helper is restructured to return what it found rather than unwrap: the crate
denies `unwrap_used` and `panic`, and `clippy.toml` allows them only inside a
`#[test]` function, so the original shape failed the gate before it ran. Its
failure message now reports the input and output lengths.
2026-10-15 19:03:24 +05:00
NetterSilverandClaude Haiku 5.5 b61122ccca Fix eraser missing brush crossings narrower than the sample spacing
Fixes #421

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:28:43 +02:00
NetterSilverandClaude Haiku 5.5 524b538908 Report changed=false when list indentation is capped
Fixes #425

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:21:05 +00:00
NetterSilverandClaude Haiku 5.5 72b826d65b Fix cross-axis arrows and drop slot on single-item wrapped lines
Fixes #422

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:17:21 +02:00
NetterSilverandClaude Haiku 5.5 0d125374d3 Don't record an undo step for an abandoned empty text box
Fixes #423

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:05:07 +00:00
NetterSilverandClaude Haiku 5.5 8199623267 Include empty outlines in no-op shapeBuilder.merge response
Fixes #424

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 14:36:34 +02:00
midasmatterhorn 6ea957ec5d doc: runs of a file that reach past the text can't overflow a text edit
StyleRun start/end are usize taken straight from a .figcraft document.json
(nothing clamps them on load; only the REST and SVG importers normalize),
so TextData::replace_range's p - removed + inserted panics in a debug or
test build and wraps to zero in release, where normalize then drops the
run and the layer silently loses its formatting.

Shift the runs with saturating arithmetic; normalize drops whatever is
left. Regression test: text_runs_past_the_text_never_overflow — panics
at crates/doc/src/text.rs:605 on main, passes with the clamp.
2026-10-15 15:36:06 +03:00
NetterSilverandClaude Haiku 5.5 b29cc01d55 Fix perf failing with 'no card' on small instance counts
Fixes #394

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:34:16 +00:00
NetterSilverandClaude Haiku 5.5 d0285d236e Keep a Rectangle in the smallest synthetic perf documents
Fixes #393

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 14:23:20 +02:00
NetterSilverandClaude Haiku 5.5 908906f3f4 Close a layer's colour picker when the selection changes
Fixes #396

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:15:35 +02:00
Seun Samuel-Ipaye 2d78f40e60 Pen tool: a click's small wobble pulls out no handle 2026-10-15 08:11:40 -04:00
NetterSilverandClaude Haiku 5.5 f6437548ba Keep a styled layer's fill variable through .fig round trips
Fixes #399

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:08:55 +00:00
NetterSilverandClaude Haiku 5.5 fbe746a776 Cap startup operator problems at the latest 200
Fixes #409

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:58:59 +02:00
NetterSilverandClaude Haiku 5.5 9853ffce61 Stop a guide drag released off the canvas from continuing on the next click
Fixes #431

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-15 13:54:07 +02:00
Andrii Volkov 2f4d3894b5 contributors: add my entry 2026-10-15 14:51:08 +03:00
Andrii VolkovandClaude Opus 5.5 bf506712ba fix(ui): show error toasts raised by a slow frame for their full 3 s
A toast's clock started from the time of the frame that first drew it. That
time is taken when the frame starts, before whatever raised the toast ran:
the Open dialog and a failed open blocked that frame for longer than 3 s, so
the error expired before it was ever seen and the open failed silently. The
clock now starts on the frame after the first one that draws the toast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:51:01 +03:00
NetterSilverandClaude Haiku 5.5 7d6a3ce137 Skip divider rows in Next page and Previous page
Fixes #413

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 11:49:53 +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 ac7c91fc9c Reject layer IDs as targets of page.rename, delete and setBackground
Fixes #414

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:44:17 +02:00
Marco Arnulfo 34565964b0 Keep nested frames out of the ready-for-dev list (fixes #455) 2026-10-15 13:29:22 +02:00
NetterSilverandClaude Haiku 5.5 b3b6b14eb4 Anti-alias rectangular frame clips on fractional device edges
Fixes #428

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:18:59 +02:00
NetterSilverandClaude Haiku 5.5 be56ca2836 Keep CMYK conversion inside PDF vector pattern tiles
Fixes #426

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:18:33 +00:00
NetterSilverandClaude Haiku 5.5 9687c0ffc0 Draw layer blurs up to the documented 10,000 radius
Fixes #430

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:12:25 +00:00
NetterSilverandClaude Haiku 5.5 8b0649da3a Drop the draw or marquee gesture a canvas double-click starts
Fixes #432

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:52:47 +00:00
NetterSilverandClaude Haiku 5.5 3293a12542 Fix view jump when returning after a pan released over a panel
Fixes #433

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:48:25 +02:00
NetterSilverandClaude Haiku 5.5 42e6f267b8 Reveal the comment's page when opening it via ui.set commentOpen
Fixes #435

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 10:43:25 +00:00
NetterSilverandClaude Haiku 5.5 831b8fed43 Fix panic drawing a selected guide label in a zero-width canvas
Fixes #434

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:42:05 +02:00
NetterSilverandClaude Haiku 5.5 72a3c8522f Keep consecutive spaces in recent file accessible names
Fixes #436

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:37:55 +02:00
NetterSilverandClaude Haiku 5.5 ec9b1197c9 Save the PDF from File > Export frames to PDF
Fixes #437

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:37:47 +00:00
NetterSilverandClaude Haiku 5.5 43e6cba2ef Keep committed line height and letter spacing in the text style editor
Fixes #438

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:26:22 +02:00
NetterSilverandClaude Haiku 5.5 8e56820168 Fix variable.update scopes: null not resetting to all supported
Fixes #443

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 10:14:49 +00:00
NetterSilverandClaude Haiku 5.5 376d524a57 Keep the colour picker and open settings on their item after a removal
Fixes #442

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:09:40 +02:00
NetterSilverandClaude Haiku 5.5 61bc027072 Fix MCP layer screenshots exceeding maxSize
Fixes #445

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:56:32 +02:00
NetterSilverandClaude Haiku 5.5 4cf1da167c Default CLI render and export to the first non-divider page
Fixes #446

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:56:15 +00:00
NetterSilverandClaude Haiku 5.5 dee13aefc5 Keep hidden export roots from enlarging the exported image
Fixes #447

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:48:39 +02:00
NetterSilverandClaude Haiku 5.5 7dd71bc2da Brace variable names in expression text that would not read back
Fixes #448

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:45:13 +00:00
NetterSilverandClaude Haiku 5.5 ef10edfe29 Skip missing segments when starting a region loop walk
Fixes #449

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:39:06 +02:00
NetterSilverandClaude Haiku 5.5 c3330c4260 Leave missing vertices out of region point queries
Fixes #451

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:33:53 +00:00
NetterSilverandClaude Haiku 5.5 e4b4fb0cac Fix paint bucket leaving a face filled when its region is duplicated
Fixes #450

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:32:07 +02:00
NetterSilverandClaude Haiku 5.5 4f9cbf702f Merge text runs made identical by style updates
Fixes #452

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:23:44 +02:00
NetterSilverandClaude Haiku 5.5 6a2eeec928 Make empty ranges link and unlink text styles on all characters
Fixes #453

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:16:00 +02:00
NetterSilverandClaude Haiku 5.5 c6fdf9bd4c Keep layer text and string defaults unchanged in rem code output
Fixes #454

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:08:53 +02:00
NetterSilverandClaude Haiku 5.5 e8a30df944 Give case-colliding variant properties distinct data attributes
Fixes #456

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:57:13 +02:00
Patrik LermonandClaude Opus 5.5 ee2640d18e Swedish interface translation (sv)
Adds Swedish (Svenska) as an interface language: 2165 strings, written from the English source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:34:20 +02:00
Andrii VolkovandClaude Opus 5.5 9eadb37622 perf(ui): look for CJK names in edits, not on every pan
Each frame checks the active document's names for CJK scripts, to install
faces for them. The check was keyed on the revision, which pans and zooms
bump too, so every frame of a pan read every layer name. In a 900 000-layer
file that was ~70 ms a frame: panning ran at ~14 fps whatever the canvas cost.

It now keeps the document version it read: the same version (a pan, a zoom)
is skipped, and a new one reads only the layers that changed since
(`Document::changed_ids`, sized by the edit), or every name past 10 000
changes. On that file the UI frame while panning went from ~71 ms to ~3 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-15 10:47:23 +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
368 changed files with 35359 additions and 2489 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/plugins"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+11 -40
View File
@@ -1,13 +1,12 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners).
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
# uploaded and no secrets are used.
# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script
# the release job runs, and checks the tarball, so a broken FreeBSD package fails here
# before it can fail a release. Nothing is uploaded and no secrets are used.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
@@ -29,36 +28,8 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# dejavu: the text tests need one real font, as the Ubuntu runner has.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
grep -E "$f" "$HOME/freebsd-contents.txt"
done
# 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/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
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
+4 -55
View File
@@ -19,58 +19,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Server install script (Caddyfile handling)
run: bash packaging/server/install-test.sh
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: FreeBSD package layout (dry run, no build)
run: bash packaging/freebsd/package.sh --dry-run
- name: No template leftovers from other apps in package metadata
run: |
if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then
echo "::error::package metadata mentions another product (see above)"
exit 1
fi
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
# 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/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
+24 -541
View File
@@ -1,11 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "FigCraft v<version>". See docs/releasing.md.
#
# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the
# `release` environment (restricted to the `release` branch), which holds the signing secrets; the
# rest build on any branch, so `gh workflow run release.yml --ref <branch>` is a real dry run.
# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed
# build. Playbook: storytold/craftrules release/playbook.md.
name: Release
on:
@@ -19,543 +11,34 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
FIGCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build
# embeds Inter only, see crates/text/build.rs). An optional build input for everyone else.
# crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump
# deliberately; every build job checks out this commit.
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications'
ldd target/release/figcraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
server:
timeout-minutes: 90
name: Server Linux ${{ matrix.arch }}
needs: version
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
# with the binary, licences, docs/server.md and the deployment examples. Same runners as the
# app, so the same glibc floor; no craft-fonts (the server draws no text).
# shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli,
# when people host there.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04 }
- { arch: aarch64, runner: ubuntu-22.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-server-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Build and package (tar.gz)
run: packaging/server/package.sh
- name: Check the archive
run: |
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
done
ldd target/release/figcraft-server
- uses: actions/upload-artifact@v4
with:
name: release-server-linux-${{ matrix.arch }}
path: dist/release/*.tar.gz
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small
# for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner.
runs-on: github-ubuntu-16core
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
grep -E "$f" "$HOME/freebsd-contents.txt"
done
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
# The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have
# no system fonts to fall back on, and the rest would push the wasm towards
# packaging/web/package.sh's 24 MiB gate.
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU.
runs-on: ubuntu-24.04
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- uses: actions/upload-artifact@v4
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="FigCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# 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/release.yml@main
with:
app-name: figcraft
app-title: FigCraft
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
has-server: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+6 -73
View File
@@ -24,77 +24,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: figcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: figcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'FigCraft'
# figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed.
foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) {
if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue }
throw "$exe not installed in $dir"
}
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed binary runs natively on ARM64 (the CLI when present, else the app's --version).
$cli = Join-Path $dir 'figcraft-cli.exe'
if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' }
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" }
Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# 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/windows-arm64.yml@main
with:
app-name: figcraft
app-dir: FigCraft
Generated
+138 -26
View File
@@ -259,6 +259,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -855,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"
@@ -1407,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"eframe",
@@ -1421,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",
@@ -1445,7 +1454,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1460,7 +1469,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1483,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1492,7 +1501,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1504,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",
@@ -1531,7 +1541,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1546,7 +1556,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1554,7 +1564,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1562,7 +1572,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1578,7 +1588,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1587,7 +1597,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1599,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",
@@ -1617,7 +1627,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1630,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",
@@ -1659,7 +1669,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1675,7 +1685,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1689,7 +1699,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1714,7 +1724,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -1734,6 +1744,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -2563,7 +2579,7 @@ dependencies = [
"skrifa 0.42.1",
"smallvec",
"subsetter",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
@@ -2820,6 +2836,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3204,6 +3229,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3264,6 +3299,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3981,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"
@@ -4440,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"
@@ -4452,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]]
@@ -4574,6 +4656,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -4730,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",
@@ -5683,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",
@@ -5726,6 +5820,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write-fonts"
version = "0.48.1"
@@ -5822,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.
+4 -6
View File
@@ -10,7 +10,7 @@
<h1 align="center">FigCraft</h1>
<p align="center">
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
in a native app that opens instantly and works offline. Open source, and yours.
</p>
@@ -62,7 +62,7 @@
<tr>
<td width="25%" valign="top">
<h3>🎛️ Familiar by design</h3>
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
</td>
<td width="25%" valign="top">
<h3>⚡ Native and fast</h3>
@@ -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.
@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+26 -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-14 · **Change:** minor (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 |
No other languages. 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,9 @@ 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.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -313,6 +316,16 @@ 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 |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. 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]
+106 -11
View File
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
match name {
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
Some(n) => doc
.pages
.iter()
@@ -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()))?;
@@ -380,6 +401,22 @@ mod tests {
assert_eq!(sanitize(""), "layer");
}
#[test]
fn default_page_skips_leading_divider() {
let mut doc = Document::new("divider");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
let divider = doc.add_page("- Divider");
doc.pages.retain(|p| *p != divider);
doc.pages.insert(0, divider);
assert!(doc.is_divider(divider));
assert_eq!(page_named(&doc, None).unwrap(), page);
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
assert_eq!((pm.width(), pm.height()), (40, 30));
assert!(render_page(&doc, divider, 1.0).is_err());
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
@@ -406,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}"))?;
+64 -34
View File
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
);
let doc = s.doc().map_err(failed)?.clone();
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let b = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
.ok_or_else(|| failed("no button glyph to drop on"))?;
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
);
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
} else {
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
}
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
if let Some(nav) = nav {
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
} else {
table.put(col, "reset all changes on a nav", f64::NAN);
table.put(col, "detach a nav", f64::NAN);
}
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let drop_at = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
match drop_at {
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
None => f64::NAN,
},
);
} else {
table.put(col, "push a button's changes to its main", f64::NAN);
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
}
if let Some(nav) = nav {
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
} else {
table.put(col, "copy + paste a nav", f64::NAN);
}
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
table.put(col, "redo", mean(10, |_| _ = s.redo()));
// As a library: publish it, use it from another file, publish a change, accept the update.
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
// One directory per run, so concurrent runs in a process don't delete each other's files.
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
@@ -533,6 +555,14 @@ mod tests {
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_components_with_a_single_instance() {
let mut t = super::Table { rows: Vec::new() };
super::measure_components(&mut t, 0, 1).unwrap();
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_a_small_document() {
let mut t = super::Table { rows: Vec::new() };
+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));
}
+37 -10
View File
@@ -347,14 +347,14 @@ impl Store {
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let (directory, problems) = Directory::open(root, cfg.directory);
let (directory, startup_problems) = Directory::open(root, cfg.directory);
let abuse = Abuse::new(cfg.hosting.abuse);
let mut store = Store {
root: root.to_path_buf(),
cfg,
key,
files: BTreeMap::new(),
problems,
problems: Vec::new(),
directory,
abuse,
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
@@ -368,6 +368,9 @@ impl Store {
free: None,
_lock: lock,
};
for problem in startup_problems {
store.note(problem);
}
store.scan(&dir);
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
@@ -385,7 +388,7 @@ impl Store {
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
Ok(list) => list,
Err(e) => {
self.problems.push(e);
self.note(e);
Vec::new()
}
}
@@ -394,7 +397,7 @@ impl Store {
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
};
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
ids.sort();
@@ -403,18 +406,18 @@ impl Store {
continue;
}
if !valid_id(&id) {
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
self.note(format!("skipped {}: not a file folder of this server", path.display()));
continue;
}
if self.files.len() >= self.cfg.max_files {
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
continue;
}
match self.check(&path) {
Ok(entry) => {
self.files.insert(id, entry);
}
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
Err(why) => self.note(format!("skipped file {id}: {why}")),
}
}
}
@@ -437,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 })
@@ -1123,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:?}");
}
@@ -0,0 +1,40 @@
use figcraft_server::config::Config;
use figcraft_server::store::{FILES, Store};
#[test]
fn startup_problem_retention() {
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
assert!(!root.exists());
let result = (|| -> Result<(), String> {
for count in [3u32, 200, 201] {
let data = root.join(count.to_string());
for i in 0..count {
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
}
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
cfg.check()?;
let store = Store::open(&data, cfg.store())?;
let expected = (count as usize).min(200);
let problems = store.problems().to_vec();
let no_loaded_files = store.is_empty();
drop(store);
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
return Err("unexpected diagnostic or loaded file".into());
}
if problems.len() != expected {
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
}
// The latest problems stay, in scan order: the first folder's is the one dropped.
let first_kept = count as usize - expected;
for (n, p) in problems.iter().enumerate() {
let id = format!("{:016x}", first_kept + n);
if !p.contains(&id) {
return Err(format!("problem {n} should be about {id}: {p}"));
}
}
}
Ok(())
})();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_ok(), "{}", result.unwrap_err());
}
+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 }))
}),
)
+7 -1
View File
@@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
arboard = { workspace = true, features = ["image-data", "wayland-data-control"] }
webbrowser = { workspace = true }
image = { workspace = true }
env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] }
@@ -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"
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
for p in paths {
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
if fig.run_user("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
+28 -4
View File
@@ -1,7 +1,10 @@
//! 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).
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
//! the app running unconfined. After a bare `--`, every argument is a file.
/// What the app was started with.
#[derive(Debug, Default, PartialEq, Eq)]
@@ -11,6 +14,13 @@ 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>,
/// `--flags` the app doesn't know, as given.
pub unknown: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -49,13 +59,27 @@ 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" {
if a == "--" {
out.files.extend(it.by_ref().cloned());
break;
}
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());
} else if let Some(path) = file_url_path(a) {
out.files.push(path);
} else if !a.starts_with("--") {
} else if a.starts_with("--") {
out.unknown.push(a.clone());
} else {
out.files.push(a.clone());
}
}
+25 -2
View File
@@ -9,13 +9,36 @@ fn args(a: &[&str]) -> Vec<String> {
#[test]
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 });
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
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 unknown_flags_are_refused_never_ignored() {
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
// unconfined. Both forms, with and without `=`.
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
assert!(a.write_roots.is_empty());
// Single-dash arguments are left as they were (files); after `--` everything is a file.
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
}
#[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(())
}
}
+72 -13
View File
@@ -6,6 +6,12 @@
//! `--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, and so
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
#![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 +21,8 @@ mod args;
mod control_server;
mod gamepad;
mod graphics;
#[cfg(target_os = "linux")]
mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -100,10 +108,13 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
with_app(|fig| services::save_state(fig));
if let Some(r) = recovery::lock(&self.recovery).take() {
r.finish();
}
// With the app out of reach (busy), its recovery copies stay for the next start.
let copies = recovery::lock(&self.recovery).take();
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
// throws on the content view AccessKit restores (craft-libs#3, #781).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -119,6 +130,23 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
if let Some(flag) = args.unknown.first() {
eprintln!(
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
);
std::process::exit(2);
}
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 +171,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 +203,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 +224,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 +246,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 +267,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));
}
}
+39 -5
View File
@@ -120,8 +120,7 @@ impl Recovery {
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
let lock = lock_folder(&dir).ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
@@ -250,6 +249,17 @@ impl Recovery {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
pub fn quit(mut self, s: &Session, asked: bool) {
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
return self.finish();
}
self.write(s, true);
self.settle(s);
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
/// How long the startup waits, once, for folder locks that may free themselves (#334).
#[cfg(unix)]
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
lock.try_lock()?;
Ok(lock)
}
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
}
}
};
let (mut free, mut held) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
continue;
}
// Still running (another window of the app), or not ours to take.
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
match lock_folder(&path) {
Ok(lock) => free.push((path, lock)),
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
Err(_) => {}
}
}
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
// lock to child processes.
#[cfg(unix)]
if !held.is_empty() {
std::thread::sleep(LOCK_WAIT);
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
}
for (path, lock) in free {
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
+40
View File
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
let root = root("quitunasked");
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
let s = edited();
Recovery::start(&root).unwrap().quit(&s, false);
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1);
let _ = std::fs::remove_dir_all(&root);
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
for (s, asked) in [(edited(), true), (Session::new(), false)] {
Recovery::start(&root).unwrap().quit(&s, asked);
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
let _ = std::fs::remove_dir_all(&root);
}
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
#[cfg(unix)]
#[test]
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
let root = root("fork-window");
crashed(&edited(), &root);
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
// exec holds it, and free it shortly after.
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
lock.try_lock().unwrap();
let freed = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
drop(lock);
});
// The next start waits for that lock rather than reading the run as still going.
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
freed.join().unwrap();
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
+73 -6
View File
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
pub fn save_state(app: &FigApp) {
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
if let Some(dir) = dir {
write_prefs(dir, &app.session.prefs);
}
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
if let Some(r) = recovery {
r.quit(&app.session, app.ui.quit);
}
drop(std::mem::take(&mut app.session.live));
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
@@ -223,12 +231,47 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle
/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no
/// timeout of its own, so an app that hangs must not hang the window too.
#[cfg(all(unix, not(target_os = "macos")))]
fn read_clipboard<T: Send + 'static>(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c)));
});
rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten()
}
/// Read the clipboard (the system's own reads don't wait on the app that copied).
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn read_clipboard<T>(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option<T>) -> Option<T> {
read(c)
}
/// Native Save as filters; other callers keep their existing combined filter.
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
if exts == ["figcraft", "fig"] {
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
} else {
vec![("File", exts)]
}
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
let mut dialog = dialog(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
@@ -239,7 +282,10 @@ impl Services for Desktop {
}
}
fn clipboard_get_text(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get_text().ok()
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
fn clipboard_get_html(&mut self) -> Option<String> {
read_clipboard(self.clipboard.as_mut()?, |c| c.get().html().ok())
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
if let Some(c) = self.clipboard.as_mut() {
@@ -247,7 +293,7 @@ impl Services for Desktop {
}
}
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
let img = self.clipboard.as_mut()?.get_image().ok()?;
let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?;
let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
// Refuse absurd sizes (and mismatched buffers) rather than allocate.
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 {
@@ -297,3 +343,24 @@ pub fn window_icon() -> Option<egui::IconData> {
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
}
#[cfg(test)]
mod save_filter_tests {
use super::save_filters;
#[test]
fn native_and_fig_save_filters_are_separate_with_native_first() {
let extensions = ["figcraft", "fig"];
assert_eq!(
save_filters(&extensions),
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
);
}
#[test]
fn other_save_filters_keep_one_combined_group() {
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
}
}
}
+35
View File
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
/// A live link that only records the sessions it was told to leave.
struct Leaves(Arc<Mutex<Vec<u64>>>);
impl figcraft_engine::live::LiveLink for Leaves {
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn leave(&mut self, session: u64) {
self.0.lock().unwrap().push(session);
}
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
Vec::new()
}
}
#[test]
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
let dir = root("shutdown");
let mut fig = start(&dir);
let left = Arc::new(Mutex::new(Vec::new()));
fig.session.live.install(Box::new(Leaves(left.clone())));
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
fig.session.prefs.user_name = "Ada".into();
crate::services::shutdown(&mut fig, Some(&dir), None);
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
assert!(!fig.session.live.available(), "nothing is left to send on");
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
+17
View File
@@ -38,4 +38,21 @@ real_name = "Justin John"
[people.Losminek]
[people.RevDonz]
[people.Nael-Nathanael]
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),
+3 -2
View File
@@ -87,8 +87,9 @@ macro_rules! node_props {
($($variant:ident => $field:ident),* $(,)?) => {
/// A node property: the tree position (`parent`, `position`) or one saved field of
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
/// binary form numbers them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Prop {
+24 -8
View File
@@ -4,12 +4,13 @@
//!
//! A conflict is the same property (or document-level entry) changed on both sides to different
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
//! merge item by item: both sides changing different items of the list (or one adding items at its
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
//! while the other changed it. Nothing that conflicts is
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
//! changes to it are left out. Everything else from both sides is kept.
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
out
}
/// List properties whose items merge one by one.
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
/// List properties whose items merge one by one: the items stand on their own, so both sides
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
/// by key, not by position.
const PER_ITEM: &[Prop] = &[
Prop::Fills,
Prop::Strokes,
Prop::Effects,
Prop::Grids,
Prop::Exports,
Prop::Guides,
Prop::Interactions,
Prop::Annotations,
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
+107 -11
View File
@@ -1,8 +1,10 @@
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
//! the same item changed both ways conflicts.
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
//! merge item by item: different items changed on each side merge, the same item changed both ways
//! conflicts.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
use serde_json::{Value, json};
use crate::merge::{Key, Side, merge_items, plan};
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
}
#[test]
fn other_lists_still_conflict_as_a_whole() {
// Docs silent: lists other than fills, strokes and effects (export settings here) still
// conflict whole when both sides change them.
fn the_other_item_lists_merge_one_by_one_too() {
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
// item lists like fills: both sides changing different items merges.
let (mut base, _, _, r) = files();
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
let n = base.get_mut(r).unwrap();
n.exports = vec![export("@1"), export("@2")];
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
n.annotations = vec![annot("a1"), annot("a2")];
n.grids = vec![grid(10.0), grid(20.0)];
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0] = export("@b");
main.get_mut(r).unwrap().exports[1] = export("@m");
// The branch changes the first item of each list; main the second.
let b = branch.get_mut(r).unwrap();
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
let m = main.get_mut(r).unwrap();
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
both_ways(&base, &branch, &main, |doc| {
let n = doc.node(r).unwrap();
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
assert_eq!(suffixes, ["@b", "@m"], "export settings");
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
assert_eq!(offsets, [11.0, 22.0], "guides");
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
assert_eq!(labels, ["b1", "m2"], "annotations");
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
assert_eq!(sizes, [12.0, 24.0], "layout grids");
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
});
}
#[test]
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
let (mut base, _, _, r) = files();
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
let n = base.get_mut(r).unwrap();
n.exports = vec![
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
];
n.code_links = vec![link("react"), link("vue")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
// Code links are one per language, not an item list: different links changed still conflict.
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
main.get_mut(r).unwrap().code_links[1].name = "M".into();
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/exports")]);
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
keys.sort();
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
want.sort();
assert_eq!(keys, want);
}
#[test]
fn interactions_merge_item_by_item() {
let ia = |duration_ms: u32| proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::Back],
transition: Default::default(),
duration_ms,
easing: Default::default(),
delay_ms: 0,
};
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
let (mut base, _, _, r) = files();
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
let fork = || {
let (mut branch, main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
(branch, main)
};
// The branch changes the first interaction and adds one; main changes the second.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
branch.get_mut(r).unwrap().interactions.push(ia(300));
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
// The branch deletes one while main leaves them alone.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions.remove(0);
main.get_mut(r).unwrap().fills[0] = red();
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
// The same interaction changed both ways, or one deleted while the other side changed the
// list, conflicts.
for delete in [false, true] {
let (mut branch, mut main) = fork();
if delete {
branch.get_mut(r).unwrap().interactions.remove(0);
} else {
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
}
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
}
}
#[test]
+12 -16
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
@@ -139,7 +140,13 @@ impl Color {
Some(Self::from_rgba8(r, g, b, a))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
pub fn from_hex_or_name(s: &str) -> Option<Self> {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -166,21 +173,10 @@ impl Color {
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
let named = match t.as_str() {
"black" => Color::BLACK,
"white" => Color::WHITE,
"transparent" => Color::TRANSPARENT,
"red" => Color::from_rgb8_const(255, 0, 0),
"green" => Color::from_rgb8_const(0, 128, 0),
"lime" => Color::from_rgb8_const(0, 255, 0),
"blue" => Color::from_rgb8_const(0, 0, 255),
"yellow" => Color::from_rgb8_const(255, 255, 0),
"orange" => Color::from_rgb8_const(255, 165, 0),
"purple" => Color::from_rgb8_const(128, 0, 128),
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
_ => return Self::from_hex(&t),
};
Some(named)
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
Self::from_hex_or_name(&t)
}
/// CSS `rgba(...)` / hex text for copy-as-CSS.
+197
View File
@@ -0,0 +1,197 @@
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
//! `FF0000`.
/// Every CSS named colour except `transparent`, sorted by name for binary search.
const NAMED: [(&str, [u8; 3]); 148] = [
("aliceblue", [240, 248, 255]),
("antiquewhite", [250, 235, 215]),
("aqua", [0, 255, 255]),
("aquamarine", [127, 255, 212]),
("azure", [240, 255, 255]),
("beige", [245, 245, 220]),
("bisque", [255, 228, 196]),
("black", [0, 0, 0]),
("blanchedalmond", [255, 235, 205]),
("blue", [0, 0, 255]),
("blueviolet", [138, 43, 226]),
("brown", [165, 42, 42]),
("burlywood", [222, 184, 135]),
("cadetblue", [95, 158, 160]),
("chartreuse", [127, 255, 0]),
("chocolate", [210, 105, 30]),
("coral", [255, 127, 80]),
("cornflowerblue", [100, 149, 237]),
("cornsilk", [255, 248, 220]),
("crimson", [220, 20, 60]),
("cyan", [0, 255, 255]),
("darkblue", [0, 0, 139]),
("darkcyan", [0, 139, 139]),
("darkgoldenrod", [184, 134, 11]),
("darkgray", [169, 169, 169]),
("darkgreen", [0, 100, 0]),
("darkgrey", [169, 169, 169]),
("darkkhaki", [189, 183, 107]),
("darkmagenta", [139, 0, 139]),
("darkolivegreen", [85, 107, 47]),
("darkorange", [255, 140, 0]),
("darkorchid", [153, 50, 204]),
("darkred", [139, 0, 0]),
("darksalmon", [233, 150, 122]),
("darkseagreen", [143, 188, 143]),
("darkslateblue", [72, 61, 139]),
("darkslategray", [47, 79, 79]),
("darkslategrey", [47, 79, 79]),
("darkturquoise", [0, 206, 209]),
("darkviolet", [148, 0, 211]),
("deeppink", [255, 20, 147]),
("deepskyblue", [0, 191, 255]),
("dimgray", [105, 105, 105]),
("dimgrey", [105, 105, 105]),
("dodgerblue", [30, 144, 255]),
("firebrick", [178, 34, 34]),
("floralwhite", [255, 250, 240]),
("forestgreen", [34, 139, 34]),
("fuchsia", [255, 0, 255]),
("gainsboro", [220, 220, 220]),
("ghostwhite", [248, 248, 255]),
("gold", [255, 215, 0]),
("goldenrod", [218, 165, 32]),
("gray", [128, 128, 128]),
("green", [0, 128, 0]),
("greenyellow", [173, 255, 47]),
("grey", [128, 128, 128]),
("honeydew", [240, 255, 240]),
("hotpink", [255, 105, 180]),
("indianred", [205, 92, 92]),
("indigo", [75, 0, 130]),
("ivory", [255, 255, 240]),
("khaki", [240, 230, 140]),
("lavender", [230, 230, 250]),
("lavenderblush", [255, 240, 245]),
("lawngreen", [124, 252, 0]),
("lemonchiffon", [255, 250, 205]),
("lightblue", [173, 216, 230]),
("lightcoral", [240, 128, 128]),
("lightcyan", [224, 255, 255]),
("lightgoldenrodyellow", [250, 250, 210]),
("lightgray", [211, 211, 211]),
("lightgreen", [144, 238, 144]),
("lightgrey", [211, 211, 211]),
("lightpink", [255, 182, 193]),
("lightsalmon", [255, 160, 122]),
("lightseagreen", [32, 178, 170]),
("lightskyblue", [135, 206, 250]),
("lightslategray", [119, 136, 153]),
("lightslategrey", [119, 136, 153]),
("lightsteelblue", [176, 196, 222]),
("lightyellow", [255, 255, 224]),
("lime", [0, 255, 0]),
("limegreen", [50, 205, 50]),
("linen", [250, 240, 230]),
("magenta", [255, 0, 255]),
("maroon", [128, 0, 0]),
("mediumaquamarine", [102, 205, 170]),
("mediumblue", [0, 0, 205]),
("mediumorchid", [186, 85, 211]),
("mediumpurple", [147, 112, 219]),
("mediumseagreen", [60, 179, 113]),
("mediumslateblue", [123, 104, 238]),
("mediumspringgreen", [0, 250, 154]),
("mediumturquoise", [72, 209, 204]),
("mediumvioletred", [199, 21, 133]),
("midnightblue", [25, 25, 112]),
("mintcream", [245, 255, 250]),
("mistyrose", [255, 228, 225]),
("moccasin", [255, 228, 181]),
("navajowhite", [255, 222, 173]),
("navy", [0, 0, 128]),
("oldlace", [253, 245, 230]),
("olive", [128, 128, 0]),
("olivedrab", [107, 142, 35]),
("orange", [255, 165, 0]),
("orangered", [255, 69, 0]),
("orchid", [218, 112, 214]),
("palegoldenrod", [238, 232, 170]),
("palegreen", [152, 251, 152]),
("paleturquoise", [175, 238, 238]),
("palevioletred", [219, 112, 147]),
("papayawhip", [255, 239, 213]),
("peachpuff", [255, 218, 185]),
("peru", [205, 133, 63]),
("pink", [255, 192, 203]),
("plum", [221, 160, 221]),
("powderblue", [176, 224, 230]),
("purple", [128, 0, 128]),
("rebeccapurple", [102, 51, 153]),
("red", [255, 0, 0]),
("rosybrown", [188, 143, 143]),
("royalblue", [65, 105, 225]),
("saddlebrown", [139, 69, 19]),
("salmon", [250, 128, 114]),
("sandybrown", [244, 164, 96]),
("seagreen", [46, 139, 87]),
("seashell", [255, 245, 238]),
("sienna", [160, 82, 45]),
("silver", [192, 192, 192]),
("skyblue", [135, 206, 235]),
("slateblue", [106, 90, 205]),
("slategray", [112, 128, 144]),
("slategrey", [112, 128, 144]),
("snow", [255, 250, 250]),
("springgreen", [0, 255, 127]),
("steelblue", [70, 130, 180]),
("tan", [210, 180, 140]),
("teal", [0, 128, 128]),
("thistle", [216, 191, 216]),
("tomato", [255, 99, 71]),
("turquoise", [64, 224, 208]),
("violet", [238, 130, 238]),
("wheat", [245, 222, 179]),
("white", [255, 255, 255]),
("whitesmoke", [245, 245, 245]),
("yellow", [255, 255, 0]),
("yellowgreen", [154, 205, 50]),
];
/// Longest name in [`NAMED`]; anything longer can't match.
const LONGEST: usize = 20;
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
pub fn lookup(s: &str) -> Option<[u8; 3]> {
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
if key.is_empty() || key.len() > LONGEST {
return None;
}
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_sorted_and_unique() {
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
}
#[test]
fn every_name_is_found() {
for (name, rgb) in NAMED {
assert_eq!(lookup(name), Some(rgb), "{name}");
}
}
#[test]
fn lookup_normalises() {
assert_eq!(lookup("Red"), Some([255, 0, 0]));
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
assert_eq!(lookup(""), None);
assert_eq!(lookup("transparent"), None);
assert_eq!(lookup("reddish"), None);
assert_eq!(lookup("rédé"), None);
assert_eq!(lookup(&"a".repeat(10_000)), None);
}
}
+17
View File
@@ -17,6 +17,23 @@ fn css_parsing() {
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
}
#[test]
fn colour_fields_take_names() {
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
// Hex wins: every digit string still reads as hex.
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
// Names are opaque; `transparent` is not a field colour.
assert!(Color::from_hex_or_name("transparent").is_none());
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
}
}
#[test]
+8 -2
View File
@@ -51,6 +51,10 @@ pub fn is_closed(n: &Node) -> bool {
}
}
/// How much further than `tol` a stroke with nothing filled reaches: a thin line or stroke-only path
/// is hard to land on with the plain tolerance, so it gets a wider band (5 screen px for the usual 2 px tolerance).
const STROKE_REACH: f64 = 2.5;
/// Whether `pt` hits `n` itself; in `outline` mode (nothing filled on the canvas) a shape only where
/// its outline is.
fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, outline: bool) -> bool {
@@ -62,7 +66,7 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Page { .. } => false,
NodeKind::Text(_) | NodeKind::Slice => n.local_rect().inflate(tol_l, tol_l).contains(lp),
NodeKind::Line => {
let half = (n.stroke.weight / 2.0).max(tol_l);
let half = (n.stroke.weight / 2.0).max(tol_l * STROKE_REACH);
lp.x >= -half && lp.x <= n.width + half && lp.y.abs() <= half
}
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
@@ -78,7 +82,9 @@ fn hits_path(n: &Node, path: &BezPath, lp: Point, tol: f64, inside: bool) -> boo
if inside && (n.fills.iter().any(|f| f.visible) || !n.has_visible_stroke()) {
return true;
}
let reach = (n.stroke.max_weight() / 2.0).max(tol);
// Only a stroke with nothing filled gets the wider band: a filled shape's edge keeps the plain one.
let stroke_only = n.has_visible_stroke() && !n.fills.iter().any(|f| f.visible);
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol * STROKE_REACH } else { tol });
if !path.bounding_box().inflate(reach, reach).contains(lp) {
return false;
}
+36 -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
@@ -781,6 +788,23 @@ impl Document {
self.paste_nodes(&src_nodes, &map, id, parent, index)
}
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
/// from one copied frame to another) point at the copies.
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
let mut map = BTreeMap::new();
let mut trees = Vec::with_capacity(ids.len());
for &id in ids {
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
for n in &src_nodes {
let nid = self.alloc_id();
map.insert(n.id, nid);
}
trees.push((id, src_nodes));
}
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
}
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
/// subtree are remapped too.
@@ -1100,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() {
@@ -1114,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,
+13 -1
View File
@@ -143,7 +143,7 @@ impl Expr {
return "…".into();
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
@@ -178,6 +178,18 @@ impl Expr {
}
}
/// A variable as written in an expression: its bare name when that reads back as this variable,
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
/// `"q"` don't read back as literals or errors.
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
let this = Expr::var(v.id.clone());
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
return v.name.clone();
}
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
}
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
/// spaces at its ends, quotes or brackets.
fn name_arg(name: &str) -> String {
+19
View File
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
}
}
}
#[test]
fn names_that_look_like_literals_read_back_as_variables() {
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
let mut v = vars();
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
var.name = name.into();
}
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
}
}
// Plain names stay bare, and literals still read as literals.
let v = vars();
assert_eq!(Expr::var("n").display(&v), "n-name");
assert_eq!(parse("1"), n(1.0));
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
}
+83
View File
@@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() {
assert_eq!(t.characters, "");
}
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
/// text unwritable.
#[test]
fn text_runs_past_the_text_never_overflow() {
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
let mut t = TextData::new("hi");
t.runs = vec![StyleRun { start: 0, end, style: style() }];
// Typing at the caret, and replacing a selection: both shift `end`.
t.replace_range(0, 0, "x");
assert_eq!(t.characters, "xhi", "end {end}");
t.replace_range(0, 3, "yo");
assert_eq!(t.characters, "yo", "end {end}");
// The text is still writable and the runs are back inside it.
t.replace_range(0, 0, "!");
assert_eq!(t.characters, "!yo", "end {end}");
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
}
}
#[test]
fn text_case() {
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
@@ -381,3 +402,65 @@ fn custom_easings() {
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
}
#[test]
fn text_style_updates_merge_equal_runs() {
let def = TextStyle { font_size: 14.0, ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(0, 2, &def, "S");
t.link_text_style(2, 4, &def, "S");
t.restyle(2, 4, |s| s.font_size = 24.0);
assert_eq!(t.runs.len(), 2);
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
let spans = t.spans();
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].0, spans[0].1), (0, 4));
assert_eq!(spans[0].2.font_size, 24.0);
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
// Remapping two links to one id merges the runs.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &def, "B");
t.map_text_styles(|_| Some("C".into()));
assert_eq!(t.spans().len(), 1);
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
// Different values stay separate.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
t.map_text_styles(|_| Some("C".into()));
let spans = t.spans();
assert_eq!(spans.len(), 2);
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
// Unlinking a run equal to the base drops it.
let mut t = TextData::new("ab");
let base = t.style.clone();
t.link_text_style(0, 1, &base, "S");
t.map_text_styles(|_| None);
assert!(t.runs.is_empty());
}
#[test]
fn text_style_empty_range_links_and_unlinks_all() {
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(2, 2, &def, "S");
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
t.unlink_text_style(2, 2);
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
// Partial ranges stay partial.
t.link_text_style(0, 4, &def, "S");
t.unlink_text_style(1, 3);
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
// Empty text changes the base style.
let mut e = TextData::new("");
e.link_text_style(0, 0, &def, "S");
assert_eq!(e.style.style_id.as_deref(), Some("S"));
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
+58 -10
View File
@@ -215,6 +215,10 @@ pub struct ParaList {
pub list: ListStyle,
/// Nesting level (0 = outermost; plain paragraphs are always 0).
pub indent: u8,
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -562,7 +566,7 @@ impl TextData {
}
}
/// Drop empty/out-of-range runs and runs equal to the base style.
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
pub fn normalize(&mut self) {
let n = self.char_count();
let base = self.style.clone();
@@ -571,6 +575,14 @@ impl TextData {
r.end = r.end.min(n);
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
}
}
self.runs = merged;
}
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
@@ -602,7 +614,9 @@ impl TextData {
if p <= start {
p
} else if p >= end {
p - removed + inserted
// Runs of a file that reach past the text (nothing clamps them on load)
// would otherwise overflow here; `normalize` below drops what is left.
p.saturating_sub(removed).saturating_add(inserted)
} else {
start + inserted
}
@@ -627,13 +641,13 @@ impl TextData {
/// Paragraph `p`'s list settings.
pub fn para_list(&self, p: usize) -> ParaList {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
let fill = ParaList { list: self.list, indent: 0 };
let fill = ParaList { list: self.list, ..ParaList::default() };
self.paragraphs.resize(n, fill);
}
@@ -647,17 +661,23 @@ impl TextData {
}
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
/// no nesting folds back into `list`.
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
if pl.list == ListStyle::None {
pl.indent = 0;
}
if pl.list != ListStyle::Numbered {
pl.start_offset = 0;
}
pl.indent = pl.indent.min(MAX_LIST_INDENT);
}
if let Some(first) = self.paragraphs.first().copied()
&& first.indent == 0
&& first == (ParaList { list: first.list, ..ParaList::default() })
&& self.paragraphs.iter().all(|p| *p == first)
{
self.list = first.list;
@@ -665,21 +685,24 @@ impl TextData {
}
}
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
/// one's settings), or None when they differ. Start numbers don't count.
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
let (a, b) = self.para_span(start, end);
let first = self.para_list(a);
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.take_text_style(def, id));
}
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.style_id = None);
}
@@ -770,4 +793,29 @@ mod case_tests {
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
}
#[test]
fn list_starts_load_from_old_files_and_follow_edits() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
}
+3 -3
View File
@@ -468,9 +468,9 @@ impl VectorNetwork {
start_dir(&self.outward(seg, at_a)?)
}
/// Whether any point has a stroke cap or join of its own.
pub fn has_point_styles(&self) -> bool {
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some())
/// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`.
pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool {
self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join))
}
/// The fill path: every region's loops.
+19 -2
View File
@@ -136,8 +136,8 @@ impl VectorNetwork {
}
let k = key(&face.loops);
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
(Some(i), None | Some(false)) => {
self.regions.remove(i);
(Some(_), None | Some(false)) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
(Some(_), Some(true)) => Some(true),
@@ -525,4 +525,21 @@ mod tests {
let _ = j.paint(Point::new(f64::NAN, 5.0));
assert!(interior_point(&BezPath::new()).is_none());
}
#[test]
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
let c = Point::new(50.0, 50.0);
let mut n = square(0.0, 0.0, 100.0);
n.regions.push(n.regions[0].clone());
assert_eq!(n.paint(c), Some(false));
assert!(!n.fill_contains(c));
assert_eq!(n.region_at(c), None);
assert_eq!(n.paint(c), Some(true));
assert!(n.fill_contains(c));
let mut e = square(0.0, 0.0, 100.0);
e.regions.push(e.regions[0].clone());
assert_eq!(e.paint_to(c, Some(false)), Some(false));
assert!(!e.fill_contains(c));
}
}
+13 -4
View File
@@ -18,8 +18,9 @@ impl VectorNetwork {
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
/// as stored. Missing segments are skipped.
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() };
let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) {
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
let Some(first) = existing.next() else { return Vec::new() };
let forward_first = match existing.next() {
Some(second) => first.b == second.a || first.b == second.b,
None => true,
};
@@ -102,10 +103,18 @@ impl VectorNetwork {
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
}
/// Every point on region `r`'s loops (holes' too), each once, sorted.
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
/// don't exist are left out).
pub fn region_points(&self, r: usize) -> Vec<usize> {
let Some(region) = self.regions.get(r) else { return Vec::new() };
let mut out: Vec<usize> = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect();
let mut out: Vec<usize> = region
.loops
.iter()
.flatten()
.filter_map(|i| self.segments.get(*i))
.flat_map(|s| [s.a, s.b])
.filter(|v| *v < self.vertices.len())
.collect();
out.sort_unstable();
out.dedup();
out
+35
View File
@@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() {
assert_eq!(n.region_at(p(300.0, 100.0)), None);
}
#[test]
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
use figcraft_geom::Shape;
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
let control = n.region_path(0).unwrap();
let control_walk = n.loop_walk(&[0, 1, 2]);
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
let path = n.region_path(0).unwrap();
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
}
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_path(0).is_none());
assert!(!n.region_contains(0, p(100.0, 100.0)));
}
#[test]
fn a_regions_points_are_listed_once() {
let mut n = nested();
@@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
}
}
#[test]
fn region_points_leave_out_segment_ends_that_do_not_exist() {
let mut n = VectorNetwork::default();
let ring = square(&mut n, 0.0, 0.0, 100.0);
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
n.segments[3] = Segment::line(3, 9);
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
assert!(n.region_path(0).is_some());
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_points(0).is_empty());
}
#[test]
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
let mut n = nested();
+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
+21
View File
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
}
#[test]
fn single_item_wrapped_lines_move_and_drop_by_line() {
let mut s = Session::new();
// Full-width items: one per row.
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
set(&mut s, row, json!({"wrap": true}));
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
arrow(&mut s, &[k[1]], 0, -1);
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
// Full-height items in a vertical wrap: one per column.
let col = al(&mut s, None, "vertical", 200.0, 160.0);
set(&mut s, col, json!({"wrap": true}));
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
arrow(&mut s, &[c[2]], -1, 0);
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
}
#[test]
fn wrapped_rows_move_up_and_down_a_row() {
let mut s = Session::new();
+1 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
+131
View File
@@ -0,0 +1,131 @@
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
/// 0x80 / 255 and 0x40 / 255.
const HALF: f32 = 128.0 / 255.0;
const QUARTER: f32 = 64.0 / 255.0;
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
let r = NodeId(r["id"].as_u64().unwrap());
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
(s, r)
}
/// The fill's effective alpha (colour alpha times paint opacity).
fn alpha(s: &Session, r: NodeId) -> f32 {
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
}
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
pix.data()[i]
}
fn near(a: f32, b: f32) -> bool {
(a - b).abs() < 2e-3
}
#[test]
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, HALF));
assert_eq!(pixel_alpha(&s, r), 128);
}
#[test]
fn a_bound_variable_alpha_applies_once() {
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
assert_eq!(pixel_alpha(&s, r), 128);
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
// Binding the same variable again changes nothing.
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF));
}
#[test]
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert_eq!(pixel_alpha(&s, r), 64);
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
assert_eq!(pixel_alpha(&s, r), 64);
// Undo restores the binding and its paint.
ex(&mut s, "edit.undo", json!({}));
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
assert_eq!(pixel_alpha(&s, r), 64);
}
#[test]
fn modes_switch_the_alpha_without_accumulating() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
}
// An opacity set while bound multiplies on top and survives mode switches.
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
assert!(near(alpha(&s, r), HALF * 0.5));
}
#[test]
fn node_set_and_style_bindings_apply_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
assert!(near(alpha(&s, r), HALF));
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
}
#[test]
fn a_fig_round_trip_keeps_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
let a = n.fills[0].solid_color().unwrap().a;
assert!(near(a, HALF * 0.5), "{a}");
}
#[test]
fn code_gives_a_translucent_stroke_its_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
}
+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 {
+67 -16
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)] = &[
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
}),
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
s, p, "vector"
)),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
@@ -162,12 +166,43 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
/// or less on an axis it has is refused rather than flattening it.
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
None => Ok(1.0),
Some(a) if have <= 1e-9 => {
if a == 0.0 {
Ok(1.0)
} else {
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
}
}
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
};
Ok(match (width, height) {
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
(Some(_), None) => {
let s = fit(width, bw, "width")?;
(s, s)
}
(None, Some(_)) => {
let s = fit(height, bh, "height")?;
(s, s)
}
(None, None) => (1.0, 1.0),
})
}
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.create";
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() {
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
};
// Vectors: path data or points define the shape and its box.
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
// for, scale the geometry: both sides independently, one side proportionally.
let mut offset = (0.0, 0.0);
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
@@ -208,15 +255,19 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
}
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
}
}
if let NodeKind::Text(td) = &mut kind {
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default());
if w > 0.0 {
td.auto_resize = TextAutoResize::Height;
}
@@ -255,7 +306,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 +364,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 +391,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()))?;
+152 -20
View File
@@ -25,6 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
true,
paste_design_layers
),
spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over),
spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace),
spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props),
@@ -496,9 +503,27 @@ 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 doc = &ds.doc;
// 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;
s.clipboard.source_doc = uid;
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
Ok(())
}
type Copied = (Vec<(NodeId, Vec<Node>)>, BTreeMap<String, ImageData>, Option<figcraft_geom::Rect>);
fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
let ids = z_sorted(doc, &roots(doc, ids));
let mut nodes = Vec::new();
let mut images = BTreeMap::new();
@@ -521,28 +546,73 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
nodes.push((*i, sub));
}
let bounds = doc.bounds_of(&ids);
let uid = ds.uid;
let source = Some(ds.doc.clone());
let source_path = ds.path.clone();
s.clipboard.source = source;
s.clipboard.source_path = source_path;
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
s.clipboard.source_doc = uid;
Ok(())
(nodes, images, bounds)
}
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
pub const CLIP_OPEN: &str = "(figma)";
pub const CLIP_CLOSE: &str = "(/figma)";
/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its
/// base64 is checked first, so neither the text nor the decoded bytes can pass it.
const MAX_CLIP_DATA: usize = 256 * 1024 * 1024;
const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4;
/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`].
fn clip_base64(text: &str) -> Option<Vec<u8>> {
use base64::Engine as _;
if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 {
return None;
}
let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect();
base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()
}
/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in
/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML
/// holds no readable design data (it may just mention the marker).
pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len();
let len = html.get(start..)?.find(CLIP_CLOSE)?;
let bytes = clip_base64(html.get(start..start + len)?)?;
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
}
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.pasteDesignLayers";
let bytes = match (string(p, "html"), string(p, "data")) {
(Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?,
(None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?,
(None, None) => return Err(bad(CMD, "pass `html` or `data`")),
};
let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??;
let doc = std::sync::Arc::new(other.doc);
let tops: Vec<NodeId> = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect();
if tops.is_empty() {
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
}
let (nodes, images, bounds) = copied(&doc, &tops);
let props = s.clipboard.props.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
}
Ok(out)
}
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).
@@ -1003,7 +1073,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
if find.is_empty() {
return Err(bad("edit.findReplace", "`find` is empty"));
}
let replace = string(p, "replace");
// Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF).
let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r));
let names = boolean(p, "layerNames").unwrap_or(false);
let page = s.page()?;
let doc = s.doc_mut()?;
@@ -1085,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 {
@@ -1097,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
@@ -1176,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,
@@ -1224,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)) {
+218 -40
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
false,
open
),
@@ -41,8 +41,20 @@ pub fn specs() -> Vec<CommandSpec> {
false,
open_recent
),
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
spec!("file.saveAs", "Save as", "{path}", false, save_as),
spec!(
"file.save",
"Save",
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save
),
spec!(
"file.saveAs",
"Save as",
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save_as
),
spec!(
"file.export",
"Export",
@@ -74,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.import",
"Import",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`)",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
true,
import
),
@@ -423,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")]
@@ -433,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
@@ -445,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")]
@@ -461,6 +469,11 @@ fn file_stem(path: &str) -> String {
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
}
/// The counts the import report shows for a freshly loaded document.
fn import_summary(doc: &Document) -> Value {
json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()})
}
/// Load a document from any supported file (REST API file JSON images come from an `images`
/// folder next to the file).
pub fn load(path: &str) -> Result<(Document, Vec<String>)> {
@@ -506,10 +519,8 @@ fn load_with(
Ok((doc.into(), false))
}
_ => {
if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
return Ok((r?, true));
}
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
@@ -525,10 +536,21 @@ fn load_with(
}
}
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
let r = figcraft_format::fig::try_import(bytes, name)?;
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
link_instances(&mut doc);
figcraft_format::Loaded { issues: warnings, ..doc.into() }
}))
}
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
super::styles::unfold_imported_bound_alpha(doc);
crate::style_vars::after_load(doc);
super::styles::apply_paint_bindings_on_load(doc);
let mut insts: Vec<(usize, NodeId)> =
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
@@ -552,11 +574,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())
}
}
@@ -579,11 +599,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)
})
}
@@ -621,20 +637,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")));
}
@@ -644,17 +669,23 @@ 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}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = req_string("file.open", p, "path")?;
let variables = variables_param("file.open", p)?;
if string(p, "path").is_none()
&& let Some(d) = string(p, "data")
{
return open_data(s, &d, p, variables.as_ref());
}
let path = req_string("file.open", p, "path")?;
// Already open: switch to it.
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
@@ -672,9 +703,26 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
});
}
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active});
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
/// the format. It opens as a new document with no path, so the first save asks where to put it.
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
let name = req_string("file.open", p, "name")?;
let data = data.split_once(',').map_or(data, |(_, b)| b);
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
let warnings = rest || ext(&name) == "svg";
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
Ok(out)
}
@@ -775,8 +823,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"profile": profile.id(), "changed": changed}))
}
/// Save the file of the active tab (the main file for a branch tab, with its branches).
/// Native saves act on the current document; a `.fig` copy exports the version shown, and a
/// pending Save As choice must leave it shown until its destination is known.
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
let path =
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
path.is_some_and(|path| ext(&path) != "fig")
}
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
/// the active document as an experimental `.fig` copy without changing its native save state.
fn save_to(s: &mut Session, path: String) -> Result<Value> {
if ext(&path) == "fig" {
return export_fig(s, &json!({"path": path}));
}
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
let bytes = file_bytes(ds)?;
@@ -795,6 +855,11 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
return save_to(s, path);
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
Some(path) if ext(&path) == "fig" => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, path),
None => {
s.request_ui("dialog.saveAs", json!({}));
@@ -824,12 +889,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
}
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
/// area), or their boxes with "include bounding box" and for slices.
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
ids.iter()
.filter_map(|i| {
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
let node = doc.node(*i);
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice {
doc.world_bounds(*i)
} else if any_visible && node.is_some_and(|n| !n.visible) {
None
} else {
figcraft_render::export_bounds(doc, *i)
}
})
.reduce(|a, b| a.union(b))
}
@@ -1110,7 +1184,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)?;
@@ -1206,8 +1280,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
};
let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None };
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
let summary = import_summary(&other.doc);
let pages = append_pages(s.doc_mut()?, &other.doc)?;
let mut out = json!({"pages": pages});
let mut out = json!({"pages": pages, "summary": summary});
if rest {
out["warnings"] = json!(other.issues);
}
@@ -1243,6 +1318,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
map.insert(id, doc.alloc_id());
}
}
// Library copies a .fig import keeps (cached definitions) have no library record to follow:
// the layers of its store come along into the store of `doc`. Recorded copies are left to
// `merge_store`, which shares them with copies `doc` already has.
let cached: Vec<NodeId> = other
.library_store()
.and_then(|s| other.node(s))
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
.unwrap_or_default();
for root in &cached {
for id in other.subtree(*root) {
map.insert(id, doc.alloc_id());
}
}
let mut new_pages = Vec::new();
for pg in &other.pages {
let Some(pn) = other.node(*pg) else { continue };
@@ -1260,6 +1348,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
}
new_pages.push(np);
}
if !cached.is_empty() {
let store = doc.ensure_library_store();
for root in &cached {
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
doc.paste_nodes(&nodes, &map, *root, store, None)?;
}
}
for new in map.values() {
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
@@ -1271,7 +1366,7 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? };
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
// A branch tab's edits are in its main file already (which then has unsaved changes).
if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
@@ -1325,6 +1420,70 @@ mod rest_import_tests {
doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
}
/// What a .fig import with a cached library component leaves: the main in the hidden store, a
/// visible instance of it and a layer linked to the main's layer.
fn cached_import() -> Document {
let mut doc = Document::new("Cached");
let page = doc.first_page().unwrap();
let store = doc.ensure_library_store();
let main = doc
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
.unwrap();
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
let inst =
doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
let mut copy = doc.node(body).unwrap().clone();
copy.id = doc.alloc_id();
copy.parent = None;
copy.source = Some(body);
let copy_id = copy.id;
doc.insert(copy, inst, None).unwrap();
assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body));
doc
}
/// Pasting layers that use a cached main into another document leaves the main behind, as it
/// does for other mains that are not published: no link points at a layer of the target.
#[test]
fn pasting_instances_of_cached_components_leaves_no_dangling_links() {
let src = cached_import();
let mut doc = Document::new("Target");
let page = doc.first_page().unwrap();
for i in 0..8 {
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
}
let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id;
let ids = src.subtree(inst);
let map: std::collections::BTreeMap<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
let nodes: Vec<Node> = ids.iter().filter_map(|i| src.node(*i).cloned()).collect();
doc.paste_nodes(&nodes, &map, inst, page, None).unwrap();
crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap();
let pasted = doc.node(map[&inst]).unwrap();
let NodeKind::Instance { component, .. } = pasted.kind else { panic!() };
assert!(!doc.contains(component), "the main stays behind: {component:?}");
assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s)));
}
#[test]
fn appending_pages_with_cached_library_components_brings_the_components() {
let other = cached_import();
let mut doc = Document::new("Target");
let page = doc.first_page().unwrap();
// Ids of the target that would collide with the other document's store.
for i in 0..8 {
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
}
append_pages(&mut doc, &other).unwrap();
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc));
let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();
let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
assert_eq!(doc.node(component).unwrap().name, "Cached main");
assert!(doc.is_remote(component), "the main comes along into the store");
let layer = doc.node(inst.children[0]).unwrap();
assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied());
}
#[test]
fn rest_json_loads_with_linked_instances() {
let bytes = serde_json::to_vec(&kit()).unwrap();
@@ -1342,6 +1501,25 @@ mod rest_import_tests {
assert!(thumbnail(&doc).is_some());
}
#[test]
fn open_takes_data_and_a_name_as_a_new_unsaved_document() {
let mut saved = Session::new();
saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let bytes = file_bytes(saved.ds().unwrap()).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut s = Session::new();
let recent = s.prefs.recent_files.clone();
let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap();
let open = s.docs.len();
assert_eq!(r["index"], open - 1, "a document of its own, made active");
assert_eq!(s.ds().unwrap().path, None, "no path to save over");
assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)));
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format");
assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err());
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
}
#[test]
fn open_and_import_rest_json() {
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
+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());
+35 -10
View File
@@ -1,5 +1,6 @@
//! Pages.
use figcraft_doc::proto::Flow;
use figcraft_doc::*;
use serde_json::{Value, json};
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
Ok(())
}
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
let Some(target) = id(p, "id") else { return s.page() };
if !s.doc()?.pages.contains(&target) {
return Err(bad(cmd, format!("{target} is not a page")));
}
Ok(target)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
let name = req_string("page.rename", p, "name")?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.rename", p)?;
s.doc_mut()?.get_mut(id)?.name = name;
settle(s, "page.rename")?;
ok()
}),
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.delete", p)?;
s.doc_mut()?.remove(id)?;
settle(s, "page.delete")?;
ok()
@@ -87,13 +97,25 @@ pub fn specs() -> Vec<CommandSpec> {
let doc = s.doc_mut()?;
let name = format!("Copy of {}", doc.get(src)?.name);
let np = doc.add_page(&name);
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
// One copy with one id map, so navigation between the copied frames points at the copies;
// then each layer and its copy (also those in sections), so flows can follow their start frames.
let children = doc.get(src)?.children.clone();
let copied = doc.duplicate_all_into(&children, np)?;
let mut copies = Vec::new();
for (c, copy) in children.iter().zip(&copied) {
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
}
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
{
*b = background;
}
for c in doc.get(src)?.children.clone() {
doc.duplicate_into(c, np, None)?;
*f = flows
.into_iter()
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
.collect();
*pr = prototype;
*g = guides;
}
// Keep the new page right after the source.
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
@@ -117,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.setBackground", p)?;
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
*background = c;
}
@@ -163,8 +185,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
guides.remove(k);
let ds = s.ds_mut()?;
if ds.guide == Some((owner, k)) {
ds.guide = None;
match ds.guide {
Some((o, i)) if o == owner && i == k => ds.guide = None,
// A later guide of the same owner moves up one place; keep the selection on it.
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
_ => {}
}
ok()
}),
+83 -20
View File
@@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same).
pub(crate) fn normalize_line_endings(s: &str) -> String {
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
}
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
match v {
@@ -260,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();
@@ -447,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)?;
@@ -528,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;
@@ -599,7 +654,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let len = t.char_count();
t.replace_range(0, len, &s);
t.replace_range(0, len, &normalize_line_endings(&s));
n.overrides.insert("characters".into());
}
"fontFamily"
@@ -720,10 +775,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
for (field, var) in o {
match var.as_str() {
Some(vid) => {
if n.bound.get(field).map(String::as_str) != Some(vid) {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
}
n.bound.insert(field.clone(), vid.to_string());
}
None => {
n.bound.remove(field);
if n.bound.remove(field).is_some() {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
}
}
}
@@ -778,12 +838,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());
}
}
@@ -805,7 +867,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",
+102 -26
View File
@@ -26,10 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"object.convertToFrame",
"Convert to frame",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
true,
|s, p| convert(s, p, "object.convertToFrame", true)
),
spec!(
"object.convertToSection",
"Convert to section",
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
true,
convert_to_section
),
spec!(
"object.wrapInSection",
"Wrap in new section",
@@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
match (&n.kind, to_frame) {
// A frame can't hold sections.
(NodeKind::Section { .. }, true)
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
{
return refuse("holds sections, which can't go inside a frame: convert those first");
}
(NodeKind::Section { .. }, true) => todo.push(i),
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
@@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
}
for i in &todo {
if to_frame {
// The group's box as it is; nothing painted or clipped, so the look stays.
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
// section keeps its fill.
let n = doc.get_mut(*i)?;
if matches!(n.kind, NodeKind::Group) {
n.fills.clear();
n.strokes.clear();
}
n.kind = NodeKind::Frame;
n.clip = false;
n.fills.clear();
n.strokes.clear();
continue;
}
let n = doc.get_mut(*i)?;
@@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
Ok(json!({"ids": todo}))
}
/// Frames on the page or in a section become sections in place (sections only sit there).
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.convertToSection";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut todo = Vec::new();
for i in ids {
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
match &n.kind {
NodeKind::Section { .. } => {}
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
NodeKind::Frame => todo.push(i),
_ => return refuse("is not a frame"),
}
}
for i in &todo {
let n = doc.get_mut(*i)?;
n.kind = NodeKind::Section { contents_hidden: false };
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
doc.get_mut(k)?.child_layout.absolute = false;
}
figcraft_layout::coherent_sizing(doc, *i);
}
Ok(json!({"ids": todo}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
@@ -309,33 +363,46 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut path = BezPath::new();
let mut even = false;
let mut net = VectorNetwork::default();
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Booleans flatten to their result; strokes don't get outlined.
let Some((mut op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0),
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
// booleans flatten to their result; strokes don't get outlined.
let mut part = match &n.kind {
NodeKind::Vector { network } => network.clone(),
_ => {
let Some((op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
figcraft_render::subtree_outline(doc, n, 0)
}
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
let mut part = VectorNetwork::from_path(&op);
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut part.regions {
r.winding = WindingRule::EvenOdd;
}
}
part
}
};
even |= rule == figcraft_render::FillRule::EvenOdd;
// Into the common parent's space.
let w = doc.world_transform(*i);
op.apply_affine(pw.inverse() * w);
path.extend(op.elements().iter().copied());
let all: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
}
if path.elements().is_empty() {
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
}
};
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
}
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
@@ -477,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}))
@@ -556,6 +628,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
for i in req_targets("vector.offset", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
// A vector can't hold children: leave containers (frames, groups, instances) alone.
if !n.children.is_empty() {
continue;
}
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+103 -3
View File
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
@@ -455,10 +458,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
match var {
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
Some((id, _)) => {
if style.bound.get(&key) != Some(&id)
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
style.bound.insert(key, id);
}
None => {
style.bound.remove(&key);
if style.bound.remove(&key).is_some()
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, false);
}
}
}
// The style takes its default-mode value, and each layer using it its own.
@@ -767,16 +779,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
if n.bound.get(&field) != Some(v) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
}
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
if n.bound.remove(&field).is_some() {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
}
}
}
}
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
let paints = match list {
BoundList::Fills => fills,
BoundList::Strokes => strokes,
_ => return,
};
if let Some(p) = paints.get_mut(i)
&& let PaintKind::Solid { color } = &mut p.kind
{
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
color.a = 1.0;
}
}
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
@@ -910,6 +947,67 @@ pub fn apply_bindings(doc: &mut Document) {
/// [`apply_bindings`] for the layers `only` (every bound layer when `None`).
pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>) {
apply_bindings_matching(doc, only, |_| true);
}
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
use super::lists::{BoundList, list_binding};
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
_ => None,
});
found.collect()
}
fn unfold(p: Option<&mut Paint>, a: f32) {
if let Some(p) = p
&& matches!(p.kind, PaintKind::Solid { .. })
{
p.opacity = (p.opacity / a).min(1.0);
}
}
let pending = doc.take_dirty();
for i in 0..doc.styles.len() {
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
for (_, j, a) in found {
unfold(paints.get_mut(j), a);
}
}
}
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
for id in ids {
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
if found.is_empty() {
continue;
}
let Ok(n) = doc.get_mut(id) else { continue };
for (list, j, a) in found {
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
}
}
doc.take_dirty();
doc.mark_dirty(pending);
}
/// Settle imported paint colours before the document is first shown. Other bindings can affect
/// layout or component structure, which must be resolved through the normal edit pipeline.
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
let pending = doc.take_dirty();
apply_bindings_matching(doc, None, |field| {
super::lists::list_binding(field).is_some_and(|(list, _, prop)| {
matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes)
&& (prop == "color" || super::lists::stop_binding(prop).is_some())
})
});
doc.take_dirty();
doc.mark_dirty(pending);
}
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
return;
@@ -929,6 +1027,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
for (field, var) in binds {
if !include(&field) {
continue;
}
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// One list item's value: a paint's colour, an effect's or a layout grid's field.
if let Some((list, i, prop)) = list_binding(&field) {
@@ -1080,7 +1181,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
stack.extend(n.children.iter().copied());
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
if let Some(c) = paint.solid_color() {
let c = c.with_alpha(c.a * paint.opacity);
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
}
}
+1 -1
View File
@@ -332,7 +332,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.preview",
"Preview text change",
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
"{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step",
false,
|s, p| {
let c = req_string("text.preview", p, "command")?;
+6 -2
View File
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let scopes = match get(p, "scopes") {
let scopes = match p.get("scopes") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => {
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
doc.variables.variables.retain(|v| !gone.contains(&v.id));
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
for id in users {
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
let n = doc.get_mut(id)?;
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
n.bound.retain(|_, v| !gone.contains(v));
}
Ok(())
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
true,
import
),
+75 -39
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.
@@ -138,8 +166,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
let pages = &ds.doc.pages;
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
let n = pages.len() as i64;
let next = (i + dir).clamp(0, n - 1) as usize;
let Some(pg) = pages.get(next).copied() else { return ok() };
// Dividers never open: step over them, and stay put when only dividers lie ahead.
let mut next = i + dir;
let pg = loop {
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
match page {
Some(p) if !ds.doc.is_divider(p) => break p,
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
_ => break ds.page,
}
};
let ds = s.ds_mut()?;
ds.page = pg;
ds.selection.clear();
@@ -227,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")}));
+52 -2
View File
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
/// in "px" are left alone.
/// in "px", markup lines (layer text), string literals and comments are left alone.
pub fn px_to_rem(code: &str, root: f64) -> String {
let mut out = String::with_capacity(code.len());
let mut in_comment = false;
for line in code.split_inclusive('\n') {
if !in_comment && line.trim_start().starts_with('<') {
out.push_str(line);
continue;
}
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
let mut plain = 0;
let mut chars = line.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if in_comment {
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
chars.next();
out.push_str(line.get(plain..i + 2).unwrap_or(""));
plain = i + 2;
in_comment = false;
}
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
chars.next();
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
plain = i;
in_comment = true;
} else if c == '"' {
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
let mut end = line.len();
while let Some((j, s)) = chars.next() {
if s == '\\' {
chars.next();
} else if s == '"' {
end = j + 1;
break;
}
}
out.push_str(line.get(i..end).unwrap_or(""));
plain = end;
}
}
let rest = line.get(plain..).unwrap_or("");
if in_comment {
out.push_str(rest);
} else {
out.push_str(&lengths_to_rem(rest, root));
}
}
out
}
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
fn lengths_to_rem(code: &str, root: f64) -> String {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = String::with_capacity(code.len());
let mut rest = code;
@@ -206,7 +256,7 @@ fn stroke_color(n: &Node) -> Option<Color> {
if !n.has_visible_stroke() {
return None;
}
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
+10 -7
View File
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
}
}
/// `data-*` attribute names for variant parameters.
fn data_attr(p: &Param) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
format!("data-{}", class_name(&p.prop, &mut used))
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
}
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, &mut Vec::new());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
let mut m = Markup::new(&vctx, lang);
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
m.root_class = Some(root_class.clone());
m.root_attrs = variants
.iter()
.map(|p| match lang {
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
.zip(&attr_names)
.map(|(p, attr)| match lang {
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
_ => format!(" {attr}={{{}}}", p.ident),
})
.collect();
m.node(c.comp, None, 2, "");
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let pairs = variant_props(&vn.name);
let attrs: String = variants
.iter()
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
.zip(&attr_names)
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
.collect();
for (path, _, decls) in pass(v) {
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
let react = code(&mut s, c, "react");
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
}
#[test]
fn case_colliding_variant_properties_get_distinct_data_attributes() {
let mut s = Session::new();
let mut variants = Vec::new();
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
let name = format!("Size={first}, size={second}");
let f = idv(&ex(
&mut s,
"frame.create",
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
for lang in ["react", "leptos"] {
let v = component_code(&mut s, set, lang);
let def = v["definition"].as_str().unwrap_or_default();
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
let attrs: Vec<&str> =
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
assert_eq!(attrs.len(), 2, "{lang}: {root}");
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
}
}
+266 -55
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) {
@@ -472,11 +476,11 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
}
}
"characters" => {
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str())
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
&& t.characters != sv
{
let len = t.char_count();
t.replace_range(0, len, sv);
t.replace_range(0, len, &sv);
}
}
"mainComponent" => {
@@ -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,18 +1730,174 @@ 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
}
/// Bounded source and parent lookups while a rebase reads an unchanged document.
struct RebaseSources<'a> {
doc: &'a Document,
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
main_changes: BTreeMap<NodeId, bool>,
}
impl<'a> RebaseSources<'a> {
const PARENTS: usize = 512;
const CACHE: usize = 8192;
fn new(doc: &'a Document) -> Self {
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
}
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
if let Some(chain) = self.chains.get(&source) {
return chain.clone();
}
let read = || {
let mut chain = Vec::new();
let mut seen = BTreeSet::new();
let mut current = Some(source);
for _ in 0..MAX_DEPTH {
let Some(id) = current else { return Some(chain) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
chain.push(id);
current = n.source;
}
current.is_none().then_some(chain)
};
let chain = read();
if self.chains.len() < Self::CACHE {
self.chains.insert(source, chain.clone());
}
chain
}
/// Validate the complete parent chain, even if `main` was found before a cycle.
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
if let Some(found) = self.parents.get(&(id, main)) {
return *found;
}
let read = || {
let mut current = Some(id);
let mut seen = BTreeSet::new();
let mut found = false;
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(found) };
if !seen.insert(id) {
return None;
}
found |= id == main;
current = self.doc.node(id)?.parent;
}
current.is_none().then_some(found)
};
let found = read();
if self.parents.len() < Self::CACHE {
self.parents.insert((id, main), found);
}
found
}
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
let chain = self.chain(source)?;
if chain.contains(&target) {
return None;
}
let mut member = None;
for id in chain {
if self.in_main(id, main)? && id != main && member.is_none() {
member = Some(id);
}
}
member
}
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
let mut owners = Vec::new();
let mut current = self.doc.node(id)?.parent;
let mut seen = BTreeSet::from([id]);
for _ in 0..Self::PARENTS {
let Some(id) = current else { return Some(owners) };
if !seen.insert(id) {
return None;
}
let n = self.doc.node(id)?;
if holds_content(n) {
return Some(owners);
}
if let NodeKind::Instance { component, .. } = &n.kind {
owners.push((id, *component));
}
current = n.parent;
}
current.is_none().then_some(owners)
}
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
}
/// A contextual child belongs to the same placed slot as its enclosing instance.
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
let Some((owner, main)) = owners.first() else { return false };
if self.member(source, *main, target).is_none() {
return false;
}
if self.in_main(source, *main) == Some(true) {
return true;
}
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
}
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
let source = self.doc.node(id)?.source?;
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|| !self.valid(source, id, owners)
|| !self.contextual(source, owners)
{
return None;
}
Some(source)
}
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
for (index, (id, main)) in owners.iter().enumerate() {
if *id == stop {
break;
}
let changed = if let Some(changed) = self.main_changes.get(id) {
*changed
} else {
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => component != main,
Some(NodeKind::Frame) => true,
_ => false,
});
if self.main_changes.len() < Self::CACHE {
self.main_changes.insert(*id, changed);
}
changed
};
if changed {
return true;
}
}
false
}
}
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
/// the look is kept exactly. Values a component property sets aren't overrides.
@@ -1722,22 +1910,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
};
let mut updates = Vec::new();
let mut sources = RebaseSources::new(doc);
let root_owners = sources.owners(inst).unwrap_or_default();
let placed_source = sources.placed(inst, &root_owners);
let mut contexts = vec![(inst, component)];
contexts.extend_from_slice(&root_owners);
for (id, path) in &mine {
let Some(n) = doc.node(*id) else { continue };
// Follow the source chain into the main first (exact), then fall back to the name path.
let mut main = None;
let mut cur = n.source;
for _ in 0..MAX_DEPTH {
let Some(c) = cur else { break };
if c != component && doc.contains(c) && doc.is_ancestor(component, c) {
main = Some(c);
break;
}
cur = doc.node(c).and_then(|x| x.source);
let owners = sources.owners(*id).unwrap_or_default();
let nearest_main = owners.first().map(|(_, main)| *main);
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
// rebase. An outer name path must not rebind those children to the old expanded copies.
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
updates.push((*id, valid_source, n.overrides.clone()));
continue;
}
let main = main.or_else(|| theirs.get(path).copied());
main = main
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
if let Some(m) = main.and_then(|m| doc.node(m)) {
if let Some(m) = source.and_then(|source| doc.node(source)) {
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
bound.extend(variable_groups(n, m));
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
@@ -1745,36 +1939,53 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
overrides.insert("component".into());
}
}
updates.push((*id, main, overrides));
updates.push((*id, source, overrides));
}
// A placed nested root compares to its actual slot. A detached root compares to its own main.
let root_base = placed_source.or_else(|| {
let chain = sources.chain(component)?;
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
});
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
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 {
groups.push("name".into());
}
if placed_source.is_some()
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
{
groups.push("component".into());
}
groups
}
_ => Vec::new(),
};
drop(sources);
for (id, main, overrides) in updates {
if let Some(n) = doc.node_mut(id) {
n.source = main;
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
}
}
let root_groups = match (doc.node(component), doc.node(inst)) {
(Some(m), Some(n)) => {
let from_variables = variable_groups(n, m);
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()))
.collect();
// A layer name of its own (a nested instance renamed in its old main) stays.
if n.name != default_instance_name(doc, component) {
groups.push("name".into());
}
groups
}
_ => Vec::new(),
};
if let Some(n) = doc.node_mut(inst) {
n.source = Some(component);
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
n.source = root_base;
// 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);
}
}
#[cfg(test)]
#[path = "components_rebase_source_tests.rs"]
mod rebase_source_tests;
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
/// the left-most).
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
@@ -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",
@@ -0,0 +1,244 @@
//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains.
use super::*;
fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap()
}
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
}
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
let mut n = doc.node(source).unwrap().clone();
let children = std::mem::take(&mut n.children);
n.id = doc.alloc_id();
n.parent = None;
n.source = Some(source);
let id = n.id;
doc.insert(n, parent, None).unwrap();
for child in children {
mirror(doc, child, id);
}
id
}
fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId {
let mut n = doc.node(main).unwrap().clone();
let children = std::mem::take(&mut n.children);
n.id = doc.alloc_id();
n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() };
n.name = name.into();
n.parent = None;
n.source = None;
let id = n.id;
doc.insert(n, parent, None).unwrap();
for child in children {
mirror(doc, child, id);
}
id
}
struct Scene {
doc: Document,
inner: NodeId,
base: NodeId,
slot: NodeId,
context: NodeId,
root: NodeId,
nested: NodeId,
child: NodeId,
}
fn scene() -> Scene {
let mut doc = Document::new("Source context");
let page = doc.first_page().unwrap();
let inner = component(&mut doc, page, "Inner");
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
let outer = component(&mut doc, page, "Outer");
let slot = instance(&mut doc, inner, outer, "Nested");
doc.node_mut(slot).unwrap().source = Some(inner);
let context = doc.node(slot).unwrap().children[0];
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
doc.node_mut(context).unwrap().opacity = 0.3;
let root = instance(&mut doc, outer, page, "Outer copy");
let nested = doc.node(root).unwrap().children[0];
let child = doc.node(nested).unwrap().children[0];
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
doc.node_mut(child).unwrap().opacity = 0.2;
Scene { doc, inner, base, slot, context, root, nested, child }
}
fn both(s: &mut Scene) {
rebase_instance(&mut s.doc, s.nested);
rebase_instance(&mut s.doc, s.root);
}
#[test]
fn rebase_default_only_sources_promote_to_the_actual_outer_context() {
let mut s = scene();
s.doc.node_mut(s.nested).unwrap().source = None;
s.doc.node_mut(s.child).unwrap().source = None;
both(&mut s);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
}
#[test]
fn rebase_rejects_a_context_source_from_another_nested_slot() {
let mut s = scene();
let outer = s.doc.node(s.slot).unwrap().parent.unwrap();
let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot");
s.doc.node_mut(sibling).unwrap().source = Some(s.inner);
let sibling_child = s.doc.node(sibling).unwrap().children[0];
mirror(&mut s.doc, sibling, s.root);
s.doc.node_mut(s.child).unwrap().source = Some(sibling_child);
both(&mut s);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0);
assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0);
}
#[test]
fn rebase_does_not_mark_inherited_context_values_as_local_overrides() {
let mut s = scene();
s.doc.node_mut(s.child).unwrap().opacity = 0.3;
both(&mut s);
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity"));
}
#[test]
fn rebase_preserves_multilevel_context_without_shortening_source_chains() {
let mut doc = Document::new("Nested source contexts");
let page = doc.first_page().unwrap();
let inner = component(&mut doc, page, "Inner");
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
let wrapper = component(&mut doc, page, "Wrapper");
let default_slot = instance(&mut doc, inner, wrapper, "Nested");
doc.node_mut(default_slot).unwrap().source = Some(inner);
let outer = component(&mut doc, page, "Outer");
let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot");
doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper);
let source_slot = doc.node(wrapper_slot).unwrap().children[0];
let context = doc.node(source_slot).unwrap().children[0];
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
let root = instance(&mut doc, outer, page, "Outer copy");
let wrapper_copy = doc.node(root).unwrap().children[0];
let nested = doc.node(wrapper_copy).unwrap().children[0];
let child = doc.node(nested).unwrap().children[0];
doc.node_mut(nested).unwrap().name = "Renamed nested".into();
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
for id in [nested, wrapper_copy, root] {
rebase_instance(&mut doc, id);
assert_eq!(doc.node(nested).unwrap().source, Some(source_slot));
assert_eq!(doc.node(child).unwrap().source, Some(context));
}
assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0]));
assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0);
assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0);
}
#[test]
fn rebase_preserves_placed_frame_to_instance_and_current_main_children() {
let mut doc = Document::new("Typed nested source");
let page = doc.first_page().unwrap();
let inner = component(&mut doc, page, "Inner");
let body = add(&mut doc, inner, NodeKind::Rectangle, "Body");
let outer = component(&mut doc, page, "Outer");
let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot");
let root = instance(&mut doc, outer, page, "Outer copy");
let nested = doc.node(root).unwrap().children[0];
doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() };
let child = mirror(&mut doc, body, nested);
rebase_instance(&mut doc, nested);
rebase_instance(&mut doc, root);
assert_eq!(doc.node(nested).unwrap().source, Some(slot));
assert_eq!(doc.node(child).unwrap().source, Some(body));
assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner));
}
#[test]
fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() {
let mut s = scene();
let page = s.doc.first_page().unwrap();
let replacement = component(&mut s.doc, page, "Replacement");
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0));
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
s.doc.remove(s.child).unwrap();
s.child = mirror(&mut s.doc, body, s.nested);
s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0));
s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into();
rebase_instance(&mut s.doc, s.nested);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
rebase_instance(&mut s.doc, s.root);
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(body));
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0);
}
#[test]
fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
let mut s = scene();
let page = s.doc.first_page().unwrap();
let detached = mirror(&mut s.doc, s.nested, page);
let child = s.doc.node(detached).unwrap().children[0];
// The old source references still exist, but their Outer context is absent here.
s.doc.node_mut(detached).unwrap().source = Some(s.slot);
s.doc.node_mut(child).unwrap().source = Some(s.context);
s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0));
rebase_instance(&mut s.doc, detached);
assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner));
assert_eq!(s.doc.node(child).unwrap().source, Some(s.base));
assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0);
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
}
#[test]
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
let mut s = scene();
let page = s.doc.first_page().unwrap();
let foreign = component(&mut s.doc, page, "Foreign");
let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body");
s.doc.node_mut(s.child).unwrap().source = Some(foreign_body);
both(&mut s);
assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body));
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
let mut s = scene();
let page = s.doc.first_page().unwrap();
let replacement = component(&mut s.doc, page, "Replacement");
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
s.doc.node_mut(s.child).unwrap().source = Some(s.context);
both(&mut s);
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap");
assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body));
}
#[test]
fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() {
let mut s = scene();
s.doc.node_mut(s.base).unwrap().source = Some(s.context);
both(&mut s);
assert_eq!(s.doc.node(s.child).unwrap().source, None);
let mut s = scene();
s.doc.node_mut(s.context).unwrap().parent = Some(s.context);
both(&mut s);
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context));
let mut s = scene();
s.doc.node_mut(s.nested).unwrap().parent = Some(s.child);
s.doc.node_mut(s.child).unwrap().parent = Some(s.nested);
let sources = RebaseSources::new(&s.doc);
assert!(sources.owners(s.child).is_none());
let mut s = scene();
s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() };
rebase_instance(&mut s.doc, s.root);
assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root));
}
-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:?}");
+49 -3
View File
@@ -1,5 +1,5 @@
//! Frames and groups converted into each other in place: the layers stay where they are on the
//! page, what only a frame has goes with it, what can't convert is refused, one undo step.
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
use serde_json::{Value, json};
@@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() {
#[test]
fn junk_parameters_are_refused_not_fatal() {
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
for j in &junk {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
@@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() {
}
}
}
#[test]
fn a_frame_on_the_page_becomes_a_section_and_back() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
// Each conversion is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Frame));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
}
#[test]
fn only_frames_on_the_page_or_in_a_section_become_sections() {
let mut s = Session::new();
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
assert!(err.contains("inside a frame"), "{err}");
let r = rect(&mut s, outer, 150.0, 150.0);
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
// A frame inside a section converts; a section holding a section can't become a frame.
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
+24 -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();
@@ -219,6 +220,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
}
#[test]
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
// A real click: the mouse moves a pixel or two between press and release.
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
assert!(s.tool.overlay.pen_handles.is_empty());
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
assert_eq!(out, Some((4.0, 0.0)));
}
#[test]
fn the_pen_shows_what_a_click_would_do() {
// The Pen tool drawing a new path: three points, then over each of them.
+108
View File
@@ -0,0 +1,108 @@
//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in
//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers.
use base64::Engine as _;
use serde_json::json;
use super::*;
use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer};
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
fn id(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
fn copied_file() -> Vec<u8> {
let mut s = Session::new();
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap());
s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap();
s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap();
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
B64.decode(r["data"].as_str().unwrap()).unwrap()
}
fn clipboard_html(buffer: &[u8]) -> String {
let mut wrapped = B64.encode(buffer);
wrapped.insert(8, '\n');
format!(r#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
}
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
let doc = s.doc().unwrap();
ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect()
}
#[test]
fn buffer_is_found_in_the_html() {
let file = copied_file();
assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file));
assert_eq!(design_clipboard_buffer("<p>plain</p>"), None);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None);
// Web text that only mentions the markers holds no design data.
assert_eq!(design_clipboard_buffer(&format!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), None);
}
#[test]
fn oversized_design_data_is_refused_before_decoding() {
// Just past the cap (256 MB of data, as base64), made of valid base64 characters.
let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4);
assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None);
let mut s = Session::new();
let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err();
assert!(e.to_string().contains("256 MB"), "{e}");
assert!(s.clipboard.nodes.is_empty());
}
#[test]
fn layers_paste_into_the_page_and_undo() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
assert_eq!(s.selection(), ids);
let doc = s.doc().unwrap();
assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page)));
let card = doc.node(ids[0]).unwrap();
assert_eq!(names(&s, &card.children), ["Badge"]);
assert_eq!((card.width, card.height), (200.0, 100.0));
assert!(s.undo().unwrap());
assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i)));
}
#[test]
fn a_second_paste_repeats_the_copied_layers() {
let mut s = Session::new();
// `data` may be wrapped and unpadded, like the HTML's base64.
let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string();
data.insert(8, '\n');
s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap();
let r = s.execute("edit.paste", &json!({})).unwrap();
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect();
assert_eq!(names(&s, &ids), ["Card", "Dot"]);
}
#[test]
fn pastes_into_the_selected_frame() {
let mut s = Session::new();
let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap());
s.execute("select.set", &json!({"ids": [host]})).unwrap();
let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap();
let first = params::id_of(&r["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host));
}
#[test]
fn other_content_is_refused_without_changes() {
let mut s = Session::new();
let before = s.doc().unwrap().nodes().count();
assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err());
assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "<b>hi</b>"})).is_err());
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err());
assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err());
assert_eq!(s.doc().unwrap().nodes().count(), before);
assert!(s.clipboard.nodes.is_empty());
}
+26
View File
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
}
#[test]
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
let mut s = Session::new();
let screen = frame(&mut s, 0.0, "Screen");
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
};
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
ex(&mut s, "dev.focus", json!({"id": screen}));
for direction in ["next", "previous"] {
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
}
// A frame directly inside a section still counts.
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
}
+2 -2
View File
@@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
}
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
/// in page and layer order (bounded).
/// in page and layer order (bounded). Only layers that can carry a status are listed.
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
let mut out = Vec::new();
let mut visited = 0usize;
@@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
return out;
}
let Some(n) = doc.node(id) else { continue };
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
}
stack.extend(n.children.iter().rev().copied());
+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");
}
}
+5 -2
View File
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
/// wrap in flow order, so a new row starts where a child sits before the one before.
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
/// does not follow the one before along the flow but sits past it across (one child per line).
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
match rows.last_mut() {
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
row.push((i, b))
}
_ => rows.push(vec![(i, b)]),
}
}
@@ -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");
}
+40
View File
@@ -50,3 +50,43 @@ fn local_samples_match_their_exports() {
}
}
}
/// Layers of an instance copy keep their link to the layer they stand for when the file is
/// opened, also after they were renamed in the copy (a name is no identity), and the override
/// that a rename is stays an override.
#[test]
fn opened_fig_links_instance_layers_by_identity_not_by_name() {
use base64::Engine as _;
let mut s = Session::new();
let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap();
let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"}));
let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}}));
let inner = make(&mut s, "component.create", json!({"ids": [body]}));
let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"}));
let outer = make(&mut s, "component.create", json!({"ids": [frame]}));
let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer}));
s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap();
let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200}));
let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0];
let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0];
s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap();
s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap();
let _ = label;
let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap();
let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id()));
std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap();
let mut opened = Session::new();
let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
result.unwrap();
let doc = opened.doc().unwrap();
let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap();
let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
let slot = doc.node(copy.children[0]).unwrap();
let label = doc.node(slot.children[0]).unwrap();
assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label"));
assert_eq!(slot.source, Some(outer.children[0]));
assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0]));
assert!(slot.overrides.contains("name") && label.overrides.contains("name"));
}
+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;
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more