Compare commits

..
Author SHA1 Message Date
bflatastic f979878d9e Merge main into fix/issue-892
# Conflicts:
#	ROADMAP.md
2026-10-20 06:09:17 +07:00
BFlat 5f0fe486db Merge pull request #900 from storytold/fix/issue-890
M1: readable disabled and selected text (#890)
2026-10-20 06:09:13 +07:00
bflatastic 64efe12e6f Merge main into fix/issue-890
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-20 05:58:56 +07:00
BFlat 4bb30782ff Merge pull request #899 from storytold/fix/issue-879
M1: node.get reports layer data (pluginData) (#879)
2026-10-20 05:58:32 +07:00
bflatastic 8a46478e81 Merge main into fix/issue-879
# Conflicts:
#	ROADMAP.md
2026-10-20 05:46:30 +07:00
BFlat 11ecf3e887 Merge pull request #898 from storytold/fix/issue-894
M1: nudge while measuring; distances follow, handles hide (#894)
2026-10-20 05:46:25 +07:00
bflatastic b82369201d M3: duplicating a main component makes an instance (#892)
Duplicate and Alt-drag copied a main component as a second main. Within
its file the copy is now an instance of it (components::copy_in_file,
shared by edit.duplicate and the Alt-drag copy), as Copy then Paste
already was. A variant duplicated in its set stays a new variant.
2026-10-20 05:44:46 +07:00
bflatastic e3b20b6540 Merge main into fix/issue-894
# Conflicts:
#	ROADMAP.md
2026-10-20 05:35:21 +07:00
BFlat b17bea9195 Merge pull request #881 from yelintong/feat/grid-auto-width
Layout grids: Auto line width for columns and rows
2026-10-20 05:35:13 +07:00
BFlat 287b506bc1 Merge pull request #889 from kk376/fix/857-live-session-large-files
fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
2026-10-20 05:34:52 +07:00
bflatastic 05da6b2125 M1: node.get reports layer data (pluginData) (#879)
Layer data written with node.set {props: {pluginData}} was saved but no
command read it back. node.get (the command, the control method and MCP
get_node) and document.inspect now report pluginData as {key: value}
when a layer or page has any, so a tool keeping state in a file can
read it on its next run. Widget keys are included: the plugin rules
only stop plugins writing them, and widget.list already reports that
state.
2026-10-20 04:58:32 +07:00
bflatastic 5617c94167 M1: nudge while measuring; distances follow, handles hide (#894)
Alt+arrows were left out of the nudge keys, so with Alt held to measure
the arrows did nothing and the distances never changed. The arrows now
nudge with Alt held too (Shift: the big step); the hover measurement is
recomputed every frame, so the labels follow each nudge. While measuring
to another layer the selection's handles and size label hide; both
layers keep their outlines.
2026-10-20 04:58:31 +07:00
bflatastic 9ee0c7f572 M1: readable disabled and selected text (#890)
The dim text token (disabled buttons, placeholders, hints) was 2.1:1 on
the light theme's panels; disabled buttons also faded to 40% opacity,
and selected text was drawn in the dark green, 1.4:1 on the dark
theme's selection highlight. The dim text token is now at least 3:1
against panels, fields and selected rows in both themes while staying
dimmer than secondary text, a disabled button is grey with that text,
and selected text takes the theme's readable green. A test checks the
ratios with the colour contrast helper.
2026-10-20 04:57:20 +07:00
bflatastic 0f95ce3d5b #881: test Auto in a fixed grid's count, scroll to the field instead of a fixed point 2026-10-20 04:45:45 +07:00
bflatastic a7d0648958 #881: fixed grids keep their pixel Width and the Stretch->Left lift to 60; the stretched Auto width reuses the number field (disabled) 2026-10-20 04:45:44 +07:00
bflatastic 309271851f #881: keep fixed grids at width 0 drawing bands as before (no hairlines) 2026-10-20 04:39:14 +07:00
bflatastic f99fdea3b6 #881: leave the progress docs to the maintainer 2026-10-20 04:39:13 +07:00
bflatastic 5d32c522d2 Merge main into #881 2026-10-20 04:39:04 +07:00
bflatastic cc4799061e #889: say a join is too large in words for people (native and web), translated; keep one test 2026-10-20 04:38:28 +07:00
BFlat 5b5a4855d4 Merge pull request #895 from storytold/fix/web-fonts
M9: web release draws text again; craft-fonts pin back to the Latin fonts (#885)
2026-10-20 04:38:11 +07:00
bflatastic ddde77db0a M9: release fonts: web build embeds craft-fonts, pin back to the Latin manifest (#885)
The browser has no system fonts, and since releases moved to the shared
workflow the web build was made without craft-fonts: no font list and no
text drawn. The Georgian change also re-pinned craft-fonts to its main,
which has no fonts/latin-manifest.txt, so the next release would fail
under CRAFT_FONTS_REQUIRED. Pin the Latin commit again until one commit
has both (craft-fonts#42); desktop Georgian uses an installed font.
2026-10-20 04:26:04 +07:00
bflatastic 89529b3d00 #889: drop the hub write-cap and core-limit changes (neither is read) 2026-10-20 04:22:26 +07:00
bflatastic ca0e689834 Merge main into #889 2026-10-20 04:22:20 +07:00
BFlat 4c1546eed2 Merge pull request #888 from Silvertree2010/fix/886-when-drag-n-dropping-multiple-fi
Place files dropped together on the canvas side by side
2026-10-20 04:21:10 +07:00
BFlat b19a81084e Merge pull request #813 from alrifatsabbir/bengali-ui-translation
Add Bengali UI Translation
2026-10-20 04:10:12 +07:00
BFlat 20a16dfd5c Merge pull request #872 from devius/ka-language
i18n: Georgian (ka) interface language
2026-10-20 03:57:09 +07:00
bflatastic 40fe1a623e Merge fix/872 into #813 2026-10-20 03:26:53 +07:00
bflatastic 4b030f027a Merge main into #872 2026-10-20 03:20:30 +07:00
Kushagra Kumar 6369b6b7a5 test(collab-net): exercise NetLink host in large welcome regression test 2026-10-20 01:43:50 +05:30
BFlat 9d9cb9915a Merge pull request #812 from lukasz-sklad/feat/polish-translation
feat(i18n): Complete Polish translation (pl)
2026-10-20 03:05:06 +07:00
BFlat 1abb9083c7 Merge pull request #821 from attisvalentin/fix/rescan-and-load-fonts
M4: fonts installed while the app runs: text.rescanFonts, text.loadFont, text.refit
2026-10-20 03:04:29 +07:00
BFlat 4788c6b819 Merge pull request #870 from MortenHusted/fix/component-paste-instance
M3: copied components paste as instances within their file
2026-10-20 03:04:03 +07:00
Kushagra Kumar e8a6033199 test(collab-net): add ignored loopback test exercising welcome delivery above 64 MiB 2026-10-20 01:33:32 +05:30
BFlat 54e3587fd8 Merge pull request #862 from MortenHusted/fix/instance-main-modes
M3: instances follow the variable modes their main component sets
2026-10-20 03:03:27 +07:00
BFlat ba4a736d5b Merge pull request #847 from MortenHusted/fix/fig-component-props
M3: .fig imports keep component property bindings and values
2026-10-20 03:02:50 +07:00
BFlat 163e840fc3 Merge pull request #818 from leandrodslv/feat/ui-size
feat(ui): add the interface size preference (#540)
2026-10-20 03:01:28 +07:00
BFlat b5904ad4c2 Merge pull request #874 from justinjohn0306/fix/857-start-on-home
M7: a preference to start on the Home screen (#857)
2026-10-20 03:00:49 +07:00
BFlat 413ec40932 Merge pull request #867 from AganzeFelicite/fix/fig-paragraph-returns-852
fix(text): a line separator breaks the line inside its paragraph (#852)
2026-10-20 03:00:11 +07:00
BFlat d32e971685 Merge pull request #832 from Ahmed5Emad/seperate-padding
adding individual padding controls in Layout panel
2026-10-20 02:59:33 +07:00
BFlat f8fc12b981 Merge pull request #854 from campbellcody/feat/smart-selection-drag-reordering
M1: smart-selection drag reordering
2026-10-20 02:58:55 +07:00
BFlat e4e872a489 Merge pull request #861 from AJMakesStuff/#855--The-Search-button-for-the-pages-doesn't-work-as-expected
#855: The Search button for the pages doesn't work as expected
2026-10-20 02:58:18 +07:00
BFlat d43375b69c Merge pull request #865 from AganzeFelicite/fix/delete-page-key-859
fix(pages): Delete deletes the page picked in the Pages list (#859)
2026-10-20 02:54:33 +07:00
Kushagra Kumar 227e182f68 fix(collab-net): report exact bytes when server message limit is not a whole unit 2026-10-20 01:23:09 +05:30
bflatastic f6090dde80 Merge #813's latest head (main merged in, contributors entry)
# Conflicts:
#	crates/text/build.rs
2026-10-20 02:44:56 +07:00
Kushagra Kumar fbb4a22e01 fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
- Configure server write loop in hub.rs with max_server_message so outgoing ServerMsg payloads above 64 MiB (e.g. document snapshots in Welcome messages) are delivered without socket termination.
- Set core max_message_bytes in local live session HostConfig to max_server_message.
- Map tungstenite Capacity errors in guest read loop to Outcome::Failed with accurate size limits rather than Outcome::Refused which falsely reported 'The host refused'.
- Add loopback tests verifying Welcome delivery above client message limits and accurate diagnostics on client capacity overrun.
2026-10-20 01:12:13 +05:30
NetterSilverandClaude Haiku 5.5 7d0ecd2294 Place files dropped together on the canvas side by side
Fixes #886

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 19:17:28 +00:00
bflatastic 948c6fb8e6 Merge main into #813 (via #872)
# Conflicts:
#	docs/translating.md
2026-10-20 02:06:07 +07:00
bflatastic da1f0a0484 Merge #812 into #872
# Conflicts:
#	docs/translating.md
2026-10-20 02:05:46 +07:00
bflatastic baf93b7756 Merge #874 into #872
# Conflicts:
#	docs/mcp.md
2026-10-20 02:05:13 +07:00
bflatastic 9c2988d408 Merge main into #872
# Conflicts:
#	crates/text/build.rs
2026-10-20 02:04:57 +07:00
bflatastic c13c4301be Merge main into #812 2026-10-20 02:04:36 +07:00
bflatastic df99f364a7 Merge main into #821 2026-10-20 02:04:35 +07:00
bflatastic f22e2ed825 Merge main into #870 2026-10-20 02:04:33 +07:00
bflatastic 4e8f258300 Merge main into #862 2026-10-20 02:04:32 +07:00
bflatastic 8aaff7bc22 Merge main into #847 2026-10-20 02:04:27 +07:00
Al Rifat Sabbir a8fa1f0fb4 Add Al Rifat Sabbir to contributors list 2026-10-20 01:01:50 +06:00
bflatastic e8b7917002 Merge #874 into #818
# Conflicts:
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/locales/fr.toml
2026-10-20 01:43:53 +07:00
bflatastic 7b478052d1 Merge main into #818 2026-10-20 01:43:44 +07:00
bflatastic bc35f02ecb Merge main into #874 2026-10-20 01:43:43 +07:00
bflatastic 66a8227bb4 Merge main into #867 2026-10-20 01:43:41 +07:00
bflatastic 7814c32a05 Merge main into #832 2026-10-20 01:43:40 +07:00
Al Rifat Sabbir fbc660b8b7 Merge branch 'main' into bengali-ui-translation 2026-10-20 00:43:24 +06:00
bflatastic 08880f6e26 Merge main into #854 2026-10-20 01:34:14 +07:00
bflatastic 704eb1cc0a Merge main into #861 2026-10-20 01:34:13 +07:00
bflatastic d54d107924 Merge main into #865 2026-10-20 01:34:10 +07:00
bflatastic cbb95c6412 Merge main into #854 2026-10-20 01:33:53 +07:00
bflatastic 7b22438739 Merge main into #861 2026-10-20 01:33:51 +07:00
BFlat a1369310d4 Merge pull request #880 from YarilaFox/feat/figcraft-brand
M7: refresh FigCraft brand colors and wordmark
2026-10-20 01:25:12 +07:00
bflatastic 85c036624d Merge main into #865
# Conflicts:
#	contributors/people.toml
2026-10-20 01:21:22 +07:00
BFlat b9bfc51273 Merge pull request #831 from YarilaFox/fix/tab-strip-overflow
M7: Keep tab strip usable with many files
2026-10-20 01:19:14 +07:00
bflatastic bf917a4c4f Merge main into #880
# Conflicts:
#	crates/ui-egui/src/toolbar.rs
2026-10-20 01:13:35 +07:00
BFlat b51c9b68d7 Merge pull request #866 from YarilaFox/fix/tab-engine-order
M7: Preserve active tab and support tab reordering
2026-10-20 01:11:21 +07:00
BFlat b23d6b917b Merge pull request #826 from slyim/fix/toolbar-icon-rhythm
M4: toolbar marks share one size and stroke weight; a simpler Slice icon (#737, #738, #739)
2026-10-20 01:08:37 +07:00
BFlat 54ddb2a200 Merge pull request #829 from Silvertree2010/fix/h2788-swiftui-compose-code-generation
Keep multiline layer names inside the SwiftUI/Compose too-deep comment
2026-10-20 01:07:34 +07:00
BFlat 6382be2197 Merge pull request #878 from Silvertree2010/fix/h636-live-parse-color-accepts-signs-i
Reject non-hex characters in live::parse_color
2026-10-20 01:06:57 +07:00
BFlat 4c21d2df39 Merge pull request #875 from Silvertree2010/fix/h599-overflowing-css-colour-channel-e
Clamp overflowing colour channels to 1 instead of 0
2026-10-20 01:06:20 +07:00
BFlat 3335612c12 Merge pull request #876 from Silvertree2010/fix/h632-invite-link-percent-decoder-acce
Reject non-hex characters inside invite link percent escapes
2026-10-20 01:05:44 +07:00
bflatastic 3afd896e2e Merge main into #876
# Conflicts:
#	crates/collab-net/src/invite_tests.rs
2026-10-20 00:48:30 +07:00
BFlat 0e0c3c7be4 Merge pull request #869 from Silvertree2010/fix/h565-server-address-parser-accepts-a
Reject server addresses whose port is not all ASCII digits
2026-10-20 00:47:23 +07:00
BFlat 3ed4578112 Merge pull request #851 from MortenHusted/fix/effect-unknown-keys
M3: effects refuse keys they don't read instead of dropping them
2026-10-20 00:46:45 +07:00
bflatastic 16f6b92519 Merge main into #880 2026-10-20 00:46:27 +07:00
BFlat f3c211eca5 Merge pull request #850 from MortenHusted/fix/make-slot-errors
M3: a refused slot names component.makeSlot
2026-10-20 00:46:07 +07:00
bflatastic 088693cfd3 #880: canvas overlays take the green that stands out on the page; white text stays on the darker green 2026-10-20 00:45:56 +07:00
bflatastic 9ea757736f #880: leave web font embedding off until the font pin carries Archivo 2026-10-20 00:45:55 +07:00
BFlat ab5a508538 Merge pull request #849 from MortenHusted/fix/css-stroke-sides
M7: CSS borders follow individual stroke sides
2026-10-20 00:45:39 +07:00
BFlat 8519af2539 Merge pull request #848 from Silvertree2010/fix/837-bug-control-port-equals-form-sil
Accept --control=PORT like --control PORT
2026-10-20 00:45:02 +07:00
BFlat 4f1da1ebe6 Merge pull request #842 from Silvertree2010/fix/h2814-variable-createcollection-accept
Refuse empty mode lists and blank names in variable.createCollection
2026-10-20 00:44:25 +07:00
bflatastic ec9bb5f978 #812: a shorter Intensity label that fits the glass field 2026-10-20 00:26:06 +07:00
bflatastic b29e17f5c0 Merge main into #842
# Conflicts:
#	crates/engine/src/variables_behaviour_tests.rs
2026-10-20 00:25:18 +07:00
BFlat 458cd5b141 Merge pull request #817 from muamerETS/fix/copy-text-as-plain-text
M1: copying text layers puts their text on the clipboard (#808)
2026-10-20 00:24:07 +07:00
BFlat 7ca8c80a8f Merge pull request #814 from muamerETS/fix/layout-remove-gap-binding
M3: removing auto layout drops its gap and padding bindings (#810)
2026-10-20 00:23:28 +07:00
bflatastic 20e4028afa #818: interface sizes are relative to the system's display scale (100% is native, Auto goes; 75-200% in Preferences, pref.uiScale takes 0.75-3, "auto" reads as 1); interface_size moved above batch_rename_dialog's doc comment; tests and docs follow 2026-10-20 00:23:14 +07:00
bflatastic a9975ccc22 #818: drop the progress-doc edits (ROADMAP revision row, ui-parity rows); the maintainer adds them 2026-10-20 00:13:55 +07:00
bflatastic 8c7f40fc18 Merge main into #818 2026-10-20 00:13:53 +07:00
bflatastic 21d5090db2 #874: --control starts in a new file whatever the preference (control callers expect a document), with a test 2026-10-20 00:13:47 +07:00
bflatastic 9877b8e6cc #874: test that the Start on the Home screen preference persists (old settings read off) 2026-10-20 00:13:46 +07:00
bflatastic 22ef4fdd99 #874: locale entries in sorted position 2026-10-20 00:13:45 +07:00
BFlat 409de1a762 Merge pull request #871 from justinjohn0306/fix/809-scalar-bound-edits
M3: explicit edits of bound scalar fields detach their variable (#809)
2026-10-20 00:10:09 +07:00
bflatastic e358c695cc #813: Bengali in script detection (cjk::ALL), restore the あ漢한 assert, doc comments, rustfmt 2026-10-20 00:05:51 +07:00
bflatastic 997444cdb6 Merge #872 into #813 2026-10-20 00:05:14 +07:00
bflatastic 3d0a93c1a1 Merge main into #813 2026-10-20 00:04:56 +07:00
bflatastic ce615ada10 #872: ka.toml keys in en.toml's order, commands sorted 2026-10-20 00:03:30 +07:00
bflatastic 97f2c99d6b #872: the web release leaves Georgian out; one pref.language line in docs/mcp.md 2026-10-20 00:03:02 +07:00
bflatastic 381ec85203 #872: keep the web release without craft-fonts (Georgian is desktop-only for now) 2026-10-20 00:02:47 +07:00
bflatastic 7543759150 #872: leave the progress docs (ROADMAP, localization-parity) to the maintainer 2026-10-20 00:02:46 +07:00
bflatastic 7614567502 Merge main into #872 2026-10-20 00:02:45 +07:00
bflatastic f2ede63dd9 #867: comments describe the line separator without naming another product 2026-10-20 00:00:09 +07:00
bflatastic d087645a00 #812: Polish in docs/translating.md and the plural comment 2026-10-20 00:00:03 +07:00
bflatastic 75784f24d6 #812: Polish for the UI strings and commands main added since 2026-10-20 00:00:00 +07:00
bflatastic 08c3f8f944 #812: mark Polish as a machine draft (status line, neutral header) 2026-10-19 23:59:59 +07:00
bflatastic bb1fd20114 Merge main into #812 2026-10-19 23:59:47 +07:00
BFlat ba496eddca Merge pull request #811 from MortenHusted/fix/bound-paint-edits-detach
M3: an explicit paint edit detaches the variable bound to what it changed
2026-10-19 23:59:18 +07:00
BFlat ec512a7767 Merge pull request #807 from MortenHusted/fix/wrap-error-command
M1: wrap errors name the command that was run
2026-10-19 23:58:39 +07:00
bflatastic 742e911652 #832: the per-side fields are two plain rows like Individual corners (toggle in the first); shut H/V fields read Mixed when their sides disagree; no comment naming the incumbent 2026-10-19 23:58:26 +07:00
bflatastic 99a8ef0e26 #832: drop the progress-doc edits (ROADMAP header, log and revision rows, auto-layout-parity row); the maintainer adds them 2026-10-19 23:55:26 +07:00
bflatastic 1fdd95070e Merge main into #832 2026-10-19 23:54:37 +07:00
bflatastic bb793470f1 #854: no reorder ring on a layer under 16 px on screen, and its hit radius is at most a quarter of the layer, so a small layer still drags the selection 2026-10-19 23:49:03 +07:00
bflatastic 109b90c0aa #854: locale entries in sorted position 2026-10-19 23:49:02 +07:00
bflatastic cfbb5a8f50 #854: no help-centre URL in selection_reorder.rs 2026-10-19 23:49:01 +07:00
bflatastic 9fddc4b058 #854: drop the progress-doc edits (ROADMAP, gaps, parity docs, generated checklist); the maintainer adds them 2026-10-19 23:49:00 +07:00
bflatastic fb0e25a258 Merge #865 into #854
# Conflicts:
#	crates/ui-egui/src/selection_spacing.rs
2026-10-19 23:44:17 +07:00
bflatastic 6325a323ee #821: move the loadFont/rescanFonts test into fonts_tests 2026-10-19 23:44:08 +07:00
bflatastic 7126deaed0 #821: es and fi labels for text.rescanFonts, text.loadFont, text.refit 2026-10-19 23:44:08 +07:00
bflatastic 203ec13e03 #821: text.loadFont reads at most MAX_FONT_FILE (the font scan's 256 MiB cap) 2026-10-19 23:44:08 +07:00
bflatastic 4cc2230013 #861: one search field for Pages and Layers; Escape closes it only from the field and goes no further; the search button shows when it's on; filter pages in the row loop 2026-10-19 23:43:38 +07:00
bflatastic b8aa1ef8f0 Merge main into #821 2026-10-19 23:42:20 +07:00
bflatastic 17da39a5ea Merge main into #870 2026-10-19 23:42:15 +07:00
bflatastic 95483273e3 Merge main into #862 2026-10-19 23:42:13 +07:00
bflatastic 163c293b8c Merge main into #847 2026-10-19 23:42:11 +07:00
叶林橦 cecc2865b0 Layout grids: a stretched grid's count takes no Auto
CodeRabbit: typing "Auto" in the count of a stretched grid set 0, which the setter clamped to 1. The count field only takes the word where 0 is Auto (a fixed-size grid); a stretched grid's count is a plain number.
2026-10-20 00:39:29 +08:00
BFlat 44a504ddcf Merge pull request #883 from storytold/community/batch-20
Community batch 20: automation roots for the CLI and links, web file opening, CI bump (4 PRs)
2026-10-19 23:38:34 +07:00
bflatastic be38c68ac5 Merge #865 into #861
# Conflicts:
#	crates/ui-egui/src/layers.rs
2026-10-19 23:38:25 +07:00
bflatastic 113d7ec106 #831: overflow menu lists open files with the shared menu rows (active checked); drop the falling close animation; update tab motion in place instead of cloning it every frame 2026-10-19 23:37:27 +07:00
YarilaFox afa431b47a M7: credit Archivo font in attribution 2026-10-19 21:26:49 +05:00
叶林橦 8450d18c8e Layout grids: Auto width takes the word only where 0 is Auto
Review fixes for #881 (FredApps, CodeRabbit).

- number_field: the word for Auto (the placeholder's own text in the UI's
  language, or "auto") is taken only by a field that asks for it (`Empty::Auto`),
  not by every field whose placeholder reads "Auto". An auto layout's gap
  ("Auto" is Space between) and a prototype overlay's offset ("Auto" is no
  offset) keep their own meaning of 0; the fixed "自动" match is gone.
- layout: Auto width has no hidden 60 px band — the bands step by the gutter
  alone, so the pitch is what the panel shows. A zero-width band snaps to one
  line, not two identical edges.
- right: the width field shows "Auto" (not 0) when there is no width, and a
  stretched grid shows it dim and not editable (its bands divide the frame),
  with the margin and gutter still editable.
- tests: the field suite checks the word on a grid's width (0) and on an auto
  layout's gap (nothing); the layout suite pins the gutter pitch, the Auto
  count and a stretched grid's bands.
- docs: ROADMAP and ui-parity state the pitch and what changes for saved grids.
2026-10-20 00:13:37 +08:00
YarilaFox 5a4b7c2795 M7: make brand accents legible and fix home tests 2026-10-19 20:51:47 +05:00
bflatastic e1da3f36bb Merge fix/830 2026-10-19 22:49:46 +07:00
bflatastic 94291f13e8 Merge fix/841 2026-10-19 22:49:45 +07:00
bflatastic 624795ba38 Merge fix/839 2026-10-19 22:49:45 +07:00
bflatastic 3d109bae1e Merge fix/819 2026-10-19 22:49:44 +07:00
bflatastic 80c8c7fa87 #830: "Couldn't read {name}." in every full catalog 2026-10-19 22:39:25 +07:00
bflatastic b744ff3e14 #830: cap files read in the browser at 512 MiB, and say why a file isn't opened 2026-10-19 22:39:23 +07:00
bflatastic bafc814712 #830: drop the progress-doc edit (docs/hardware-parity.md) 2026-10-19 22:22:32 +07:00
bflatastic 5738ce673c #839: mcp reuses roots_of for its automation roots 2026-10-19 22:20:37 +07:00
bflatastic 0008fc7901 #878: move the parse_color test into live_tests 2026-10-19 22:18:15 +07:00
bflatastic 479313bf67 Merge main into #830 2026-10-19 22:13:15 +07:00
bflatastic 07eb207d7c Merge main into #839 2026-10-19 22:13:04 +07:00
bflatastic 25899735a8 Merge main into #819 2026-10-19 22:12:58 +07:00
bflatastic 09427e72be #871: text style shortcuts and text.set detach the bound field they change 2026-10-19 22:09:05 +07:00
bflatastic 2f14eeda60 #871: leave progress docs to the maintainer 2026-10-19 22:09:04 +07:00
bflatastic 46ce6102d8 Merge main into #829 2026-10-19 22:08:02 +07:00
bflatastic 1e1b59280d Merge main into #849 2026-10-19 22:07:53 +07:00
bflatastic 918a28a4f4 Merge main into #848 2026-10-19 22:07:51 +07:00
bflatastic 97cd06fd14 Merge main into #842 2026-10-19 22:07:50 +07:00
bflatastic ee99a89e08 Merge main into #817 2026-10-19 22:07:49 +07:00
bflatastic 9c7b315301 Merge main into #814 2026-10-19 22:07:48 +07:00
bflatastic b55f009198 Merge main into #811 2026-10-19 22:07:45 +07:00
bflatastic ca3832c0d9 Merge main into #807 2026-10-19 22:07:44 +07:00
叶林橦 3e02bffbbc Layout grids: Auto line width for columns and rows
Columns and rows can now set their line width to Auto: a width of 0 (or
typing the word "Auto" / "自动" in the field) draws a one-pixel hairline
down the band's middle, like the square grid, instead of a filled band.

- grid_lines: a non-stretched grid with size <= 0 emits a hairline at each
  band's middle rather than a zero/negative-width rect.
- canvas: a degenerate (zero-width or zero-height) grid rect is painted as a
  1px line; a filled band is still painted as a rectangle.
- number_field: a field whose placeholder is "Auto" also accepts the word
  "Auto"/"自动", mapping it to 0.
- right: the grid size field shows "Auto" and the align menu no longer
  forces a 60px band on a zero width.
- props: document the Auto semantics of LayoutGrid::size.
- ROADMAP.md / docs/ui-parity.md: note the change.
- layout tests: regression test for the Auto hairline.
2026-10-19 22:51:57 +08:00
YarilaFox 4b12a82839 M7: refresh FigCraft brand colors and wordmark 2026-10-19 19:43:58 +05:00
NetterSilverandClaude Haiku 5.5 258d6f61c0 Reject non-hex characters in live::parse_color
Found while testing crates/engine/src/live.rs:85

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 14:49:19 +02:00
NetterSilverandClaude Haiku 5.5 df42e380f2 Reject non-hex characters inside invite link percent escapes
Found while testing crates/collab-net/src/invite.rs:242 (percent_decode)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 12:37:21 +00:00
Davit MatchakhelidzeandClaude Opus 5.5 d2367d1eac Web: offer Georgian only when Noto Sans Georgian is built in; ROADMAP lists the Georgian draft
Without craft-fonts the web build has no Georgian face and can't read installed fonts, so the
language picker and System must skip Georgian there, as they already skip Japanese, Korean and
Chinese without their faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-19 16:35:09 +04:00
NetterSilverandClaude Haiku 5.5 ccddaa74cd Clamp overflowing colour channels to 1 instead of 0
Found while testing crates/color/src/lib.rs:63 (clamp01, used by Color::new via parse_css at :155-166)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 12:27:23 +00:00
justinjohn0306andClaude Opus 5.5 82a50dad1b M7: a preference to start on the Home screen (#857)
Launching opens a new empty file, which then has to be closed to get
back to Home. Preferences ▸ "Start on the Home screen"
(`pref.startOnHome {on?}`, off by default) starts the desktop app on Home
instead. Files named on the command line and recovered files still open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:53:24 +05:30
justinjohn0306andClaude Opus 5.5 6a163721dc M3: explicit edits of bound scalar fields detach their variable (#809)
An explicit edit of a bound width, height, opacity, gap, padding, corner
radius, stroke weight, arc or text field reported ok, but normalize wrote
the variable's value straight back. Now the edit sticks and that field's
binding is dropped, as paint edits already do.

- `styles::bound_before` / `detach_edited`: compare each layer-level
  bound field's stored value before and after the edit (`bound_field`),
  so keys written unchanged keep their bindings and fields moved along
  with another (proportions, min/max) lose theirs. A binding the edit
  itself made (`boundVariables`) stays. List and component property
  bindings are left to their own rules.
- Hooked into node.set, transform.resize, object.opacityKeys, text.style
  and `Session::preview` (canvas drags of the selected layers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:33:29 +05:30
Davit MatchakhelidzeandClaude Opus 5.5 41c123657e i18n: Georgian (ka) interface language
Adds crates/ui-egui/locales/ka.toml (ქართული): every key of en.toml (1,730 UI
strings), every command label (567) and every menu wording (31), marked as a
draft. Georgian has no letter case and keeps a noun singular after a numeral,
so plural entries give the same text for one and other. System picks it for
ka, ka-GE and ka_GE.UTF-8; pref.language's help text lists ka.

Inter and egui's fonts have no Georgian, so the UI draws it in craft-fonts'
Noto Sans Georgian: it joins the built-in faces in theme::install_fonts and
the web build keeps it (crates/text/build.rs). craft-fonts-ref moves to
a71c20e3 (storytold/craft-fonts#27, the commit that adds the face) in the
release and FreeBSD workflows, and the release builds the web version with
craft-fonts (web-craft-fonts: true).

Tests: the Georgian locale tags, draft status and count-neutral plurals, and
that the built-in Georgian face draws every Georgian letter in the catalog
(skipped without craft-fonts). Docs: localization-parity, translating, mcp,
README and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-19 15:53:03 +04:00
MortenHustedandCodex 0108a7c2f5 M3: copied components paste as instances within their file
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-19 13:34:40 +02:00
NetterSilverandClaude Haiku 5.5 b7c9c712b1 Reject server addresses whose port is not all ASCII digits
Found while testing crates/collab-net/src/invite.rs:valid_host (around line 205)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 11:21:34 +00:00
AganzeFelicite 710c95e527 test(text): a selection across a line separator shows the break (#852) 2026-10-19 12:52:09 +02:00
AganzeFelicite 6e7b63bc16 fix(pages): Delete that removes an interaction ends the Pages list focus 2026-10-19 12:50:43 +02:00
AganzeFelicite 3e83ae44b7 fix(pages): a prototype gesture on the canvas ends the Pages list focus 2026-10-19 12:50:43 +02:00
dependabot[bot] 40d7ce14ce ci(deps): bump actions/checkout
Bumps the actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-19 10:18:38 +00:00
AganzeFelicite b930ea5871 test(text): the caret's line on either side of a line separator (#852) 2026-10-19 12:13:12 +02:00
AganzeFelicite 1b66fec3ec fix(text): the caret after a line break sits on the next line
`caret` put the index right after a line break (a newline, or a line
separator) at the end of the line above, so after Enter the caret showed
on the previous line until something was typed, and Up/Down counted
lines from there. The index before the break stays at the end of its
line; the one after it is now on the next line, as after a wrap.
Selection highlights already started such lines at their own start
(#665); their comment no longer describes the old caret.
2026-10-19 12:12:54 +02:00
AganzeFelicite e17fccd7ab fix(pages): a second Delete after deleting layers keeps the page
After a click in the Pages list, layers selected since (Select all, say)
took the first Delete, which left nothing selected, so a second Delete
deleted the page. Delete that doesn't take the page now ends the Pages
list's hold on the keyboard.
2026-10-19 11:50:07 +02:00
AganzeFelicite 9f895a37d5 fix(pages): Delete deletes no page once the Pages list is off screen
The Pages list kept the keyboard after a switch to the Assets tab or to
another open file, so Delete with nothing selected deleted a page there.
It now deletes a page only while the list that was clicked is on screen
(File tab, list expanded, panel and interface shown) and its file is the
one open.
2026-10-19 11:48:35 +02:00
AganzeFelicite 7af44512e7 refactor(pages): remember which file the Pages list focus belongs to 2026-10-19 11:46:47 +02:00
AganzeFelicite 9e4dcc9204 test(text): line separators in layout, lists and carets (#852) 2026-10-19 11:34:58 +02:00
AganzeFelicite 692f45123c fix(text): a line separator breaks the line inside its paragraph (#852)
Figma stores Shift+Enter as U+2028. `.fig` imports keep it, but layout
only broke lines at a newline, so imported text ran soft-broken lines
together ("Option 1SL: …"), lost the empty line of a separator followed
by a newline, and put glyph bullets on one line.

A line separator now ends its line without ending the paragraph: no
paragraph spacing and no new list marker after it, the next line keeps
the item's indent, and it draws no glyph. A trailing one leaves an empty
last line, and a line it cuts short isn't stretched when justified.
2026-10-19 11:34:47 +02:00
AganzeFelicite 855bbb328f refactor(text): Line::break_after for carets and selections
Carets, clicks and selections asked `para_end && !last_line` to mean
"the line ends with a break char". Record that on the line instead, so a
break that doesn't end the paragraph can use the same rules. No
behaviour change: today only a newline ends a line early.
2026-10-19 11:34:34 +02:00
AganzeFelicite fd02772d22 refactor(text): one helper for the chars that end a line 2026-10-19 11:34:21 +02:00
YarilaFox 2af54a26fb M7: keep tab strip usable with many files 2026-10-19 14:23:04 +05:00
YarilaFox d2dfe2ef20 M7: preserve active tab and support tab reordering 2026-10-19 14:21:52 +05:00
AganzeFelicite 139e0ba308 chore(contributors): add AganzeFelicite to people.toml 2026-10-19 10:53:34 +02:00
AganzeFelicite 5d40b3f42f fix(pages): Delete deletes the page picked in the Pages list (#859)
Clicking a page row (or the add-page button) gives the Pages list the
keyboard: Delete or Backspace with no layers selected now deletes the
current page instead of failing with "nothing is selected". No
confirmation; a "Page deleted" toast says so and Undo brings it back.
The context menu's Delete page shows the same toast. Selecting layers,
clicking a layer row or pressing on the canvas gives Delete back to the
layers.
2026-10-19 10:53:34 +02:00
slyimandClaude Opus 5.5 be212f00e6 #826: the stroke-width test covers the Frame and Text marks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:25:34 +03:00
MortenHustedandClaude Opus 5.5 be9e72f74d M3: instances follow the variable modes their main component sets
An instance copied its main's variable modes when it was made and kept
the copy as modes of its own. Switching it to another variant kept the
old variant's modes, and a mode set on the main later never reached it.

Instances now take the modes their main sets through the link
(`Document::inherited_modes`), closer than any frame around them. Modes
set on the instance itself still win and stay through variant switches.
Bindings are re-applied for the instances of a main that chose another
mode and for an instance that switched mains. Detaching keeps the modes
the instance took from its main, and the Variable modes panel shows them
as the instance's Auto value.

Instances made before this change keep their copy as modes of their
own; setting one back to Auto lets it follow its main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:15:34 +02:00
AJ Richardson 3b7b28c03c improve page search functionality to close with Escape key even when unfocused 2026-10-19 16:37:52 +10:00
AJ Richardson caf0f44d90 #855: The Search button for the pages doesn't work as expected 2026-10-19 16:08:26 +10:00
Cody Campbell 4e4bf54bb6 M1: smart-selection drag reordering 2026-10-18 18:53:32 -04:00
MortenHustedandClaude Opus 5.5 6f90717e71 M3: effects refuse keys they don't read instead of dropping them
node.set refuses unknown properties, but the effects inside it were read
by serde, which drops keys it doesn't know. A drop shadow given
offsetX/offsetY (the names variable bindings use for its offset) was
stored with the default offset [0, 4] and the call said ok. effect.add
and effect.set dropped them the same way.

The three now refuse what the effect doesn't read and say what it
takes: "effects[0]: unknown properties: offsetX, offsetY (a dropShadow
takes blend, color, offset, radius, showBehind, spread, type, visible)".
A key counts as read when setting it to null makes the effect fail to
parse or come out different, so serde's aliases count as read without a
list of them here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:05 +02:00
MortenHustedandClaude Opus 5.5 147a374350 M3: a refused slot names component.makeSlot
component.makeSlot adds its slot property through add_prop, which named
itself in every error: making a second slot called "Content" said
"invalid parameters for `component.addProperty`: a property named
`Content` already exists", a command the caller never ran. add_prop now
takes the command errors name, the fix #807 makes for wrap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 017517fbb9 #847: an instance's values are kept under the original definition
An assignment and the layers that read it can name different copies of
one definition (the set's own id on one side, a variant's copy on the
other). The values were kept under the id the assignment names and read
under the id the layer names, so such a value never reached its layer.
Both sides now go through prop_root, the definition the copies were
made from (CodeRabbit on #847). In Untitled UI, 143 assignments name a
copy that no layer reads while another copy of it is read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 f97789ade1 M7: CSS borders follow individual stroke sides
A layer with individual stroke sides (a header with only a bottom divider,
strokeSides [0, 0, 1, 0]) came out of the CSS, HTML, React and Leptos
code as `border: 1px solid ...` on all four sides, because the border
was written from the stroke weight alone. With individual sides it is now
a border-top/right/bottom/left for each side that has a weight, and a
side bound to a variable names it (border-top: var(--border-top, 3px)).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 c530b4067d M3: .fig imports read property values written only as variables
A component property assignment carries its value in `value`, and in
current files also in the variable form `varValue`. Many assignments
leave `value` empty and say it only in `varValue` (booleans as
boolValue, instance swaps as symbolIdValue, text as textDataValue), and
those were ignored: a hidden layer showed and a swapped icon stayed the
default. In Untitled UI that is 1,643 booleans and 185 swaps, so inputs
showed their hint text, help icon and leading icon on top of the
neighbouring layers. Both forms are read now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:04 +02:00
MortenHustedandClaude Opus 5.5 544b2455c9 M8: a path in a root reached through a link is told why it is refused
When a path can't be resolved (a link that leads nowhere, `..` below a
missing folder), the reason is given only for paths inside a root.
That check compared the path as typed with the roots resolved, so a
root reached through a link (`/tmp/work` on macOS is
`/private/tmp/work`) called its own dangling links "outside the folders
that may be written: /private/tmp/work", and a link out of a root still
had its reason told. The existing part of the path is now resolved
before the comparison, as the roots were.

This is also why `links_that_lead_out_of_a_root_are_outside` and
`outside_paths_are_refused_alike_whatever_exists_there` failed on
macOS, where the temp folder is under `/var` -> `/private/var`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:03 +02:00
MortenHustedandClaude Opus 5.5 4a54aa733f M3: .fig imports keep the properties variant layers are bound to
Each variant of a component set keeps a copy of the set's property
definitions (an id and a parentPropDefId, no name or type), and the
variant's layers and its instances' assignments use the copy's id. Those
copies were skipped, so imported variants lost every boolean, text and
instance swap binding, and instances lost their property values. In
Untitled UI (8,074 copies) an input's "Hint text" no longer followed its
property. A copy now names the definition it was made from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:03 +02:00
Muamer MustaficandCursor 63dc418cc2 M7: a web file picker closed without a cancel event settles on refocus (#759)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:50:27 +02:00
Al Rifat Sabbir 847e610550 Add Bengali script support in build.rs 2026-10-19 03:46:01 +06:00
Muamer MustaficandCursor 79ac16a256 M3: doc comments for the structure commands and binding re-apply (#810)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:40:35 +02:00
Muamer MustaficandCursor 85c5286d39 M1: doc comments for the menu invoke and SVG clipboard export (#808)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:40:33 +02:00
Al Rifat Sabbir 3878f89bd6 Change order of characters in language script test 2026-10-19 03:27:24 +06:00
skylar taylor-barrick a0d1162976 Desktop: refuse unknown command-line options instead of ignoring them (#836) (#844)
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 15:20:15 -06:00
NetterSilverandClaude Haiku 5.5 23d0d9e49e Accept --control=PORT like --control PORT
Fixes #837

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 21:14:08 +00:00
Al Rifat Sabbir 0a3f2341a8 Add support for Bengali text in the UI
Updated cjk.rs
2026-10-19 03:10:55 +06:00
Al Rifat Sabbir 98a1561183 Update supported languages in translating.md
Added Bengali to the list of supported languages and clarified the status of Russian and Turkish translations.
2026-10-19 03:04:00 +06:00
Hamza 35cc685e90 cli: an unconfined write still follows a symbolic link
Routing the CLI's writes through `file_access` changed what happens when the
output path is a symbolic link. `file_access::write` stages the bytes and
renames them into place, and a rename replaces the link with a regular file,
where `std::fs::write` followed it and wrote to its target. A render to a link
reported success while the target kept its old contents.

Unconfined writes go back to `std::fs::write`. Confined ones keep the staged
write, which is right there: `Roots::check` resolves the path first and has
already refused a link leading out of the root, so the write lands on the
resolved target inside it.
2026-10-19 00:52:40 +05:00
NetterSilverandClaude Haiku 5.5 39f30d63c8 Refuse empty mode lists and blank names in variable.createCollection
Found while testing crates/engine/src/cmd/styles.rs:119-124

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 21:27:31 +02: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
Hamza c3cb8c3e0e cli: confine info, export, convert, render and run to automation roots
`figcraft-cli mcp` takes `--automation-read-root` / `--automation-write-root`
to confine the files an agent's commands touch, but the file subcommands took
neither: `figcraft-cli run script.json --out /anywhere/x.figcraft` read and
wrote as the user who started it. The README invites agent use, and a script
or agent driving `run`, `export` or `convert` had no way to say which folders
it may reach (#833).

`info`, `export`, `convert`, `render` and `run` take the same two flags, with
the same semantics: repeatable, the folders must exist, reads inside a read or
write root, writes only inside a write root, everything else refused -- so
giving no write root denies writing altogether. Without the flags they behave
as before.

The flags would have been decorative on their own: `figcraft_engine` enforces
roots through `file_access`, which the engine's own loads already go through,
but the CLI wrote its output with `std::fs` and read its script the same way.
Those three calls now go through `file_access` too, so the confinement reaches
the files the CLI itself touches.
2026-10-18 23:48:54 +05:00
Ahmed5Emad 02e8f644fd M2: doc comments for the padding diff's functions 2026-10-18 21:02:22 +03:00
slyimandClaude Opus 5.5 54458cfb20 #826: tests for the inset Frame and Text marks and the connected Slice knife
frame_and_text_marks_are_inset fails if either mark reaches the box edge
again; slice_is_one_connected_knife fails if the handle stops short of the
blade's base or the icon goes back to separate marks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:52:39 +03:00
Muamer MustaficandCursor 309bac6185 M7: the web build opens files: File ▸ Open, Import and dropped files (#759)
File ▸ Open, Import and Place image did nothing in the browser (the pickers
return a path, which a browser never has) and dropped files were ignored
(eframe hands their contents over asynchronously).

- Services::pick_open_contents: a platform can pick a file's name and
  contents; the web build uses an <input type="file">. Open and Import pass
  them to file.open / file.import as data + name.
- A dropped file without a path waits behind the opening card until its
  contents are read (bytes_async on wasm), or says it couldn't be read.
- Canvas drops still import, as on the desktop.

Closes #759.

Co-authored-by: Cursor <[email protected]>
2026-10-18 19:32:46 +02:00
NetterSilverandClaude Haiku 5.5 72990373c4 Keep multiline layer names inside the SwiftUI/Compose too-deep comment
Found while testing crates/engine/src/codegen.rs:865 (swift_layer) and :1038 (compose_layer)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 19:27:45 +02:00
Ahmed5Emad aec3a22fdf M2: shut padding pair fields edit their own axis
The closed Spacing row paired each icon with the other axis: the
side-bars field wrote top/bottom and the top/bottom-bars field wrote
left/right. The H field now binds paddingHorizontal (left+right) and the
V field paddingVertical (top+bottom); the PaddingH/PaddingV paintings
were swapped to match so every icon is pixel-identical.
(shut_pair_fields_edit_their_own_axis).
2026-10-18 20:24:12 +03:00
slyimandClaude Opus 5.5 7a5bd57611 #826: the Slice handle overlaps the blade's base so the knife joins at every size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:11:38 +03:00
slyimandClaude Opus 5.5 7558ac0444 #826: doc comments for draw and main_toolbar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:06:21 +03:00
Ahmed5Emad bc10bb190d M2: individual padding in the Layout panel
The Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a
2x2 grid in place of the H/V fields, each bound to its own padding key with
one undo step; shut H/V fields show both values ("16,54") when the sides
disagree instead of "Mixed". Original code-drawn side icons, English and
Spanish strings (padding_individual_tests).
2026-10-18 20:03:43 +03:00
slyimandClaude Opus 5.5 f03fc476c8 M4: toolbar marks share one size and stroke weight; a simpler Slice icon (#737, #738, #739)
Every mark in the bottom toolbar is now painted in one 18-point box. The
mode switcher drew its marks in a 14-point box, so Draw, Prototype and
Inspect read smaller than the tools. The flyout carets move from a
10-point box, whose stroke rounded to one physical pixel thinner than the
row's, to a 14-point box that rounds to the same width at 1x, 1.5x and 2x.

Frame and Text are inset by a unit: their long pixel-hinted strokes read
heavier than the anti-aliased outlines beside them. Slice is a knife (a
slim blade and a solid handle), one silhouette instead of a box crossed
by a diagonal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:59:56 +03:00
Attila Valentin NémethandClaude Opus 5.5 33c0f94cc8 M4: font review fixes: new styles of loaded families, ids checks, repeat loads
- text.rescanFonts offers a loaded family the files it hasn't read (styles
  installed since) and refits every family that gained faces; `added` still
  lists only the families that were unknown
- text.refit refuses an `ids` that isn't a list instead of refitting the
  whole document
- text.loadFont with a file already loaded returns its families without
  keeping its bytes or adding its faces again
- doc comments on the functions this change touches

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:46:20 +02:00
Attila Valentin NémethandClaude Opus 5.5 ba4c502ada M4: text.refit: auto-sizing text measures its box again once its font is there
A text box keeps the size it was measured at, so text laid out while its
family was missing kept the fallback font's box after the font arrived (a
hugging chip clipped its label).

- text.refit {ids?} measures auto-sizing text again (whole document by
  default) → {refitted}
- text.rescanFonts and text.loadFont refit the open document's text in the
  families they add, as one undo step

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:35:31 +02:00
Attila Valentin NémethandClaude Opus 5.5 972e228f41 M4: text.rescanFonts and text.loadFont: fonts installed while the app runs
The system fonts are indexed once per process, so a font installed while
FigCraft (or `figcraft-cli mcp`) runs stayed missing until a restart.

- text.rescanFonts walks the font folders again and offers the families
  installed since (loaded families keep their faces) → {added}
- text.loadFont {path | data} adds a font file without installing it → {families}
- both clear the layout cache so text lays out again in the new face

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:13:49 +02:00
Muamer MustaficandCursor b778ba17cd M1: copying text layers puts their text on the clipboard (#808)
edit.copy and edit.cut mirror a selection made only of text layers to the system clipboard as plain text, one layer per line, instead of SVG. Other selections stay SVG; pasting back into FigCraft still pastes the copied layers.

Closes #808

Co-authored-by: Cursor <[email protected]>
2026-10-18 16:08:12 +02:00
Al Rifat Sabbir bbf0d62863 Update Bengali translations in bn.toml
Reviewed and updated bugs.
2026-10-18 19:43:17 +06:00
Muamer MustaficandCursor 4506f49ad4 M3: removing auto layout drops its gap and padding bindings (#810)
layout.remove and node.set {layout: null|"none"} now drop the bindings of auto layout fields (gap, crossGap, padding...), as frame-to-section already did for some of them (it now drops all of them). Re-applying a binding no longer creates an auto layout on a frame without one: the binding waits for the next auto layout.

Closes #810

Co-authored-by: Cursor <[email protected]>
2026-10-18 15:21:28 +02:00
Al Rifat Sabbir a3e9af917d Add Bengali UI Translation
Add Bengali translation (bn.toml)
2026-10-18 19:08:34 +06:00
LucasandAntigravity 32bc822451 fix(i18n): Restrict Polish plural One to n == 1 per CLDR rules
Polish integer plural rules use Plural::One strictly for n == 1, while
higher counts ending in 1 (e.g. 21, 101) use Plural::Many.
Also adds unit tests for Polish integer plural coverage.

Co-Authored-By: Antigravity <[email protected]>
2026-10-18 14:45:19 +02:00
leandrodslvandClaude Sonnet 5.5 4d9d12ea13 fix(ui): let escape close an open menu before its dialog
Escape closed the whole Preferences dialog even while a menu was open over
it. That held while Preferences had no menu of its own; the Interface size
menu is the first, so one Escape threw away the dialog with the menu.
Dialog keys now leave Escape to an open menu, and the next Escape closes the
dialog as before.

Adds a keyboard test of the Interface size menu: Tab reaches it, Enter opens
it, the arrows move through the sizes, Enter picks one and closes the menu,
and Escape closes only the menu.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:58 +02:00
leandrodslvandClaude Sonnet 5.5 a6659dcdfc test(ui): sweep every window size, system scale and interface size
Draws the interface the way a real window does (a fixed number of physical
pixels, its size in points being that divided by the scale drawn now) over
10 window sizes, 6 system scales and every interface size. Guards the rule a
fixed size promises: above 100 % the window still holds the smallest layout,
the panels leave room for a canvas, and the size settles without flicker.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:58 +02:00
leandrodslvandClaude Sonnet 5.5 2c3ea03c2e feat(ui): add the interface size preference (#540)
Preferences > Interface size and the `pref.uiScale` command make the whole
interface larger or smaller. Auto keeps the system's display scale; a fixed
size from 75 % to 300 % is absolute (100 % is one screen pixel per point on
every system). The size is held back while the window is too small for it.

Strings are added in en, es and fr; the control protocol, MCP, UI parity and
roadmap docs are updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:57 +02: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
LucasandAntigravity 9ae3223d22 feat(i18n): Complete Polish translation (pl)
Add full Polish localization for FigCraft UI, commands, and menus.
Includes Polish CLDR integer plural rules (one, few, many, other)
and eliminates all untranslated strings and borrowings.

Co-Authored-By: Antigravity <[email protected]>
2026-10-18 14:20:19 +02:00
MortenHustedandClaude Opus 5.5 d71f24741a #807: test the object.mask and vector.outlineStroke refusals too
Both reach wrap() (a page in a mask, a section outlined into a group)
and named `object.group` before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:18:25 +02:00
MortenHustedandClaude Opus 5.5 c7261cb907 M3: an explicit paint edit detaches the variable bound to what it changed
fill.set, stroke.set and node.set fill/fills/stroke/strokes reported ok
while normalize put the bound colour straight back, and node.set
{fills: []} brought the bound paint back. They now drop a paint's
colour, opacity or stop binding where the edit changed that value, as
selection.setPaint already did (its comparison moves to
lists::rebind_edited_paint, which also lets stop bindings follow a
removed stop). boundVariables applies last in props::apply, so a
binding given with the paints in one call stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:14:38 +02:00
MortenHusted d3327d79f3 Contributors: add my name for the About window 2026-10-18 13:58:13 +02:00
MortenHustedandClaude Opus 5.5 4707cb1579 M1: wrap errors name the command that was run
Grouping, framing, auto layout, masks, outline stroke and the boolean
commands share wrap(), which named `object.group` in every refusal
(`layout.add` on a page: "invalid parameters for `object.group`").
wrap() now takes the calling command's id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 13:58:13 +02: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 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 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 b377fab0a5 Merge main into #652 2026-10-17 16:05:22 +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 54bb194531 Merge main into #671 2026-10-17 15:58:30 +07:00
bflatastic 5b963dce08 Merge main into #667 2026-10-17 15:57:01 +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 efe4911eb0 Merge main into #624 2026-10-17 15:42:48 +07: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
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
Marco Arnulfo 7d7a724764 Text: selection highlights after a line break start at the line's start, and the empty last line shows (fixes #665) 2026-10-16 13:27:01 +02:00
Andrii VolkovandClaude Opus 5.5 52eb736241 perf(render): look each layer up once per render
Node lookups in the document's B-tree were still a third of a render on a
900 000-layer file: a small subtree was walked for its bounds, again to
see whether it could be splatted, and again to splat it, and each child
of a drawn layer was looked up by paint_runs, by the mask check and by
draw_node.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 12:31:55 +03:00
Yarila 6d3e7aad20 M7: isolate presenter tab and device controls 2026-10-16 14:16:44 +05:00
Andrii VolkovandClaude Opus 5.5 afdb21bad6 fix(ui): drag the line between pages and layers to size the pages list
The pages list took a fixed 40% of the left panel (#272). The line under
it now drags to make the list taller or shorter: at least three rows, and
always leaving the layers their header and three rows. A double-click puts
back the default. The main window keeps the height in the preferences, as
it does the panel widths, saved once the drag is over.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 11:24:39 +03:00
Skyfuller1303 a4156d2910 M3: re-read a library file whose stamp is as recent as the read
A library file is cached by its length and modification time. File systems keep the time in coarse ticks, so a same-length rewrite right after a read can keep the stamp, and library.updates then reports no change. A stamp now proves the file unchanged only once its modification time lies at least two seconds before the read; otherwise the file is read again.
2026-10-16 09:37:07 +02:00
Ivan Shishkanov 5be9a8caef M1: cache smart-selection geometry and skip unchanged drag edits 2026-10-16 08:06:26 +03:00
Ivan Shishkanov 1ac0e475c1 M3: adapt Selection colors to current main 2026-10-16 07:58:58 +03:00
krinry b525fde8d1 M1: pop up colour picker when adding a fill or stroke (#404)
Clicking the + button to add a fill or stroke in the Design panel now
immediately opens the colour picker dialog for the newly added paint,
matching Figma's behaviour.
2026-10-16 06:45:40 +05:30
vxMacDev 9c1d65d924 fix(render): text strokes follow their alignment (#615)
The canvas renderer and PDF export stroked text with the alignment
forced to centre, so Inside and Outside on text did nothing (SVG export
already honoured them). Glyph outlines are closed, so text strokes now
go through stroke_area like other shapes' strokes, with the same fill
rule: inside clips to the glyphs, outside to around them.
2026-10-15 19:36:39 -05:00
Edivan TeixeiraandClaude Opus 5.5 f05eecdaa0 node.set: test that a refused call changes nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:46:04 -03:00
Edivan TeixeiraandClaude Opus 5.5 8cccc48516 node.set: apply auto layout keys to the frames and name the rest; resolve fontStyle like text does
- Several ids: auto layout keys go to the frame-like layers, the others get the remaining keys
  and are named in `warnings`. Refused only when no target can have auto layout.
- fontStyle is never refused: it draws in the nearest face, as a text layer resolves it.
  `warnings` says so when that face's weight or slant differs from what the name asks for.
- set_properties passes `warnings` on to the agent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:43:52 -03:00
Daniel Miguel c6a3f25224 M0.9: sync reviewed GNOME controls fix with upstream 2026-10-16 00:13:38 +02:00
Daniel Miguel b83a0de7a8 M0.9: scope Wayland decorations and avoid bundled title fonts 2026-10-16 00:01:55 +02:00
Edivan TeixeiraandClaude Opus 5.5 c8ba7b4a4b node.set: refuse auto layout keys off frames, and font styles the family lacks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:26:08 -03:00
Ivan Shishkanov ce6726aad3 M1: merge current main and resolve PR #595 conflicts
Preserve the upstream behaviour and regression tests alongside this feature. Align the control-server connection-limit test with main closing invalid JSON connections: assert EOF instead of shutting down an already closed socket on macOS.
2026-10-15 23:12:40 +03:00
Ivan Shishkanov 692d52c514 M3: merge current main and resolve PR #575 conflicts
Preserve the upstream behaviour and regression tests alongside this feature. Align the control-server connection-limit test with main closing invalid JSON connections: assert EOF instead of shutting down an already closed socket on macOS.
2026-10-15 23:08:41 +03:00
Skyfuller1303 fd24a6b883 M7: resolve canvas snapping conflicts with current main 2026-10-15 21:29:33 +02:00
Skyfuller1303 35005a571d M7: isolate canvas pixel snapping and resolve review feedback 2026-10-15 21:09:19 +02:00
Ivan Shishkanov 97d09369d1 M1: edit smart-selection spacing from fields and canvas (#402) 2026-10-15 22:07:53 +03:00
VanTan 69154a03f1 M3: defer Selection colors discovery during gestures 2026-10-15 21:57:53 +03:00
Daniel Miguel 9809caaa30 fix(linux): restore Adwaita window controls on GNOME Wayland 2026-10-15 20:50:46 +02:00
Ivan Shishkanov 7a5885cde9 M3: add Selection colors for mixed selections (#403) 2026-10-15 19:05:24 +03:00
IanHollow d0c1c12969 M7: test credits column sizing and horizontal scrolling
Restore the original cell spacing and check the table geometry across text sizes instead of requiring every font to fit without scrolling.

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

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

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:33:28 +02:00
Ahmed5Emad 93a75a4720 M1: adding auto layout to an existing frame keeps its size instead of hugging 2026-10-15 02:02:00 +03:00
Skyfuller1303 39d0f2d872 M7: show windowed text and sidebar sharpness close-ups 2026-10-15 00:15:01 +02:00
Skyfuller1303 7a8e5373b1 M7: keep Windows windowed UI and canvas text sharp 2026-10-15 00:02:41 +02:00
IanHollow 1ddc2c338b Fit contributor columns inside the About window
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-14 13:12:53 -07:00
267 changed files with 27702 additions and 1424 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)"
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
@@ -74,7 +74,7 @@ jobs:
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+6
View File
@@ -24,8 +24,14 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
# Re-pin once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
# crates/text/build.rs keeps the web build to Inter.
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1
View File
@@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input
| Family | Licence | Upstream |
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
Generated
+72 -26
View File
@@ -856,6 +856,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1408,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"arboard",
"eframe",
@@ -1422,17 +1427,20 @@ dependencies = [
"fmv-macos-events",
"image",
"log",
"raw-window-handle",
"rfd",
"serde_json",
"wat",
"wayland-client",
"webbrowser",
"windows",
"winit",
"winresource",
]
[[package]]
name = "figcraft-cli"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1446,7 +1454,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1461,7 +1469,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1484,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1493,7 +1501,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1505,9 +1513,10 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1532,7 +1541,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1547,7 +1556,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"kurbo",
"serde",
@@ -1555,7 +1564,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1563,7 +1572,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1579,7 +1588,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1588,7 +1597,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1600,12 +1609,12 @@ dependencies = [
"krilla",
"log",
"thiserror 2.0.21",
"tiny-skia",
"tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-plugin"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1618,7 +1627,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1631,12 +1640,12 @@ dependencies = [
"moxcms",
"png",
"rayon",
"tiny-skia",
"tiny-skia 0.12.0",
]
[[package]]
name = "figcraft-server"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1660,7 +1669,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1676,7 +1685,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1690,7 +1699,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1715,7 +1724,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -2570,7 +2579,7 @@ dependencies = [
"skrifa 0.42.1",
"smallvec",
"subsetter",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
@@ -4018,6 +4027,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia 0.11.4",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -4477,6 +4497,20 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path 0.11.4",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
@@ -4489,7 +4523,18 @@ dependencies = [
"cfg-if",
"log",
"png",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
@@ -4778,7 +4823,7 @@ dependencies = [
"skrifa 0.44.0",
"strict-num",
"svgtypes",
"tiny-skia-path",
"tiny-skia-path 0.12.0",
"unicode-bidi",
"unicode-script",
"unicode-vo",
@@ -5731,6 +5776,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -5888,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.7.0"
dependencies = [
"figcraft-engine",
"serde_json",
+4 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
thiserror = "2"
+31
View File
@@ -20,3 +20,34 @@ licence texts.
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
similar permissive licenses); see each crate's metadata.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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>
@@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **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)).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian 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 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).
@@ -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>
+22 -13
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
@@ -99,25 +99,25 @@ native speakers' reviews of the six language drafts need people.
| Language | Code | Strings | Status |
|---|---|---|---|
| English | en | 100% | full |
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
| Arabic | ar | 0% | none |
| French | fr | 100% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
| Indonesian | id | 0% | none |
| Japanese | ja | 100% | draft (needs native review) |
| German | de | 100% | draft (needs native review) |
| Korean | ko | 100% | draft (needs native review) |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
## Upcoming
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
@@ -315,6 +316,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
+18 -8
View File
@@ -1,5 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options, and boolean `--flag`s. Unknown options are usage errors.
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors.
use std::collections::BTreeMap;
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
#[derive(Debug, Default)]
pub struct Args {
pub positional: Vec<String>,
values: BTreeMap<String, String>,
values: BTreeMap<String, Vec<String>>,
flags: Vec<String>,
}
@@ -34,7 +35,7 @@ impl Args {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
a.values.insert(name.to_string(), v);
a.values.entry(name.to_string()).or_default().push(v);
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
@@ -45,7 +46,12 @@ impl Args {
}
pub fn value(&self, k: &str) -> Option<String> {
self.values.get(k).cloned()
self.values.get(k).and_then(|v| v.last()).cloned()
}
/// Every value of an option that may be given more than once, in order.
pub fn values(&self, k: &str) -> &[String] {
self.values.get(k).map_or(&[], Vec::as_slice)
}
pub fn flag(&self, k: &str) -> bool {
@@ -62,9 +68,9 @@ impl Args {
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
/// `--bleed-marks`, `--cmyk`.
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
let bleed = match self.values.get("bleed") {
let bleed = match self.value("bleed") {
None => 0.0,
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
};
@@ -72,7 +78,7 @@ impl Args {
}
pub fn scale(&self) -> CliResult<f64> {
match self.values.get("scale") {
match self.value("scale") {
None => Ok(1.0),
Some(s) => s
.trim_end_matches('x')
@@ -85,7 +91,7 @@ impl Args {
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
match self.values.get(k) {
match self.value(k) {
None => Ok(default.to_vec()),
Some(s) => s
.split(',')
@@ -110,6 +116,10 @@ mod tests {
assert_eq!(a.value("o").as_deref(), Some("out"));
assert_eq!(a.scale().unwrap(), 2.0);
assert!(a.flag("json"));
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
assert_eq!(a.values("root"), ["a", "b"]);
assert_eq!(a.value("root").as_deref(), Some("b"));
assert!(a.values("none").is_empty());
}
#[test]
+21 -3
View File
@@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
}
}
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
/// confined run can only write inside a write root (#833).
///
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
/// `Roots::check` has already refused a link that leads out of the root.
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
}
if figcraft_engine::file_access::current().is_some() {
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
@@ -155,6 +166,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 +176,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 +200,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)
}
@@ -238,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
@@ -323,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
}
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
Some(path) => {
+56 -15
View File
@@ -22,8 +22,9 @@ USAGE:
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
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,8 +43,33 @@ 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).
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
as the user who started it, as before. 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.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
if read.is_empty() && write.is_empty() {
return Ok(None);
}
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
}
/// Runs `f` confined to the roots `a` carries.
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(a)?.map(std::sync::Arc::new);
figcraft_engine::file_access::confined(roots.as_ref(), f)
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
@@ -94,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &[], &["json"])?;
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
files::info(&file, a.flag("json"))
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
files::export(&file, &out, &target)
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &[], &[])?;
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => files::convert(i, o),
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let a = Args::parse(rest, &["in", "out"], &[])?;
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
@@ -133,11 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach"], &[])?;
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
mcp(a.value("attach").as_deref())
let attach = a.value("attach");
// The app runs the commands: only its own roots can confine them.
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
@@ -205,7 +243,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 +258,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}"))?;
+141
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,9 +123,147 @@ 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();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v.as_array().unwrap().len() > 200);
}
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
/// the same commands behave as they always did.
#[test]
fn file_subcommands_are_confined_to_automation_roots() {
let d = tmp("file-roots");
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
// A document to work on, made unconfined.
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", out_of(&made));
// Inside the roots: renders as usual.
let ok = bin()
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.output()
.unwrap();
assert!(ok.status.success(), "{}", out_of(&ok));
assert!(d.join("out/ok.png").is_file());
// A write outside the write root is refused, and nothing is written.
let escaped = d.join("escaped.png");
let refused = bin()
.args([
"render",
&p("in/doc.figcraft"),
"-o",
&escaped.to_string_lossy(),
"--automation-read-root",
&p("in"),
"--automation-write-root",
&p("out"),
])
.output()
.unwrap();
assert!(!refused.status.success());
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
assert!(!escaped.exists(), "the refused render wrote a file anyway");
// No write root at all: writing is refused outright, reading still works.
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
assert!(!no_write.status.success());
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
// A read outside every root is refused: `info` on the document with only `out` readable.
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
assert!(!read_refused.status.success());
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
// `convert` and `run` take them as well, and a root that is not a folder is refused.
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
assert!(!missing.status.success());
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
// Unconfined, the same render still writes wherever it is told.
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
assert!(plain.status.success(), "{}", out_of(&plain));
assert!(escaped.is_file());
let _ = std::fs::remove_dir_all(&d);
}
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
/// replaced the link with a regular file, because that write stages the bytes and renames them
/// into place.
#[cfg(unix)]
#[test]
fn an_unconfined_write_follows_a_symbolic_link() {
let d = tmp("symlink-out");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("real")).unwrap();
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let target = d.join("real/target.png");
std::fs::write(&target, b"placeholder").unwrap();
let link = d.join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
let written = std::fs::read(&target).unwrap();
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
let _ = std::fs::remove_dir_all(&d);
}
+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}");
}
+1 -1
View File
@@ -182,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 {
+1 -1
View File
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+99 -4
View File
@@ -1,7 +1,7 @@
//! The browser shell: web `Services` and the eframe web runner.
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
}
}
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
@@ -85,6 +86,16 @@ struct WebServices {
}
impl Services for WebServices {
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
Some(Box::pin(async move {
pick_file(&accept).await.unwrap_or_else(|e| {
log::error!("couldn't open a file: {e:?}");
None
})
}))
}
fn downloads(&self) -> bool {
true
}
@@ -136,6 +147,90 @@ impl Services for WebServices {
}
}
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
/// can't be read.
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
// Some browsers (older Safari) open the picker only for an input in the page.
body.append_child(&input)?;
let (closed, picker) = PickerClosed::new(&window, input.clone());
input.click();
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
let file = input.files().and_then(|files| files.get(0));
drop(picker);
result?;
let Some(file) = file else { return Ok(None) };
let name = file.name();
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
return Ok(Some(Err(why)));
}
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
Err(e) => {
log::error!("couldn't read {name}: {e:?}");
Err(figcraft_ui_egui::unreadable(&name))
}
}))
}
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
struct PickerClosed {
window: web_sys::Window,
input: web_sys::HtmlInputElement,
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
}
impl PickerClosed {
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
let slot = resolve.clone();
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if let Some(res) = resolve.borrow_mut().take() {
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
}
});
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if t.get().is_none() {
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
}
});
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
}
}
impl Drop for PickerClosed {
fn drop(&mut self) {
if let Some(t) = self.timer.take() {
self.window.clear_timeout_with_handle(t);
}
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
self.input.remove();
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
+6
View File
@@ -43,3 +43,9 @@ wat = { workspace = true }
[build-dependencies]
winresource = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
# Use vector window controls without a title-font renderer or embedded Cantarell.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
raw-window-handle = "0.6"
wayland-client = "0.31"
+30 -6
View File
@@ -1,16 +1,26 @@
//! 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)]
pub struct Args {
/// `--control <port>`: the JSON-lines control server.
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
pub control: Option<u16>,
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" {
out.control = it.next().and_then(|p| p.parse().ok());
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 flag == "--control" {
out.control = inline.or_else(|| it.next().cloned()).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());
}
}
+33 -2
View File
@@ -9,13 +9,44 @@ 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 the_control_port_accepts_the_equals_form() {
let a = parse(&args(&["--control=7980", "a.figcraft"]));
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
// A bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
}
#[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";
+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(())
}
}
+94 -16
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.
@@ -164,17 +193,18 @@ fn main() -> eframe::Result {
// default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup();
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
// tips and templates; later runs start in a new file.
let first_run = svc.prefs.is_none() && args.join.is_none();
if session.docs.is_empty() && !first_run {
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
let _ = session.execute("file.new", &json!({}));
}
// The interface language from the first frame on, and for the message when the window can't
// 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 +222,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 +244,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 +265,49 @@ fn main() -> eframe::Result {
}
result
}
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
/// callers expect a document to act on.
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
!first_run && (!start_on_home || control)
}
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));
}
}
#[cfg(test)]
mod start_tests {
use super::opens_new_file;
#[test]
fn the_home_preference_starts_on_home_except_for_control_callers() {
assert!(opens_new_file(false, false, false), "a new file by default");
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
assert!(opens_new_file(false, true, true), "--control still starts with a document");
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
}
}
+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()
+11 -3
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
+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);
}
+18
View File
@@ -37,6 +37,11 @@ real_name = "Justin John"
[people.Losminek]
[people.alrifatsabbir]
real_name = "Al Rifat Sabbir"
display_name = "Al Rifat Sabbir"
sync_github_name = true
[people.RevDonz]
[people.Nael-Nathanael]
@@ -49,3 +54,16 @@ real_name = "Andrii Volkov"
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
[people.MortenHusted]
sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
+4 -1
View File
@@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status};
use web_time::Instant;
use crate::inbox::Inbox;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -114,6 +114,9 @@ impl<S: Socket> Driver<S> {
/// A binary message from the host.
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
if bytes.len() > self.cfg.limits.max_server_message {
return self.end(too_large(&self.cfg.limits), now);
}
c.last_heard = now;
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
+10 -1
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, Status};
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
}
// A message over the size limit: the file is too large for this app, not refused by the host.
let inbox = Inbox::new(Arc::new(|| {}));
let mut cfg = GuestConfig::new(invite(), "Ada".into());
cfg.limits.max_server_message = 2 << 20;
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
d.opened(t0);
d.message(&vec![0; (2 << 20) + 1], t0);
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
}
#[test]
+2 -2
View File
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
Ok(_) => {}
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
Err(e) => break Outcome::Retry(e.to_string()),
}
};
+6 -2
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}") }
@@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool {
return true;
}
let Some((name, port)) = s.rsplit_once(':') else { return false };
port.parse::<u16>().is_ok_and(|p| p != 0)
port.bytes().all(|b| b.is_ascii_digit())
&& port.parse::<u16>().is_ok_and(|p| p != 0)
&& (1..=253).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
&& !name.starts_with(['-', '.'])
@@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
while let Some(&b) = bytes.get(i) {
if b == b'%' {
let hex = bytes.get(i + 1..i + 3)?;
if !hex.iter().all(u8::is_ascii_hexdigit) {
return None;
}
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
i += 3;
} else {
+16
View File
@@ -123,3 +123,19 @@ proptest! {
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
}
}
#[test]
fn plus_or_sign_inside_a_percent_escape_is_broken() {
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
for bad in ["%+1", "%-1", "%1", "%+a"] {
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
}
}
#[test]
fn port_must_be_all_digits() {
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
}
}
+7 -1
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
use crate::inbox::Inbox;
use crate::invite::Invite;
@@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
})
}
/// The host sent a message over this app's size limit (the file's welcome, usually): final, and
/// not the host's doing.
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
}
/// The server's answer to the join code: the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
+29 -1
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status};
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -642,3 +642,31 @@ fn the_host_removes_someone_and_they_cant_come_back() {
host.stop();
assert_eq!(host.connections(), 0);
}
#[test]
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
let mut doc = Document::new("B".repeat(1 << 20));
doc.repair();
let probe = Probe::new();
let cfg = HostConfig {
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
doc,
name: "Hana".into(),
limits: fast_limits(),
core: Limits::default(),
};
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
let mut gp = Probe::new();
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
assert!(!welcomed(&gp.events));
host.stop();
}
+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]
+1 -1
View File
@@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color {
}
fn clamp01(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
}
impl Default for Color {
+9 -1
View File
@@ -80,7 +80,7 @@ fn blend_names() {
#[test]
fn nan_inputs_are_sanitized() {
let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY);
assert_eq!(c.to_rgba8(), [0, 255, 0, 0]);
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
}
#[test]
@@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() {
assert!((a - b).abs() < 1e-9);
}
}
#[test]
fn infinite_channels_clamp() {
let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap();
assert_eq!(c.r, 1.0);
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
+47 -16
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
@@ -942,21 +949,39 @@ impl Document {
self.styles.iter().find(|s| s.id == id || s.name == id)
}
/// The effective variable modes for a node (walking up its ancestors).
/// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`].
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
let mut out = BTreeMap::new();
let mut chain = vec![id];
chain.extend(self.ancestors(id));
for c in chain.into_iter().rev() {
if let Some(n) = self.node(c) {
for (k, v) in &n.var_modes {
out.insert(k.clone(), v.clone());
}
}
let mut out = self.inherited_modes(id);
if let Some(n) = self.node(id) {
out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone())));
}
out
}
/// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the
/// ones its main component sets, which are closer than any container's.
pub fn inherited_modes(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
let mut out = BTreeMap::new();
for c in self.ancestors(id).into_iter().rev() {
if let Some(n) = self.node(c) {
out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone())));
}
}
if let Some(n) = self.node(id) {
out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone())));
}
out
}
/// The modes an instance's main component sets (none for other layers).
fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator<Item = (&'a CollectionId, &'a ModeId)> {
let main = match n.kind {
NodeKind::Instance { component, .. } => self.node(component),
_ => None,
};
main.into_iter().flat_map(|m| &m.var_modes)
}
/// Check structural invariants (used by tests and after loading untrusted files).
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
@@ -1117,8 +1142,14 @@ impl Document {
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
let kids = &n.children;
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
let kids: Vec<Option<&Node>> = n.children.iter().map(|k| self.node(*k)).collect();
Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z))
}
/// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run
/// first when `reverse_z`.
pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec<std::ops::Range<usize>> {
let node = |i: usize| kids.get(i).copied().flatten();
let mut runs = Vec::new();
let mut i = 0;
while i < kids.len() {
@@ -1131,7 +1162,7 @@ impl Document {
runs.push(i..j);
i = j;
}
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
if reverse_z {
runs.reverse();
}
runs
+92 -1
View File
@@ -860,7 +860,35 @@ pub struct Device {
impl PrototypeSettings {
/// The devices to pick from ("None" presents the frame at its own size).
pub const DEVICES: [Device; 6] = [
pub const DEVICES: [Device; 19] = [
Device {
name: "iPhone 17",
width: 402.0,
height: 874.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)],
},
Device {
name: "iPhone 17 Pro",
width: 402.0,
height: 874.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 17 Pro Max",
width: 440.0,
height: 956.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone Air",
width: 420.0,
height: 912.0,
rotates: true,
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 16",
width: 393.0,
@@ -874,6 +902,13 @@ impl PrototypeSettings {
c("Blue", 0x72, 0x84, 0xD4),
],
},
Device {
name: "iPhone 16 Pro",
width: 402.0,
height: 874.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)],
},
Device {
name: "iPhone 16 Pro Max",
width: 440.0,
@@ -881,6 +916,55 @@ impl PrototypeSettings {
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)],
},
Device {
name: "iPhone 16 Plus",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
},
Device {
name: "iPhone 15 Pro",
width: 393.0,
height: 852.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 15 Pro Max",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 15 Plus",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
},
Device {
name: "iPhone 14 Pro",
width: 393.0,
height: 852.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 14 Pro Max",
width: 430.0,
height: 932.0,
rotates: true,
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
},
Device {
name: "iPhone 14",
width: 390.0,
height: 844.0,
rotates: true,
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
},
Device {
name: "Android Compact",
width: 412.0,
@@ -888,6 +972,13 @@ impl PrototypeSettings {
rotates: true,
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)],
},
Device {
name: "Android Medium",
width: 700.0,
height: 840.0,
rotates: true,
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)],
},
Device {
name: "iPad mini",
width: 744.0,
+1
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+39
View File
@@ -0,0 +1,39 @@
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
# the automation roots (#715), so every command is confined without a check of its own.
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-methods = [
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
]
+48
View File
@@ -55,3 +55,51 @@ fn reduce_motion_is_a_preference_that_persists() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.reduce_motion);
}
#[test]
fn interface_size_is_relative_to_the_system_and_persists() {
let mut s = Session::new();
assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default");
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
assert_eq!(s.prefs.ui_scale, 1.25);
// Reading changes nothing; "auto" (older scripts) is the system's own size.
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0);
// Nothing to undo: the document didn't change.
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
// Saved with the other preferences and read back.
s.prefs.ui_scale = 0.9;
let v = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(v["ui_scale"], 0.9);
let back: crate::Prefs = serde_json::from_value(v).unwrap();
assert_eq!(back.ui_scale, 0.9);
}
#[test]
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
let mut s = Session::new();
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
s.prefs.ui_scale = 1.5;
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
assert_eq!(s.prefs.ui_scale, 1.5, "{junk}");
}
}
#[test]
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert_eq!(old.ui_scale, 1.0);
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}");
}
// A number out of range is clamped rather than refused.
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
}
+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
+75 -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}));
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
fn brand_id(s: &Session) -> String {
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
}
#[test]
fn explicit_paint_edits_detach_the_colour_binding() {
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
// selection colours already do), instead of putting the variable's colour back.
let edits = [
("fill.set", json!({"color": "#00FF00"})),
("node.set", json!({"props": {"fill": "#00FF00"}})),
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
];
for (cmd, mut p) in edits {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p.clone());
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
}
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
let (mut s, r) = setup();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p);
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
assert!(n.bound.is_empty(), "{cmd}");
}
}
#[test]
fn clearing_bound_fills_adds_no_paint_back() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
assert!(bound(&s, r).is_empty());
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
}
#[test]
fn paint_opacity_edits_keep_the_colour_binding() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
// The same colour with another opacity, through node.set, keeps it too.
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
assert_eq!(bound(&s, r).len(), 1);
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
}
#[test]
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
let brand = brand_id(&s);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
}
+1 -1
View File
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
+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>) {
+108
View File
@@ -19,7 +19,21 @@ 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.reorder",
"Reorder selection",
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
true,
reorder
),
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)),
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
@@ -521,6 +535,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get(i)?;
let (ow, oh) = (n.width, n.height);
@@ -544,5 +559,98 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
super::props::restore_worlds(doc, &kept)?;
}
super::styles::detach_edited(doc, &before);
ok()
}
/// Reorder visual slots without rebuilding or reparenting layers.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "arrange.reorder";
let ids = req_targets(cmd, s, p)?;
let source = req_id(cmd, p, "source")?;
let target = req_id(cmd, p, "target")?;
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_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
let positions = layout
.positions(source, target, boolean(p, "swap") == Some(true))
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
// Validate every delta before editing; absolute targets always come from the gesture's
// snapshot so crossing several slots and returning to the start does not accumulate edits.
let doc = s.doc()?;
let mut moves = Vec::new();
for (id, at) in positions {
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
if !dx.is_finite() || !dy.is_finite() {
return Err(bad(cmd, "non-finite reorder result"));
}
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
let node = doc.get(id)?;
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
return Err(bad(cmd, "non-finite local transform"));
}
moves.push((id, dx, dy));
}
}
if !moves.is_empty() {
let doc = s.doc_mut()?;
for (id, dx, dy) in moves {
move_world(doc, id, dx, dy)?;
}
}
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()
}
+24 -5
View File
@@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
// Own layers lose their link (slot content stays as it is); nested instances stay
// instances of their own mains.
let nested = unlink_layers(doc, i, Some(i));
keep_main_modes(doc, i)?;
let n = doc.get_mut(i)?;
n.kind = NodeKind::Frame;
n.source = None;
@@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": out}))
}
/// Make the variable modes instance `i` takes from its main its own (it's being detached).
fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> {
let modes = match doc.get(i)?.kind {
NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(),
_ => return Ok(()),
};
let own = &mut doc.get_mut(i)?.var_modes;
for (c, m) in modes {
own.entry(c).or_insert(m);
}
Ok(())
}
fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.resetInstance";
let ids = req_targets(cmd, s, p)?;
@@ -608,7 +622,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
}
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
@@ -678,8 +692,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 +705,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 {
@@ -1039,7 +1053,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
}
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.addProperty";
add_prop_for("component.addProperty", s, p)
}
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
/// slot property here).
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
let t = req_string(cmd, p, "type")?;
+63 -12
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,6 +166,37 @@ 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"))?;
@@ -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,10 +255,14 @@ 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);
}
}
@@ -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).
+111 -16
View File
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
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.paste",
"Paste",
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
true,
paste
),
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
@@ -261,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
let i = req_index("effect.set", p, "index")?;
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
for id in req_targets("effect.set", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if let Some(slot) = n.effects.get_mut(i) {
@@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
let idx = doc.index_in_parent(i).map(|x| x + 1);
let w = n.width;
let nid = doc.duplicate_into(i, parent, idx)?;
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
(None, Some(dy)) => (0.0, dy),
@@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
nn.transform = Affine::translate((dx, dy)) * nn.transform;
}
}
if rename {
// An instance keeps the name instances get by default.
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
let nn = doc.get_mut(nid)?;
nn.name = bump_name(&nn.name);
}
@@ -515,6 +522,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
s.clipboard.source_doc = uid;
s.clipboard.copied_components = nodes
.iter()
.filter(|(_, sub)| {
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
})
.map(|(root, _)| *root)
.collect();
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
@@ -593,7 +607,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
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 };
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -623,6 +638,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
// From another file: its instances follow the mains this file can have.
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
let copy_local = s.ds()?.uid == clip.source_doc;
let doc = s.doc_mut()?;
for (k, v) in &clip.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
@@ -636,6 +652,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
let mut out = Vec::new();
let mut all = BTreeMap::new();
for (root, nodes) in &clip.nodes {
if copy_local
&& clip.copied_components.contains(root)
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
{
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
out.push(instance);
continue;
}
let mut map = BTreeMap::new();
for n in nodes {
map.insert(n.id, doc.alloc_id());
@@ -661,10 +687,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
let sel = s.selection();
let doc = s.doc()?;
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
let target = id(p, "parent").or_else(|| {
sel.first()
.copied()
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
});
let parent = target.unwrap_or(page);
let at = match (num(p, "x"), num(p, "y")) {
@@ -931,9 +959,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
let digits = req_string("object.opacityKeys", p, "digits")?;
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
for i in req_targets("object.opacityKeys", s, p)? {
s.doc_mut()?.get_mut(i)?.opacity = o;
let ids = req_targets("object.opacityKeys", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
doc.get_mut(i)?.opacity = o;
}
super::styles::detach_edited(doc, &before);
Ok(json!({"opacity": o}))
}
@@ -1156,10 +1188,37 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
.collect(),
None => Vec::new(),
};
let doc = s.doc_mut()?;
for i in ids {
super::props::apply("node.set", doc, i, &props)?;
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()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
for (i, q) in pinned {
let d = o - doc.world_transform(i) * q;
@@ -1168,7 +1227,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
@@ -1281,7 +1376,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
if let Some(b) = blend(p, "blend") {
slot.blend = b;
}
super::lists::remap_stop_bindings(n, fills, at, &old);
super::lists::rebind_edited_paint(n, fills, at, &old);
if fills {
n.styles.fill = None;
} else {
@@ -1295,7 +1390,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)) {
@@ -1315,8 +1410,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
let e: Effect = match get(p, "effect") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
Some(e) => e,
None => {
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
+64 -27
View File
@@ -92,6 +92,20 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
let from = req_index("file.moveTab", p, "from")?;
let to = req_index("file.moveTab", p, "to")?;
if from >= s.docs.len() || to >= s.docs.len() {
return Err(bad("file.moveTab", "no such document"));
}
if from != to {
let active_uid = s.docs.get(s.active).map(|d| d.uid);
let doc = s.docs.remove(from);
s.docs.insert(to, doc);
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
}
Ok(json!({"index": to}))
}),
spec!(
"file.recover",
"Recover file",
@@ -435,7 +449,7 @@ pub(crate) fn recovered(path: &str) -> Result<DocState> {
#[cfg(not(target_arch = "wasm32"))]
fn read(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
crate::file_access::read(path)
}
#[cfg(target_arch = "wasm32")]
@@ -445,11 +459,7 @@ fn read(path: &str) -> Result<Vec<u8>> {
/// The bytes of file `path`, refused when it's larger than `max`.
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
}
read(path)
crate::file_access::read_capped(path, max)
}
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
@@ -457,7 +467,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")]
@@ -552,6 +562,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
/// 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)> =
@@ -577,11 +588,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
return None;
}
let dir = dir.as_ref()?;
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
let p = dir.join(format!("{r}{e}"));
let meta = std::fs::metadata(&p).ok()?;
if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
})
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"]
.iter()
.find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok())
}
}
@@ -604,11 +613,7 @@ fn sibling_variables(path: &str) -> Option<Value> {
let file = std::path::Path::new(path);
let dir = file.parent()?;
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
let meta = std::fs::metadata(&c).ok()?;
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
return None;
}
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?;
figcraft_format::rest::is_variables_json(&v).then_some(v)
})
}
@@ -646,20 +651,29 @@ fn remember(s: &mut Session, path: &str) {
}
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
use crate::file_access::{Access, allow, allowed};
const CMD: &str = "file.openRecent";
// Confined to automation roots, the recent files outside them are never listed, opened, removed
// or named: `index` counts the files listed.
let listed = |r: &String| allowed(r, Access::Read);
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
s.prefs.recent_files.retain(|r| !listed(r));
}
if let Some(gone) = string(p, "remove") {
s.prefs.recent_files.retain(|r| *r != gone);
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
(Some(path), _) => {
allow(&path, Access::Read)?;
Some(path)
}
(None, Some(i)) => {
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
}
_ => None,
};
if let Some(path) = path {
if !std::path::Path::new(&path).exists() {
if !crate::file_access::exists(&path) {
s.prefs.recent_files.retain(|r| *r != path);
return Err(bad(CMD, format!("{path} no longer exists")));
}
@@ -669,9 +683,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
.prefs
.recent_files
.iter()
.filter(|r| listed(r))
.map(|r| {
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
})
.collect();
Ok(json!({"files": files}))
@@ -1183,7 +1198,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)?;
@@ -1373,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
}
let uid = ds.uid;
let active_uid = s.docs.get(s.active).map(|d| d.uid);
s.docs.remove(i);
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
// Removing a tab before the active one shifts its index; keep the same document selected.
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
crate::branches::close_tabs(s, uid, None);
ok()
}
#[cfg(test)]
mod tab_order_tests {
use super::*;
#[test]
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
let mut session = Session::new();
for name in ["Two", "Three", "Four"] {
session.execute("file.new", &json!({"name": name})).unwrap();
}
session.execute("doc.switch", &json!({"index": 2})).unwrap();
let active_uid = session.docs[session.active].uid;
let moved_uid = session.docs[0].uid;
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
assert_eq!(session.docs[2].uid, moved_uid);
assert_eq!(session.docs[session.active].uid, active_uid);
session.execute("file.close", &json!({"index": 0})).unwrap();
assert_eq!(session.docs[session.active].uid, active_uid);
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
}
}
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
let n = s.docs.len() as i64;
+29
View File
@@ -149,6 +149,35 @@ mod tests {
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
}
/// Each side key writes its own padding slot and leaves the other three alone.
#[test]
fn each_side_padding_sets_its_side_alone() {
let mut s = Session::new();
let (f, _, _) = al_frame(&mut s);
// One side at a time: the other three stay 0 ([top, right, bottom, left]).
for (key, v, want) in [
("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]),
("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]),
("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]),
("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]),
] {
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}}));
ex(&mut s, "node.set", json!({"id": f, "props": {key: v}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}");
}
// The [t, r, b, l] array and the axis pairs agree with the four sides.
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]);
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
// Negatives clamp to 0; junk is refused.
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
}
#[test]
fn baseline_alignment_lines_up_text() {
let mut s = Session::new();
+40 -3
View File
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
/// Returns the removed stop's index.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
use figcraft_color::PaintKind;
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
// The first stop that differs is the removed one when the rest moved down unchanged.
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
if a.stops.get(k + 1..) != b.stops.get(k..) {
return;
return None;
}
Some(k)
}
(_, Some(PaintKind::Gradient(_))) => return,
(_, Some(PaintKind::Gradient(_))) => return None,
_ => None,
};
let keys: Vec<(String, Option<String>)> = n
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
}
}
n.bound.extend(kept);
removed
}
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
let removed = remap_stop_bindings(n, fills, index, old);
let mut old = old.clone();
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
&& k < g.stops.len()
{
g.stops.remove(k);
}
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
n.bound.retain(|key, _| match list_binding(key) {
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
_ => true,
});
}
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
/// `new` as in `old`.
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
use figcraft_color::PaintKind;
match field {
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
"opacity" => old.opacity == new.opacity,
_ => match (stop_binding(field), &old.kind, &new.kind) {
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
},
}
}
/// Where item `k` lands when the item at `from` moves to `to`.
+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());
+159 -33
View File
@@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value {
}
}
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
/// so a shadow given `offsetY` kept the default offset.
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
}
Ok(effects)
}
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
let Some(v) = get(p, key) else { return Ok(None) };
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
Ok(Some(effect))
}
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
let Some(fields) = given.as_object() else { return Ok(()) };
let parsed = parse(given);
let ignored: Vec<&str> = fields
.iter()
.filter(|(_, v)| !v.is_null())
.filter(|(k, _)| {
let mut probe = fields.clone();
probe.insert((*k).clone(), Value::Null);
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
})
.map(|(k, _)| k.as_str())
.collect();
if ignored.is_empty() {
return Ok(());
}
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
}
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
@@ -265,18 +307,88 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
}
/// 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();
// Geometry keys first so rotation applies to the final size.
let order = |k: &str| match k {
/// 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",
];
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
/// re-applying them would bring it back.
pub fn drop_layout_bindings(n: &mut Node) {
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
}
/// 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))
}
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
for (i, p) in old.iter().enumerate() {
super::lists::rebind_edited_paint(n, fills, i, p);
}
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"boundVariables" => 4,
_ => 1,
};
}
}
/// 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();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| order(k));
keys.sort_by_key(|(k, _)| apply_order(k));
for (k, v) in keys {
let k = k.as_str();
match k {
@@ -348,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
doc.get_mut(id)?.blend = b;
}
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
"fill" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
}
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
"stroke" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
@@ -412,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"devStatus" => {
@@ -436,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
return Err(bad(cmd, "auto layout needs a frame"));
}
match v {
Value::Null => n.layout = None,
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
Value::Null => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) if s.eq_ignore_ascii_case("none") => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) => {
let mode = match s.to_ascii_lowercase().as_str() {
"horizontal" | "row" => LayoutMode::Horizontal,
@@ -452,56 +570,56 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
"gap" | "itemSpacing" | "columnGap" => {
ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"crossGap" | "counterAxisSpacing" | "rowGap" => {
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"padding" => {
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
*slot = val;
}
}
"paddingHorizontal" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
l.padding[1] = val;
l.padding[3] = val;
}
"paddingVertical" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
l.padding[0] = val;
l.padding[2] = val;
}
"mainAlign" | "primaryAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).main_align =
layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
}
"crossAlign" | "counterAxisAlignItems" => {
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"contentAlign" | "counterAxisAlignContent" => {
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let l = ensure_layout(doc.get_mut(id)?);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
// Tracks past the new count go away (new tracks are 1fr).
let (n, tracks) =
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
*n = count;
tracks.truncate(count as usize);
}
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
"sizingH" | "layoutSizingHorizontal" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, true)?;
@@ -533,7 +651,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;
@@ -725,10 +843,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);
}
}
}
}
@@ -783,12 +906,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
if !unknown.is_empty() {
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
}
// Inside an instance, record which property groups were overridden (an instance's own export
// settings and interactions are its own, not overrides).
// Inside an instance, record which property groups were overridden (an instance's own
// resizing, export settings and interactions are its own, not overrides).
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
let n = doc.get_mut(id)?;
if n.source.is_some() {
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
for g in
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
{
n.overrides.insert(g.to_string());
}
}
@@ -810,7 +935,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",
+29 -19
View File
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
/// add/remove, move to page and vector path edits.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
spec!(
"object.convertToGroup",
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Wrap in new section",
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
true,
|s, p| wrap(s, p, Wrap::Section)
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
),
spec!(
"object.resizeToFit",
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
// Hugging needs auto layout and the children that filled have nothing to fill: both
// become fixed (the frame keeps filling its own auto layout parent).
let doc = s.doc_mut()?;
doc.get_mut(i)?.layout = None;
let n = doc.get_mut(i)?;
n.layout = None;
super::props::drop_layout_bindings(n);
figcraft_layout::coherent_sizing(doc, i);
}
ok()
@@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0;
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
/// Wrap the selection in a new boolean group doing `op`.
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
wrap(s, p, Wrap::Bool(op))
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
wrap(s, p, cmd, Wrap::Bool(op))
}
/// Wrap the selection in a new container at the topmost selected layer's place.
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
let ids = req_targets("object.group", s, p)?;
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
/// the command that asked.
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
// Sections sit on the page or in sections, never in frames or groups.
let board = |doc: &Document, i: NodeId| {
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
};
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
}
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
return Err(bad("object.group", "sections can't go in groups or frames"));
return Err(bad(cmd, "sections can't go in groups or frames"));
}
let index = doc.index_in_parent(top).map(|i| i + 1);
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
@@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
super::props::drop_layout_bindings(n);
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
@@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
return ok();
}
// Group, then the bottom-most child is the mask.
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
let doc = s.doc_mut()?;
if let Some(first) = doc.get(gid)?.children.first().copied() {
@@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let f = doc.get_mut(fill_id)?;
f.strokes.clear();
}
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
@@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let f = s.doc_mut()?.get_mut(fid)?;
f.fills.clear();
@@ -544,11 +549,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}))
+200 -9
View File
@@ -110,16 +110,26 @@ 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 cmd = "variable.createCollection";
let name = req_string(cmd, p, "name")?.trim().to_string();
if name.is_empty() {
return Err(bad(cmd, "name can't be empty"));
}
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(),
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
_ => vec!["Mode 1".into()],
};
if modes.is_empty() {
return Err(bad(cmd, "a collection needs at least one mode"));
}
if modes.iter().any(String::is_empty) {
return Err(bad(cmd, "mode names can't be empty"));
}
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
Ok(json!({"id": id}))
@@ -458,10 +468,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.
@@ -770,16 +789,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";
@@ -916,6 +960,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
apply_bindings_matching(doc, only, |_| true);
}
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
}
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
/// changed drops its binding, which would otherwise write the variable's value back (#809).
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
/// another (proportions, min/max) lose theirs.
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
for old in before {
let Some(n) = doc.node(old.id) else { continue };
let edited: Vec<String> = n
.bound
.keys()
// A binding the edit itself made (`boundVariables`) stays.
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
.filter(|f| bound_field(old, f) != bound_field(n, f))
.cloned()
.collect();
if edited.is_empty() {
continue;
}
if let Ok(n) = doc.get_mut(old.id) {
for f in edited {
n.bound.remove(&f);
}
}
}
}
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
fn bound_field(n: &Node, field: &str) -> Option<String> {
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
_ => None,
};
let layout = n.layout.as_ref();
let cl = &n.child_layout;
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
Some(match field {
"width" => format!("{}", n.width),
"height" => format!("{}", n.height),
"minWidth" => format!("{:?}", cl.min_w),
"maxWidth" => format!("{:?}", cl.max_w),
"minHeight" => format!("{:?}", cl.min_h),
"maxHeight" => format!("{:?}", cl.max_h),
"opacity" => format!("{}", n.opacity),
"visible" => format!("{}", n.visible),
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
"padding" => format!("{:?}", layout.map(|l| l.padding)),
"paddingTop" => format!("{:?}", pad(0)),
"paddingRight" => format!("{:?}", pad(1)),
"paddingBottom" => format!("{:?}", pad(2)),
"paddingLeft" => format!("{:?}", pad(3)),
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
"cornerRadius" => format!("{:?}", n.corners.radii),
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
NodeKind::Ellipse { start, sweep, inner } => format!(
"{}",
if field == "arcStart" {
start
} else if field == "arcSweep" {
sweep
} else {
inner
}
),
_ => return None,
},
"characters" => match &n.kind {
NodeKind::Text(t) => t.characters.clone(),
_ => return None,
},
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
_ => return None,
},
"fontFamily" => text(&|s| s.font_family.clone())?,
"fontStyle" => text(&|s| s.font_style.clone())?,
"fontWeight" => text(&|s| s.weight().to_string())?,
"fontSize" => text(&|s| s.font_size.to_string())?,
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
_ => return None,
})
}
/// 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) {
@@ -930,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
doc.mark_dirty(pending);
}
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
/// node when `None`). Auto layout fields wait while their node has no auto layout.
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() {
@@ -944,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
let modes = doc.modes_for(id);
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let has_layout = n.layout.is_some();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
@@ -953,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
if !include(&field) {
continue;
}
// A binding never creates an auto layout: without one it waits for the next.
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
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) {
@@ -1104,7 +1296,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;
}
}
+48 -6
View File
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
let bound = super::styles::bound_before(doc, &[id]);
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
if a == b {
t.restyle(0, t.char_count(), &f);
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
t.restyle(a, b, &f);
}
}
super::styles::detach_edited(doc, &bound);
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
let before = super::styles::bound_before(doc, &ids);
for d in ids {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
super::styles::detach_edited(doc, &before);
ok()
}
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
super::styles::detach_edited(doc, &before);
ok()
}),
spec!(
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
let ids = req_targets("text.style", s, p)?;
let m = p.as_object().cloned().unwrap_or_default();
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get_mut(i)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::props::apply_text_style("text.style", t, a, b, k, v)?;
}
}
super::styles::detach_edited(doc, &before);
ok()
}
),
@@ -474,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
spec!(
"text.rescanFonts",
"Rescan installed fonts",
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
true,
|s, _| {
let rescan = figcraft_text::rescan_system_fonts();
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
Ok(json!({"added": rescan.added, "refitted": refitted}))
}
),
spec!(
"text.loadFont",
"Load font file",
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
true,
|s, p| {
let cmd = "text.loadFont";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
} else {
return Err(bad(cmd, "pass `path` or `data` (base64)"));
};
let families = figcraft_text::load_font_bytes(bytes);
if families.is_empty() {
return Err(bad(cmd, "not a font file"));
}
let refitted = crate::fonts::refit_open(s, None, Some(&families));
Ok(json!({"families": families, "refitted": refitted}))
}
),
]
}
+5 -1
View File
@@ -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
),
+105 -38
View File
@@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul
Ok(json!({"on": *slot}))
}
/// One of a view's on/off settings.
type Slot = fn(&mut ViewState) -> &mut bool;
/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and
/// whether the preferences keep each: files opened or made later start with the kept ones. Outline
/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one.
const TOGGLES: &[(&str, Slot, bool)] = &[
("view.pixelGrid", |v| &mut v.pixel_grid, true),
("view.layoutGrids", |v| &mut v.layout_grids, true),
("view.rulers", |v| &mut v.rulers, true),
("view.slices", |v| &mut v.show_slices, true),
("view.comments", |v| &mut v.comments, true),
("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true),
("view.outlines", |v| &mut v.outlines, false),
("view.outlinesSelection", |v| &mut v.outlines_selection, false),
("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true),
("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true),
("view.maskOutlines", |v| &mut v.mask_outlines, true),
("view.frameOutlines", |v| &mut v.frame_outlines, true),
("view.proofSrgb", |v| &mut v.proof_srgb, false),
("view.snapGeometry", |v| &mut v.snap_geometry, true),
("view.snapObjects", |v| &mut v.snap_objects, true),
("view.snapPixel", |v| &mut v.snap_pixel, true),
];
/// View toggle `id`: the file's view changes, and the preferences remember a kept one.
fn toggle(s: &mut Session, p: &Value, id: &str) -> Result<Value> {
let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) };
let r = flag(s, p, f)?;
if keep && let Some(on) = r.get("on").and_then(Value::as_bool) {
s.prefs.view.insert(id.to_string(), on);
}
Ok(r)
}
/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are).
pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) {
for (id, f, _) in TOGGLES.iter().filter(|t| t.2) {
if let Some(on) = prefs.view.get(*id) {
*f(v) = *on;
}
}
}
/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command
/// that isn't one.
pub fn checked(s: &Session, id: &str) -> Option<bool> {
let v = &s.ds().ok()?.view;
Some(match id {
"view.pixelGrid" => v.pixel_grid,
"view.layoutGrids" => v.layout_grids,
"view.rulers" => v.rulers,
"view.slices" => v.show_slices,
"view.comments" => v.comments,
"view.multiplayerCursors" => v.multiplayer_cursors,
"view.outlines" => v.outlines,
"view.outlinesSelection" => v.outlines_selection,
"view.outlinesHiddenLayers" => v.outline_hidden,
"view.outlinesObjectBounds" => v.outline_bounds,
"view.maskOutlines" => v.mask_outlines,
"view.pixelPreview" => v.pixel_preview,
"view.frameOutlines" => v.frame_outlines,
"view.proofSrgb" => v.proof_srgb,
"view.snapGeometry" => v.snap_geometry,
"view.snapObjects" => v.snap_objects,
"view.snapPixel" => v.snap_pixel,
_ => return None,
})
let mut v = s.ds().ok()?.view.clone();
if id == "view.pixelPreview" {
return Some(v.pixel_preview);
}
TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v))
}
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
@@ -235,46 +263,46 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")),
spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")),
spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")),
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
flag(s, p, |v| &mut v.multiplayer_cursors)
toggle(s, p, "view.multiplayerCursors")
}),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
flag(s, p, |v| &mut v.outlines_selection)
toggle(s, p, "view.outlinesSelection")
}),
spec!(
"view.outlinesHiddenLayers",
"Include hidden layers in outlines",
"{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there",
false,
|s, p| { flag(s, p, |v| &mut v.outline_hidden) }
|s, p| { toggle(s, p, "view.outlinesHiddenLayers") }
),
spec!(
"view.outlinesObjectBounds",
"Include object bounds in outlines",
"{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer",
false,
|s, p| { flag(s, p, |v| &mut v.outline_bounds) }
|s, p| { toggle(s, p, "view.outlinesObjectBounds") }
),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")),
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")),
spec!(
"view.proofSrgb",
"Soft-proof sRGB",
"{on?} mark the colours of a Display P3 file that sRGB screens and files can't show",
false,
|s, p| { flag(s, p, |v| &mut v.proof_srgb) }
|s, p| { toggle(s, p, "view.proofSrgb") }
),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
@@ -342,7 +370,25 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
"Interface size",
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
None => {}
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
Some(_) => {
let scale = req_num("pref.uiScale", p, "scale")?;
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
}
}
Ok(json!({"scale": s.prefs.ui_scale}))
}
),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
@@ -381,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.language",
"Interface language",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
false,
|s, p| {
let lang = req_string("pref.language", p, "language")?;
@@ -494,3 +540,24 @@ pub fn specs() -> Vec<CommandSpec> {
#[allow(dead_code)]
fn unused(_: NodeId) {}
#[cfg(test)]
mod start_on_home_tests {
use serde_json::json;
use crate::Session;
#[test]
fn start_on_home_is_a_preference_that_persists() {
let mut s = Session::new();
assert!(!s.prefs.start_on_home);
assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true);
assert!(s.prefs.start_on_home);
// Saved with the other preferences and read back; older settings files without it read as off.
let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(saved.start_on_home);
assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false);
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.start_on_home);
}
}
+51 -6
View File
@@ -256,7 +256,26 @@ 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())
}
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
/// on each side that has a weight.
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
let Some(c) = stroke_color(n) else { return Vec::new() };
let Some(sides) = n.stroke.sides else {
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
};
let color = tok(doc, n, &["stroke"], c.to_css(), t);
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
named
.zip(sides)
.filter(|(_, weight)| *weight > 0.0)
.map(|((side, field), weight)| {
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
})
.collect()
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
}
if let Some(c) = stroke_color(n) {
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
for (key, value) in css_borders(doc, n, t) {
put(&key, value);
}
let shadows: Vec<String> = n
.effects
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
swift_layer(ctx, n, depth, out, extra);
}
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
fn one_line(name: &str) -> String {
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
}
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a view.
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a composable.
@@ -1168,4 +1191,26 @@ mod tests {
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
}
#[test]
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
let mut s = Session::new();
let name = "deep\nevil()\r\u{2028}more()";
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
let root = parent;
for _ in 0..16 {
parent =
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
.unwrap();
}
let doc = s.doc().unwrap();
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
let code = code_for(doc, root, lang);
assert!(code.contains("// deep evil() more() …\n"), "{code}");
for line in code.lines() {
let t = line.trim_start();
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
}
}
}
}
+72 -24
View File
@@ -11,12 +11,12 @@
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
//! settings and interactions are its own, like its place. `size` and `position` overrides of
//! layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
//! main; the instance itself resizes.
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
//! export settings, interactions and resizing are its own, like its place. `position`
//! overrides of layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
//!
//! Switching variants keeps an override where the layer's name path matches and the two variants
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
@@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
"exports",
"interactions",
"size",
"resizing",
"position",
"component",
"props",
@@ -284,11 +285,28 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.dev_resources.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
// So do the variable modes the main sets (see `Document::inherited_modes`).
root.var_modes.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
}
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
/// has one main per component, so a main component's copy is an instance of it; a variant copied
/// into its set stays a new variant, and every other layer is deep-copied.
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
let n = doc.get(id)?;
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
return doc.duplicate_into(id, parent, index);
}
let transform = n.transform;
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
doc.get_mut(instance)?.transform = transform;
Ok(instance)
}
/// Which part of the carried overrides to apply.
#[derive(Clone, Copy, PartialEq)]
enum Carry {
@@ -388,11 +406,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
"size" => {
to.width = from.width;
to.height = from.height;
to.child_layout = from.child_layout.clone();
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
b.auto_resize = a.auto_resize;
}
}
"resizing" => {
let (a, b) = (&from.child_layout, &mut to.child_layout);
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
}
"position" => to.transform = from.transform,
"component" => {
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
@@ -773,6 +794,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 +1015,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 +1060,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 +1146,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 +1454,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 +1515,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 +1558,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 +1570,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 +1711,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 +1729,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
l
})
};
let size = |x: &Node| (x.width, x.height);
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
// Child layout settings other than the resizing ones.
let placing = |x: &Node| {
let mut c = x.child_layout.clone();
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
c
};
if mode(n) != mode(b) && mode(ma) == mode(mb) {
return Some("The auto layout direction");
}
@@ -1698,14 +1747,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
return Some("A layer's position");
}
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
return Some("A layer's size");
}
if n.constraints != b.constraints && ma.constraints == mb.constraints {
return Some("A layer's constraints");
}
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
return Some("A layer's resizing");
if placing(n) != placing(b) && placing(ma) == placing(mb) {
return Some("A layer's place in auto layout");
}
None
}
@@ -1923,6 +1969,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let mut groups: Vec<String> = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
.filter(|g| placed_source.is_some() || g != "resizing")
.collect();
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
@@ -1946,7 +1993,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
}
if let Some(n) = doc.node_mut(inst) {
n.source = root_base;
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
n.overrides.extend(root_groups);
}
}
+173 -15
View File
@@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData {
n.kind.text().unwrap()
}
#[test]
fn copying_a_main_component_pastes_a_linked_instance() {
for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "select.set", json!({"ids": [k.card]}));
ex(&mut s, "edit.copy", json!({}));
if command != "edit.paste" {
ex(&mut s, "select.set", json!({"ids": [k.inst]}));
}
let before = undo_len(&s);
let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card, "{command}");
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
assert_eq!(node(&s, pasted).source, Some(k.card));
assert_eq!(undo_len(&s), before + 1);
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(pasted).is_none());
ex(&mut s, "edit.redo", json!({}));
assert_eq!(component_of(&s, pasted), k.card);
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
healthy(&s);
}
}
#[test]
fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
ex(&mut s, "select.set", json!({"ids": [hover]}));
ex(&mut s, "edit.copy", json!({}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), hover);
assert_eq!(node(&s, pasted).name, node(&s, set).name);
let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
assert_eq!(props["Show dot"], true);
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
ex(&mut s, "select.set", json!({"ids": [set]}));
ex(&mut s, "edit.copy", json!({}));
let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
assert_eq!(kids(&s, copied_set).len(), 2);
for variant in kids(&s, copied_set) {
assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
assert!(!kids(&s, set).contains(&variant));
}
healthy(&s);
}
#[test]
fn cut_components_and_components_copied_to_another_file_remain_components() {
for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, copy, json!({"ids": [k.card]}));
if undo_cut {
ex(&mut s, "edit.undo", json!({}));
}
if new_file {
ex(&mut s, "file.new", json!({}));
}
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
assert_eq!(kids(&s, pasted).len(), 3);
healthy(&s);
}
}
#[test]
fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "edit.copy", json!({"ids": [main]}));
s.undo().unwrap();
s.undo().unwrap();
let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
ex(&mut s, "component.create", json!({"ids": [other]}));
assert_eq!(other, main, "undo makes the original id available again");
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert_eq!(node(&s, pasted).name, "Copied main");
assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
healthy(&s);
}
#[test]
fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
let original = s.doc().unwrap().world_transform(k.card);
let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card);
for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
assert!((actual - expected).abs() < 1e-9);
}
assert_eq!(node(&s, pasted).parent, Some(frame));
let before = s.doc().unwrap().clone();
assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
healthy(&s);
}
#[test]
fn duplicating_a_main_component_makes_a_linked_instance() {
for rename in [false, true] {
let mut s = Session::new();
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
let k = kit(&mut s);
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
let before = s.doc().unwrap().clone();
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
assert_eq!(s.selection(), ids);
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
assert_eq!(component_of(&s, copy), k.card);
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
let other = *ids.iter().find(|i| **i != copy).unwrap();
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
healthy(&s);
}
}
#[test]
fn alt_dragging_a_main_component_copies_an_instance() {
use crate::tools::{PointerEvent, PointerKind};
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
let [copy] = s.selection()[..] else { panic!("one copy selected") };
assert_eq!(component_of(&s, copy), main);
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
healthy(&s);
}
#[test]
fn duplicating_a_variant_adds_a_variant_to_its_set() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
assert_eq!(node(&s, copy).parent, Some(set));
assert_eq!(kids(&s, set).len(), 3);
// A whole set duplicates as a new set of variants.
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
healthy(&s);
}
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
@@ -316,14 +489,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 +496,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",
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
assert!(doc.node(glyph).unwrap().overrides.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
// and stays through main edits until the override is reset.
let mut s = Session::new();
let (chip, _) = bound_chip(&mut s);
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
let n = s.doc().unwrap().node(bg).unwrap().clone();
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
assert!(!n.bound.contains_key("fill"));
assert!(n.overrides.contains("fills"));
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
let doc = s.doc().unwrap();
healthy(doc);
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
let n = s.doc().unwrap().node(bg).unwrap();
assert!(n.bound.contains_key("fill"));
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
}
-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:?}");
@@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
#[test]
fn wrapping_errors_name_the_command_that_was_run() {
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
// the command that asked, not `object.group`.
let mut s = Session::new();
let page = s.page().unwrap();
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
}
let r = rect(&mut s, page, 0.0, 0.0);
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
assert!(err.contains("`object.mask`"), "{err}");
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
assert!(err.contains("`vector.outlineStroke`"), "{err}");
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
let inner = rect(&mut s, f, 10.0, 10.0);
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
assert!(err.contains("`object.wrapInSection`"), "{err}");
}
+6 -5
View File
@@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
let before = s.doc().unwrap().world_bounds(inner).unwrap();
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
ex(&mut s, "select.set", json!({"ids": [inner]}));
// Resizing it by its right edge: only the main component can.
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow");
// Resizing it by its right edge: a size of its own (an override).
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone");
// Moving it: refused too.
let before = s.doc().unwrap().world_bounds(inner).unwrap();
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
// Moving it: only the main component can.
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
+48
View File
@@ -0,0 +1,48 @@
//! Which URLs stored in a document may be handed to the browser or the system opener.
//!
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
//! up at the platform's opener, so a document that someone else made chooses what that opener is
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
//!
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
//! the app's own origin, so a saved document becomes stored XSS.
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
//! schemes to whichever application registered them.
//!
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
//! dangerous schemes is outgrown by every new handler a platform adds.
/// The schemes a URL stored in a document may use.
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
/// Whether `url`, stored in a document, may be opened.
///
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
/// refused: the opener would resolve it against the app's own page on the web. Control characters
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
pub fn openable(url: &str) -> bool {
let url = url.trim();
if url.is_empty() || url.chars().any(char::is_control) {
return false;
}
let Some((scheme, rest)) = url.split_once(':') else { return false };
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
return false;
}
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
return false;
}
if scheme.eq_ignore_ascii_case("mailto") {
// `mailto:` names an address.
return !rest.is_empty();
}
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
// and `http:///p` has no host.
let Some(after) = rest.strip_prefix("//") else { return false };
!after.is_empty() && !after.starts_with('/')
}
+87
View File
@@ -0,0 +1,87 @@
//! A URL stored in a document reaches the browser or the system opener, so only the schemes a
//! browser follows on a plain link are allowed ([`crate::doc_urls`]).
use crate::doc_urls::openable;
#[test]
fn web_links_are_opened() {
for url in [
"http://example.com",
"https://example.com",
"https://example.com/a/b?c=d#e",
"HTTPS://EXAMPLE.COM",
"HtTp://example.com",
"mailto:[email protected]",
"mailto:[email protected]?subject=hi",
" https://example.com ",
"https://user:[email protected]:8443/p",
] {
assert!(openable(url), "{url:?} should open");
}
}
#[test]
fn script_and_handler_schemes_are_refused() {
for url in [
// Runs in the app's own origin on the web build: a saved document would be stored XSS.
"javascript:alert(1)",
"JavaScript:alert(1)",
"jAvAsCrIpT:alert(document.cookie)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
"blob:https://example.com/x",
// Reach the file system; a UNC host also sends Windows credentials.
"file:///etc/passwd",
"file://attacker.example/share/x",
"smb://attacker.example/share/x",
"webdav://attacker.example/x",
// Windows application handlers.
"ms-msdt:/id PCWDiagnostic",
"ms-officecmd:x",
"search-ms:query=x",
"shell:startup",
// Anything else a platform may have registered.
"tel:+1234",
"sms:+1234",
"ssh://host",
"ftp://host/x",
"figcraft://join?host=127.0.0.1",
"zoommtg://zoom.us/join?x",
"view-source:https://example.com",
"jar:https://example.com/x!/y",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
#[test]
fn a_scheme_is_required_and_control_characters_are_refused() {
for url in [
// No scheme: the web opener would resolve these against the app's own page.
"",
" ",
"example.com",
"/etc/passwd",
"//example.com",
"\\\\host\\share\\x",
"about",
// Browsers ignore some control characters inside a scheme, so a smuggled one must not
// leave `javascript` looking like an unknown scheme that we then refuse by luck.
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"java\rscript:alert(1)",
"javascript\u{0}:alert(1)",
"https://example.com/\u{0}",
// A colon in ordinary text is not a scheme.
"see page 2: the diagram",
"1https://example.com",
// The allowed schemes still need their own part.
"mailto:",
"http:",
"http:example.com",
"http:///p",
"https://",
] {
assert!(!openable(url), "{url:?} must be refused");
}
}
@@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() {
cmd_d(&mut s);
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
}
/// Drag `from` by `by` with no modifier: moving the selection, not copying it.
fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
s.pointer(&ev(PointerKind::Down, from)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap();
}
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
}
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
/// and the step is measured from where the copy ended up, whichever way it got there.
#[test]
fn duplicate_repeats_the_step_set_by_dragging_the_copy() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
drag_move(&mut s, (50.0, 50.0), (150.0, 20.0));
assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one");
assert_eq!(s.selection(), vec![c1], "and left it selected");
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
let c2 = cmd_d(&mut s);
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
let c3 = cmd_d(&mut s);
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
}
/// And a step set with the arrow keys, which is how a small offset is usually made exact.
#[test]
fn duplicate_repeats_a_nudged_step() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0}));
assert_eq!(at(&s, c1), (40.0, 0.0));
let c2 = cmd_d(&mut s);
assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step");
}
+26
View File
@@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() {
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
}
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
#[test]
fn effects_refuse_keys_they_dont_have() {
let mut s = Session::new();
let r = rect(&mut s);
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
assert!(err.contains("radious"), "{err}");
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
assert!(err.contains("spred"), "{err}");
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
}
#[test]
fn scaling_scales_grain_and_glass() {
let mut s = Session::new();
+305
View File
@@ -0,0 +1,305 @@
//! 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 inside_as_far_as_it_exists(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` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(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),
}
}
let mut rest: Vec<&OsStr> = Vec::new();
let mut at = lexical.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
break base;
}
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
rest.push(name);
at = parent;
};
roots.iter().any(|r| resolved.starts_with(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;
+388
View File
@@ -0,0 +1,388 @@
//! 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"));
// Reached through a link in the root, it is outside all the same.
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
outside.push(f.p("root/escape/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 a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
let f = Fixture::new("linked-root");
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
let r = Roots::new(&[], &[f.p("via")]).unwrap();
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
}
#[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")));
}
+160
View File
@@ -0,0 +1,160 @@
//! 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. Once such a font is
//! there, [`refit`] lets auto-sizing text measure its box in it again.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{get, req_targets};
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
}
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
/// layers whose size changed.
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
let ids: Vec<NodeId> = doc
.nodes()
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
.filter(|n| {
let NodeKind::Text(t) = &n.kind else { return false };
let uses = |w: &BTreeSet<String>| {
w.contains(&t.style.font_family.trim().to_lowercase())
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
};
want.as_ref().is_none_or(uses)
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
})
.map(|n| n.id)
.collect();
if !ids.is_empty() {
let before = doc.clone();
doc.mark_dirty(ids.iter().copied());
crate::normalize(doc, &before);
}
ids
}
/// [`refit`] in the open document, if there is one.
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
match s.doc_mut() {
Ok(doc) => refit(doc, under, families),
Err(_) => Vec::new(),
}
}
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
/// whole document.
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
let under = match get(p, "ids") {
None | Some(Value::Null) => None,
Some(_) => Some(req_targets("text.refit", s, p)?),
};
s.doc()?;
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
}
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}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
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
),
spec!(
"text.refit",
"Fit text to fonts",
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
true,
refit_text
),
]
}
#[cfg(test)]
#[path = "fonts_tests.rs"]
mod tests;
+215
View File
@@ -0,0 +1,215 @@
use serde_json::json;
use super::*;
use crate::params::id_of;
use figcraft_doc::{Style, StyleRun, TextStyle};
fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId {
let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
t.style.font_family = family.into();
}
id
}
fn families(s: &mut Session) -> Vec<(String, u64)> {
let r = s.execute("text.missingFonts", &json!({})).unwrap();
r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect()
}
/// An installed family (built in, or found by the system scan), if this machine has any.
fn installed() -> Option<String> {
figcraft_text::scan_system_fonts();
figcraft_text::family_names().into_iter().next()
}
#[test]
fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
let mut s = Session::new();
text(&mut s, "FigCraft Missing A");
let b = text(&mut s, "figcraft missing a");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind {
let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() };
t.runs.push(StyleRun { start: 0, end: 3, style });
}
let doc = s.doc_mut().unwrap();
doc.styles.push(Style {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap();
s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap();
let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone();
s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var);
assert_eq!(
families(&mut s),
vec![
("FigCraft Missing A".to_string(), 2),
("FigCraft Missing B".to_string(), 1),
("FigCraft Missing C".to_string(), 0),
("FigCraft Missing D".to_string(), 0),
("FigCraft Missing H".to_string(), 0),
]
);
assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts"));
}
#[test]
fn installed_families_are_not_listed() {
let Some(family) = installed() else {
eprintln!("skipped: no fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
text(&mut s, &family.to_uppercase());
text(&mut s, "FigCraft Missing E");
assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]);
}
#[test]
fn a_family_found_only_by_the_system_scan_is_not_missing() {
figcraft_text::scan_system_fonts();
let embedded: Vec<String> = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect();
let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else {
eprintln!("skipped: no system fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
assert!(families(&mut s).is_empty(), "{family} is installed");
}
#[test]
fn blank_families_and_aliases_are_handled() {
let mut s = Session::new();
text(&mut s, " ");
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap();
let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone();
let t = text(&mut s, "FigCraft Missing G");
let doc = s.doc_mut().unwrap();
// A variable that only aliases another, and one aliasing itself.
let mut alias = doc.variables.get(&base).unwrap().clone();
alias.id = "V:alias".into();
alias.name = "Alias".into();
for v in alias.values.values_mut() {
*v = VariableValue::Alias(base.clone());
}
let mut cycle = alias.clone();
cycle.id = "V:cycle".into();
cycle.name = "Cycle".into();
for v in cycle.values.values_mut() {
*v = VariableValue::Alias("V:cycle".into());
}
doc.variables.variables.push(alias);
doc.variables.variables.push(cycle);
doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into());
let other = text(&mut s, "FigCraft Missing G");
s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into());
// The bound layer takes the value its alias resolves to (bindings are applied after the next
// command); the cycle resolves to nothing.
let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() };
assert_eq!(bound.style.font_family, "FigCraft Missing F");
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
let doc = s.doc_mut().unwrap();
let n = doc.get_mut(id).unwrap();
let want = figcraft_text::auto_size(n).unwrap().0;
n.width = want + dw;
doc.take_dirty();
want
}
/// The width of layer `id`.
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
s.doc().unwrap().node(id).unwrap().width
}
/// The ids a refit reports.
fn refitted(r: &Value) -> Vec<u64> {
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
#[test]
fn refit_measures_stale_text_boxes_again() {
let mut s = Session::new();
let a = text(&mut s, "FigCraft Refit A");
let b = text(&mut s, "FigCraft Refit B");
let fixed = text(&mut s, "FigCraft Refit A");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
t.auto_resize = figcraft_doc::TextAutoResize::None;
}
s.execute("select.none", &json!({})).unwrap();
let want_a = stale(&mut s, a, -30.0);
let want_b = stale(&mut s, b, 25.0);
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
s.doc_mut().unwrap().take_dirty();
// Only the families asked for.
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
assert_eq!(only_a, vec![a]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, b), want_b + 25.0);
// Only the layers asked for, then everything; a fixed box keeps its size.
stale(&mut s, a, 12.0);
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, fixed), 7.0);
// Nothing stale: nothing changes.
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
// An `ids` that isn't a list is refused, not taken as the whole document.
stale(&mut s, a, 9.0);
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
assert_eq!(width(&s, a), want_a + 9.0);
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
}
/// Text inside an instance is refitted too.
#[test]
fn refit_reaches_text_in_instances() {
let mut s = Session::new();
let t = text(&mut s, "FigCraft Refit C");
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
let want = stale(&mut s, copy, -20.0);
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
assert_eq!(refitted(&r), vec![copy.0]);
assert_eq!(width(&s, copy), want);
}
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
#[test]
fn fonts_load_from_files_and_rescans_report_new_families() {
let mut s = Session::new();
assert!(s.execute("text.loadFont", &json!({})).is_err());
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
}
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert!(r["added"].is_array());
}
+23 -1
View File
@@ -1,6 +1,7 @@
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
//! hidden, deleted or other pages' frames do nothing.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
let flat = [Point::new(1.0, 1.0); 4];
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
}
#[test]
fn double_clicking_a_label_asks_to_rename_its_frame() {
let (mut s, f, q) = setup();
let c = center(&q);
let before = s.doc().unwrap().world_bounds(f).unwrap();
let undo = s.ds().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
s.pointer(&ev(kind, c)).unwrap();
}
assert_eq!(s.selection(), vec![f]);
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
// Other tools ignore the label.
s.ui_requests.clear();
s.set_tool(crate::tools::ToolId::Hand);
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
}
+4
View File
@@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("operandOperation".into(), json!(op));
}
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
}
match &n.kind {
NodeKind::Text(t) => {
o.insert("characters".into(), json!(t.characters));
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
}
}
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
/// boxed in, and a side bound to a variable names it.
#[test]
fn css_borders_follow_individual_stroke_sides() {
let mut s = Session::new();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
let c = css(&mut s, r);
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
let col = col["id"].as_str().unwrap().to_string();
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
let c = css(&mut s, r);
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
}
#[test]
fn native_code_for_a_simple_layer() {
let mut s = Session::new();
+128
View File
@@ -0,0 +1,128 @@
//! Variable modes on instances: an instance is on the modes its main component sets, under the
//! ones set on the instance itself, so switching variants or changing the main's mode reaches it.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn fill(s: &Session, n: NodeId) -> String {
s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn set_mode(s: &mut Session, n: NodeId, mode: Value) {
run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode}));
}
/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B.
fn tone(s: &mut Session) {
run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]}));
run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}}));
}
/// A component filled from `c`, on `mode` when one is given.
fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId {
let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name})));
run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"}));
if let Some(m) = mode {
set_mode(s, f, json!(m));
}
id(run(s, "component.create", json!({"ids": [f]})))
}
/// A set "Kind" whose variants A and B each set their own Tone mode.
fn kinds(s: &mut Session) -> (NodeId, NodeId) {
tone(s);
let a = swatch(s, "Kind=A", Some("A"));
let b = swatch(s, "Kind=B", Some("B"));
let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]})));
(set, b)
}
fn instance(s: &mut Session, component: NodeId) -> NodeId {
id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0})))
}
fn switch(s: &mut Session, i: NodeId, kind: &str) {
run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}}));
}
#[test]
fn switching_variants_takes_the_new_variants_mode() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
assert_eq!(fill(&s, i), "FF0000");
switch(&mut s, i, "B");
assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B");
switch(&mut s, i, "A");
assert_eq!(fill(&s, i), "FF0000");
assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own");
}
#[test]
fn instances_follow_a_mode_their_main_sets_later() {
let mut s = Session::new();
tone(&mut s);
let main = swatch(&mut s, "Chip", None);
let i = instance(&mut s, main);
assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default");
set_mode(&mut s, main, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
set_mode(&mut s, main, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "the main back on Auto");
}
#[test]
fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
set_mode(&mut s, i, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
for kind in ["B", "A"] {
switch(&mut s, i, kind);
assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}");
}
set_mode(&mut s, i, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode");
}
#[test]
fn a_main_mode_is_closer_than_the_frame_around_the_instance() {
let mut s = Session::new();
let (_, b) = kinds(&mut s);
let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100})));
set_mode(&mut s, frame, json!("A"));
let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame})));
assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A");
// Nested: an outer component on mode A holding an instance of B.
let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"})));
run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame}));
set_mode(&mut s, outer_frame, json!("A"));
let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]})));
let copy = instance(&mut s, outer);
let nested = s.doc().unwrap().node(copy).unwrap().children[0];
assert_eq!(fill(&s, nested), "0000FF");
}
#[test]
fn detaching_keeps_the_mode_it_took_from_its_main() {
let mut s = Session::new();
let (set, b) = kinds(&mut s);
let i = instance(&mut s, set);
switch(&mut s, i, "B");
run(&mut s, "component.detach", json!({"ids": [i]}));
set_mode(&mut s, b, json!("A"));
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
}
@@ -0,0 +1,190 @@
//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or
//! keep a fixed size of its own, and that override is kept through relayout, edits of the main
//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import,
//! detach and reset.
use base64::Engine;
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{copy_of, ex, healthy, idv, node};
use figcraft_doc::*;
/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding
/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of
/// it. Returns (component, atom, dot, instance).
fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"})));
ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}}));
let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"})));
ex(
s,
"node.set",
json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}),
);
let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}})));
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100})));
(c, atom, dot, i)
}
fn sizing_h(s: &Session, id: NodeId) -> Sizing {
node(s, id).child_layout.sizing_h
}
/// The layer named `name` below instance `inst`.
fn named(s: &Session, inst: NodeId, name: &str) -> NodeId {
let doc = s.doc().unwrap();
doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap()
}
/// The first instance in the document.
fn instance(s: &Session) -> NodeId {
s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id
}
fn open(s: &mut Session, bytes: &[u8], name: &str) {
ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name}));
}
#[test]
fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() {
let mut s = Session::new();
let (c, atom, dot, i) = field(&mut s);
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide");
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance");
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
// Edits of the main relay the instance out: the override stays, other properties follow.
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}}));
assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0));
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}}));
assert_eq!(node(&s, atom).width, 120.0);
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0)));
assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main");
// The main fixing its own width is the same property: the instance keeps filling.
ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}}));
assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0));
// A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves.
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0));
healthy(&s);
// Back to the main's resizing: no override left.
ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width));
assert!(!node(&s, ia).overrides.contains("resizing"));
healthy(&s);
}
#[test]
fn size_and_resizing_overrides_undo_reset_and_detach() {
let mut s = Session::new();
let (_, atom, dot, i) = field(&mut s);
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0));
assert!(node(&s, ia).overrides.is_empty());
// A fixed size of its own: kept when the main resizes the layer.
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}}));
assert!(node(&s, idot).overrides.contains("size"));
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0));
// A min width of its own.
ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}}));
assert_eq!(node(&s, ia).width, 150.0);
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}}));
assert_eq!(node(&s, ia).width, 150.0);
healthy(&s);
// Reset all changes: the main's values again.
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0));
assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty());
// Detaching keeps what the instance showed.
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
ex(&mut s, "component.detach", json!({"ids": [i]}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None));
healthy(&s);
}
#[test]
fn layers_in_instances_resize_but_keep_their_place() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
let it = copy_of(&s, i, title);
ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}}));
assert_eq!(node(&s, it).width, 150.0, "a text box resizes");
assert!(node(&s, it).overrides.contains("size"));
// Its place, rotation and constraints are still the main's.
for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] {
assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused");
}
// The instance's own resizing is its own, not an override.
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}}));
assert!(!node(&s, i).overrides.contains("resizing"));
healthy(&s);
}
#[test]
fn resizing_overrides_survive_save_and_reopen() {
let mut s = Session::new();
let (_, atom, dot, i) = field(&mut s);
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}}));
let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] {
let mut s = Session::new();
open(&mut s, &bytes, name);
let i = instance(&s);
let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot"));
assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides);
assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides);
// An edit of the main relays the instance out.
let main = |s: &Session, id: NodeId| node(s, id).source.unwrap();
let (atom, dot) = (main(&s, ia), main(&s, idot));
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}}));
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}");
healthy(&s);
}
}
#[test]
fn imported_resizing_overrides_survive_relayout() {
let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}});
let atom = |id: &str, dot_id: &str, w: f64, more: Value| {
let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20},
"layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]});
a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
a
};
let field = |id: &str, ty: &str, y: f64, kids: Value| {
json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL",
"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids})
};
let mut inst =
field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))]));
inst["componentId"] = json!("1:1");
inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]);
let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]}
]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}});
let mut s = Session::new();
open(&mut s, file.to_string().as_bytes(), "field.json");
let i = instance(&s);
let ia = named(&s, i, "Atom");
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
// The main's first edit relays every instance out from it.
let main_atom = node(&s, ia).source.unwrap();
ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}}));
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
healthy(&s);
}
+111 -4
View File
@@ -6,6 +6,8 @@
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
//! edits and are journaled the same way.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml).
#![cfg_attr(test, allow(clippy::disallowed_methods))]
pub mod al_keys;
pub mod annotations;
@@ -24,7 +26,10 @@ pub mod dev_resources;
pub mod devcompare;
pub mod devinspect;
pub mod distance;
pub mod doc_urls;
pub mod drop;
pub mod file_access;
pub mod fonts;
pub mod guard;
pub mod guides;
pub mod inspect;
@@ -43,6 +48,9 @@ pub mod plugins;
pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_colors;
pub mod selection_reorder;
pub mod selection_spacing;
pub mod shape_handles;
pub mod shapebuilder;
pub mod snap;
@@ -369,6 +377,8 @@ pub struct DocState {
pub guide: Option<(NodeId, usize)>,
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
/// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]).
view_from_prefs: bool,
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
@@ -430,6 +440,7 @@ impl DocState {
focus: None,
guide: None,
rotation_origin: None,
view_from_prefs: false,
last_duplicate: None,
};
st.zoom_to_fit();
@@ -593,6 +604,23 @@ impl DocState {
}
}
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
/// system's own size).
pub const UI_SCALE_MIN: f64 = 0.75;
pub const UI_SCALE_MAX: f64 = 3.0;
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`.
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
}
/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a
/// number out of range is clamped, so a settings file edited by hand never costs the other
/// preferences.
fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0))
}
/// App-wide preferences that commands read (nudge amounts, behaviours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -609,6 +637,9 @@ pub struct Prefs {
/// The widths of the main window's left and right panels (the interface keeps them within its
/// limits; other windows have their own while they are open).
pub panel_widths: (f32, f32),
/// The height asked for the main window's pages list, dragged at its lower edge (`None`: a
/// share of the panel; the interface keeps it within its limits).
pub pages_height: Option<f32>,
/// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser
/// language). Languages without a catalog show English.
pub language: String,
@@ -617,9 +648,16 @@ pub struct Prefs {
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
/// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the
/// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and
/// [`UI_SCALE_MAX`]). The canvas zoom is separate.
#[serde(deserialize_with = "read_ui_scale")]
pub ui_scale: f64,
/// Files that have a path are saved by themselves a few seconds after they change (the app's
/// timer; off: only File ▸ Save writes them).
pub auto_save: bool,
/// The desktop app starts on the Home screen instead of in a new file.
pub start_on_home: bool,
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
/// builds, off for development builds.
pub invite_links: Option<bool>,
@@ -655,6 +693,10 @@ pub struct Prefs {
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
/// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id:
/// files opened or made later start with them. Missing: the toggle's default.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub view: BTreeMap<String, bool>,
/// Recently run actions (the Actions palette's Recents), newest first, at most
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
/// `widget:<plugin>`.
@@ -677,10 +719,13 @@ impl Default for Prefs {
show_dimensions: true,
theme: "system".into(),
panel_widths: (240.0, 240.0),
pages_height: None,
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
ui_scale: 1.0,
auto_save: false,
start_on_home: false,
invite_links: None,
plugins: Vec::new(),
plugin_settings: BTreeMap::new(),
@@ -692,6 +737,7 @@ impl Default for Prefs {
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
view: BTreeMap::new(),
recent_actions: Vec::new(),
}
}
@@ -716,6 +762,8 @@ pub struct Clipboard {
/// The page-space bounds of the copied selection.
pub bounds: Option<figcraft_geom::Rect>,
pub source_doc: u64,
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
pub copied_components: std::collections::BTreeSet<NodeId>,
/// The document copied from (pastes into other files follow its mains) and its file path.
pub source: Option<Arc<Document>>,
pub source_path: Option<String>,
@@ -844,6 +892,14 @@ impl Session {
s
}
/// Files new to the session start with the View toggles the preferences keep.
pub fn apply_view_prefs(&mut self) {
for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) {
cmd::view::apply_kept(&self.prefs, &mut ds.view);
ds.view_from_prefs = true;
}
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
@@ -885,6 +941,7 @@ impl Session {
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
}
branches::sync(self);
self.apply_view_prefs();
if textpreview::COMMANDS.contains(&id) {
// A live preview is no edit: no undo step, normalisation or rollback here.
let r = guard::run(id, || (spec.run)(self, params));
@@ -910,6 +967,7 @@ impl Session {
match &result {
Ok(_) => {
self.after_command(spec, before);
self.apply_view_prefs();
if spec.history && id != "edit.repeat" {
self.last_command = Some((id.to_string(), params.clone()));
}
@@ -1069,8 +1127,11 @@ impl Session {
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
let mut doc = (*it.doc).clone();
let mut sel = it.selection.clone();
// What the gesture changes on the selected layers is an explicit edit.
let before = cmd::styles::bound_before(&doc, &it.selection);
let res = f(&mut doc, &mut sel);
if res.is_ok() {
cmd::styles::detach_edited(&mut doc, &before);
normalize(&mut doc, &it.doc);
// The same preview again (a handle held still) keeps the document: a new one would
// re-render the canvas for nothing.
@@ -1137,6 +1198,13 @@ impl Session {
}
ds.clean_selection();
ds.revision += 1;
let (uid, doc) = (ds.uid, ds.doc.clone());
if self.clipboard.source_doc == uid {
self.clipboard.copied_components.retain(|id| {
doc.node(*id)
.is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. }))
});
}
Ok(true)
}
@@ -1431,21 +1499,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
}
/// The layers whose variable bindings an edit may have changed: every bound layer when a variable
/// changed, else the touched layers and everything below those that moved, are new or chose
/// another variable mode (modes are inherited).
/// changed, else the touched layers and everything below those that moved, are new, chose
/// another variable mode or became an instance of another main (modes are inherited, an
/// instance's from its main too), and the instances of a main that chose another mode.
fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>) -> Option<BTreeSet<NodeId>> {
if doc.variables != before.variables {
return None;
}
let main = |n: &Node| match n.kind {
figcraft_doc::NodeKind::Instance { component, .. } => Some(component),
_ => None,
};
let mut out = BTreeSet::new();
for &id in touched {
let Some(n) = doc.node(id) else { continue };
let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent);
if inherited {
let Some(b) = before.node(id) else {
out.extend(doc.subtree(id));
continue;
};
let modes = b.var_modes != n.var_modes;
if modes || b.parent != n.parent || main(b) != main(n) {
out.extend(doc.subtree(id));
} else {
out.insert(id);
}
if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) {
for i in components::instances_of(doc, id) {
out.extend(doc.subtree(i));
}
}
}
Some(out)
}
@@ -1525,6 +1607,8 @@ mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod branch_versions_tests;
@@ -1567,6 +1651,8 @@ mod devfocus_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod doc_urls_tests;
#[cfg(test)]
mod double_click_tests;
#[cfg(test)]
mod draw_tools_tests;
@@ -1619,12 +1705,18 @@ mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod instance_modes_tests;
#[cfg(test)]
mod instance_resizing_tests;
#[cfg(test)]
mod interaction_rollback_tests;
#[cfg(test)]
mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_path_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
@@ -1651,6 +1743,8 @@ mod menu_gaps_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
@@ -1673,12 +1767,16 @@ mod panels_behaviour_tests;
#[cfg(test)]
mod pdf_print_tests;
#[cfg(test)]
mod pen_end_tests;
#[cfg(test)]
mod pen_overlay_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod playground_tests;
#[cfg(test)]
mod plugin_data_tests;
#[cfg(test)]
mod plugin_directory_tests;
#[cfg(test)]
mod plugin_panels_tests;
@@ -1705,6 +1803,8 @@ mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod rest_slot_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;
@@ -1777,6 +1877,8 @@ mod variables_behaviour_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod variables_import_shapes_tests;
#[cfg(test)]
mod variables_io_tests;
#[cfg(test)]
mod vector_behaviour_tests;
@@ -1792,3 +1894,8 @@ mod version_tests;
mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod selection_reorder_tests;
#[cfg(test)]
mod selection_spacing_tests;
+34 -5
View File
@@ -449,9 +449,27 @@ struct Stamp {
#[derive(Clone, Debug)]
struct Entry {
stamp: Option<Stamp>,
/// When the file was read: a stamp only proves the file unchanged once its modification time
/// is safely older than that, since file systems keep the time in coarse ticks.
read_at: Option<std::time::SystemTime>,
result: std::result::Result<Arc<PublishedLibrary>, String>,
}
/// How far a file's modification time must lie before its read for the stamp to be trusted.
const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2);
impl Entry {
/// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on
/// every load until the clock passes it.
fn still_valid(&self, now: &Stamp) -> bool {
let settled = match (now.modified, self.read_at) {
(Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at),
_ => false,
};
settled && self.stamp.as_ref() == Some(now)
}
}
/// Library files read by a session, by path (re-read when they change on disk).
#[derive(Clone, Debug, Default)]
pub struct Cache {
@@ -481,10 +499,11 @@ impl Cache {
return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024)));
}
if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp)
&& e.stamp.as_ref() == Some(st)
&& e.still_valid(st)
{
return e.result.clone().map_err(EngineError::Other);
}
let read_at = now();
let result = match &stamp {
Ok(_) => read_file(path).map(Arc::new),
Err(e) => Err(e.clone()),
@@ -492,7 +511,7 @@ impl Cache {
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
self.entries.clear();
}
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() });
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() });
result.map_err(EngineError::Other)
}
@@ -506,13 +525,23 @@ impl Cache {
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
self.entries.clear();
}
self.entries.insert(path.to_string(), Entry { stamp: None, result });
self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result });
}
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> Option<std::time::SystemTime> {
Some(std::time::SystemTime::now())
}
#[cfg(target_arch = "wasm32")]
fn now() -> Option<std::time::SystemTime> {
None
}
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
}
@@ -526,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
match figcraft_format::read_library(std::io::BufReader::new(f)) {
Ok(Some(lib)) => Ok(lib),
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
+30
View File
@@ -0,0 +1,30 @@
//! A library link is stored in the document, so it is followed only on this computer
//! ([`crate::library_server`]): a path on another server would make opening someone else's file
//! contact that server, and on Windows send the credentials of whoever opened it.
use crate::Session;
#[test]
fn a_library_is_opened_only_from_this_computer() {
let mut s = Session::new();
for path in [
r"\\attacker.example\share\lib.figcraft",
"//attacker.example/share/lib.figcraft",
"file://attacker.example/share/lib.figcraft",
r"\\?\UNC\attacker.example\share\lib.figcraft",
r"\\.\pipe\lib",
"https://attacker.example/lib.figcraft",
] {
let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused"));
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
}
}
#[test]
fn a_local_library_path_is_still_followed() {
let mut s = Session::new();
// Refused for being absent, not for where it is: the gate let it through.
let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft");
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
}
+7
View File
@@ -129,6 +129,13 @@ impl Session {
/// document uses the library.
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
if !is_server_link(path) {
// A library link is stored in the document, so opening someone else's file would
// otherwise reach whatever path it names. Only a path on this computer is followed: a
// network share would make opening a document contact that server, and on Windows
// send the credentials of whoever opened it.
if !craft_docpath::is_local(path) {
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
}
return self.libraries.load(path);
}
let have = match self.libraries.cached(path) {
+18
View File
@@ -445,3 +445,21 @@ fn library_survives_saving_with_versions() {
healthy(&user);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
use std::time::{Duration, SystemTime};
let read_at = SystemTime::now();
let entry = |modified: SystemTime| Entry {
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
read_at: Some(read_at),
result: Err("unused".into()),
};
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
let fresh = entry(read_at);
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
// Written well before the read: an unchanged stamp means an unchanged file.
let old = read_at - Duration::from_secs(10);
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
}
+5 -1
View File
@@ -81,11 +81,15 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
/// `#RRGGBB` → (r, g, b); `None` for anything else.
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some((byte(0)?, byte(2)?, byte(4)?))
}
/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the
/// limit in MB). The UI translates it.
pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)";
/// The connection to a live session.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "state")]
+8
View File
@@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() {
assert!(e.contains("join a file"), "{e}");
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
}
#[test]
fn parse_color_rejects_signs_and_non_hex() {
use crate::live::parse_color;
assert_eq!(parse_color("#+1+2+3"), None);
assert_eq!(parse_color("#-1-2-3"), None);
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
}
@@ -0,0 +1,95 @@
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
//! `fontStyle` the family lacks says which face draws instead.
use serde_json::{Value, json};
use crate::{EngineError, Session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> crate::NodeId {
crate::params::id_of(v).unwrap()
}
fn bad_msg(r: crate::Result<Value>) -> String {
match r {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
let mut s = Session::new();
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]);
for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] {
let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}})));
assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}");
assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}");
}
// Frames still take them, with or without a layout mode first.
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
assert!(out.get("warnings").is_none(), "{out}");
}
#[test]
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
let mut s = Session::new();
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
let doc = s.doc().unwrap();
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
let w = out["warnings"][0].as_str().unwrap();
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
// One undo step for all of it.
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
}
#[test]
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
figcraft_text::scan_system_fonts();
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
let st = figcraft_text::family_styles(&f);
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
});
let Some((family, styles)) = pick else { return };
let mut s = Session::new();
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
let out = set(&mut s, &style);
assert!(out.get("warnings").is_none(), "{style}: {out}");
}
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
let out = set(&mut s, "Black");
let w = out["warnings"][0].as_str().unwrap();
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
assert!(out.get("warnings").is_none(), "{out}");
}
#[test]
fn a_refused_call_changes_nothing() {
let mut s = Session::new();
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
let before = s.doc().unwrap().node(t).unwrap().fills.clone();
// `fill` comes before `gap` (keys apply in order), so this checks the rollback.
assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err());
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before);
}
+72
View File
@@ -0,0 +1,72 @@
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::vectoredit::VTool;
use figcraft_doc::NodeKind;
fn click(s: &mut Session, x: f64, y: f64) {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
}
}
fn layers(s: &Session) -> usize {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
}
/// (points, segments) of vector `id`.
fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) {
match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
k => panic!("not a vector: {k:?}"),
}
}
/// A two-point pen path, ended with `end`.
fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
click(&mut s, 200.0, 100.0);
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
let undo = s.undo_depth().0;
end(&mut s);
assert!(s.tool.pen.is_none(), "the path ended");
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector");
assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end");
(s, id)
}
#[test]
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
// Escape, and Enter (its key runs `pen_end`).
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
for end in ends {
let (mut s, id) = drawn(end);
// The next path goes into the same layer, unconnected to the first.
click(&mut s, 100.0, 300.0);
click(&mut s, 200.0, 300.0);
assert_eq!(layers(&s), 1, "no new layer");
assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network");
// Escape ends that path, the next one leaves edit mode.
s.escape().unwrap();
assert!(s.ds().unwrap().vector_edit.is_some());
s.escape().unwrap();
assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode");
assert_eq!(s.ds().unwrap().selection, vec![id]);
}
}
#[test]
fn escape_before_a_segment_leaves_nothing_to_edit() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
s.escape().unwrap();
assert!(s.tool.pen.is_none());
assert!(s.ds().unwrap().vector_edit.is_none());
assert_eq!(layers(&s), 0);
}
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
if let Some(path) = string(p, "path") {
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
let png = crate::cmd::file::export_bytes(&copy, &[inst], ExportFormat::Png, scale)?;
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::file_access::write(&path, &png)?;
out["path"] = json!(path);
}
Ok(out)
+30
View File
@@ -0,0 +1,30 @@
//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what
//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a
//! layer without any reports none.
use crate::Session;
use crate::components::tests::{ex, idv};
use serde_json::json;
#[test]
fn node_get_reports_the_layer_data_node_set_wrote() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let page = s.ds().unwrap().page;
assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field");
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}}));
ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}}));
let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone();
assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored");
assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too");
assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none");
// The document tree reports it as well (the page and its children).
let tree = crate::inspect::document(&s, 2, false);
assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"}));
// A null removes a key; undo brings it back.
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}}));
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2");
}
+11 -4
View File
@@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
// The install folder is the app's own (set by the desktop app, never by a command), so automation
// roots don't apply to it.
#[allow(clippy::disallowed_methods)]
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
#[cfg(not(target_arch = "wasm32"))]
@@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option
}
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
#[allow(clippy::disallowed_methods)]
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
@@ -208,7 +212,7 @@ pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
use crate::plugins::{module_file, read_file};
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
if crate::file_access::is_dir(&file) {
file.push(MANIFEST_FILE);
}
let text =
@@ -217,9 +221,12 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
let wasm = module_file(&file, &m)?;
let mut assets = Vec::new();
let dir = file.parent().map(|d| d.join(ASSETS));
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> =
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> = entries
.into_iter()
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
.collect();
names.sort();
if names.len() > MAX_ASSETS {
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
+20 -9
View File
@@ -251,12 +251,11 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
/// Read a file of at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
let shown = path.display();
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let meta = crate::file_access::metadata(path)?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
crate::file_access::read(path)
}
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
@@ -264,7 +263,7 @@ pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
if file.is_dir() {
if crate::file_access::is_dir(&file) {
file.push(MANIFEST_FILE);
}
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
@@ -295,10 +294,22 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
let Some(path) = &p.path else {
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
};
#[cfg(not(target_arch = "wasm32"))]
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
#[cfg(target_arch = "wasm32")]
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
module_from_disk(s, p, path)
}
#[cfg(not(target_arch = "wasm32"))]
fn module_from_disk(s: &Session, p: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
let file = std::path::Path::new(path);
let read = || module_file(file, &p.manifest).map(Arc::new);
// A plugin installed from a directory lives in the app's own install folder, not among the
// files automation roots confine.
let own = p.source.is_some() && s.plugins.install_dir.as_deref().is_some_and(|d| crate::file_access::within_app_folder(file, d));
if own { crate::file_access::app_owned(read) } else { read() }
}
#[cfg(target_arch = "wasm32")]
fn module_from_disk(_: &Session, _: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
Err(EngineError::Other(format!("{path}: there are no files in the browser")))
}
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
+91
View File
@@ -0,0 +1,91 @@
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
//! moves of the mains, and through saving.
use crate::Session;
use crate::components::tests::{ex, healthy, idv};
use crate::components::*;
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
fn text(id: &str, s: &str) -> Value {
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
/// holds a text and a chip.
fn file() -> Value {
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]},
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
]}]}})
}
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
}
/// The instance's slot and the names of what it holds.
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
let doc = s.doc().unwrap();
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
(slot, names)
}
#[test]
fn rest_slot_content_survives_main_edits_moves_and_saving() {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
healthy(&s);
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
let (slot, names) = slot_content(&s);
assert_eq!(names, want);
let doc = s.doc().unwrap();
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
assert!(slot_edited(doc, slot), "the instance's own content");
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
assert!(!inside_instance(doc, chip), "content stands alone");
// Edit and move both mains (the container's and the one used in the content).
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
let title = doc.node(main).unwrap().children[0];
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
healthy(&s);
assert_eq!(slot_content(&s).1, want, "kept across syncs");
// Saving and opening again keeps it.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
let mut again = Session::new();
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
healthy(&again);
let (slot, names) = slot_content(&again);
assert_eq!(names, want);
assert!(slot_edited(again.doc().unwrap(), slot));
// Resetting the slot shows the main's (empty) default content.
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
assert!(slot_content(&again).1.is_empty());
}
+218
View File
@@ -0,0 +1,218 @@
//! Paints used by the selection, including descendants and effective text-range fills.
//! Targets name paint slots rather than colors: a live edit must not capture other slots when
//! its color happens to become equal to another selection color.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PaintTarget {
pub id: NodeId,
pub fills: bool,
pub index: usize,
/// Character offsets, not byte offsets. None means the node's fill or stroke list.
pub range: Option<(usize, usize)>,
}
impl PaintTarget {
pub fn paints(&self, doc: &Document) -> Option<Vec<Paint>> {
let n = doc.node(self.id)?;
if let Some((start, end)) = self.range {
let t = n.kind.text()?;
if !self.fills || start > end || end > t.char_count() || (start == end && end != 0) {
return None;
}
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
} else {
Some(if self.fills { n.fills.clone() } else { n.strokes.clone() })
}
}
pub fn paint(&self, doc: &Document) -> Option<Paint> {
self.paints(doc)?.get(self.index).cloned()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct SelectionColor {
pub paint: Paint,
pub style: Option<String>,
pub variable: Option<String>,
pub targets: Vec<PaintTarget>,
}
/// Deterministic order, one target per slot even when a parent and child are both selected.
/// Images and patterns are assets rather than selection colors. Hidden paints are omitted.
pub fn collect(doc: &Document, ids: &[NodeId]) -> Vec<SelectionColor> {
let mut groups: Vec<SelectionColor> = Vec::new();
let mut seen = BTreeSet::new();
let mut by_paint: BTreeMap<(Option<String>, Option<String>, String), usize> = BTreeMap::new();
let mut pending: Vec<_> = ids.iter().rev().copied().collect();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let Some(n) = doc.node(id) else { continue };
if n.is_mask {
continue;
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
pending.extend(n.children.iter().rev().copied());
}
let mut add = |paints: &[Paint], fills: bool, range: Option<(usize, usize)>| {
for (index, paint) in paints.iter().enumerate() {
if !paint.visible || !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
continue;
}
let target = PaintTarget { id, fills, index, range };
let inherited = range.is_none_or(|(start, _)| n.kind.text().is_some_and(|t| t.style_at(start).fills.is_none()));
let style = inherited.then(|| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten();
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let variable = inherited
.then(|| {
n.bound.iter().find_map(|(key, var)| {
list_binding(key).filter(|(l, i, field)| *l == list && *i == index && *field == "color").map(|_| var.clone())
})
})
.flatten();
let Ok(value) = serde_json::to_string(paint) else { continue };
let key = (style.clone(), variable.clone(), value);
if let Some(index) = by_paint.get(&key).copied() {
if let Some(g) = groups.get_mut(index) {
g.targets.push(target);
}
} else {
by_paint.insert(key, groups.len());
groups.push(SelectionColor { paint: paint.clone(), style, variable, targets: vec![target] });
}
}
};
if let NodeKind::Text(t) = &n.kind {
let spans = t.spans();
if spans.iter().all(|(_, _, style)| style.fills.is_none()) {
add(&n.fills, true, None);
} else {
for (start, end, style) in spans {
add(style.fills.as_deref().unwrap_or(&n.fills), true, Some((start, end)));
}
}
} else {
add(&n.fills, true, None);
}
add(&n.strokes, false, None);
}
groups
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"selection.colors",
"Selection colors",
"{ids?} → {colors: [{paint, targets}]}; includes descendants and text ranges",
false,
|s, p| { Ok(json!({"colors": collect(s.doc()?, &targets(s, p))})) }
),
spec!("selection.setPaint", "Change selection color", "{targets: [{id, fills, index, range: [start,end] | null}], paint}", true, set_paint),
spec!(
"selection.applyColorStyle",
"Apply selection color style",
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
true,
apply_style
),
spec!(
"selection.selectColor",
"Select layers with color",
"{targets} → {ids}; selects the layers owning these paint slots",
false,
select_color
),
]
}
fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>> {
let ts: Vec<PaintTarget> = parse(cmd, p, "targets")?.ok_or_else(|| bad(cmd, "`targets` is required"))?;
if ts.is_empty() {
return Err(bad(cmd, "`targets` must not be empty"));
}
for t in &ts {
if t.paint(s.doc()?).is_none() {
return Err(bad(cmd, "a selection color target no longer exists"));
}
}
Ok(ts)
}
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.setPaint";
let targets = checked_targets(s, p, CMD)?;
let paint = get(p, "paint").and_then(paint_of).ok_or_else(|| bad(CMD, "`paint` must be a paint"))?;
for target in &targets {
let mut paints = target.paints(s.doc()?).ok_or_else(|| bad(CMD, "invalid target"))?;
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
if *slot == paint {
continue;
}
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
// Variables detach only where the edit changed their value; range fills override the node's
// paints, whose bindings still belong to the other characters.
if let Some((start, end)) = target.range {
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
}
continue;
}
if target.fills {
n.fills = paints;
n.styles.fill = None;
} else {
n.strokes = paints;
n.styles.stroke = None;
}
rebind_edited_paint(n, target.fills, target.index, &old);
}
Ok(json!({"ok": true}))
}
fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.applyColorStyle";
let targets = checked_targets(s, p, CMD)?;
let name = req_string(CMD, p, "style")?;
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(CMD, "a color style is required"));
}
for target in &targets {
if let Some((start, end)) = target.range {
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
// values just as an explicit range fill does; never replace its font or size.
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
let n = s.doc_mut()?.get_mut(target.id)?;
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
}
} else {
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
}
}
Ok(json!({"ok": true}))
}
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
let ts = checked_targets(s, p, "selection.selectColor")?;
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
crate::cmd::edit::select_and_page(s, ids.clone())?;
Ok(json!({"ids": ids}))
}
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more