Compare commits

...
Author SHA1 Message Date
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 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
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 0008fc7901 #878: move the parse_color test into live_tests 2026-10-19 22:18:15 +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
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 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
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
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
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
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
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
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
137 changed files with 11573 additions and 595 deletions

No files matched your search

+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) |
+1 -1
View File
@@ -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).
+4
View File
@@ -316,6 +316,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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. |
+3 -3
View File
@@ -9,7 +9,7 @@
/// 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.
@@ -71,8 +71,8 @@ pub fn parse(args: &[String]) -> Args {
};
if let Some(roots) = roots {
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
} else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if 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) {
+8
View File
@@ -16,6 +16,14 @@ fn files_and_the_control_port() {
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"]));
+22 -3
View File
@@ -193,10 +193,8 @@ 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
@@ -268,6 +266,14 @@ 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 }
@@ -292,3 +298,16 @@ mod linux_window_tests {
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");
}
}
+11
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]
@@ -56,3 +61,9 @@ 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()),
}
};
+5 -1
View File
@@ -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();
}
+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));
}
+28 -10
View File
@@ -949,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();
+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"));
}
+74
View File
@@ -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()?;
+51
View File
@@ -27,6 +27,13 @@ pub fn specs() -> Vec<CommandSpec> {
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),
@@ -528,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);
@@ -551,6 +559,49 @@ 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()
}
+21 -2
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> {
@@ -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")?;
+34 -7
View File
@@ -261,7 +261,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) {
@@ -515,6 +515,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 +600,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 +631,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 +645,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 +680,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 +952,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}))
}
@@ -1181,10 +1206,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
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;
@@ -1342,7 +1369,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 {
@@ -1376,8 +1403,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}`")))?
+39 -3
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",
@@ -1374,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`.
+82 -14
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.
@@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[
"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`).
@@ -315,18 +363,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
Ok(ensure_layout(n))
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
// Geometry keys first so rotation applies to the final size.
let order = |k: &str| match k {
/// 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 {
@@ -398,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"))?,
@@ -462,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" => {
@@ -486,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,
+22 -17
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();
+121 -6
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}))
@@ -950,6 +960,104 @@ 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.
@@ -1007,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() {
@@ -1021,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();
@@ -1030,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) {
+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}))
}
),
]
}
+40 -1
View File
@@ -370,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);
@@ -409,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")?;
@@ -522,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);
}
}
+50 -5
View File
@@ -259,6 +259,25 @@ fn stroke_color(n: &Node) -> Option<Color> {
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> {
n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind {
EffectKind::DropShadow(s) => Some(s),
@@ -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}");
}
}
}
}
+2
View File
@@ -285,6 +285,8 @@ 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)
@@ -82,6 +82,118 @@ 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 overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
@@ -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");
}
@@ -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}");
}
+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();
+70 -10
View File
@@ -1,13 +1,15 @@
//! 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.
//! 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;
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
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.
@@ -79,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
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
)]
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)]
+89
View File
@@ -124,3 +124,92 @@ 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());
}
+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);
}
+63 -4
View File
@@ -49,6 +49,7 @@ 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;
@@ -603,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)]
@@ -630,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>,
@@ -698,7 +723,9 @@ impl Default for Prefs {
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(),
@@ -735,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>,
@@ -1098,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.
@@ -1166,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)
}
@@ -1460,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)
}
@@ -1652,6 +1705,8 @@ 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;
@@ -1720,6 +1775,8 @@ 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;
@@ -1838,5 +1895,7 @@ mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod selection_reorder_tests;
#[cfg(test)]
mod selection_spacing_tests;
+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)));
}
+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");
}
+7 -34
View File
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
@@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
Ok(ts)
}
/// Detach variables only where the explicit edit changed their value. In particular, an
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
// Range fills override the node's paints; the node binding still belongs to other chars.
if t.range.is_some() {
return;
}
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
n.bound.retain(|key, _| {
let Some((l, index, field)) = list_binding(key) else { return true };
if l != list || index != t.index {
return true;
}
match field {
"color" => match (&old.kind, &new.kind) {
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
_ => false,
},
"opacity" => old.opacity == new.opacity,
_ => {
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
match (stop, &old.kind, &new.kind) {
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
}
}
}
});
}
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.setPaint";
let targets = checked_targets(s, p, CMD)?;
@@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
unbind_changed(n, target, &old, &paint);
// 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()));
}
} else if target.fills {
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}))
}
+106
View File
@@ -0,0 +1,106 @@
//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns
//! and complete top-left-aligned grids have unambiguous insertion slots.
//! Marking several items and irregular grids are out of scope.
use figcraft_doc::{Document, NodeId};
use figcraft_geom::{Point, Rect};
use crate::selection_spacing::selection_spacing;
#[derive(Clone, Debug)]
pub struct ReorderLayout {
/// Visual order, independent of selection order and layer stacking.
pub slots: Vec<(NodeId, Rect)>,
columns: usize,
rows: usize,
gap_x: f64,
gap_y: f64,
}
impl ReorderLayout {
pub fn new(doc: &Document, ids: &[NodeId]) -> Option<Self> {
let layout = selection_spacing(doc, ids);
let h = layout.horizontal.as_ref();
let v = layout.vertical.as_ref();
let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis {
Some(a) => a.value.filter(|v| *v >= 0.0),
None => Some(0.0),
};
let (gap_x, gap_y) = (gap(h)?, gap(v)?);
let columns = h.map_or(1, |a| a.tracks.len());
let rows = v.map_or(1, |a| a.tracks.len());
if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() {
return None;
}
let mut cells = std::collections::BTreeMap::new();
for id in ids {
let col = match h {
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
None => 0,
};
let row = match v {
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
None => 0,
};
let b = doc.world_bounds(*id)?;
if columns > 1 && rows > 1 {
let x = h?.tracks.get(col)?.bounds.x0;
let y = v?.tracks.get(row)?.bounds.y0;
if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 {
return None;
}
}
if cells.insert((row, col), (*id, b)).is_some() {
return None;
}
}
Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y })
}
/// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and
/// original gaps are retained, so unequal-sized layers reflow instead of overlapping.
pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option<Vec<(NodeId, Point)>> {
let from = self.slots.iter().position(|(id, _)| *id == source)?;
let to = self.slots.iter().position(|(id, _)| *id == target)?;
if from == to {
return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect());
}
let mut order = self.slots.clone();
if swap {
order.swap(from, to);
} else {
let item = order.remove(from);
order.insert(to, item);
}
let mut widths = vec![0.0_f64; self.columns];
let mut heights = vec![0.0_f64; self.rows];
for (i, (_, b)) in order.iter().enumerate() {
let w = widths.get_mut(i % self.columns)?;
*w = w.max(b.width());
let h = heights.get_mut(i / self.columns)?;
*h = h.max(b.height());
}
let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min);
let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min);
let starts = |sizes: &[f64], mut at: f64, gap: f64| {
sizes
.iter()
.map(|size| {
let start = at;
at += size + gap;
start
})
.collect::<Vec<_>>()
};
let xs = starts(&widths, x0, self.gap_x);
let ys = starts(&heights, y0, self.gap_y);
order
.iter()
.enumerate()
.map(|(i, (id, b))| {
let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? };
let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? };
Some((*id, Point::new(x, y)))
})
.collect()
}
}
@@ -0,0 +1,152 @@
use crate::{Session, selection_reorder::ReorderLayout};
use figcraft_doc::NodeId;
use serde_json::json;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
}
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
#[test]
fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() {
for vertical in [false, true] {
let mut s = Session::new();
let mut ids = vec![];
for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] {
ids.push(rect(
&mut s,
if vertical { 0. } else { at },
if vertical { at } else { 0. },
if vertical { 30. } else { size },
if vertical { size } else { 30. },
None,
));
}
let before = s.doc().unwrap().clone();
// Explicit selection order must not determine visual order.
s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap();
for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] {
assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) });
let old = before.node(id).unwrap();
let new = s.doc().unwrap().node(id).unwrap();
assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent));
}
assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children);
s.undo().unwrap();
assert_eq!(s.doc().unwrap(), &before);
s.redo().unwrap();
assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) });
}
}
#[test]
fn grids_insert_across_rows_and_swap_only_the_two_entries() {
let mut s = Session::new();
let mut ids = vec![];
for row in 0..2 {
for col in 0..3 {
ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None));
}
}
let before = s.doc().unwrap().clone();
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap();
assert_eq!(xy(&s, ids[0]), (70., 60.));
assert_eq!(xy(&s, ids[3]), (140., 0.));
assert_eq!(xy(&s, ids[5]), (140., 60.));
s.undo().unwrap();
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap();
assert_eq!(xy(&s, ids[0]), (70., 60.));
assert_eq!(xy(&s, ids[4]), (0., 0.));
for id in [ids[1], ids[2], ids[3], ids[5]] {
assert_eq!(s.doc().unwrap().node(id), before.node(id));
}
}
#[test]
fn grid_reflow_keeps_gaps_with_different_sizes() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 40., 30., None);
let b = rect(&mut s, 50., 0., 60., 30., None);
let c = rect(&mut s, 0., 50., 40., 50., None);
let d = rect(&mut s, 50., 50., 60., 50., None);
s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap();
assert_eq!(xy(&s, b), (0., 0.));
assert_eq!(xy(&s, c), (70., 0.));
assert_eq!(xy(&s, d), (0., 70.));
assert_eq!(xy(&s, a), (70., 70.));
}
#[test]
fn previews_are_absolute_and_returning_to_start_is_a_noop() {
let mut s = Session::new();
let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect();
s.execute("select.set", &json!({"ids":ids})).unwrap();
let before = s.doc().unwrap().clone();
let history = s.undo_depth().0;
s.begin_interaction("Reorder selection").unwrap();
for target in [ids[2], ids[1], ids[0]] {
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth().0, history);
s.begin_interaction("Reorder selection").unwrap();
for target in [ids[1], ids[2]] {
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(s.undo_depth().0, history + 1);
assert_eq!(s.selection(), ids);
s.undo().unwrap();
assert_eq!(s.doc().unwrap(), &before);
}
#[test]
fn invalid_selections_and_targets_do_not_edit_the_document() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 160., 0., 50., 40., None);
for args in [
json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps.
json!({"ids":[a,b],"source":c,"target":b}),
json!({"ids":[a,b],"source":a,"target":999999}),
json!({"ids":[a,a,b],"source":a,"target":b}),
json!({"ids":[a,b],"source":[],"target":b}),
] {
let before = s.doc().unwrap().clone();
assert!(s.execute("arrange.reorder", &args).is_err());
assert_eq!(s.doc().unwrap(), &before);
}
for props in [json!({"locked":true}), json!({"visible":false})] {
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none());
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
}
let d = rect(&mut s, 0., 70., 50., 40., None);
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots");
let frame =
NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap());
let x = rect(&mut s, 0., 0., 50., 40., Some(frame));
let y = rect(&mut s, 70., 0., 50., 40., Some(frame));
assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none());
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none());
}
#[test]
fn reordering_children_of_a_rotated_parent_uses_page_coordinates() {
let mut s = Session::new();
let frame = NodeId(
s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
);
let a = rect(&mut s, 0., 0., 50., 40., Some(frame));
let b = rect(&mut s, 70., 0., 50., 40., Some(frame));
let (pa, pb) = (xy(&s, a), xy(&s, b));
s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap();
let (qa, qb) = (xy(&s, a), xy(&s, b));
assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7);
assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7);
}
+11
View File
@@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
}
/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the
/// property command it adds its slot property through.
#[test]
fn a_refused_slot_names_make_slot() {
let mut s = Session::new();
let (c, ..) = slot_card(&mut s);
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"})));
let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string();
assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}");
}
#[test]
fn slots_are_frames_of_a_main_with_a_slot_property() {
let mut s = Session::new();
+6
View File
@@ -376,6 +376,7 @@ fn pointer_draw_move_resize() {
assert_eq!(s.selection(), vec![r]);
}
/// No command panics on junk parameters.
#[test]
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
@@ -418,6 +419,9 @@ fn every_command_survives_junk_params() {
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
// Smart-selection reorder targets and modes as junk.
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
@@ -434,6 +438,8 @@ fn every_command_survives_junk_params() {
| "file.placeImage"
| "image.place"
| "file.revert"
// Takes no params and walks the font folders: one walk per junk is slow.
| "text.rescanFonts"
) {
continue;
}
+3 -1
View File
@@ -782,6 +782,7 @@ mod tests {
assert!(r.is_null() || r["axes"].is_array());
}
/// No text command panics on junk parameters while a text is being edited.
#[test]
fn text_commands_survive_junk_while_editing() {
let junk = [
@@ -793,7 +794,8 @@ mod tests {
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
];
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
for j in &junk {
let (mut s, id) = setup("Hi there\nyou");
begin(&mut s, id, Some(2), false).unwrap();
@@ -279,6 +279,34 @@ fn bindings_by_field_and_type() {
}
}
#[test]
fn auto_layout_bindings_go_with_the_auto_layout() {
// Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings,
// and a binding never brings an auto layout back. One made without auto layout waits for it.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12}));
let removals =
[("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))];
for (cmd, mut p) in removals {
let f = frame(&mut s, None);
run(&mut s, "layout.add", json!({"ids": [f]}));
for field in ["gap", "crossGap", "paddingTop"] {
run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"}));
}
p["ids"] = json!([f]);
run(&mut s, cmd, p.clone());
let n = node(&s, f);
assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed");
assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped");
}
let f = frame(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"}));
assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout");
run(&mut s, "layout.add", json!({"ids": [f]}));
assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout");
}
#[test]
fn deleting_a_bound_variable_keeps_the_last_value() {
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
@@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() {
s.undo().unwrap();
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
}
#[test]
fn create_collection_keeps_at_least_one_named_mode() {
// A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode),
// so createCollection refuses an empty mode list, blank mode names and a blank collection name.
let mut s = Session::new();
for p in [
json!({"name": "C", "modes": []}),
json!({"name": "C", "modes": [1]}),
json!({"name": "C", "modes": ["", " "]}),
json!({"name": "C", "modes": ["A", " "]}),
json!({"name": " "}),
json!({"name": ""}),
] {
assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused");
}
assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing");
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]}));
let names: Vec<String> = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect();
assert_eq!(names, ["Light", "Dark"]);
}
#[test]
fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() {
// #809: an edit of a bound field detaches its binding (as a paint edit does) instead of
// the variable's value coming back; bindings whose value the edit kept stay.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48})));
let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40})));
let r = rect(&mut s, None);
for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] {
run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v}));
}
assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4));
run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4}));
let n = node(&s, r);
assert_eq!(n.width, 100.0);
assert!(!n.bound.contains_key("width"));
assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings");
run(&mut s, "transform.resize", json!({"ids": [r], "height": 30}));
assert_eq!(node(&s, r).height, 30.0);
assert!(!node(&s, r).bound.contains_key("height"));
run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"}));
assert_eq!(node(&s, r).opacity, 0.9);
assert!(node(&s, r).bound.is_empty());
// A value set with its binding in the same call keeps the new binding.
run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}}));
assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w)));
}
#[test]
fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8})));
let r = rect(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v}));
s.begin_interaction("Edit").unwrap();
let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]);
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
s.commit_interaction().unwrap();
assert_eq!(node(&s, r).corners.radii, [2.0; 4]);
assert!(node(&s, r).bound.is_empty());
let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
run(&mut s, "text.style", json!({"id": t, "fontSize": 20}));
assert!(node(&s, t).bound.is_empty());
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0);
// The font size/weight/tracking shortcuts and text content edits detach too.
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0);
run(&mut s, "text.sizeUp", json!({"ids": [t]}));
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0);
assert!(node(&s, t).bound.is_empty());
let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c}));
run(&mut s, "text.set", json!({"id": t, "characters": "Bye"}));
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
assert!(node(&s, t).bound.is_empty());
}
+26
View File
@@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() {
assert_eq!(main(&s), variants[1]);
healthy(&s);
}
#[test]
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
let mut s = Session::new();
let r = gradient_rect(&mut s);
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
// Removing the first stop moves both bindings down with their stops.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops.remove(0);
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
// Recolouring one bound stop detaches that stop only.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops[0].color = Color::WHITE;
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
healthy(&s);
}
+64 -18
View File
@@ -368,6 +368,9 @@ struct Mapper<'a> {
blobs: Vec<&'a [u8]>,
/// Component property definitions: id → (REST name "Name#id", type).
prop_defs: BTreeMap<String, (String, &'a str)>,
/// The copies of a set's definitions each variant keeps (no name or type of their own; the
/// variant's layers and its instances' assignments use them): copy id → the definition's id.
prop_parents: BTreeMap<String, String>,
/// File nodes by asset `key` and `version`: references can name a collection, variable or
/// style as the asset instead of the guid, and the file carries the resources to resolve
/// them (the collection node itself says the same `key`).
@@ -402,6 +405,7 @@ impl<'a> Mapper<'a> {
let mut nodes = BTreeMap::new();
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
let mut prop_defs = BTreeMap::new();
let mut prop_parents = BTreeMap::new();
let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default();
// The last change of a GUID wins; a REMOVED change deletes what came before it.
for n in changes {
@@ -423,10 +427,11 @@ impl<'a> Mapper<'a> {
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
}
for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() {
if let (Some(did), Some(name), Some(ty)) =
(d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str))
{
let Some(did) = d.get("id").and_then(guid) else { continue };
if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) {
prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
} else if let Some(parent) = d.get("parentPropDefId").and_then(guid) {
prop_parents.insert(did, parent);
}
}
}
@@ -450,6 +455,7 @@ impl<'a> Mapper<'a> {
kids,
blobs,
prop_defs,
prop_parents,
assets,
styles: Obj::new(),
components: Obj::new(),
@@ -640,7 +646,7 @@ impl<'a> Mapper<'a> {
targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid));
for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() {
if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) {
targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid));
targets.extend(assigned(assignment).and_then(prop_guid));
}
}
let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default();
@@ -651,7 +657,31 @@ impl<'a> Mapper<'a> {
}
fn is_instance_swap(&self, def: &Kv) -> Option<()> {
matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
}
/// The definition a copy of it was made from, followed to the original: the values an instance
/// sets are kept under it, since an assignment and the layers that read it may name different
/// copies of one definition.
fn prop_root(&self, def: &str) -> String {
let mut def = def;
for _ in 0..=MAX_DEPTH {
let Some(parent) = self.prop_parents.get(def) else { break };
def = parent;
}
def.to_string()
}
/// The definition `def` names: its own, or the set's for a variant's copy of it.
fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> {
let mut def = def;
for _ in 0..=MAX_DEPTH {
if let Some(found) = self.prop_defs.get(def) {
return Some(found);
}
def = self.prop_parents.get(def)?;
}
None
}
/// The branches of the internal canvas `id` that hold the `required_mains` (with the frames
@@ -908,7 +938,7 @@ impl<'a> Mapper<'a> {
}
self.prop_refs(&mut o, &view, inst);
let slot_content = if own == "FRAME" {
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned()))
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned()))
} else {
None
};
@@ -1892,11 +1922,11 @@ impl<'a> Mapper<'a> {
// Expanded frames must follow their main frame's source link. A direct slot reference
// makes the engine resolve ownership against the instance, where the property is not defined.
if !(field == "slot" && inst.is_some())
&& let Some((name, _)) = self.prop_defs.get(&def)
&& let Some((name, _)) = self.prop_def(&def)
{
refs.insert(field.into(), json!(name));
}
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
match field {
"visible" => {
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
@@ -1911,7 +1941,7 @@ impl<'a> Mapper<'a> {
if own_text(v) != Some(t) {
o.remove("characterStyleOverrides");
o.remove("styleOverrideTable");
if let Some(tv) = val.get("textValue") {
if let Some(tv) = prop_text_data(val) {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
char_styles(o, tv, size, &mut |w| self.warn(w));
}
@@ -1933,7 +1963,7 @@ impl<'a> Mapper<'a> {
// An instance swap property value picks the main.
if let Some(i) = outer
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
&& let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid)
&& let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p))
{
sym = Some(g);
}
@@ -1945,12 +1975,12 @@ impl<'a> Mapper<'a> {
let assignments = v.list("componentPropAssignments");
let mut props = Obj::new();
for a in assignments {
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue };
let val = a.get("value");
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue };
let val = assigned(a);
let p = match ty {
"BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})),
"TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})),
"INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
"INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
_ => None,
};
if let Some(p) = p {
@@ -2027,11 +2057,11 @@ impl<'a> Mapper<'a> {
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
all_props.entry(d).or_insert(val);
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
all_props.entry(self.prop_root(&d)).or_insert(val);
}
let Some(def) = a.get("defID").and_then(guid) else { continue };
if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
continue;
}
let var_value = a.get("varValue");
@@ -2052,7 +2082,7 @@ impl<'a> Mapper<'a> {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
slot_contents.entry(def).or_insert(target);
slot_contents.entry(self.prop_root(&def)).or_insert(target);
}
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
symbols.push(sym.clone());
@@ -2777,7 +2807,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(Str
/// The text of a component property value.
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str)
}
/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form.
fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
v.get("textValue").or_else(|| v.get("textDataValue"))
}
/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form.
fn prop_guid(v: &Kv) -> Option<String> {
v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid))
}
/// The value a component property assignment sets: `value`, or the variable form `varValue` holds
/// when `value` is empty, as current files write many booleans and instance swaps.
fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value"))
}
/// The property a layer's `field` is bound to (`parameterConsumptionMap`).
+2
View File
@@ -26,6 +26,8 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
+188
View File
@@ -0,0 +1,188 @@
//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a
//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and
//! assignments that carry their value only in the variable form (`varValue`).
use figcraft_doc::NodeKind;
use serde_json::Value;
use super::kiwi::Kv;
use super::map::to_rest;
fn g(local: u32) -> Kv<'static> {
Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))])
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn s(text: &str) -> Kv<'static> {
Kv::Str(text.into())
}
fn node(local: u32, kind: &'static str, name: &str, parent: Option<u32>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))];
if let Some(parent) = parent {
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))])));
}
all.append(&mut fields);
o(all)
}
/// A layer's `field` bound to property definition `def`.
fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) {
let entry = o(vec![
("variableField", Kv::Enum(field)),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])),
]);
("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))]))
}
fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> {
o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)])
}
/// An assignment of `def` that leaves `value` empty and says it in `varValue` only.
fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> {
o(vec![
("defID", g(def)),
("value", o(vec![])),
("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])),
])
}
fn message(nodes: Vec<Kv<'static>>) -> Kv<'static> {
let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
all.extend(nodes);
o(vec![("nodeChanges", Kv::Array(all))])
}
fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value {
fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> {
if v.get("id").and_then(Value::as_str) == Some(id) {
return Some(v);
}
v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id))
}
find(v, id).unwrap_or_else(|| panic!("no layer {id}"))
}
fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap<String, Value> {
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap();
let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") };
props
}
/// A "Field" set with a BOOL definition (9:70), a variant whose "Hint text" reads its copy (9:71),
/// and an instance of the variant that sets the property false through definition `assigned`.
fn field_set(assigned: u32) -> Kv<'static> {
message(vec![
node(
9,
"FRAME",
"Field",
Some(2),
vec![
("isStateGroup", Kv::Bool(true)),
("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])),
],
),
node(
10,
"SYMBOL",
"State=Default",
Some(9),
vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))],
),
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]),
node(
20,
"INSTANCE",
"Instance",
Some(2),
vec![
("symbolData", o(vec![("symbolID", g(10))])),
("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])),
],
),
])
}
#[test]
fn variant_layers_bind_to_the_set_properties_their_copies_name() {
let msg = field_set(71);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN");
assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70");
assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false);
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap();
assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint"));
assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false)));
}
/// An assignment reaches layers that read another copy of its definition: here the instance sets
/// the set's own definition while the variant's layer reads the variant's copy.
#[test]
fn assignments_reach_layers_that_read_another_copy_of_their_definition() {
let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES);
let instance = by_id(&rest["document"], "9:20");
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
}
#[test]
fn property_values_written_only_as_variables_are_applied() {
let msg = message(vec![
node(
10,
"SYMBOL",
"Field",
Some(2),
vec![(
"componentPropDefs",
Kv::Array(vec![
definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])),
definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])),
definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])),
]),
)],
),
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]),
node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]),
node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]),
node(40, "SYMBOL", "Mail", Some(2), vec![]),
node(41, "SYMBOL", "Card", Some(2), vec![]),
node(
20,
"INSTANCE",
"Instance",
Some(2),
vec![
("symbolData", o(vec![("symbolID", g(10))])),
(
"componentPropAssignments",
Kv::Array(vec![
variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])),
variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])),
variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])),
]),
),
],
),
]);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
let instance = by_id(&rest["document"], "9:20");
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card");
assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41");
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card");
assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41");
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let props = instance_props(&doc);
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
}
+4 -3
View File
@@ -65,9 +65,10 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, Stri
if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() {
continue;
}
// Web builds have a size budget: Inter, plus Noto Sans CJK (every CJK script, for CJK UI
// text) when asked.
if wasm && *family != "Inter" && !cjk {
// Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the
// SemiBold wordmark, and optionally Noto Sans CJK (every CJK script, for CJK UI text).
let wordmark = *family == "Archivo" && *style == "SemiBold";
if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+99 -1
View File
@@ -444,9 +444,22 @@ pub struct FontDb {
families: BTreeMap<String, (String, Vec<usize>)>,
/// System faces known by name but not loaded (lower-case family → entries).
pending: BTreeMap<String, (String, Vec<Pending>)>,
/// System faces read from disk (file, face index), so a rescan only offers new ones.
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec<String>>,
system_scanned: bool,
}
/// What a later walk of the font folders found ([`rescan_system_fonts`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rescan {
/// Families that were unknown before.
pub added: Vec<String>,
/// Every family that gained faces: the new ones and those with styles installed since.
pub changed: Vec<String>,
}
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
/// Set by [`scan_system_fonts_on_demand`].
static ON_DEMAND: AtomicBool = AtomicBool::new(false);
@@ -580,6 +593,7 @@ impl FontDb {
};
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
self.add(face);
self.loaded_files.insert((e.path, e.index));
}
}
}
@@ -670,6 +684,35 @@ impl FontDb {
self.faces.len()
}
/// Offer the families of a later walk of the font folders: new families become pending, a
/// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
/// the files it hasn't read (styles installed since) as pending, read when it is next used.
fn merge_rescan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) -> Rescan {
let mut out = Rescan::default();
for (k, (display, entries)) in found {
if self.families.contains_key(&k) {
let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default();
let new: Vec<Pending> = entries
.into_iter()
.filter(|e| {
!self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index)
})
.collect();
if !new.is_empty() {
out.changed.push(display.clone());
self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new);
}
continue;
}
if !self.pending.contains_key(&k) {
out.added.push(display.clone());
}
out.changed.push(display.clone());
self.pending.insert(k, (display, entries));
}
out
}
/// Index the system font directories (desktop only): family names are read now, faces loaded
/// when first used. Safe to call more than once.
pub fn scan_system(&mut self) {
@@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
// buffer, so only those parts are ever touched.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
if meta.len() > MAX_FONT_FILE {
continue;
}
let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue };
for (index, family, style) in faces_in(&bytes) {
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
}
}
}
/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB).
pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024;
/// The parts of a font file the scan reads: headers, table directories and `name` tables, at their
/// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan).
fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
@@ -887,6 +936,55 @@ pub fn scan_system_fonts() {
});
}
/// Walk the system font folders again, for fonts installed while the app runs (the first scan is
/// once per process): the families that were unknown before, and every family that gained faces.
/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`.
pub fn rescan_system_fonts() -> Rescan {
#[cfg(not(target_arch = "wasm32"))]
if !system_fonts_opted_out() {
if SCANNED.get().is_none() {
let before: std::collections::BTreeSet<String> =
raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect();
scan_system_fonts();
let added: Vec<String> = family_names().into_iter().filter(|f| !before.contains(f)).collect();
return Rescan { changed: added.clone(), added };
}
let mut found: BTreeMap<String, (String, Vec<Pending>)> = BTreeMap::new();
for dir in system_font_dirs() {
scan_dir(&dir, 0, &mut found);
}
let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found);
if !rescan.changed.is_empty() {
crate::clear_layout_cache();
}
return rescan;
}
Rescan::default()
}
/// Load font file bytes (every face of a collection) into the global db, for a font that isn't
/// installed. Returns the families of its faces (none: not a font file); the same file again
/// returns them without adding its faces twice.
pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
let key = (h.finish(), bytes.len());
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(families) = db.loaded_bytes.get(&key) {
return families.clone();
}
let faces = db.add_bytes(bytes);
let mut families: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
families.dedup();
if !families.is_empty() {
db.loaded_bytes.insert(key, families.clone());
drop(db);
crate::clear_layout_cache();
}
families
}
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts.
#[cfg(not(target_arch = "wasm32"))]
fn system_fonts_opted_out() -> bool {
+46
View File
@@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() {
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() };
let mut db = pending(&["Regular"]);
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
};
// A new family is added; a pending one takes the walk's files (a new style); both changed.
let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])]));
assert_eq!(r.added, ["Brand Sans"]);
assert_eq!(r.changed, ["Brand Sans", "CJK UI"]);
assert!(db.has_family("brand sans"));
assert_eq!(left(&db), ["Regular", "Bold"]);
// A loaded family keeps its faces and gets only the files it hasn't read, once.
db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new()));
db.pending.remove("brand sans");
db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0));
let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])]));
assert_eq!(same, Rescan::default());
assert!(!db.pending.contains_key("brand sans"));
let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])]));
assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()]));
let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::<Vec<_>>()).unwrap_or_default();
assert_eq!(waiting(&db), ["Black"]);
assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default());
assert_eq!(waiting(&db), ["Black"]);
}
/// Font file bytes add their faces once; junk adds nothing.
#[test]
fn font_bytes_load_into_the_db() {
assert!(load_font_bytes(b"not a font".to_vec()).is_empty());
assert!(load_font_bytes(Vec::new()).is_empty());
// With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added,
// and loading the same file again returns it without adding its faces twice.
if let Some(f) = CRAFT_FONTS.first() {
let families = load_font_bytes(f.bytes.to_vec());
assert_eq!(families.len(), 1);
assert!(families.iter().all(|f| has_family(f)));
let faces = db().read().unwrap().face_count();
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
assert_eq!(db().read().unwrap().face_count(), faces);
}
}
+47 -23
View File
@@ -82,6 +82,9 @@ pub struct Line {
pub x: f64,
/// The line ends a paragraph (hard break or end of text).
pub para_end: bool,
/// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks
/// the line inside its paragraph.
pub break_after: bool,
/// The paragraph runs right to left (its first strong character is right-to-left).
pub rtl: bool,
}
@@ -157,6 +160,15 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 {
if v.is_finite() { v } else { auto }
}
/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft
/// break (imported text keeps it; typed text has newlines only).
const LINE_SEPARATOR: char = '\u{2028}';
/// A char that ends its line: a newline (which also ends the paragraph) or a line separator.
fn is_line_break(c: char) -> bool {
c == '\n' || c == LINE_SEPARATOR
}
/// Opening punctuation, which hangs outside a line's start.
fn opens(c: char) -> bool {
matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}')
@@ -332,7 +344,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
// for a text with emoji, or with characters nothing above covers.
let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() };
fallbacks.extend(emoji.iter().cloned());
drop(dbw);
@@ -370,7 +382,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
{
return f.clone();
}
if c == '\n' || primary.has_char(c) {
if is_line_break(c) || primary.has_char(c) {
return primary.clone();
}
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
@@ -382,13 +394,15 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut seg_end = seg_start + 1;
while seg_end < *end
&& chars.get(seg_end).is_some_and(|c| {
*c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
!is_line_break(*c)
&& !is_line_break(first)
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
})
&& level_at(seg_end) == level
{
seg_end += 1;
}
if first == '\n' {
if is_line_break(first) {
items.push(Item {
face: face.clone(),
coords: no_coords(),
@@ -401,7 +415,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
char_start: seg_start,
char_end: seg_start + 1,
style: st_idx,
ch: '\n',
ch: first,
level,
});
} else {
@@ -465,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
para += 1;
continue;
}
// A line separator (a soft break) breaks the line inside its paragraph: no
// paragraph spacing and no new list marker after it.
if it.ch == LINE_SEPARATOR {
lines.push((line_start, i + 1, false, para));
i += 1;
line_start = i;
x = 0.0;
last_break = None;
para_first_line = false;
continue;
}
let line_indent = line_indent_of(para, para_first_line, it.char_start);
let w = it.adv;
// Hanging punctuation takes no room inside the box.
@@ -491,7 +516,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
i += 1;
}
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) {
let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {
lines.push((line_start, items.len(), true, para));
}
@@ -546,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let baseline = y + (lh - (asc + desc)) / 2.0 + asc;
// Trailing spaces don't count for width/alignment.
let mut vis_end = *e;
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') {
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) {
vis_end -= 1;
}
// Letter spacing separates glyphs: none after the line's last visible one.
@@ -563,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Justify: extra space per gap.
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count();
let justify_extra = match (text.align_h, wrap) {
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0),
(TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {
((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
}
_ => 0.0,
};
// Where the visible text ends once justified.
@@ -574,7 +602,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
.get(vis_end..*e)
.unwrap_or(&[])
.iter()
.filter(|it| it.ch != '\n')
.filter(|it| !is_line_break(it.ch))
.map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 })
.sum();
let mut pen = if rtl_line { -trailing } else { line_indent - hang_l };
@@ -649,7 +677,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
(0..line_items.len()).collect()
};
for it in order.iter().filter_map(|k| line_items.get(*k)) {
if it.ch == '\n' {
if is_line_break(it.ch) {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
@@ -687,6 +715,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
width: width + line_indent - hang_l - hang_r,
x: 0.0,
para_end: *para_end,
break_after: break_after(*e),
rtl: rtl_line,
});
y += lh;
@@ -1142,16 +1171,12 @@ impl TextLayout {
pub fn caret(&self, index: usize) -> (f64, usize) {
for (li, line) in self.lines.iter().enumerate() {
let last_line = li + 1 == self.lines.len();
// A caret at a line's end belongs to the next line, whether the line wraps or ends with a
// line break (before the break it is still on this line: past its glyphs).
let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end));
// A caret at the end of a soft-wrapped line belongs to the next line.
if !(in_line || index == line.char_end && line.para_end && !last_line) {
if !in_line {
continue;
}
if index == line.char_end && line.para_end && !last_line {
// Before the newline: end of this line.
let x = self.line_end_x(line);
return (x, li);
}
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
for g in gl {
if g.char_end == g.char_start {
@@ -1179,8 +1204,8 @@ impl TextLayout {
if !line.rtl && !gl.iter().any(|g| g.rtl) {
return None;
}
// The caret can't sit after a paragraph's newline on its own line.
let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end };
// The caret can't sit after a line's break on its own line.
let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end };
let next = (line.char_start..=end.max(line.char_start))
.filter(|i| *i != index)
.map(|i| (i, self.caret(i).0))
@@ -1247,7 +1272,7 @@ impl TextLayout {
}
}
let last_line = li + 1 == self.lines.len();
let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
// Right of a right-to-left line is its start.
if line.rtl { line.char_start } else { end }
}
@@ -1274,12 +1299,11 @@ impl TextLayout {
out.extend(Self::mixed_selection(gl, a, b, line));
continue;
}
// From the line's own start: `caret(char_start)` after a hard break is the end of the
// line above.
// From the line's own start.
let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 };
let x1 = if b >= line.char_end {
// The last line has no paragraph break to show, so it ends at its glyphs.
self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
} else {
self.caret(b).0
};
+5 -2
View File
@@ -16,8 +16,9 @@ mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand,
system_fonts_scanned,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;
@@ -150,6 +151,8 @@ mod behaviour_tests;
#[cfg(test)]
mod cache_tests;
#[cfg(test)]
mod line_separator_tests;
#[cfg(test)]
mod list_tests;
#[cfg(test)]
mod metrics_tests;
+97
View File
@@ -0,0 +1,97 @@
//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the
//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852).
use super::*;
use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData};
const EPS: f64 = 1e-6;
/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts.
fn text(s: &str) -> Option<TextData> {
scan_system_fonts();
let f = db().write().unwrap().default_face()?;
let mut t = TextData::new(s);
(t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone());
t.style.font_size = 20.0;
t.style.line_height = LineHeight::Pixels(30.0);
t.auto_resize = TextAutoResize::WidthAndHeight;
Some(t)
}
/// The chars of each line.
fn line_texts(t: &TextData, l: &TextLayout) -> Vec<String> {
let chars: Vec<char> = t.characters.chars().collect();
l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::<String>()).collect()
}
#[test]
fn breaks_the_line_but_not_the_paragraph() {
let Some(mut t) = text("one\u{2028}two\nthree") else { return };
t.paragraph_spacing = 10.0;
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]);
assert!(!l.lines[0].para_end && l.lines[0].break_after);
assert!(l.lines[1].para_end && l.lines[1].break_after);
assert!(!l.lines[2].break_after);
// Paragraph spacing after the newline only: 30, then 30 + 10.
assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top);
// The separator draws nothing.
assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3)));
}
#[test]
fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() {
// "Hi,⏎¶Season" in #852: an empty line between the two.
let Some(t) = text("Hi,\u{2028}\nSeason") else { return };
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]);
let Some(t) = text("end\u{2028}") else { return };
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]);
assert!((l.height - 60.0).abs() < EPS, "{}", l.height);
}
#[test]
fn a_list_item_continues_after_it_without_a_new_marker() {
let Some(mut t) = text("item\u{2028}more\nnext") else { return };
t.list = ListStyle::Bulleted;
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]);
// Markers are the glyphs that cover no char: one per paragraph.
assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2);
// The line after the separator lines up with the item's text, past the bullet.
let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x);
let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap());
assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}");
}
#[test]
fn carets_and_clicks_treat_it_like_a_newline() {
let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return };
let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None));
for i in 0..=5 {
assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}");
}
for line in &ln.lines {
for x in [-10.0, 5.0, line.width + 50.0] {
assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top);
}
}
// Before the separator: the end of the first line; after it: the start of the second.
assert_eq!(ls.caret(2).1, 0);
assert!(ls.caret(2).0 > 0.0);
assert_eq!(ls.caret(3), (0.0, 1));
// A click right of the first line lands before the separator, not after it.
assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2);
}
#[test]
fn a_selection_across_it_shows_the_break_like_a_newline() {
let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return };
let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None));
let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5));
assert_eq!((before.len(), across.len()), (1, 2));
// Reaching past the separator adds the 4 px break mark at the first line's end.
assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width());
assert_eq!(across, ln.selection_rects(0, 5));
}
+16
View File
@@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() {
// A break followed by text still doesn't highlight the next line's start.
assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none());
}
#[test]
fn the_caret_after_a_line_break_sits_on_the_next_line() {
if !have_fonts() {
return;
}
// Before the break: the end of its line; after it: the start of the next (after Enter, the
// caret shows on the new line, and Up/Down count from there).
let l = layout(&TextData::new("ab\ncd"), None, None);
assert_eq!(l.caret(2).1, 0);
assert!(l.caret(2).0 > 0.0);
assert_eq!(l.caret(3), (0.0, 1));
// A trailing break: the caret sits on the empty last line.
let l = layout(&TextData::new("ab\n"), None, None);
assert_eq!(l.caret(3), (0.0, 1));
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -930,6 +930,7 @@ status = "draft — needs native review"
"PDF print settings" = "Nastavení tisku PDF"
"Padding" = "Vnitřní odsazení"
"Page" = "Stránka"
"Page deleted" = "Stránka smazána"
"Pages" = "Stránky"
"Pages added to the document" = "Stránky přidány do dokumentu"
"Placed on the current page" = "Umístěno na aktuální stránku"
@@ -1851,6 +1852,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportovat vrstvy"
"file.exportSvg" = "Exportovat výběr jako SVG"
"file.import" = "Importovat"
"file.moveTab" = "Přesunout kartu"
"file.new" = "Nový soubor návrhu"
"file.newFromTemplate" = "Nový ze šablony"
"file.open" = "Otevřít"
+4
View File
@@ -899,6 +899,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-Druckeinstellungen"
"Padding" = "Innenabstand"
"Page" = "Seite"
"Page deleted" = "Seite gelöscht"
"Pages" = "Seiten"
"Pages added to the document" = "Seiten zum Dokument hinzugefügt"
"Placed on the current page" = "Auf der aktuellen Seite platziert"
@@ -1225,6 +1226,7 @@ status = "draft — needs native review"
"Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis"
"Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen"
"Start on path" = "Anfang auf dem Pfad"
"Start on the Home screen" = "Mit Start beginnen"
"Start point" = "Startpunkt"
"Start sharing" = "Teilen starten"
"Starting to share…" = "Teilen wird gestartet…"
@@ -1797,6 +1799,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Ebenen exportieren"
"file.exportSvg" = "Auswahl als SVG exportieren"
"file.import" = "Importieren"
"file.moveTab" = "Tab verschieben"
"file.new" = "Neue Designdatei"
"file.open" = "Öffnen"
"file.openRecent" = "Zuletzt verwendete öffnen"
@@ -1934,6 +1937,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Bewegung reduzieren"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
"pref.startOnHome" = "Mit Start beginnen"
"pref.userName" = "Ihr Name in Kommentaren"
"proto.action.add" = "Prototyp-Aktion hinzufügen"
"proto.action.move" = "Prototyp-Aktion neu anordnen"
+15
View File
@@ -172,6 +172,7 @@ name = "English"
"Both directions" = "Both directions"
"Bottom" = "Bottom"
"Bottom left radius" = "Bottom left radius"
"Bottom padding" = "Bottom padding"
"Bottom right radius" = "Bottom right radius"
"Branch" = "Branch"
"Branch archived" = "Branch archived"
@@ -633,6 +634,7 @@ name = "English"
"Included" = "Included"
"Included in layout" = "Included in layout"
"Individual corners" = "Individual corners"
"Individual padding" = "Individual padding"
"Individual strokes" = "Individual strokes"
"Ink" = "Ink"
"Inner shadow" = "Inner shadow"
@@ -654,6 +656,7 @@ name = "English"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
"Interface size" = "Interface size"
"Intersect" = "Intersect"
"Intersects" = "Intersects"
"Invalid color" = "Invalid color"
@@ -703,6 +706,7 @@ name = "English"
"Leaves" = "Leaves"
"Left" = "Left"
"Left & right" = "Left & right"
"Left padding" = "Left padding"
"Left side" = "Left side"
"Left side width" = "Left side width"
"Letter case" = "Letter case"
@@ -951,6 +955,7 @@ name = "English"
"PDF print settings" = "PDF print settings"
"Padding" = "Padding"
"Page" = "Page"
"Page deleted" = "Page deleted"
"Pages" = "Pages"
"Pages added to the document" = "Pages added to the document"
"Placed on the current page" = "Placed on the current page"
@@ -1062,6 +1067,7 @@ name = "English"
"Reduce motion" = "Reduce motion"
"Refraction" = "Refraction"
"Regular expression" = "Regular expression"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."
"Remove" = "Remove"
"Remove all" = "Remove all"
"Remove annotation" = "Remove annotation"
@@ -1100,6 +1106,7 @@ name = "English"
"Rename on the server…" = "Rename on the server…"
"Rename page" = "Rename page"
"Rename to" = "Rename to"
"Reorder selection" = "Reorder selection"
"Replace all" = "Replace all"
"Replace mixed export settings" = "Replace mixed export settings"
"Replace with" = "Replace with"
@@ -1135,6 +1142,7 @@ name = "English"
"Review and merge" = "Review and merge"
"Ribbon" = "Ribbon"
"Right" = "Right"
"Right padding" = "Right padding"
"Right side" = "Right side"
"Right side width" = "Right side width"
"Rotate 90°" = "Rotate 90°"
@@ -1185,6 +1193,7 @@ name = "English"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
"Search shortcuts" = "Search shortcuts"
"Section" = "Section"
"Section color" = "Section color"
@@ -1299,6 +1308,7 @@ name = "English"
"Start from a blank canvas" = "Start from a blank canvas"
"Start from a template" = "Start from a template"
"Start on path" = "Start on path"
"Start on the Home screen" = "Start on the Home screen"
"Start point" = "Start point"
"Start sharing" = "Start sharing"
"Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives"
@@ -1372,6 +1382,7 @@ name = "English"
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
"The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."
"The whole file" = "The whole file"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%."
"Theme" = "Theme"
"There's no destination for that action" = "There's no destination for that action"
"Thickness" = "Thickness"
@@ -1382,6 +1393,7 @@ name = "English"
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
"This file has unsaved changes" = "This file has unsaved changes"
"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
"This frame is new on the branch." = "This frame is new on the branch."
@@ -1403,6 +1415,7 @@ name = "English"
"Top" = "Top"
"Top & bottom" = "Top & bottom"
"Top left radius" = "Top left radius"
"Top padding" = "Top padding"
"Top right radius" = "Top right radius"
"Transform" = "Transform"
"Triangle arrow" = "Triangle arrow"
@@ -1451,6 +1464,7 @@ name = "English"
"Use number keys for opacity" = "Use number keys for opacity"
"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension."
"Use the layer's fill" = "Use the layer's fill"
"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size."
"Used in {files} · {instances}" = "Used in {files} · {instances}"
"Username" = "Username"
"Value" = "Value"
@@ -1722,6 +1736,7 @@ name = "English"
"{n}m" = "{n}m"
"{n}w" = "{n}w"
"{page}, nothing selected" = "{page}, nothing selected"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (current)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."
"{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)."
+22
View File
@@ -175,6 +175,7 @@ status = "draft — needs native review"
"Both directions" = "Ambas direcciones"
"Bottom" = "Abajo"
"Bottom left radius" = "Radio inferior izquierdo"
"Bottom padding" = "Margen interno inferior"
"Bottom right radius" = "Radio inferior derecho"
"Branch" = "Rama"
"Branch archived" = "Rama archivada"
@@ -634,6 +635,7 @@ status = "draft — needs native review"
"Included" = "Incluidos"
"Included in layout" = "Incluidos en el diseño"
"Individual corners" = "Esquinas individuales"
"Individual padding" = "Margen interno individual"
"Individual strokes" = "Trazos individuales"
"Ink" = "Tinta"
"Inner shadow" = "Sombra interior"
@@ -655,6 +657,7 @@ status = "draft — needs native review"
"Interaction" = "Interacción"
"Interactions" = "Interacciones"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
"Interface size" = "Tamaño de la interfaz"
"Intersect" = "Intersecar"
"Intersects" = "Intersecciones"
"Invalid color" = "Color no válido"
@@ -704,6 +707,7 @@ status = "draft — needs native review"
"Leaves" = "Hojas"
"Left" = "Izquierda"
"Left & right" = "Izquierda y derecha"
"Left padding" = "Margen interno izquierdo"
"Left side" = "Lado izquierdo"
"Left side width" = "Ancho del lado izquierdo"
"Letter case" = "Mayúsculas y minúsculas"
@@ -952,6 +956,7 @@ status = "draft — needs native review"
"PDF print settings" = "Ajustes de impresión PDF"
"Padding" = "Margen interno"
"Page" = "Página"
"Page deleted" = "Página eliminada"
"Pages" = "Páginas"
"Pages added to the document" = "Páginas añadidas al documento"
"Placed on the current page" = "Colocado en la página actual"
@@ -1063,6 +1068,7 @@ status = "draft — needs native review"
"Reduce motion" = "Reducir movimiento"
"Refraction" = "Refracción"
"Regular expression" = "Expresión regular"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente."
"Remove" = "Quitar"
"Remove all" = "Quitar todo"
"Remove annotation" = "Quitar anotación"
@@ -1101,6 +1107,7 @@ status = "draft — needs native review"
"Rename on the server…" = "Cambiar el nombre en el servidor…"
"Rename page" = "Cambiar nombre de página"
"Rename to" = "Nuevo nombre"
"Reorder selection" = "Reordenar selección"
"Replace all" = "Reemplazar todo"
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
"Replace with" = "Reemplazar con"
@@ -1136,6 +1143,7 @@ status = "draft — needs native review"
"Review and merge" = "Revisar y fusionar"
"Ribbon" = "Cinta"
"Right" = "Derecha"
"Right padding" = "Margen interno derecho"
"Right side" = "Lado derecho"
"Right side width" = "Ancho del lado derecho"
"Rotate 90°" = "Girar 90°"
@@ -1186,6 +1194,7 @@ status = "draft — needs native review"
"Search results" = "Resultados de búsqueda"
"Search fonts" = "Buscar fuentes"
"Search layers" = "Buscar capas"
"Search pages" = "Buscar páginas"
"Search shortcuts" = "Buscar atajos"
"Section" = "Sección"
"Section color" = "Color de sección"
@@ -1300,6 +1309,7 @@ status = "draft — needs native review"
"Start from a blank canvas" = "Empieza con un lienzo en blanco"
"Start from a template" = "Empieza con una plantilla"
"Start on path" = "Inicio en el trazado"
"Start on the Home screen" = "Empezar en Inicio"
"Start point" = "Punto inicial"
"Start sharing" = "Empezar a compartir"
"Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da"
@@ -1373,6 +1383,7 @@ status = "draft — needs native review"
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
"The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."
"The whole file" = "El archivo completo"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%."
"Theme" = "Tema"
"There's no destination for that action" = "No hay destino para esa acción"
"Thickness" = "Grosor"
@@ -1383,6 +1394,7 @@ status = "draft — needs native review"
"This condition is edited as an expression" = "Esta condición se edita como expresión"
"This file" = "Este archivo"
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
"This frame is new on the branch." = "Este marco es nuevo en la rama."
@@ -1404,6 +1416,7 @@ status = "draft — needs native review"
"Top" = "Arriba"
"Top & bottom" = "Arriba y abajo"
"Top left radius" = "Radio superior izquierdo"
"Top padding" = "Margen interno superior"
"Top right radius" = "Radio superior derecho"
"Transform" = "Transformar"
"Triangle arrow" = "Flecha triangular"
@@ -1452,6 +1465,7 @@ status = "draft — needs native review"
"Use number keys for opacity" = "Usar teclas numéricas para la opacidad"
"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig."
"Use the layer's fill" = "Usar el relleno de la capa"
"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz."
"Used in {files} · {instances}" = "Usado en {files} · {instances}"
"Username" = "Usuario"
"Value" = "Valor"
@@ -1723,6 +1737,7 @@ status = "draft — needs native review"
"{n}m" = "{n} min"
"{n}w" = "{n} sem"
"{page}, nothing selected" = "{page}, nada seleccionado"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (actual)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual."
"{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)."
@@ -1765,6 +1780,7 @@ status = "draft — needs native review"
"arrange.front" = "Traer al frente"
"arrange.packH" = "Agrupar en horizontal"
"arrange.packV" = "Agrupar en vertical"
"arrange.reorder" = "Reordenar selección"
"arrange.roundToPixels" = "Redondear a píxeles"
"arrange.spacing" = "Espaciado"
"arrange.tidy" = "Ordenar"
@@ -1892,6 +1908,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportar capas"
"file.exportSvg" = "Exportar selección como SVG"
"file.import" = "Importar"
"file.moveTab" = "Mover pestaña"
"file.new" = "Nuevo archivo de diseño"
"file.newFromTemplate" = "Nuevo desde plantilla"
"file.open" = "Abrir"
@@ -2034,6 +2051,8 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Reducir movimiento"
"pref.renameDuplicates" = "Renombrar capas duplicadas"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.startOnHome" = "Empezar en Inicio"
"pref.uiScale" = "Tamaño de la interfaz"
"pref.userName" = "Tu nombre en los comentarios"
"proto.action.add" = "Añadir acción de prototipo"
"proto.action.move" = "Reordenar acción de prototipo"
@@ -2139,6 +2158,7 @@ status = "draft — needs native review"
"text.listIndent" = "Aumentar sangría de la lista"
"text.listOutdent" = "Reducir sangría de la lista"
"text.listStyle" = "Estilo de lista"
"text.loadFont" = "Cargar archivo de fuente"
"text.maxLines" = "Máximo de líneas"
"text.missingFonts" = "Fuentes que faltan"
"text.numberList" = "Lista numerada"
@@ -2147,7 +2167,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Espacio entre párrafos"
"text.pathOptions" = "Opciones de texto en trazado"
"text.preview" = "Previsualizar cambio de texto"
"text.refit" = "Ajustar texto a las fuentes"
"text.removeFromPath" = "Quitar texto del trazado"
"text.rescanFonts" = "Volver a buscar fuentes instaladas"
"text.set" = "Establecer texto"
"text.setFeatures" = "Funciones OpenType"
"text.setVariations" = "Ejes de fuente variable"
+7
View File
@@ -953,6 +953,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-tulostusasetukset"
"Padding" = "Sisämarginaali"
"Page" = "Sivu"
"Page deleted" = "Sivu poistettu"
"Pages" = "Sivut"
"Pages added to the document" = "Sivut lisätty asiakirjaan"
"Pages: {pages}" = "Sivut: {pages}"
@@ -1301,6 +1302,7 @@ status = "draft — needs native review"
"Start from a blank canvas" = "Aloita tyhjältä piirtoalueelta"
"Start from a template" = "Aloita mallista"
"Start on path" = "Aloituskohta polulla"
"Start on the Home screen" = "Aloita Aloitus-näkymästä"
"Start point" = "Alkupiste"
"Start sharing" = "Aloita jakaminen"
"Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi"
@@ -1893,6 +1895,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Vie tasot"
"file.exportSvg" = "Vie valinta SVG-muodossa"
"file.import" = "Tuo"
"file.moveTab" = "Siirrä välilehti"
"file.new" = "Uusi suunnittelutiedosto"
"file.newFromTemplate" = "Uusi mallista"
"file.open" = "Avaa"
@@ -2035,6 +2038,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Vähennä liikettä"
"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen"
"pref.showDimensions" = "Näytä objektien mitat"
"pref.startOnHome" = "Aloita Aloitus-näkymästä"
"pref.userName" = "Nimesi kommenteissa"
"proto.action.add" = "Lisää prototyyppitoiminto"
"proto.action.move" = "Muuta prototyyppitoimintojen järjestystä"
@@ -2141,6 +2145,7 @@ status = "draft — needs native review"
"text.listIndent" = "Sisennä luettelo"
"text.listOutdent" = "Vähennä luettelon sisennystä"
"text.listStyle" = "Luettelotyyli"
"text.loadFont" = "Lataa fonttitiedosto"
"text.maxLines" = "Rivien enimmäismäärä"
"text.missingFonts" = "Puuttuvat fontit"
"text.numberList" = "Numeroitu luettelo"
@@ -2149,7 +2154,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Kappaleväli"
"text.pathOptions" = "Tekstipolun asetukset"
"text.preview" = "Esikatsele tekstimuutosta"
"text.refit" = "Sovita teksti fontteihin"
"text.removeFromPath" = "Poista teksti polulta"
"text.rescanFonts" = "Etsi asennetut fontit uudelleen"
"text.set" = "Aseta teksti"
"text.setFeatures" = "OpenType-ominaisuudet"
"text.setVariations" = "Muuttuvan fontin akselit"
+10
View File
@@ -653,6 +653,7 @@ status = "draft — needs native review"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
"Interface size" = "Taille de l’interface"
"Intersect" = "Intersection"
"Intersects" = "Intersections"
"Invalid color" = "Couleur non valide"
@@ -948,6 +949,7 @@ status = "draft — needs native review"
"PDF print settings" = "Réglages d’impression PDF"
"Padding" = "Marge intérieure"
"Page" = "Page"
"Page deleted" = "Page supprimée"
"Pages" = "Pages"
"Pages added to the document" = "Pages ajoutées au document"
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil"
@@ -1059,6 +1061,7 @@ status = "draft — needs native review"
"Reduce motion" = "Réduire les animations"
"Refraction" = "Réfraction"
"Regular expression" = "Expression régulière"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct."
"Remove" = "Retirer"
"Remove all" = "Tout retirer"
"Remove annotation" = "Retirer l’annotation"
@@ -1294,6 +1297,7 @@ status = "draft — needs native review"
"Start from a blank canvas" = "Commencer avec un canevas vide"
"Start from a template" = "Commencer avec un modèle"
"Start on path" = "Début sur le tracé"
"Start on the Home screen" = "Démarrer sur l’Accueil"
"Start point" = "Point de départ"
"Start sharing" = "Commencer le partage"
"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne"
@@ -1367,6 +1371,7 @@ status = "draft — needs native review"
"The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un"
"The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}."
"The whole file" = "Le fichier entier"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %."
"Theme" = "Thème"
"There's no destination for that action" = "Cette action n’a pas de destination"
"Thickness" = "Épaisseur"
@@ -1444,6 +1449,7 @@ status = "draft — needs native review"
"Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité"
"Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig."
"Use the layer's fill" = "Utiliser le remplissage du calque"
"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface."
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
"Username" = "Nom d’utilisateur"
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
@@ -1712,6 +1718,7 @@ status = "draft — needs native review"
"{n}m" = "{n} min"
"{n}w" = "{n} sem."
"{page}, nothing selected" = "{page}, aucune sélection"
"{percent}%" = "{percent} %"
"{profile} (current)" = "{profile} (actuel)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel."
"{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)."
@@ -1881,6 +1888,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exporter les calques"
"file.exportSvg" = "Exporter la sélection en SVG"
"file.import" = "Importer"
"file.moveTab" = "Déplacer l’onglet"
"file.new" = "Nouveau fichier de design"
"file.newFromTemplate" = "Nouveau à partir d’un modèle"
"file.open" = "Ouvrir"
@@ -2023,6 +2031,8 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Réduire les animations"
"pref.renameDuplicates" = "Renommer les calques dupliqués"
"pref.showDimensions" = "Afficher les dimensions sur les objets"
"pref.startOnHome" = "Démarrer sur l’Accueil"
"pref.uiScale" = "Taille de l’interface"
"pref.userName" = "Votre nom dans les commentaires"
"proto.action.add" = "Ajouter une action de prototype"
"proto.action.move" = "Réordonner l’action de prototype"
+2
View File
@@ -934,6 +934,7 @@ status = "draft — needs native review"
"PDF print settings" = "Pengaturan cetak PDF"
"Padding" = "Padding"
"Page" = "Halaman"
"Page deleted" = "Halaman dihapus"
"Pages" = "Halaman"
"Pages added to the document" = "Halaman ditambahkan ke dokumen"
"Placed on the current page" = "Ditempatkan di halaman saat ini"
@@ -1855,6 +1856,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Ekspor layer"
"file.exportSvg" = "Ekspor pilihan sebagai SVG"
"file.import" = "Impor"
"file.moveTab" = "Pindahkan tab"
"file.new" = "File desain baru"
"file.newFromTemplate" = "Baru dari templat"
"file.open" = "Buka"
+4
View File
@@ -939,6 +939,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF 印刷設定"
"Padding" = "パディング"
"Page" = "ページ"
"Page deleted" = "ページは削除済み"
"Pages" = "ページ"
"Pages added to the document" = "ページをドキュメントに追加した"
"Placed on the current page" = "現在のページに配置した"
@@ -1283,6 +1284,7 @@ status = "draft — needs native review"
"Start from a blank canvas" = "空白のキャンバスから始める"
"Start from a template" = "テンプレートから始める"
"Start on path" = "パス上の開始位置"
"Start on the Home screen" = "起動時にホームを表示"
"Start point" = "始点"
"Start sharing" = "共有を開始"
"Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える"
@@ -1866,6 +1868,7 @@ status = "draft — needs native review"
"file.exportLayers" = "レイヤーを書き出し"
"file.exportSvg" = "選択範囲を SVG で書き出し"
"file.import" = "インポート"
"file.moveTab" = "タブを移動"
"file.new" = "新規デザインファイル"
"file.newFromTemplate" = "テンプレートから新規作成"
"file.open" = "開く"
@@ -2007,6 +2010,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "動きを減らす"
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
"pref.showDimensions" = "オブジェクトに寸法を表示"
"pref.startOnHome" = "起動時にホームを表示"
"pref.userName" = "コメントに表示する名前"
"proto.action.add" = "プロトタイプのアクションを追加"
"proto.action.move" = "プロトタイプのアクションの順序を変更"
File diff suppressed because it is too large. Load diff
+4
View File
@@ -893,6 +893,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF 인쇄 설정"
"Padding" = "패딩"
"Page" = "페이지"
"Page deleted" = "페이지가 삭제됨"
"Pages" = "페이지"
"Pages added to the document" = "문서에 페이지 추가됨"
"Placed on the current page" = "현재 페이지에 배치됨"
@@ -1219,6 +1220,7 @@ status = "draft — needs native review"
"Start ascending from / stop descending at" = "오름차순 시작 값 / 내림차순 끝 값"
"Start from a blank canvas" = "빈 캔버스로 시작"
"Start on path" = "패스 시작 위치"
"Start on the Home screen" = "홈에서 시작"
"Start point" = "시작점"
"Start sharing" = "공유 시작"
"Starting to share…" = "공유를 시작하는 중…"
@@ -1788,6 +1790,7 @@ status = "draft — needs native review"
"file.exportLayers" = "레이어 내보내기"
"file.exportSvg" = "선택 항목을 SVG로 내보내기"
"file.import" = "가져오기"
"file.moveTab" = "탭 이동"
"file.new" = "새 디자인 파일"
"file.open" = "열기"
"file.openRecent" = "최근 파일 열기"
@@ -1925,6 +1928,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "동작 줄이기"
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
"pref.showDimensions" = "개체에 치수 표시"
"pref.startOnHome" = "홈에서 시작"
"pref.userName" = "댓글에 표시할 이름"
"proto.action.add" = "프로토타입 액션 추가"
"proto.action.move" = "프로토타입 액션 순서 변경"
+4
View File
@@ -953,6 +953,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-afdrukinstellingen"
"Padding" = "Padding"
"Page" = "Pagina"
"Page deleted" = "Pagina verwijderd"
"Pages" = "Pagina's"
"Pages added to the document" = "Pagina's toegevoegd aan het document"
"Pages: {pages}" = "Pagina's: {pages}"
@@ -1301,6 +1302,7 @@ status = "draft — needs native review"
"Start from a blank canvas" = "Begin vanaf een leeg canvas"
"Start from a template" = "Begin met een sjabloon"
"Start on path" = "Begin op het pad"
"Start on the Home screen" = "Starten op Home"
"Start point" = "Startpunt"
"Start sharing" = "Delen starten"
"Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt"
@@ -1892,6 +1894,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Lagen exporteren"
"file.exportSvg" = "Selectie exporteren als SVG"
"file.import" = "Importeren"
"file.moveTab" = "Tabblad verplaatsen"
"file.new" = "Nieuw ontwerpbestand"
"file.newFromTemplate" = "Nieuw vanuit sjabloon"
"file.open" = "Openen"
@@ -2034,6 +2037,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Beweging verminderen"
"pref.renameDuplicates" = "Gedupliceerde lagen hernoemen"
"pref.showDimensions" = "Afmetingen op objecten tonen"
"pref.startOnHome" = "Starten op Home"
"pref.userName" = "Je naam in reacties"
"proto.action.add" = "Prototypeactie toevoegen"
"proto.action.move" = "Volgorde van prototypeactie wijzigen"
File diff suppressed because it is too large. Load diff
+4
View File
@@ -892,6 +892,7 @@ status = "draft — needs native review"
"PDF print settings" = "Configurações de impressão em PDF"
"Padding" = "Espaçamento interno"
"Page" = "Página"
"Page deleted" = "Página excluída"
"Pages" = "Páginas"
"Pages added to the document" = "Páginas adicionadas ao documento"
"Placed on the current page" = "Colocado na página atual"
@@ -1218,6 +1219,7 @@ status = "draft — needs native review"
"Start ascending from / stop descending at" = "Contar para cima a partir de / para baixo até"
"Start from a blank canvas" = "Comece com uma tela em branco"
"Start on path" = "Início no caminho"
"Start on the Home screen" = "Começar no Início"
"Start point" = "Ponto inicial"
"Start sharing" = "Começar a compartilhar"
"Starting to share…" = "Começando a compartilhar…"
@@ -1787,6 +1789,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportar camadas"
"file.exportSvg" = "Exportar seleção como SVG"
"file.import" = "Importar"
"file.moveTab" = "Mover aba"
"file.new" = "Novo arquivo de design"
"file.open" = "Abrir"
"file.openRecent" = "Abrir recente"
@@ -1924,6 +1927,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Reduzir movimento"
"pref.renameDuplicates" = "Renomear camadas duplicadas"
"pref.showDimensions" = "Mostrar dimensões nos objetos"
"pref.startOnHome" = "Começar no Início"
"pref.userName" = "Seu nome nos comentários"
"proto.action.add" = "Adicionar ação de protótipo"
"proto.action.move" = "Reordenar ação de protótipo"
+2
View File
@@ -933,6 +933,7 @@ name = "Русский"
"PDF print settings" = "Настройки печати PDF"
"Padding" = "Внутренние отступы"
"Page" = "Страница"
"Page deleted" = "Страница удалена"
"Pages" = "Страницы"
"Pages added to the document" = "Страницы добавлены в документ"
"Placed on the current page" = "Размещено на текущей странице"
@@ -1857,6 +1858,7 @@ name = "Русский"
"file.exportLayers" = "Экспортировать слои"
"file.exportSvg" = "Экспортировать выделение в SVG"
"file.import" = "Импорт"
"file.moveTab" = "Переместить вкладку"
"file.new" = "Новый файл дизайна"
"file.newFromTemplate" = "Создать из шаблона"
"file.open" = "Открыть"
+4
View File
@@ -943,6 +943,7 @@ name = "Svenska"
"PDF print settings" = "PDF-utskriftsinställningar"
"Padding" = "Utfyllnad"
"Page" = "Sida"
"Page deleted" = "Sidan har raderats"
"Pages" = "Sidor"
"Pages added to the document" = "Sidor tillagda i dokumentet"
"Placed on the current page" = "Placerat på den aktuella sidan"
@@ -1287,6 +1288,7 @@ name = "Svenska"
"Start from a blank canvas" = "Börja med en tom arbetsyta"
"Start from a template" = "Börja med en mall"
"Start on path" = "Början på banan"
"Start on the Home screen" = "Börja på Start"
"Start point" = "Startpunkt"
"Start sharing" = "Börja dela"
"Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får"
@@ -1874,6 +1876,7 @@ name = "Svenska"
"file.exportLayers" = "Exportera lager"
"file.exportSvg" = "Exportera som SVG"
"file.import" = "Importera"
"file.moveTab" = "Flytta flik"
"file.new" = "Nytt"
"file.newFromTemplate" = "Nytt från mall"
"file.open" = "Öppna"
@@ -2015,6 +2018,7 @@ name = "Svenska"
"pref.reduceMotion" = "Minska rörelse"
"pref.renameDuplicates" = "Byt namn på duplicerade lager"
"pref.showDimensions" = "Visa dimensioner på objekt"
"pref.startOnHome" = "Börja på Start"
"pref.userName" = "Ditt namn i kommentarer"
"proto.action.add" = "Lägg till prototypåtgärd"
"proto.action.move" = "Ändra ordning på prototypåtgärd"
+2
View File
@@ -934,6 +934,7 @@ name = "Türkçe"
"PDF print settings" = "PDF baskı ayarları"
"Padding" = "İç boşluk"
"Page" = "Sayfa"
"Page deleted" = "Sayfa silindi"
"Pages" = "Sayfalar"
"Pages added to the document" = "Sayfalar belgeye eklendi"
"Placed on the current page" = "Geçerli sayfaya yerleştirildi"
@@ -1855,6 +1856,7 @@ name = "Türkçe"
"file.exportLayers" = "Katmanları dışa aktar"
"file.exportSvg" = "Seçimi SVG olarak dışa aktar"
"file.import" = "İçe aktar"
"file.moveTab" = "Sekmeyi taşı"
"file.new" = "Yeni tasarım dosyası"
"file.newFromTemplate" = "Şablondan yeni"
"file.open" = "Aç"
+4
View File
@@ -894,6 +894,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF 打印设置"
"Padding" = "内边距"
"Page" = "页面"
"Page deleted" = "页面已删除"
"Pages" = "页面"
"Pages added to the document" = "页面已添加到文档"
"Placed on the current page" = "已放置在当前页面"
@@ -1220,6 +1221,7 @@ status = "draft — needs native review"
"Start ascending from / stop descending at" = "升序起点 / 降序终点"
"Start from a blank canvas" = "从空白画布开始"
"Start on path" = "路径起点"
"Start on the Home screen" = "启动时显示主页"
"Start point" = "起点"
"Start sharing" = "开始分享"
"Starting to share…" = "正在开始分享…"
@@ -1789,6 +1791,7 @@ status = "draft — needs native review"
"file.exportLayers" = "导出图层"
"file.exportSvg" = "将所选项导出为 SVG"
"file.import" = "导入"
"file.moveTab" = "移动标签页"
"file.new" = "新建设计文件"
"file.open" = "打开"
"file.openRecent" = "打开最近的文件"
@@ -1926,6 +1929,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "减少动态效果"
"pref.renameDuplicates" = "重命名副本图层"
"pref.showDimensions" = "在对象上显示尺寸"
"pref.startOnHome" = "启动时显示主页"
"pref.userName" = "评论中显示的名字"
"proto.action.add" = "添加原型操作"
"proto.action.move" = "调整原型操作顺序"
+1 -1
View File
@@ -316,7 +316,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
);
crate::widgets::select_all_on_focus(ui, &r, &filter);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), egui::StrokeKind::Inside);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
filter.clear();
}
+77 -48
View File
@@ -12,7 +12,7 @@ use serde_json::json;
use crate::FigApp;
use crate::i18n::{tr, trf, trn};
use crate::render_worker::{Done, Job, Worker};
use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold};
use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold};
pub struct CanvasState {
pub rect: Rect,
@@ -233,6 +233,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
})
.unwrap_or(t.canvas);
painter.rect_filled(rect, CornerRadius::ZERO, bg);
crate::theme::set_canvas_page(&ctx, bg);
if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
// Re-project the last render onto the current view.
let tl = app.to_screen(v.origin.0, v.origin.1);
@@ -247,8 +248,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) {
let area = Rect::from_two_pos(start, p);
if area.width() > 4.0 || area.height() > 4.0 {
painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1));
painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside);
painter.rect_filled(area, CornerRadius::ZERO, accent(&painter).gamma_multiply(0.1));
painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, accent(&painter)), StrokeKind::Inside);
}
}
crate::dev_mode::draw(app, &painter);
@@ -490,6 +491,8 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods:
/// then doesn't show.
fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if crate::proto_canvas::input(app, ui, resp) {
// A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
app.ui.pages_focus = None;
return true;
}
let ctx = ui.ctx().clone();
@@ -540,6 +543,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
// A press on the canvas gives the keyboard back to it (arrows nudge again).
if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) {
app.ui.layers_focus = false;
app.ui.pages_focus = None;
app.ui.key_box = None;
}
// Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans.
@@ -805,11 +809,11 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod
}
/// The outline colour for a layer: purple for components, sets and instances and every layer in
/// them, blue otherwise.
fn outline_colour(doc: &Document, id: NodeId) -> Color32 {
/// them, the page's overlay `green` otherwise.
fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 {
use figcraft_engine::components::{owning_component, owning_instance};
let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. }));
if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT }
if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green }
}
/// The outline path of shapes (ellipses, vectors) for hover/selection outlines.
@@ -942,20 +946,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
&& app.session.prefs.highlight_on_hover
&& app.session.tool.overlay.measure_box.is_none()
{
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5));
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5));
}
// Inside a boolean group (an operand selected or in vector edit mode): every operand's outline,
// so the shapes making the live result stay visible while one of them changes.
let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id));
for id in operands.iter().filter(|o| !sel.contains(o)) {
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0));
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0));
}
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
for id in doc.subtree(page) {
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if view.mask_outlines && n.is_mask {
draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0));
draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0));
} else if view.frame_outlines && n.kind.is_frame_like() {
draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
} else if view.show_slices
@@ -971,20 +975,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if let Some(d) = ov.drop_target
&& let Some(q) = node_quad(app, &doc, d)
{
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0));
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0));
}
slot_badges(app, painter, &doc, &sel, ov.drop_target);
if let Some(t) = ov.swap_target {
swap_highlight(app, painter, &doc, t);
}
if let Some([x0, y0, x1, y1]) = ov.insert_line {
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0));
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0));
}
if let Some([x0, y0, x1, y1]) = ov.drop_cell {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12));
painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12));
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0));
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0));
}
// Vector edit mode, with the transform box around several selected points.
// The selection's size label, which measurement labels keep clear of.
@@ -1007,13 +1011,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
width_overlay(app, painter, &doc, &ve, zoom);
} else if let Some(c) = app.session.vector_edit_box() {
let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot()));
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label);
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label));
}
}
// Selection.
let editing = ds.text_edit.clone();
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT };
let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
for id in &sel {
draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
}
@@ -1031,8 +1035,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
// A layer smaller than its handles on screen shows just its outline (they still answer
// just outside it).
let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
// Measuring to another layer (Alt held) shows just the outlines: no handles or size.
let measuring = app.session.tool.overlay.measure_box.is_some();
// Locked layers (selected in the layers panel) can't be resized: no handles either.
let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
0
} else if is_line {
2
@@ -1041,9 +1047,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
} else {
4
};
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label);
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label));
if !measuring {
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
}
if app.session.tool.tool == ToolId::Scale {
// The point that stays put: the dragged pivot, else the anchor.
let pivot = scale
@@ -1082,12 +1090,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
{
let layout = &text.layout;
if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(ACCENT, 1.0));
quad(painter, q, stroke(accent(painter), 1.0));
}
let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
if a != b && preedit.is_none() {
for q in text.selection(app, a, b) {
painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE));
painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
}
}
if let Some(p) = preedit {
@@ -1118,8 +1126,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
// Marquee.
if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1));
painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside);
painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1));
painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside);
}
// Pen path.
if !ov.pen.is_empty() {
@@ -1134,7 +1142,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
}
for p in &pts {
painter.circle_filled(*p, 3.5, Color32::WHITE);
painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0));
painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0));
}
if let Some([x0, y0, x1, y1]) = ov.rubber {
let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1));
@@ -1146,11 +1154,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
[a, h, b, b],
false,
Color32::TRANSPARENT,
stroke(ACCENT, 1.0),
stroke(accent(painter), 1.0),
));
}
None => {
painter.line_segment([a, b], stroke(ACCENT, 1.0));
painter.line_segment([a, b], stroke(accent(painter), 1.0));
}
}
}
@@ -1161,7 +1169,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
let c = app.to_screen(x, y);
let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
painter.circle_stroke(c, rs, stroke(ACCENT, 1.5));
painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
}
// Lasso (vector edit mode).
if ov.lasso.len() > 1 {
@@ -1169,7 +1177,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if let Some(first) = pts.first().copied() {
pts.push(first);
}
painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0));
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
// Guides (under the rulers).
crate::guides::draw(app, painter);
@@ -1181,8 +1189,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
/// corner handles and, when dimensions are shown, the size `label` under it (its box returned).
fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option<Rect> {
/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned).
fn transform_box(
app: &FigApp,
painter: &egui::Painter,
q: [Pos2; 4],
col: Color32,
outline: bool,
handles: usize,
label: Option<String>,
) -> Option<Rect> {
if outline {
quad(painter, q, stroke(col, 1.0));
}
@@ -1191,7 +1207,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color
painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE);
painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside);
}
if app.session.prefs.show_dimensions {
if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) {
let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max);
let cx = q.iter().map(|p| p.x).sum::<f32>() / 4.0;
return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col));
@@ -1203,7 +1219,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color
fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect {
let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE);
let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(r, CornerRadius::same(3), col);
painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col));
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
r
}
@@ -1260,7 +1276,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| {
let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1]));
let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT };
let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) };
painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0));
painter.line_segment([a, b], stroke(col, 1.0));
for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] {
@@ -1272,7 +1288,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
let on = !ghost && ve.width_point == Some(h.index);
let hovered = !ghost && hover == Some(WHit::Point { index: h.index });
let r = if hovered { 5.0 } else { 4.0 };
painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE });
painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE });
painter.circle_stroke(c, r, stroke(col, 1.0));
};
for h in width_handles(doc, ve, zoom) {
@@ -1288,9 +1304,9 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of
/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike).
fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) {
painter.line_segment([v, e], stroke(ACCENT, 1.0));
painter.line_segment([v, e], stroke(accent(painter), 1.0));
painter.circle_filled(e, r, fill);
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
painter.circle_stroke(e, r, stroke(accent(painter), 1.0));
}
/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and
@@ -1309,7 +1325,7 @@ fn vector_overlay(
// Paint bucket target: hatched like a fill to come.
if active && !ov.face.is_empty() {
let polys: Vec<Vec<Pos2>> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect();
hatched(painter, &polys, ACCENT);
hatched(painter, &polys, accent(painter));
}
if active {
region_hatch(app, painter);
@@ -1327,7 +1343,7 @@ fn vector_overlay(
} else {
1.5
};
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w)));
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w)));
}
if !points {
return;
@@ -1335,7 +1351,7 @@ fn vector_overlay(
for h in &o.handles {
let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start });
let on = o.selected_handles.contains(&(h.seg, h.start));
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE });
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE });
}
for (i, v) in o.vertices.iter().enumerate() {
let p = sp(v);
@@ -1343,14 +1359,14 @@ fn vector_overlay(
let size = if hovered { 9.0 } else { 7.0 };
let r = Rect::from_center_size(p, vec2(size, size));
let on = o.selected_vertices.contains(&i);
painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE });
painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside);
painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE });
painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside);
if o.anchor == Some(i) {
painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0));
painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0));
}
}
if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) {
painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0));
painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0));
}
}
@@ -1367,7 +1383,7 @@ fn region_hatch(app: &FigApp, painter: &egui::Painter) {
let r = painter.clip_rect();
let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y));
for [a, b] in app.session.vector_region_hatch(view, Some(clip)) {
painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(ACCENT, REGION_HATCH_WIDTH));
painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH));
}
}
@@ -1382,10 +1398,10 @@ fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figc
for r in &arr.regions {
for poly in screen(r) {
painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5)));
painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0)));
painter.add(Shape::closed_line(poly, stroke(accent(painter), 1.0)));
}
}
let col = if ov.builder_remove { t.danger } else { ACCENT };
let col = if ov.builder_remove { t.danger } else { accent(painter) };
for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) {
hatched(painter, &screen(r), col);
}
@@ -1495,8 +1511,16 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel
// Highlight the selection's span.
if let Some(b) = doc.bounds_of(sel) {
let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15));
painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15));
painter.rect_filled(
Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)),
CornerRadius::ZERO,
accent(painter).gamma_multiply(0.15),
);
painter.rect_filled(
Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)),
CornerRadius::ZERO,
accent(painter).gamma_multiply(0.15),
);
}
let mut x = (p0.x / step).floor() * step;
while x <= p1.x {
@@ -1524,3 +1548,8 @@ pub fn fmt(v: f64) -> String {
#[cfg(test)]
#[path = "zoom_preview_tests.rs"]
mod zoom_preview_tests;
/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]).
fn accent(p: &egui::Painter) -> Color32 {
crate::theme::canvas_accent(p.ctx())
}
+49 -12
View File
@@ -1,8 +1,9 @@
//! Chinese, Japanese and Korean text in the UI. egui's own fonts and Inter have no CJK glyphs, so
//! the UI adds a face per script it needs: the UI language's, the scripts in the open file's names
//! Chinese, Japanese, Korean and Bengali text in the UI. egui's own fonts and Inter have no glyphs
//! for them, so the UI adds a face per script it needs: the UI language's, the scripts in the open file's names
//! and comments, the language picker's and typed text. Desktop finds them among the installed
//! fonts (the system font scan), only once one is needed, so English users load none; the web
//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none.
//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none (no
//! Bengali face is built in, so the web leaves Bengali out).
use std::sync::Arc;
@@ -12,6 +13,13 @@ pub const KANA: u8 = 1;
pub const HANGUL: u8 = 2;
/// Han characters (and CJK punctuation and full-width forms).
pub const HAN: u8 = 4;
/// Bengali script.
pub const BENGALI: u8 = 8;
/// Every script bit: nothing more to look for once all have a face.
pub const ALL: u8 = KANA | HANGUL | HAN | BENGALI;
/// Bengali faces: cross-platform, Windows, macOS, then Linux names.
const BENGALI_FONTS: &[&str] = &["Noto Sans Bengali", "Nirmala UI", "Bangla Sangam MN", "Kalpurush", "SolaimanLipi"];
/// Japanese faces, the built-in one first: Windows, macOS, then Linux names.
const JAPANESE: &[&str] = &[
@@ -46,36 +54,47 @@ const CHINESE: &[&str] = &[
"Droid Sans Fallback",
];
/// The CJK scripts in `s`.
/// The CJK and Bengali scripts in `s`.
pub fn scripts(s: &str) -> u8 {
s.chars().fold(0, |m, c| {
m | match u32::from(c) {
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => KANA,
0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => HANGUL,
0x3000..=0x303F | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF01..=0xFF65 | 0x20000..=0x3FFFF => HAN,
0x0980..=0x09FF => BENGALI,
_ => 0,
}
})
}
/// The CJK scripts a UI language (a catalog code) is written in.
/// The scripts a UI language (a catalog code) is written in.
pub fn language(code: &str) -> u8 {
match code.split('-').next() {
Some("ja") => KANA | HAN,
Some("ko") => HANGUL,
Some("zh") => HAN,
Some("bn") => BENGALI,
_ => 0,
}
}
/// Whether the UI can draw language `code`: always on desktop (a system face is found when it's
/// needed; tofu at worst), on the web only with built-in faces for its scripts. The language
/// picker and the system language skip a language it can't.
/// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts'
/// Noto Sans Georgian). The language picker and the system language skip a language it can't.
pub fn can_show(code: &str) -> bool {
shows(code, cfg!(target_arch = "wasm32"))
}
/// [`can_show`] in a desktop or (`web`) a web build.
fn shows(code: &str, web: bool) -> bool {
if !web {
return true;
}
if code.split('-').next() == Some("ka") {
return figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some();
}
let need = language(code);
need == 0
|| !cfg!(target_arch = "wasm32")
|| samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some())
need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some())
}
/// A character standing for each script in `need`, with the families to look for it in, the UI
@@ -89,10 +108,16 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> {
(false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()),
(false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()),
};
let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec()))];
let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec())), (BENGALI, ('অ', BENGALI_FONTS.to_vec()))];
match ui {
HANGUL => v.reverse(),
HAN => v.swap(0, 1),
BENGALI => {
if let Some(pos) = v.iter().position(|(bit, _)| *bit == BENGALI) {
let item = v.remove(pos);
v.insert(0, item);
}
}
_ => {}
}
v.into_iter().filter(|(bit, _)| need & bit != 0).map(|(_, s)| s).collect()
@@ -124,20 +149,32 @@ mod tests {
assert_eq!(scripts("ボタン"), KANA);
assert_eq!(scripts("버튼"), HANGUL);
assert_eq!(scripts("按钮"), HAN);
assert_eq!(scripts("বাংলা"), BENGALI);
assert_eq!(scripts("日本語のレイヤー(1)"), KANA | HAN);
assert_eq!(language("ja"), KANA | HAN);
assert_eq!(language("ko"), HANGUL);
assert_eq!(language("zh-Hant"), HAN);
assert_eq!(language("zh-Hans"), HAN);
assert_eq!(language("bn"), BENGALI);
assert_eq!(language("fr"), 0);
assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c)));
}
#[test]
fn the_web_offers_georgian_only_with_its_face() {
assert!(shows("ka", false), "desktop always lists Georgian");
let face = figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some();
assert_eq!(shows("ka", true), face);
assert_eq!(shows("ka-GE", true), face);
assert!(shows("en", true) && shows("tr", true));
}
#[test]
fn the_ui_language_script_comes_first() {
let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::<String>();
let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied();
assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한");
assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한");
assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한");
assert_eq!(order(KANA | HAN | HANGUL, HANGUL), "한漢あ");
assert_eq!(order(HANGUL, 0), "한");
@@ -150,6 +187,6 @@ mod tests {
assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ");
assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f)));
assert!(faces(0, "en", &|_| false).is_empty());
assert!(faces(KANA | HANGUL | HAN, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws");
assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws");
}
}
+1 -1
View File
@@ -1013,7 +1013,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option<PresetAction> {
let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset"));
let hot = resp.hovered() || del_resp.hovered();
if hot {
ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT);
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
let col = if hot { Color32::WHITE } else { t.text };
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.5), col);
+6 -5
View File
@@ -189,10 +189,10 @@ pub fn cluster(points: &[Pos2], r: f32) -> Vec<Vec<usize>> {
}
/// A message's text with its @mentions of `people` picked out, wrapped at `width`.
pub fn rich_text(text: &str, people: &[&str], color: Color32, width: f32) -> LayoutJob {
pub fn rich_text(text: &str, people: &[&str], color: Color32, mention_color: Color32, width: f32) -> LayoutJob {
let mut job = LayoutJob::default();
let plain = TextFormat { font_id: regular(12.0), color, ..Default::default() };
let mention = TextFormat { font_id: semibold(12.0), color: ACCENT, ..Default::default() };
let mention = TextFormat { font_id: semibold(12.0), color: mention_color, ..Default::default() };
let mut at = 0;
for r in mention_ranges(text, people) {
job.append(text.get(at..r.start).unwrap_or(""), 0.0, plain.clone());
@@ -400,7 +400,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
if let Some((x, y, _)) = &app.ui.comments.draft {
let r = pin_rect(app.to_screen(*x, *y));
painter.rect_filled(r, BUBBLE, ACCENT);
painter.circle_filled(r.center(), 3.0, Color32::WHITE);
painter.circle_filled(r.center(), 3.0, crate::theme::ACCENT_ON);
}
}
@@ -729,7 +729,7 @@ fn message(
ui.add_space(indent);
let mut names: Vec<&str> = cx.people.iter().map(String::as_str).collect();
names.push(cx.me);
ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, ui.available_width())).wrap());
ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, t.accent_text, ui.available_width())).wrap());
});
}
}
@@ -1062,7 +1062,8 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
ui.add_space(16.0);
ui.label(egui::RichText::new(tr(app.ui.comments.filter.label())).font(regular(11.0)).color(t.text_secondary));
ui.add_space(4.0);
if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(ACCENT)).sense(Sense::click())).clicked() {
if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())).clicked()
{
app.ui.comments.filter = Filter::Open;
}
});
+1 -1
View File
@@ -628,7 +628,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Link, t.icon_secondary);
let x = r.min.x + PAD + 22.0;
ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), ACCENT);
ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), t.accent_text);
let below = match item.from.and_then(|f| doc.node(f)) {
Some(m) => trf("From {name}", &[("name", &m.name)]),
None => url.clone(),
+55 -7
View File
@@ -3,7 +3,7 @@
use std::path::Path;
use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use egui::{Align2, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use figcraft_color::ColorProfile;
use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId};
use figcraft_engine::cmd::file::{export_jobs, exportable_layers};
@@ -12,8 +12,7 @@ use serde_json::{Value, json};
use crate::FigApp;
use crate::export_panel::{export_layers, save_one, suggested_name};
use crate::i18n::{self, tr, tr_noop, trf, trn};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::theme::{Tokens, regular, semibold};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -122,10 +121,10 @@ pub(crate) fn tab_strip<T: Copy + PartialEq>(ui: &mut egui::Ui, t: &Tokens, tabs
fn about_body(ui: &mut egui::Ui, t: &Tokens) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT);
icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE);
crate::theme::paint_app_icon(ui, r);
ui.vertical(|ui| {
ui.label(RichText::new("FigCraft").font(semibold(18.0)));
let (wordmark, _) = ui.allocate_exact_size(vec2(130.0, 23.0), Sense::hover());
crate::theme::paint_wordmark(ui.painter(), egui::pos2(wordmark.min.x, wordmark.center().y), 18.0, t);
ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary));
});
});
@@ -143,8 +142,12 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) {
}
/// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's
/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too.
/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu
/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone.
pub fn keys(app: &mut FigApp, ctx: &Context) {
if egui::Popup::is_any_open(ctx) {
return;
}
let u = &mut app.ui;
let open = u.color_profile.is_some()
|| u.menu_input.is_some()
@@ -223,6 +226,9 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) {
toggles.push("pref.autoSave");
}
if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.start_on_home, tr("Start on the Home screen")) {
toggles.push("pref.startOnHome");
}
for c in toggles {
app.run(c, json!({}));
}
@@ -273,6 +279,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
}
});
ui.add_space(8.0);
interface_size(app, ui, prefs.ui_scale);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Theme"));
for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] {
@@ -473,6 +481,46 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value {
p
}
/// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the
/// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there
/// the row only says so.
fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) {
let t = Tokens::get(ui.ctx());
if cfg!(target_arch = "wasm32") {
ui.label(tr("Interface size"));
crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary);
return;
}
let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]);
let shown = percent(chosen);
ui.horizontal(|ui| {
ui.label(tr("Interface size"));
let resp = crate::widgets::dropdown(ui, &shown, 120.0, None);
crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp));
crate::right::menu_popup(&resp).show(|ui| {
for size in crate::ui_scale::SIZES {
if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") {
app.run("pref.uiScale", json!({"scale": size}));
ui.close();
}
}
});
});
crate::widgets::label(
ui,
tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."),
t.text_secondary,
);
// Said in words, not only by the menu's value: the window is too small for the size chosen.
if app.ui.ui_scale.limited {
let note = trf(
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.",
&[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))],
);
crate::widgets::label(ui, &note, t.text_secondary);
}
}
/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers
/// start, a preview of the first new names, then one `object.batchRename`.
fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) {
+85
View File
@@ -549,3 +549,88 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}");
assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}");
}
/// The middle of the field with response `id`.
fn at(u: &Ui, id: Id) -> Pos2 {
u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
}
/// Type `text` into the field whose response is `id` and press Enter.
fn type_at(u: &mut Ui, id: Id, text: &str) {
let p = at(u, id);
click(u, p);
u.frame(vec![Event::Text(text.into())]);
key(u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
}
/// A frame with one layout grid, its settings open in the Design panel.
fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap());
s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true));
u.frame(vec![]);
(u, f)
}
/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the
/// grid settings sit below the fold.
fn scroll_to(u: &mut Ui, id: Id) {
let wheel = Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -300.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
};
for _ in 0..10 {
let p = at(u, id);
if p.y < 860.0 {
break;
}
u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]);
// Let the scroll settle.
for _ in 0..30 {
u.frame(vec![]);
}
}
}
/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count
/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's
/// placeholder said 0 everywhere, which turned the gap's mode off.
#[test]
fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() {
let count_id = Id::new(("grid_field", "count", 0usize));
let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10}));
let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count;
scroll_to(&mut u, count_id);
type_at(&mut u, count_id, "Auto");
assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0");
type_at(&mut u, count_id, "4");
assert_eq!(count(&u), 4, "and a number is that count");
// A stretched grid works its width out from the frame: the field shows Auto and takes no edit.
let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0}));
let size_id = Id::new(("grid_field", "size", 0usize));
scroll_to(&mut u, size_id);
type_at(&mut u, size_id, "40");
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set");
// Nor its count: a stretched grid has at least one band, so the word commits nothing.
type_at(&mut u, count_id, "Auto");
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count");
// The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0.
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap());
s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap();
s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap;
let before = gap(&u);
type_at(&mut u, Id::new("al_gap"), "Auto");
assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode");
}
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod
let col = if is_comp {
COMPONENT
} else if selected || hovered {
ACCENT
crate::theme::canvas_accent(painter.ctx())
} else {
t.text_secondary
};
+12 -7
View File
@@ -11,7 +11,7 @@ use figcraft_geom::Point;
use serde_json::json;
use crate::colorpicker::PickerTarget;
use crate::theme::{ACCENT, semibold};
use crate::theme::semibold;
use crate::{FigApp, RightTab};
pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
@@ -293,7 +293,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0)));
painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
if i == selected {
painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside);
painter.rect_stroke(
r.expand(2.0),
CornerRadius::same(3),
Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())),
StrokeKind::Outside,
);
}
}
for (i, p) in [a, b, c].iter().enumerate() {
@@ -301,7 +306,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
continue;
}
painter.circle_filled(*p, 5.0, Color32::WHITE);
painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT));
painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())));
}
return;
}
@@ -311,8 +316,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120)));
// The start is hollow (least blur), the end filled (most blur by default).
painter.circle_filled(a, 5.0, Color32::WHITE);
painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT));
painter.circle_filled(b, 5.0, ACCENT);
painter.circle_stroke(a, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())));
painter.circle_filled(b, 5.0, crate::theme::canvas_accent(painter.ctx()));
painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE));
return;
}
@@ -320,11 +325,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
let Some(c) = crop_corners(app, id, r) else { return };
let outline = vec![c[0], c[1], c[2], c[3], c[0]];
painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE)));
painter.add(Shape::line(outline, Stroke::new(1.0, ACCENT)));
painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()))));
for p in c {
let hr = Rect::from_center_size(p, vec2(8.0, 8.0));
painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE);
painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside);
painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside);
}
return;
}
+5 -5
View File
@@ -198,9 +198,9 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
let mut y = left.min.y + 20.0;
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
y += 48.0;
crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0)));
crate::theme::paint_wordmark(ui.painter(), pos2(left.min.x + 52.0, y + 12.0), 16.0, t);
y += 52.0;
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
let response =
@@ -320,8 +320,8 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b
}
card_frame(ui, t, r, &resp);
let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0));
ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT);
icons::paint(ui.painter(), ir.shrink(8.0), icon, Color32::WHITE);
ui.painter().rect_filled(ir, CornerRadius::same(6), t.accent);
icons::paint(ui.painter(), ir.shrink(8.0), icon, t.accent_on);
ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text);
ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary);
resp.clicked()
+2 -2
View File
@@ -738,8 +738,8 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) {
let r = Rect::from_center_size(center, vec2(16.0, 16.0));
match FileKind::of(Path::new(path)) {
FileKind::FigCraft | FileKind::Fig => {
ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT);
icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE);
ui.painter().rect_filled(r, CornerRadius::same(4), t.accent);
icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on);
}
kind => {
ui.painter().rect_filled(r, CornerRadius::same(4), t.field);
+4 -2
View File
@@ -256,9 +256,11 @@ fn rects_of(s: &[Shape], c: egui::Color32) -> usize {
s.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == c)).count()
}
/// Images drawn (meshes with a texture of their own).
/// Preview images in the main content area, excluding the branded sidebar icon.
fn images(s: &[Shape]) -> usize {
s.iter().filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count()
s.iter()
.filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.vertices.iter().any(|v| v.pos.x > 240.0)))
.count()
}
#[test]
+27
View File
@@ -699,6 +699,33 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc
assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::<Vec<_>>(), [2, 1]);
}
#[test]
fn dropping_several_files_on_canvas_places_them_side_by_side() {
let dir = Temp::new();
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##;
let paths: Vec<_> = ["a.svg", "b.svg", "c.svg"].iter().map(|n| dir.path(n)).collect();
for path in &paths {
std::fs::write(path, svg).unwrap();
}
let mut u = Ui::new(Session::new());
u.app.ui.home = false;
u.frame(vec![]);
let pos = u.app.canvas.rect.center();
let files: Vec<egui::DroppedFileHandle> = paths.iter().map(|p| std::sync::Arc::new(Dropped(p.clone())) as egui::DroppedFileHandle).collect();
u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: files, ..Default::default() });
for _ in 0..4 {
u.frame(vec![]);
}
assert!(u.app.ui.pending_opens.is_empty());
let doc = u.app.session.doc().unwrap();
let page = u.app.session.page().unwrap();
let rects: Vec<_> = doc.node(page).unwrap().children.iter().map(|c| doc.world_bounds(*c).unwrap()).collect();
assert_eq!(rects.len(), 3);
for pair in rects.windows(2) {
assert!(pair[1].x0 > pair[0].x1, "left to right with a gap: {rects:?}");
}
}
#[test]
fn canvas_import_uses_queued_page_and_restores_the_page_selected_while_waiting() {
let dir = Temp::new();
+4 -3
View File
@@ -171,9 +171,10 @@ fn plural(code: &str, n: usize) -> Plural {
"cs" if n == 1 => Plural::One,
"cs" if (2..=4).contains(&n) => Plural::Few,
"cs" => Plural::Other,
"pl" if n == 1 => Plural::One,
"ru" if n % 10 == 1 && n % 100 != 11 => Plural::One,
"ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
"ru" => Plural::Many,
"ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few,
"ru" | "pl" => Plural::Many,
"ja" | "ko" | "zh-Hans" => Plural::Other,
"fr" | "pt-BR" if n <= 1 => Plural::One,
_ if n == 1 => Plural::One,
@@ -182,7 +183,7 @@ fn plural(code: &str, n: usize) -> Plural {
}
// CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing
// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms.
// "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms.
/// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in
/// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules).
+66 -1
View File
@@ -135,6 +135,7 @@ fn dynamic_labels() -> Vec<&'static str> {
v.extend(Group::ALL.iter().map(|g| g.label()));
v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other]));
v.extend(DATA_LABELS);
v.push(figcraft_engine::live::TOO_LARGE_TO_JOIN);
// The starter templates' names, descriptions and kinds.
v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()]));
v
@@ -327,6 +328,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() {
assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t)));
}
#[test]
fn a_live_failure_the_ui_knows_is_read_back_for_translation() {
use figcraft_engine::live::TOO_LARGE_TO_JOIN;
let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024");
assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason));
assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_)));
}
#[test]
fn system_languages_pick_the_first_one_with_a_catalog() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
@@ -400,7 +409,7 @@ fn french_and_portuguese_count_zero_as_singular() {
for code in ["fr", "pt-BR"] {
assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}");
}
for code in ["en", "es", "de", "cs"] {
for code in ["en", "es", "de", "cs", "pl"] {
for n in [0, 1, 2, 3, 4, 5, 21] {
assert_eq!(one(code, n), n == 1, "{code}: {n}");
}
@@ -421,6 +430,41 @@ fn japanese_korean_and_chinese_have_no_plural_forms() {
}
}
#[test]
fn georgian_is_a_draft_with_count_neutral_plurals() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] {
assert_eq!(code("system", Some(tag)), "ka", "{tag}");
}
let ka = LANGS.iter().position(|l| l.code == "ka").unwrap();
assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული");
// A noun stays singular after a numeral: both forms read the same where English counts in both.
for (k, t) in LANGS[ka].ui {
if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) {
assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}");
}
}
}
/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces),
/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses.
#[test]
fn the_built_in_georgian_face_draws_the_georgian_catalog() {
let Some(face) = figcraft_text::find_face("Noto Sans Georgian", "Regular").filter(|f| f.family == "Noto Sans Georgian") else {
eprintln!("built without craft-fonts' Noto Sans Georgian; skipping");
return;
};
let ka = &LANGS[LANGS.iter().position(|l| l.code == "ka").unwrap()];
let georgian = |c: &char| matches!(u32::from(*c), 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F);
let mut letters: BTreeSet<char> = BTreeSet::new();
for (_, t) in ka.ui.iter().chain(ka.commands).chain(ka.menu) {
letters.extend(forms(t).into_iter().flat_map(str::chars).filter(georgian));
}
assert!(letters.len() > 30, "{letters:?}");
let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect();
assert!(missing.is_empty(), "no glyph for {missing:?}");
}
#[test]
fn chinese_reads_simplified_and_traditional_has_no_catalog() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
@@ -493,6 +537,27 @@ fn russian_language_tags_and_integer_plurals() {
}
}
#[test]
fn polish_integer_plurals() {
let pl = LANGS.iter().position(|l| l.code == "pl").unwrap();
assert_eq!(LANGS[pl].name, "Polski");
assert_eq!(plural("pl", 1), Plural::One);
assert_eq!(fill(counted(pl, "{n} layers", 1), &[("n", &1)]), "1 warstwa");
for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] {
assert_eq!(plural("pl", n), Plural::Few, "{n}");
assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstwy"));
}
for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 21, 25, 31, 100, 101, 111, 112, 114, 1_000_000] {
assert_eq!(plural("pl", n), Plural::Many, "{n}");
assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstw"));
}
for (key, text) in LANGS[pl].ui {
if let Text::Plural { one, few, many, .. } = text {
assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Polish plural forms");
}
}
}
#[test]
fn plural_fallback_uses_the_language_of_its_catalog() {
let la = LANGS.iter().position(|l| l.code == "es-419").unwrap();
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more