Compare commits

...
Author SHA1 Message Date
bflatastic 335002c009 Docs: review fixes: one Change summary per header, no stale language rows, hidden windows fail at once, parity rows name the landed fixes 2026-10-24 01:38:57 +07:00
bflatastic 0984ec8231 Merge remote-tracking branch 'origin/main' into docs/community-rows
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/hardware-parity.md
#	docs/localization-parity.md
#	docs/roadmap.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-24 01:26:52 +07:00
aiongg 6ba0223636 Tests: the paragraph indent test loads system fonts and skips only without glyphs (#1227) 2026-10-23 13:50:42 -04:00
aiongg c48102350b Packaging: pin the signing client and appimagetool, and check their hashes (#950)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Reject AppImage download for architectures without a pinned appimagetool

* Pin the AppImage runtime
2026-10-23 13:02:11 -04:00
aiongg fc7a744519 CI: one test binary per crate, nextest, line tables only (#1208)
* Dev profile: line tables for workspace crates, no debug info for dependencies

* Tests: one integration-test binary per crate with several test files

* xtask ci: nextest, doctests, tests without examples, --keep-going; add xtask test
2026-10-23 13:01:12 -04:00
Dylan M. Taylor e66780a39b packaging: use [[ for bash conditional tests (SonarCloud S7688) (#1225)
SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests.
2026-10-23 12:56:34 -04:00
Dylan M. Taylor 8d790607ff packaging: bash test and function hygiene (SonarCloud S7688, S7679, S7682, S131, S7677) (#1201)
- Use [[ ]] instead of [ ] for conditional tests in the bash packaging scripts (S7688). Every
  converted test compares quoted values to literals, so no pattern-matching change.
- Name positional parameters with locals inside functions (S7679).
- End the env.sh helpers with an explicit return that keeps their exit status (S7682).
- Give case statements a default branch (S131).
- Send the check-app-specific.sh error to stderr (S7677).

packaging/freebsd/package.sh is left alone while #1158 rewrites it.
2026-10-23 11:17:32 -04:00
Dylan M. Taylor 92b83521af Merge pull request #1221 from dylanmtaylor/sonar/security-reliability
test: explain the discarded reads in the fake servers (SonarCloud rust:S7450)
2026-10-23 09:57:08 -04:00
Dylan M. Taylor 32735549c4 test: explain the discarded reads in the fake servers (SonarCloud)
read() returns Result<Message>, not a lock guard; these test loops
deliberately ignore what the client sent first.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-23 09:44:02 -04:00
Brandon ThomasandClaude Opus 5.5 80f916326f Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:39:04 -04:00
Dylan M. Taylor 9ec80bf2c2 Say the library computes month and day; time-of-day is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 07:12:29 -04:00
Dylan M. Taylor a0e937918c Name the 400-year range extension as the one exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
2026-10-22 07:12:29 -04:00
Dylan M. Taylor e3169e5a61 collab/ui-egui: keep old UTC output past chrono's year range
`utc_date` (collab) and `date` (ui-egui) took `u64` seconds, which the old
hand-rolled civil-from-days code converted for the whole `u64` range. The
chrono rewrite used `from_timestamp(..).unwrap_or_default()`, so any instant
past chrono's own year limit (262143) silently became the epoch — changing
existing output. `u64::MAX` seconds, for example, used to read
`584554051223-11-09 07:00 UTC` / `Nov 9, 584554051223`, not 1970.

Fix: add a `utc_parts(days, sod, nanos)` helper per crate that moves the
instant into chrono's range by whole 400-year Gregorian cycles (146_097 days),
lets chrono do the month/day/time, and restores the real year afterwards.
chrono stays the only calendar logic; the cycle count just shifts the year, and
every `u64` day count fits in `i64` so the conversion is always exact (no epoch
fallback). Split seconds with `u64` arithmetic first to avoid the `i64` seconds
overflow near `u64::MAX`, matching the old code.

Tests now pin the old output instead of the epoch:
- collab utc_date(u64::MAX) == "584554051223-11-09 07:00 UTC"
  (and utc_date(253_402_300_800) == "10000-01-01 00:00 UTC", already pinned)
- ui-egui date(253_402_300_800) == "Jan 1, 10000"
  date(u64::MAX) == "Nov 9, 584554051223"
2026-10-22 07:12:29 -04:00
Dylan M. Taylor 630e0dd403 Keep 5-digit years unprefixed in utc_date
chrono's %Y writes a '+' before years past 9999, whereas the old hand-rolled
{y:04} did not. utc_date now writes the year with {:04} to keep the exact old
output. from_timestamp already reads a clock past chrono's range as the epoch
rather than panicking. Tests cover the 5-digit-year and out-of-range cases.
2026-10-22 07:12:29 -04:00
Dylan M. Taylor 0e3876e6d6 Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the two hand-rolled Howard Hinnant days-from-civil copies with chrono
as a workspace dependency:
- crates/collab/src/message.rs: utc_date (YYYY-MM-DD HH:MM UTC)
- crates/ui-egui/src/home_recents.rs: date (Mon D, YYYY, UTC; keeps the
  translated month names and trf formatting)
Output is unchanged. Neither function had a test, so each gains one pinning the
exact strings the old code produced (leap days included). chrono uses only std,
so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.

Follow-up to storytold/cadcraft#1785
2026-10-22 07:12:29 -04:00
BFlat 5fa586306a Merge pull request #1049 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:01:42 +07:00
bflatastic ce8a224c9e README: the app icon above the title 2026-10-22 17:57:45 +07:00
Dylan M. Taylor a86897dc91 ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#1015)
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
2026-10-21 19:36:23 -04:00
Dylan M. Taylor cb7966556c security: enforce HTTPS on curl downloads (SonarCloud S6506) (#1016)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 19:36:11 -04:00
Dylan M. Taylor c1a4f2d954 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:36 -04:00
Dylan M. Taylor 0ab000b74b ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:46:57 -04:00
Brandon ThomasandClaude Opus 5.5 28de91d9ae Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:56:51 -04:00
Dylan M. Taylor 35da7b7cff feat(window): adopt shared craft-window for Linux decorations (depends on craft-libs#217) (#971)
* feat(window): adopt shared craft-window for Linux decorations

Replace FigCraft's bespoke Wayland xdg-decoration probe
(apps/figcraft/src/linux_window.rs) with the shared craft-window crate
from craft-libs, so every Craft app agrees on who draws the window frame
per desktop.

The decision is now made once, before the viewport is built, by
craft_window::Choice::probe(Prefix::new("FIGCRAFT")) and mapped onto
FigCraft's existing TitleBar enum:

  Frame::Desktop => TitleBar::Native   (TitleBar::Inset on macOS, unchanged)
  Frame::App(_)  => TitleBar::Custom

This drops the old runtime send_viewport_cmd(Decorations(..)) switch and
the linux_window probe. macOS keeps its inset traffic lights and Windows
keeps the system frame exactly as before. The only behaviour change: on
KDE and other Wayland compositors with server-side decorations (Sway,
Hyprland, COSMIC) FigCraft now keeps the native frame instead of always
drawing its own custom title bar, matching the rest of the suite.

NOT MERGEABLE YET: craft-window is pinned to the unmerged feature branch
from storytold/craft-libs#217 and must be repointed to a merged rev
before this can land.

* fix(window): preserve Windows titlebar; pin craft-window to merged rev

- Keep FigCraft's own custom title bar on Windows. craft-window returns
  Frame::Desktop on Windows, and the mapper was turning that into
  TitleBar::Native, flipping Windows from its existing undecorated custom
  bar to the system frame. Map Frame::Desktop to Custom on Windows (as
  macOS maps to Inset); only Linux/BSD Frame::Desktop becomes Native. This
  keeps macOS and Windows unchanged; the sole change stays KDE/Wayland.
- Repoint craft-window from the (now-merged) feature branch to the squash
  commit on storytold/craft-libs (craft-libs#217), and commit Cargo.lock.

Verified: cargo build -p figcraft and cargo test -p figcraft (53 tests)
pass against the merged dependency; rustfmt clean.
2026-10-21 06:05:08 -04:00
BFlat 74c4a84da3 Merge pull request #907 from RiceDee/fix/idle-tests-font-scan
test(ui-egui): idle harness scans system fonts before its first frame
2026-10-21 16:32:52 +07:00
BFlat 123e46550f Merge pull request #909 from RiceDee/fix/component-set-name
#903 follow-up: name new component sets from the variant path, not the first property
2026-10-21 16:31:04 +07:00
BFlat 44e64d8d47 Merge pull request #933 from SiadeLucas/feat/pages-scroll-area
ui: keep the current page scrolled into view in the pages list (#930)
2026-10-21 16:21:38 +07:00
BFlat bb532a5790 Merge pull request #908 from RiceDee/feat/903-variants-discoverability
#903: Combine as variants in the context menu and Design panel; Add variant on a lone component
2026-10-21 16:01:25 +07:00
BFlat f6f44dcc53 Merge pull request #928 from AganzeFelicite/fix/web-drop-opens-documents-916
M7: a document dropped on the canvas opens as a new file (#916)
2026-10-21 15:50:24 +07:00
BFlat 96a82edecf Merge pull request #939 from YarilaFox/fix/comment-draft-on-tool-switch
M7: dismiss comment draft when switching tools
2026-10-21 15:48:42 +07:00
BFlat f143260c3a Merge pull request #935 from YarilaFox/fix/saved-document-title
M7: show the chosen filename for saved documents
2026-10-21 15:47:09 +07:00
BFlat bb93e5e433 Merge pull request #929 from Silvertree2010/fix/h881-local-from-link-accepts-a-sign-i
Reject non-hex characters in Local::from_link token
2026-10-21 15:44:45 +07:00
BFlat da96d54157 Merge pull request #961 from Silvertree2010/fix/h1145-reversesubpaths-reverse-subpaths
Keep open subpaths open in ReverseSubpaths::reverse_subpaths
2026-10-21 15:35:17 +07:00
BFlat a1758c163f Merge pull request #958 from Silvertree2010/fix/h1112-live-text-svg-export-capitalises
Fix Title Case restarting mid-word across styled runs in SVG export
2026-10-21 15:34:55 +07:00
BFlat b1d00f0802 Merge pull request #943 from Silvertree2010/fix/h932-trim-num-prints-any-whole-number
Fix trim_num saturating whole numbers beyond i64 range
2026-10-21 15:34:34 +07:00
BFlat efb95b29bf Merge pull request #938 from Silvertree2010/fix/h911-same-server-ignores-the-scheme-s
Strip default port in same_server when the address has a path
2026-10-21 15:34:08 +07:00
BFlat 9257f97d69 Merge pull request #911 from Silvertree2010/fix/h737-parse-css-accepts-a-malformed-al
Reject malformed alpha and extra args in Color::parse_css
2026-10-21 15:33:47 +07:00
NetterSilverandClaude Haiku 5.5 2e12bdfd91 Keep open subpaths open in ReverseSubpaths::reverse_subpaths
Found while testing crates/geom/src/lib.rs:287

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 02:46:23 +02:00
Siade Lucas f53f37dfe0 ui: scope page tracker by document UID and update on render
Include document UID in pages_seen_page to correctly handle page selection and scroll tracking when switching between documents. Record page as seen only after the active row renders so that pages filtered out by search scroll into view when the filter is cleared.
2026-10-20 21:28:09 -03:00
NetterSilverandClaude Haiku 5.5 59f3b3b733 Fix Title Case restarting mid-word across styled runs in SVG export
Found while testing crates/svg/src/lib.rs:589 (also :604 and :611)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-21 00:31:03 +02:00
NetterSilverandClaude Haiku 5.5 77f4271061 Fix trim_num saturating whole numbers beyond i64 range
Found while testing crates/doc/src/text.rs:64

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 17:03:45 +00:00
YarilaFox 8f3afbf6a1 M7: clear comment drafts for control-channel tool changes 2026-10-20 21:04:04 +05:00
Siade Lucas 1c79c1646f ui: scroll active page into view when selection changes 2026-10-20 12:53:21 -03:00
YarilaFox bf3fc3cc6e M7: dismiss comment draft when switching tools 2026-10-20 20:47:16 +05:00
NetterSilverandClaude Haiku 5.5 8b198ea364 Strip default port in same_server when the address has a path
Found while testing crates/engine/src/online.rs:118

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 15:41:47 +00:00
Brandon ThomasandClaude Opus 5.5 d9c924c3ba Skip CodeRabbit reviews on Dependabot PRs (#937)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:39:30 -04:00
Dylan M. Taylor 92290dd208 docs: add SonarCloud and CodeRabbit badges to README (#820)
* docs: add SonarCloud and CodeRabbit badges to README

* docs: put SonarCloud and CodeRabbit badges on their own line
2026-10-20 11:32:08 -04:00
NetterSilverandClaude Haiku 5.5 7c68ebf183 Reject non-hex characters in Local::from_link token
Found while testing crates/collab/src/code.rs:~150 (Local::from_link, u64::from_str_radix on the token)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 14:27:09 +00:00
AganzeFelicite fc37c808aa M7: a document dropped on the canvas opens as a new file (#916)
A .figcraft, .fig or file JSON dropped on the canvas ran file.import,
which added its pages to the file in front. It now opens as a file of
its own, as on the home screen, in the browser too. Images and SVG
still import at the drop point; File > Import still adds pages.

- pending_open::open_dropped queues a dropped file as file.open; the
  home screen and the canvas share it.
- Dragging only documents over the canvas says "Drop to open" (not in
  the browser, which names no file until the drop).
- The tab-switch import test drops an SVG: it pinned where an import
  lands, using a .figcraft only as a file to import.
2026-10-20 15:27:27 +02:00
YarilaFox 23463f719b M7: keep saved document titles in sync with filenames 2026-10-20 17:56:30 +05:00
NetterSilverandClaude Haiku 5.5 49586b1c47 Reject malformed alpha and extra args in Color::parse_css
Found while testing crates/color/src/lib.rs:165 (also :173)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 09:30:01 +00:00
RiceDee b5a315a748 #903: name a new component set from its variants' path, not their first property
A component set took its name from the first variant's name up to the first '/' or '=',
so variants named "State=Default, Size=Small" made a set called "State". The set now
takes the path before the last '/' ("Button/State=Hover" -> "Button",
"Icons/Arrow/Dir=Up" -> "Icons/Arrow"), keeps a plain name as is ("Button"), and
otherwise gets the next numbered default ("Component 1"), as a new component does.
2026-10-20 16:32:16 +11:00
RiceDee bcd5b6e632 #903: Combine as variants in the context menu and Design panel; Add variant on a lone component 2026-10-20 16:18:13 +11:00
RiceDee bbe1a12eeb test(ui-egui): idle harness scans system fonts before its first frame 2026-10-20 15:29:27 +11:00
Brandon ThomasandClaude Opus 5.5 594dc8c6cf Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:20:57 -04:00
BFlat dd6bd3fe1c Merge pull request #901 from pechnikov2026-star/community/russian-catch-up
M9: Russian catalog catch-up: the 56 strings added since the review (machine-drafted)
2026-10-20 06:32:45 +07:00
bflatastic 9b711e212b Merge main into #901 2026-10-20 06:20:04 +07:00
BFlat b5038726c4 Merge pull request #902 from storytold/fix/issue-892
M3: duplicating a main component makes an instance (#892)
2026-10-20 06:19:17 +07:00
BFlat 9c6920cf7d Merge pull request #896 from johnpetersa19/main
Add missing Brazilian Portuguese UI translations
2026-10-20 06:12:04 +07:00
BFlat 2bcd004071 Merge pull request #897 from devius/ka-header
i18n(ka): say how the Georgian catalog was made (AI-assisted, native-reviewed); still a draft
2026-10-20 06:11:27 +07:00
bflatastic f979878d9e Merge main into fix/issue-892
# Conflicts:
#	ROADMAP.md
2026-10-20 06:09:17 +07:00
BFlat 5f0fe486db Merge pull request #900 from storytold/fix/issue-890
M1: readable disabled and selected text (#890)
2026-10-20 06:09:13 +07:00
sanches eb7ead9455 M9: Russian catalog catch-up: the 56 strings added since the review (machine-drafted) 2026-10-20 01:59:51 +03:00
bflatastic 64efe12e6f Merge main into fix/issue-890
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-20 05:58:56 +07:00
BFlat 4bb30782ff Merge pull request #899 from storytold/fix/issue-879
M1: node.get reports layer data (pluginData) (#879)
2026-10-20 05:58:32 +07:00
bflatastic ab59877559 Merge main into #897 2026-10-20 05:47:17 +07:00
bflatastic 77ffc57432 #896: "Individual padding" uses the catalog's word for padding (Preenchimento is Fill) 2026-10-20 05:47:15 +07:00
bflatastic 4e7256bf6a Merge main into #896 2026-10-20 05:47:14 +07:00
bflatastic 8a46478e81 Merge main into fix/issue-879
# Conflicts:
#	ROADMAP.md
2026-10-20 05:46:30 +07:00
BFlat 11ecf3e887 Merge pull request #898 from storytold/fix/issue-894
M1: nudge while measuring; distances follow, handles hide (#894)
2026-10-20 05:46:25 +07:00
bflatastic b82369201d M3: duplicating a main component makes an instance (#892)
Duplicate and Alt-drag copied a main component as a second main. Within
its file the copy is now an instance of it (components::copy_in_file,
shared by edit.duplicate and the Alt-drag copy), as Copy then Paste
already was. A variant duplicated in its set stays a new variant.
2026-10-20 05:44:46 +07:00
bflatastic e3b20b6540 Merge main into fix/issue-894
# Conflicts:
#	ROADMAP.md
2026-10-20 05:35:21 +07:00
BFlat b17bea9195 Merge pull request #881 from yelintong/feat/grid-auto-width
Layout grids: Auto line width for columns and rows
2026-10-20 05:35:13 +07:00
BFlat 287b506bc1 Merge pull request #889 from kk376/fix/857-live-session-large-files
fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
2026-10-20 05:34:52 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 e123a2efe7 i18n(ka): the draft status says the review is done and approval pending; the guide and the field doc say both kinds of draft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-20 02:33:25 +04:00
Davit MatchakhelidzeandClaude Opus 5.5 bae2f057a1 i18n(ka): the catalog header and docs say how Georgian was made (AI-assisted, native-reviewed); still a draft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-20 02:11:55 +04: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
John Peter Sa c3fe69c629 Merge branch 'storytold:main' into main 2026-10-19 18:58:22 -03: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
johnpetersa19andCopilot App 629c297de9 M2.1: add missing Brazilian Portuguese translations
Co-authored-by: Copilot App <[email protected]>
2026-10-19 18:49:06 -03:00
bflatastic 0f95ce3d5b #881: test Auto in a fixed grid's count, scroll to the field instead of a fixed point 2026-10-20 04:45:45 +07:00
bflatastic a7d0648958 #881: fixed grids keep their pixel Width and the Stretch->Left lift to 60; the stretched Auto width reuses the number field (disabled) 2026-10-20 04:45:44 +07:00
bflatastic 309271851f #881: keep fixed grids at width 0 drawing bands as before (no hairlines) 2026-10-20 04:39:14 +07:00
bflatastic f99fdea3b6 #881: leave the progress docs to the maintainer 2026-10-20 04:39:13 +07:00
bflatastic 5d32c522d2 Merge main into #881 2026-10-20 04:39:04 +07:00
bflatastic cc4799061e #889: say a join is too large in words for people (native and web), translated; keep one test 2026-10-20 04:38:28 +07:00
BFlat 5b5a4855d4 Merge pull request #895 from storytold/fix/web-fonts
M9: web release draws text again; craft-fonts pin back to the Latin fonts (#885)
2026-10-20 04:38:11 +07:00
bflatastic ddde77db0a M9: release fonts: web build embeds craft-fonts, pin back to the Latin manifest (#885)
The browser has no system fonts, and since releases moved to the shared
workflow the web build was made without craft-fonts: no font list and no
text drawn. The Georgian change also re-pinned craft-fonts to its main,
which has no fonts/latin-manifest.txt, so the next release would fail
under CRAFT_FONTS_REQUIRED. Pin the Latin commit again until one commit
has both (craft-fonts#42); desktop Georgian uses an installed font.
2026-10-20 04:26:04 +07:00
bflatastic 89529b3d00 #889: drop the hub write-cap and core-limit changes (neither is read) 2026-10-20 04:22:26 +07:00
bflatastic ca0e689834 Merge main into #889 2026-10-20 04:22:20 +07:00
BFlat 4c1546eed2 Merge pull request #888 from Silvertree2010/fix/886-when-drag-n-dropping-multiple-fi
Place files dropped together on the canvas side by side
2026-10-20 04:21:10 +07:00
BFlat b19a81084e Merge pull request #813 from alrifatsabbir/bengali-ui-translation
Add Bengali UI Translation
2026-10-20 04:10:12 +07:00
BFlat 20a16dfd5c Merge pull request #872 from devius/ka-language
i18n: Georgian (ka) interface language
2026-10-20 03:57:09 +07:00
bflatastic 40fe1a623e Merge fix/872 into #813 2026-10-20 03:26:53 +07:00
bflatastic 4b030f027a Merge main into #872 2026-10-20 03:20:30 +07:00
Kushagra Kumar 6369b6b7a5 test(collab-net): exercise NetLink host in large welcome regression test 2026-10-20 01:43:50 +05:30
BFlat 9d9cb9915a Merge pull request #812 from lukasz-sklad/feat/polish-translation
feat(i18n): Complete Polish translation (pl)
2026-10-20 03:05:06 +07:00
BFlat 1abb9083c7 Merge pull request #821 from attisvalentin/fix/rescan-and-load-fonts
M4: fonts installed while the app runs: text.rescanFonts, text.loadFont, text.refit
2026-10-20 03:04:29 +07:00
BFlat 4788c6b819 Merge pull request #870 from MortenHusted/fix/component-paste-instance
M3: copied components paste as instances within their file
2026-10-20 03:04:03 +07:00
Kushagra Kumar e8a6033199 test(collab-net): add ignored loopback test exercising welcome delivery above 64 MiB 2026-10-20 01:33:32 +05:30
BFlat 54e3587fd8 Merge pull request #862 from MortenHusted/fix/instance-main-modes
M3: instances follow the variable modes their main component sets
2026-10-20 03:03:27 +07:00
BFlat ba4a736d5b Merge pull request #847 from MortenHusted/fix/fig-component-props
M3: .fig imports keep component property bindings and values
2026-10-20 03:02:50 +07:00
BFlat 163e840fc3 Merge pull request #818 from leandrodslv/feat/ui-size
feat(ui): add the interface size preference (#540)
2026-10-20 03:01:28 +07:00
BFlat b5904ad4c2 Merge pull request #874 from justinjohn0306/fix/857-start-on-home
M7: a preference to start on the Home screen (#857)
2026-10-20 03:00:49 +07:00
BFlat 413ec40932 Merge pull request #867 from AganzeFelicite/fix/fig-paragraph-returns-852
fix(text): a line separator breaks the line inside its paragraph (#852)
2026-10-20 03:00:11 +07:00
BFlat d32e971685 Merge pull request #832 from Ahmed5Emad/seperate-padding
adding individual padding controls in Layout panel
2026-10-20 02:59:33 +07:00
BFlat f8fc12b981 Merge pull request #854 from campbellcody/feat/smart-selection-drag-reordering
M1: smart-selection drag reordering
2026-10-20 02:58:55 +07:00
BFlat e4e872a489 Merge pull request #861 from AJMakesStuff/#855--The-Search-button-for-the-pages-doesn't-work-as-expected
#855: The Search button for the pages doesn't work as expected
2026-10-20 02:58:18 +07:00
BFlat d43375b69c Merge pull request #865 from AganzeFelicite/fix/delete-page-key-859
fix(pages): Delete deletes the page picked in the Pages list (#859)
2026-10-20 02:54:33 +07:00
Kushagra Kumar 227e182f68 fix(collab-net): report exact bytes when server message limit is not a whole unit 2026-10-20 01:23:09 +05:30
bflatastic f6090dde80 Merge #813's latest head (main merged in, contributors entry)
# Conflicts:
#	crates/text/build.rs
2026-10-20 02:44:56 +07:00
Kushagra Kumar fbb4a22e01 fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
- Configure server write loop in hub.rs with max_server_message so outgoing ServerMsg payloads above 64 MiB (e.g. document snapshots in Welcome messages) are delivered without socket termination.
- Set core max_message_bytes in local live session HostConfig to max_server_message.
- Map tungstenite Capacity errors in guest read loop to Outcome::Failed with accurate size limits rather than Outcome::Refused which falsely reported 'The host refused'.
- Add loopback tests verifying Welcome delivery above client message limits and accurate diagnostics on client capacity overrun.
2026-10-20 01:12:13 +05:30
NetterSilverandClaude Haiku 5.5 7d0ecd2294 Place files dropped together on the canvas side by side
Fixes #886

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:03 +02:00
Muamer MustaficandCursor 63dc418cc2 M7: a web file picker closed without a cancel event settles on refocus (#759)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:50:27 +02:00
Al Rifat Sabbir 847e610550 Add Bengali script support in build.rs 2026-10-19 03:46:01 +06:00
Muamer MustaficandCursor 79ac16a256 M3: doc comments for the structure commands and binding re-apply (#810)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:40:35 +02:00
Muamer MustaficandCursor 85c5286d39 M1: doc comments for the menu invoke and SVG clipboard export (#808)
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:40:33 +02:00
Al Rifat Sabbir 3878f89bd6 Change order of characters in language script test 2026-10-19 03:27:24 +06:00
skylar taylor-barrick a0d1162976 Desktop: refuse unknown command-line options instead of ignoring them (#836) (#844)
A mistyped or unsupported flag (e.g. --automation-write-roots) used to be
dropped silently, so the app started unconfined. It now exits 2 with
'unknown option', like a bad automation root. A bare -- ends the options.

Adds a desktop control-channel test that file.save / file.saveAs outside the
write root are refused and create nothing, while saves inside succeed, and
documents that the root flags arrived in 0.7.0 (0.6.0 ignores them).
2026-10-18 15:20:15 -06:00
NetterSilverandClaude Haiku 5.5 23d0d9e49e Accept --control=PORT like --control PORT
Fixes #837

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

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

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 21:27:31 +02:00
Dylan M. Taylor 7fcb193f2a ci: configure Dependabot for Cargo and GitHub Actions (#825)
* ci: configure Dependabot for Cargo and GitHub Actions

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

* ci: consolidate Cargo Dependabot directories in figcraft
2026-10-18 15:10:42 -04:00
Hamza c3cb8c3e0e cli: confine info, export, convert, render and run to automation roots
`figcraft-cli mcp` takes `--automation-read-root` / `--automation-write-root`
to confine the files an agent's commands touch, but the file subcommands took
neither: `figcraft-cli run script.json --out /anywhere/x.figcraft` read and
wrote as the user who started it. The README invites agent use, and a script
or agent driving `run`, `export` or `convert` had no way to say which folders
it may reach (#833).

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

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

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

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

Closes #759.

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

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

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

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

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

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

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

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

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

Closes #808

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

Closes #810

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 14:39:57 +02:00
Dylan M. Taylorandbflatastic d8ff1441b1 docs: drop references to competing commercial software (#786)
* docs: drop references to competing commercial software

* docs: clarify parity checklist coverage in status callout

* #786: say interface design once in the tagline

---------

Co-authored-by: bflatastic <[email protected]>
2026-10-18 19:34:53 +07:00
LucasandAntigravity 9ae3223d22 feat(i18n): Complete Polish translation (pl)
Add full Polish localization for FigCraft UI, commands, and menus.
Includes Polish CLDR integer plural rules (one, few, many, other)
and eliminates all untranslated strings and borrowings.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 13:58:13 +02:00
BFlat 018b8166b2 M3: a bound colour variable's alpha applies once, not squared (#802) (#804)
Fixes #802.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 11:14:11 +07:00
sanches e9be17427f M9: French catalog at 100% of the strings (still a draft)
The 91 UI strings, 12 command labels and 2 menu wordings the strings added since the French catalog was drafted left in English are translated in the catalog's conventions (NBSP before : ! and inside guillemets, one/other plurals, 0 singular): starter templates and the Templates tab, the home screen's recents table with its sorting and dates in the French way, dropping files to open or import, the Getting started tips with sharing by code, the Playground's bound-variable chip, smart-selection spacing, outline mode's options, the converting and panel-width commands. Appended to each section; still requires native review.
2026-10-18 05:51:39 +03:00
Yarila 1fe78aec66 M7: respect theme in presenter controls and backdrop 2026-10-18 03:14:53 +05:00
Yarila 1f0d9a9107 M7: show file previews in Home search 2026-10-18 01:49:22 +05:00
Yarila dd980c884f M7: search known files and templates from Home 2026-10-18 01:49:21 +05:00
bflatastic 154baa4467 M7: a rename selects the whole name, so typing replaces it 2026-10-18 01:49:02 +07:00
Natnael-Dev 1e7983a550 docs(evidence): record verification evidence for #767 fix 2026-10-17 21:47:40 +03:00
Natnael-Dev bc5a2b8fa7 fix(proto): delete prototype interaction without deleting component (#767)
When an interaction noodle or interaction editor was selected in the
Prototype tab, pressing Delete or Backspace (or executing edit.delete)
targeted the selected layer/component instead of deleting the prototype
connection.

- Intercept Backspace and Delete in shortcuts::key_event and run_binding
  when an interaction is selected in the Prototype tab to remove the
  interaction using proto.interaction.remove rather than deleting the layer.
- Ensure clicking on empty canvas or outside noodles/handles deselects the
  active interaction noodle/editor.
- Close the interaction editor when navigating away from the Prototype tab.
- Add regression tests in proto_editor_tests verifying interaction deletion
  with Delete/Backspace keeps the component intact, and Escape closes the
  interaction editor before deselecting the component.
2026-10-17 21:45:51 +03:00
NetterSilverandClaude Haiku 5.5 89c5d3dfe6 Keep View toggles such as Rulers in the preferences
Fixes #379

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 23:23:25 +07:00
RealtaNua cae69a7ae8 Merge remote-tracking branch 'upstream/main' into feat/752-report-missing-fonts
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
2026-10-17 22:21:25 +07:00
Filip Strajnar 6ce46b3532 M1: double-click a frame's name on the canvas to rename it (#757)
A frame's name above its top edge (or a section's pill) only selected and
dragged it; a double-click there did nothing, since the label lies outside
the frame and hits no layer. With the Move or Scale tool, a double-click on
a label now selects that frame and asks the UI (`canvas.renameLabel`) for a
field over the label with the name selected, as the layers panel and pages
list do. Enter or a click elsewhere renames it (`object.rename`, one undo
step) and Escape leaves the name as it was. Rotated labels work too.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:05:49 +02:00
RealtaNuaandClaude Opus 5.5 825cb1e7af M7: list the fonts a file uses that aren't installed (#752)
Text in a family that isn't installed was drawn in a fallback font
without a word. Now:

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 21:49:32 +07:00
bflatastic 3da66a8467 Merge remote-tracking branch 'origin/main' into docs/community-rows
# Conflicts:
#	ROADMAP.md
#	docs/localization-parity.md
#	docs/ui-parity.md
2026-10-16 02:25:54 +07:00
bflatastic 345ae50867 Docs: roadmap entries for the community PRs in #507, #510, #529 and #530 2026-10-16 01:14:15 +07:00
NetterSilverandClaude Haiku 5.5 3d90d8edfe Keep View toggles such as Rulers in the preferences
Fixes #379

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:33:28 +02:00
267 changed files with 21769 additions and 1600 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/plugins"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+6 -6
View File
@@ -33,7 +33,7 @@ jobs:
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
@@ -42,12 +42,12 @@ jobs:
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
@@ -74,11 +74,11 @@ jobs:
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
+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 }}
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+1
View File
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
+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
View File
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
Generated
+204 -258
View File
File diff suppressed because it is too large. Load diff
+13 -4
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -34,6 +34,9 @@ figcraft-plugin = { path = "crates/plugin" }
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`.
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -42,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
imbl = { version = "7", features = ["serde"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
@@ -98,11 +103,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
[profile.dev.package."*"]
opt-level = 2
# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
# the test build faster and the target dir smaller.
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.release]
lto = "thin"
+11 -6
View File
@@ -7,10 +7,14 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
</p>
<h1 align="center">FigCraft</h1>
<p align="center">
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
in a native app that opens instantly and works offline. Open source, and yours.
</p>
@@ -20,6 +24,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-0EA5C6?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_figcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/figcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -62,7 +69,7 @@
<tr>
<td width="25%" valign="top">
<h3>🎛️ Familiar by design</h3>
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
</td>
<td width="25%" valign="top">
<h3>⚡ Native and fast</h3>
@@ -227,7 +234,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
@@ -262,7 +269,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
@@ -348,8 +355,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+67 -4
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
native speakers' reviews of the six language drafts need people.
native speakers' reviews of the nine language drafts need people.
## By dimension
@@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -126,11 +126,19 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
@@ -316,6 +324,61 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
+21 -3
View File
@@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
}
}
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
/// confined run can only write inside a write root (#833).
///
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
/// `Roots::check` has already refused a link that leads out of the root.
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
}
if figcraft_engine::file_access::current().is_some() {
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
@@ -155,6 +166,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
})
.collect();
let missing = figcraft_engine::fonts::missing(&doc);
let v = json!({
"file": path,
"name": doc.name,
@@ -164,6 +176,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
"variables": doc.variables.variables.len(),
"images": doc.images.len(),
"notes": notes,
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
});
if as_json {
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -187,6 +200,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
}
}
for m in &missing {
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
}
Ok(ExitCode::SUCCESS)
}
@@ -238,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
@@ -323,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
}
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
Some(path) => {
+50 -28
View File
@@ -22,6 +22,7 @@ USAGE:
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
@@ -42,12 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
writes only inside a write root, everything else refused. A running app confines itself: start it
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
as the user who started it, as before. A running app confines itself: start it with
`figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
if read.is_empty() && write.is_empty() {
return Ok(None);
}
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
}
/// Runs `f` confined to the roots `a` carries.
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(a)?.map(std::sync::Arc::new);
figcraft_engine::file_access::confined(roots.as_ref(), f)
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
@@ -98,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &[], &["json"])?;
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
files::info(&file, a.flag("json"))
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
files::export(&file, &out, &target)
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &[], &[])?;
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => files::convert(i, o),
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let a = Args::parse(rest, &["in", "out"], &[])?;
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
@@ -137,23 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
(true, _) => None,
// The app runs the commands: only its own roots can confine them.
(false, Some(_)) => {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
};
mcp(a.value("attach").as_deref(), roots)
let attach = a.value("attach");
// The app runs the commands: only its own roots can confine them.
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
@@ -1,5 +1,4 @@
//! The binary end to end.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
@@ -172,3 +171,98 @@ fn commands_json_lists_everything() {
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v.as_array().unwrap().len() > 200);
}
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
/// the same commands behave as they always did.
#[test]
fn file_subcommands_are_confined_to_automation_roots() {
let d = tmp("file-roots");
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
// A document to work on, made unconfined.
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", out_of(&made));
// Inside the roots: renders as usual.
let ok = bin()
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.output()
.unwrap();
assert!(ok.status.success(), "{}", out_of(&ok));
assert!(d.join("out/ok.png").is_file());
// A write outside the write root is refused, and nothing is written.
let escaped = d.join("escaped.png");
let refused = bin()
.args([
"render",
&p("in/doc.figcraft"),
"-o",
&escaped.to_string_lossy(),
"--automation-read-root",
&p("in"),
"--automation-write-root",
&p("out"),
])
.output()
.unwrap();
assert!(!refused.status.success());
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
assert!(!escaped.exists(), "the refused render wrote a file anyway");
// No write root at all: writing is refused outright, reading still works.
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
assert!(!no_write.status.success());
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
// A read outside every root is refused: `info` on the document with only `out` readable.
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
assert!(!read_refused.status.success());
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
// `convert` and `run` take them as well, and a root that is not a folder is refused.
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
assert!(!missing.status.success());
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
// Unconfined, the same render still writes wherever it is told.
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
assert!(plain.status.success(), "{}", out_of(&plain));
assert!(escaped.is_file());
let _ = std::fs::remove_dir_all(&d);
}
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
/// replaced the link with a regular file, because that write stages the bytes and renames them
/// into place.
#[cfg(unix)]
#[test]
fn an_unconfined_write_follows_a_symbolic_link() {
let d = tmp("symlink-out");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("real")).unwrap();
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let target = d.join("real/target.png");
std::fs::write(&target, b"placeholder").unwrap();
let link = d.join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
let written = std::fs::read(&target).unwrap();
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
let _ = std::fs::remove_dir_all(&d);
}
@@ -1,5 +1,4 @@
//! Depth refusal through the actual CLI, including its exit status and destination writes.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
@@ -1,6 +1,5 @@
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
@@ -45,3 +44,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
}
#[test]
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
let text =
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
let out = cli().arg("info").arg(&file).output().unwrap();
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
+12
View File
@@ -0,0 +1,12 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_depth;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod fonts;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
@@ -1,5 +1,4 @@
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
@@ -1,5 +1,4 @@
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
+3 -1
View File
@@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() {
break;
}
}
let _ = ws.read();
// read() returns Result<Message>, not a lock guard; the fuzz loop ends here on purpose and
// the close frame from the server is not interesting.
let _ = ws.read(); // NOSONAR
}
// Still serving, the file intact.
let mut b = join(&p.edit, "Bo");
@@ -2,7 +2,6 @@
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod header_recovery;
mod problem_retention;
+1 -1
View File
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+99 -4
View File
@@ -1,7 +1,7 @@
//! The browser shell: web `Services` and the eframe web runner.
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
}
}
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
@@ -85,6 +86,16 @@ struct WebServices {
}
impl Services for WebServices {
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
Some(Box::pin(async move {
pick_file(&accept).await.unwrap_or_else(|e| {
log::error!("couldn't open a file: {e:?}");
None
})
}))
}
fn downloads(&self) -> bool {
true
}
@@ -136,6 +147,90 @@ impl Services for WebServices {
}
}
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
/// can't be read.
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
// Some browsers (older Safari) open the picker only for an input in the page.
body.append_child(&input)?;
let (closed, picker) = PickerClosed::new(&window, input.clone());
input.click();
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
let file = input.files().and_then(|files| files.get(0));
drop(picker);
result?;
let Some(file) = file else { return Ok(None) };
let name = file.name();
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
return Ok(Some(Err(why)));
}
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
Err(e) => {
log::error!("couldn't read {name}: {e:?}");
Err(figcraft_ui_egui::unreadable(&name))
}
}))
}
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
struct PickerClosed {
window: web_sys::Window,
input: web_sys::HtmlInputElement,
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
}
impl PickerClosed {
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
let slot = resolve.clone();
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if let Some(res) = resolve.borrow_mut().take() {
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
}
});
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if t.get().is_none() {
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
}
});
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
}
}
impl Drop for PickerClosed {
fn drop(&mut self) {
if let Some(t) = self.timer.take() {
self.window.clear_timeout_with_handle(t);
}
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
self.input.remove();
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
+6 -3
View File
@@ -16,6 +16,10 @@ figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-format = { workspace = true }
figcraft-text = { workspace = true }
# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the
# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
craft-window = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
serde_json = { workspace = true }
@@ -45,7 +49,6 @@ wat = { workspace = true }
winresource = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
# Use vector window controls without a title-font renderer or embedded Cantarell.
# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor
# probe that used to need raw-window-handle and wayland-client here now lives in craft-window.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
raw-window-handle = "0.6"
wayland-client = "0.31"
+15 -5
View File
@@ -2,12 +2,14 @@
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
//! the app running unconfined. After a bare `--`, every argument is a file.
/// What the app was started with.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Args {
/// `--control <port>`: the JSON-lines control server.
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
pub control: Option<u16>,
pub files: Vec<String>,
/// The last invite link given.
@@ -17,6 +19,8 @@ pub struct Args {
/// which is refused at startup.
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
/// `--flags` the app doesn't know, as given.
pub unknown: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--" {
out.files.extend(it.by_ref().cloned());
break;
}
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
let roots = match flag {
"--automation-read-root" => Some(&mut out.read_roots),
@@ -63,13 +71,15 @@ 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) {
out.files.push(path);
} else if !a.starts_with("--") {
} else if a.starts_with("--") {
out.unknown.push(a.clone());
} else {
out.files.push(a.clone());
}
}
+22 -1
View File
@@ -9,13 +9,21 @@ fn args(a: &[&str]) -> Vec<String> {
#[test]
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
#[test]
fn the_control_port_accepts_the_equals_form() {
let a = parse(&args(&["--control=7980", "a.figcraft"]));
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
// A bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
}
#[test]
fn automation_roots_repeat_and_are_never_files() {
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
@@ -26,6 +34,19 @@ fn automation_roots_repeat_and_are_never_files() {
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
}
#[test]
fn unknown_flags_are_refused_never_ignored() {
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
// unconfined. Both forms, with and without `=`.
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
assert!(a.write_roots.is_empty());
// Single-dash arguments are left as they were (files); after `--` everything is a file.
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
}
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
-112
View File
@@ -1,112 +0,0 @@
//! Request winit's frame only on Wayland compositors without xdg-decoration.
//! Desktop/session names cannot identify the backend of an individual window.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry;
use wayland_client::{Connection, Dispatch, QueueHandle};
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
/// Never consume, duplicate or query that inherited connection: there may be no
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
pub fn display_socket() -> Option<PathBuf> {
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
return None;
}
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
let path = PathBuf::from(display);
if path.is_absolute() {
Some(path)
} else {
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
}
}
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
if !wayland {
log::debug!("window controls: non-Wayland window, retaining custom controls");
return false;
}
let server_decorations = socket.and_then(probe);
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
native_frame(wayland, server_decorations)
}
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
wayland && server_decorations == Some(false)
}
fn probe(socket: PathBuf) -> Option<bool> {
// The compositor must not be able to block startup on a registry roundtrip.
// On failure/timeout retain FigCraft's own working title bar and buttons.
let (send, receive) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("wayland-decorations".into())
.spawn(move || {
let _ = send.send(registry_has_decorations(socket));
})
.ok()?;
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
}
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
// A fresh connection leaves winit's event queue and inherited descriptors alone.
let stream = UnixStream::connect(socket).ok()?;
let connection = Connection::from_socket(stream).ok()?;
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
}
struct Registry;
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_queue: &QueueHandle<Self>,
) {
}
}
#[cfg(test)]
mod tests {
#[test]
fn only_wayland_without_server_decorations_uses_the_frame() {
for (wayland, server_decorations, expected) in [
(false, None, false),
(false, Some(false), false),
(false, Some(true), false),
(true, Some(true), false),
(true, Some(false), true),
(true, None, false),
] {
assert_eq!(super::native_frame(wayland, server_decorations), expected);
}
}
#[test]
fn an_unavailable_display_keeps_the_custom_controls() {
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
}
#[test]
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
let start = std::time::Instant::now();
assert_eq!(super::probe(socket.clone()), None);
assert!(start.elapsed() < std::time::Duration::from_secs(2));
drop(listener);
let _ = std::fs::remove_file(socket);
Ok(())
}
}
+119 -25
View File
@@ -10,7 +10,8 @@
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
//! the command line are not. A root that isn't an existing folder stops the app at startup.
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@@ -20,8 +21,6 @@ mod args;
mod control_server;
mod gamepad;
mod graphics;
#[cfg(target_os = "linux")]
mod linux_window;
mod recovery;
mod services;
mod startup;
@@ -107,10 +106,13 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
with_app(|fig| services::save_state(fig));
if let Some(r) = recovery::lock(&self.recovery).take() {
r.finish();
}
// With the app out of reach (busy), its recovery copies stay for the next start.
let copies = recovery::lock(&self.recovery).take();
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
// throws on the content view AccessKit restores (craft-libs#3, #781).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -126,6 +128,12 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
if let Some(flag) = args.unknown.first() {
eprintln!(
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
);
std::process::exit(2);
}
let roots = match (
args.read_roots.is_empty() && args.write_roots.is_empty(),
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
@@ -161,6 +169,7 @@ fn main() -> eframe::Result {
log::error!("{f}: {e}");
}
}
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
// The preferences first: they hold the online files a recovered document may be one of.
@@ -182,10 +191,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
@@ -194,6 +201,8 @@ fn main() -> eframe::Result {
let mut fig = FigApp::new(session, Box::new(svc));
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
fig.ui.font_checks = opened;
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
@@ -211,8 +220,13 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
// Start frameless on Linux; the app creator checks the actual display backend
// before enabling a client-side frame. Other windows inherit that decision.
// Who draws the window frame is decided once, before the viewport is built, by the shared
// craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is
// kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the
// decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway,
// Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter —
// which draws no server-side frame — leaves it to the app's own custom title bar. This replaces
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
if let Some(icon) = icon {
@@ -221,8 +235,6 @@ fn main() -> eframe::Result {
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
#[cfg(target_os = "linux")]
let linux_display = linux_window::display_socket();
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
@@ -233,11 +245,6 @@ fn main() -> eframe::Result {
"FigCraft",
options,
Box::new(move |_cc| {
#[cfg(target_os = "linux")]
if linux_window::needs_native_frame(_cc, linux_display) {
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
}
Ok(Box::new(App {
queue,
ctx_slot,
@@ -255,9 +262,43 @@ 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)
}
/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app
/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR`
/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the
/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager
/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one).
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT"));
title_bar_for(choice.into_frame())
}
/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`].
///
/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop`
/// means the desktop draws it, but FigCraft keeps each platform's existing look there:
/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged.
/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used
/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that.
/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only
/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame.
fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar {
use figcraft_ui_egui::TitleBar;
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
match frame {
craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset,
// Windows kept FigCraft's own title bar before this change; keep it.
craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom,
craft_window::Frame::Desktop => TitleBar::Native,
// The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override).
craft_window::Frame::App(_) => TitleBar::Custom,
}
}
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
@@ -269,13 +310,66 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T
}
}
#[cfg(all(test, target_os = "linux"))]
mod linux_window_tests {
#[cfg(test)]
mod title_bar_tests {
use figcraft_ui_egui::TitleBar;
/// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override)
/// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the
/// decorations so FigCraft draws its own.
#[test]
fn linux_starts_with_the_existing_frameless_title_bar() {
let bar = super::desktop_title_bar();
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
fn the_app_frame_maps_to_the_custom_title_bar() {
let layout = craft_window::Layout::default();
let bar = super::title_bar_for(craft_window::Frame::App(layout));
assert_eq!(bar, TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(false));
}
/// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic
/// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's
/// native frame. The Linux case is the only behaviour change: KDE and the other
/// server-side-decoration compositors now keep their native frame.
#[test]
fn the_desktop_frame_is_mapped_per_platform() {
let bar = super::title_bar_for(craft_window::Frame::Desktop);
if cfg!(target_os = "macos") {
assert_eq!(bar, TitleBar::Inset);
} else if cfg!(target_os = "windows") {
// Windows kept its own title bar before and must keep it now.
assert_eq!(bar, TitleBar::Custom);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(false));
} else {
assert_eq!(bar, TitleBar::Native);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.decorations, Some(true));
}
}
/// macOS is untouched by this change: its inset title bar with the system traffic lights stays,
/// and `window_chrome` still asks for the full-size content view rather than toggling the
/// window decorations.
#[test]
#[cfg(target_os = "macos")]
fn macos_keeps_its_inset_title_bar() {
let bar = super::desktop_title_bar();
assert_eq!(bar, TitleBar::Inset);
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
assert_eq!(viewport.fullsize_content_view, Some(true));
assert_eq!(viewport.title_shown, Some(false));
}
}
#[cfg(test)]
mod start_tests {
use super::opens_new_file;
#[test]
fn the_home_preference_starts_on_home_except_for_control_callers() {
assert!(opens_new_file(false, false, false), "a new file by default");
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
assert!(opens_new_file(false, true, true), "--control still starts with a document");
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
}
}
+39 -5
View File
@@ -120,8 +120,7 @@ impl Recovery {
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
let lock = lock_folder(&dir).ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
@@ -250,6 +249,17 @@ impl Recovery {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
pub fn quit(mut self, s: &Session, asked: bool) {
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
return self.finish();
}
self.write(s, true);
self.settle(s);
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
/// How long the startup waits, once, for folder locks that may free themselves (#334).
#[cfg(unix)]
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
lock.try_lock()?;
Ok(lock)
}
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
}
}
};
let (mut free, mut held) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
continue;
}
// Still running (another window of the app), or not ours to take.
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
match lock_folder(&path) {
Ok(lock) => free.push((path, lock)),
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
Err(_) => {}
}
}
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
// lock to child processes.
#[cfg(unix)]
if !held.is_empty() {
std::thread::sleep(LOCK_WAIT);
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
}
for (path, lock) in free {
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
+40
View File
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
let root = root("quitunasked");
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
let s = edited();
Recovery::start(&root).unwrap().quit(&s, false);
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1);
let _ = std::fs::remove_dir_all(&root);
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
for (s, asked) in [(edited(), true), (Session::new(), false)] {
Recovery::start(&root).unwrap().quit(&s, asked);
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
let _ = std::fs::remove_dir_all(&root);
}
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
#[cfg(unix)]
#[test]
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
let root = root("fork-window");
crashed(&edited(), &root);
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
// exec holds it, and free it shortly after.
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
lock.try_lock().unwrap();
let freed = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
drop(lock);
});
// The next start waits for that lock rather than reading the run as still going.
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
freed.join().unwrap();
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
+11 -3
View File
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
pub fn save_state(app: &FigApp) {
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
if let Some(dir) = dir {
write_prefs(dir, &app.session.prefs);
}
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
if let Some(r) = recovery {
r.quit(&app.session, app.ui.quit);
}
drop(std::mem::take(&mut app.session.live));
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
+35
View File
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
/// A live link that only records the sessions it was told to leave.
struct Leaves(Arc<Mutex<Vec<u64>>>);
impl figcraft_engine::live::LiveLink for Leaves {
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn leave(&mut self, session: u64) {
self.0.lock().unwrap().push(session);
}
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
Vec::new()
}
}
#[test]
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
let dir = root("shutdown");
let mut fig = start(&dir);
let left = Arc::new(Mutex::new(Vec::new()));
fig.session.live.install(Box::new(Leaves(left.clone())));
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
fig.session.prefs.user_name = "Ada".into();
crate::services::shutdown(&mut fig, Some(&dir), None);
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
assert!(!fig.session.live.available(), "nothing is left to send on");
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
+18
View File
@@ -24,6 +24,10 @@ display_name = "VanTan"
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
[people.SiadeLucas]
real_name = "Siade Lucas"
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
@@ -37,6 +41,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]
@@ -53,3 +62,12 @@ sync_github_name = false
[people.krinry]
display_name = "krinry"
sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
[people.MortenHusted]
sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
+3 -1
View File
@@ -141,7 +141,9 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
let t = std::thread::spawn(move || {
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let _ = ws.read();
// read() returns Result<Message>, not a lock guard; this test server deliberately
// ignores whatever the client sent first.
let _ = ws.read(); // NOSONAR
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
let _ = ws.close(None);
+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
View File
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
+3 -2
View File
@@ -87,8 +87,9 @@ macro_rules! node_props {
($($variant:ident => $field:ident),* $(,)?) => {
/// A node property: the tree position (`parent`, `position`) or one saved field of
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
/// binary form numbers them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Prop {
+1 -1
View File
@@ -135,7 +135,7 @@ impl Local {
let query = link.strip_prefix("figcraft://join?")?;
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
}
+1
View File
@@ -104,6 +104,7 @@ fn only_local_session_links_have_a_code() {
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
"figcraft://join?host=10.0.0.1:47900&file=local&token=local-+0000000001",
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
"",
+24 -8
View File
@@ -4,12 +4,13 @@
//!
//! A conflict is the same property (or document-level entry) changed on both sides to different
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
//! merge item by item: both sides changing different items of the list (or one adding items at its
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
//! while the other changed it. Nothing that conflicts is
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
//! changes to it are left out. Everything else from both sides is kept.
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
out
}
/// List properties whose items merge one by one.
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
/// List properties whose items merge one by one: the items stand on their own, so both sides
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
/// by key, not by position.
const PER_ITEM: &[Prop] = &[
Prop::Fills,
Prop::Strokes,
Prop::Effects,
Prop::Grids,
Prop::Exports,
Prop::Guides,
Prop::Interactions,
Prop::Annotations,
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
+107 -11
View File
@@ -1,8 +1,10 @@
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
//! the same item changed both ways conflicts.
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
//! merge item by item: different items changed on each side merge, the same item changed both ways
//! conflicts.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
use serde_json::{Value, json};
use crate::merge::{Key, Side, merge_items, plan};
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
}
#[test]
fn other_lists_still_conflict_as_a_whole() {
// Docs silent: lists other than fills, strokes and effects (export settings here) still
// conflict whole when both sides change them.
fn the_other_item_lists_merge_one_by_one_too() {
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
// item lists like fills: both sides changing different items merges.
let (mut base, _, _, r) = files();
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
let n = base.get_mut(r).unwrap();
n.exports = vec![export("@1"), export("@2")];
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
n.annotations = vec![annot("a1"), annot("a2")];
n.grids = vec![grid(10.0), grid(20.0)];
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0] = export("@b");
main.get_mut(r).unwrap().exports[1] = export("@m");
// The branch changes the first item of each list; main the second.
let b = branch.get_mut(r).unwrap();
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
let m = main.get_mut(r).unwrap();
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
both_ways(&base, &branch, &main, |doc| {
let n = doc.node(r).unwrap();
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
assert_eq!(suffixes, ["@b", "@m"], "export settings");
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
assert_eq!(offsets, [11.0, 22.0], "guides");
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
assert_eq!(labels, ["b1", "m2"], "annotations");
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
assert_eq!(sizes, [12.0, 24.0], "layout grids");
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
});
}
#[test]
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
let (mut base, _, _, r) = files();
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
let n = base.get_mut(r).unwrap();
n.exports = vec![
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
];
n.code_links = vec![link("react"), link("vue")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
// Code links are one per language, not an item list: different links changed still conflict.
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
main.get_mut(r).unwrap().code_links[1].name = "M".into();
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/exports")]);
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
keys.sort();
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
want.sort();
assert_eq!(keys, want);
}
#[test]
fn interactions_merge_item_by_item() {
let ia = |duration_ms: u32| proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::Back],
transition: Default::default(),
duration_ms,
easing: Default::default(),
delay_ms: 0,
};
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
let (mut base, _, _, r) = files();
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
let fork = || {
let (mut branch, main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
(branch, main)
};
// The branch changes the first interaction and adds one; main changes the second.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
branch.get_mut(r).unwrap().interactions.push(ia(300));
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
// The branch deletes one while main leaves them alone.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions.remove(0);
main.get_mut(r).unwrap().fills[0] = red();
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
// The same interaction changed both ways, or one deleted while the other side changed the
// list, conflicts.
for delete in [false, true] {
let (mut branch, mut main) = fork();
if delete {
branch.get_mut(r).unwrap().interactions.remove(0);
} else {
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
}
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
}
}
#[test]
+52 -14
View File
@@ -696,22 +696,38 @@ pub enum ServerMsg {
},
}
/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
/// years (146_097 days), so month, day and time of day come from chrono and only the year is
/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
/// math: chrono still does the date, the cycle count just shifts the year.
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
/// messages).
/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into
/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the
/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`).
pub fn utc_date(secs: u64) -> String {
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the
// seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting
// with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`.
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
let (y, t) = utc_parts(days, sod, 0);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not).
format!("{y:04}-{}", t.format("%m-%d %H:%M UTC"))
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
@@ -752,3 +768,25 @@ pub fn decode_json<T: DeserializeOwned>(text: &str, max_bytes: usize) -> Result<
}
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
/// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days
/// code produced (leap days and all).
#[test]
fn utc_date_formats_seconds_since_1970() {
assert_eq!(utc_date(0), "1970-01-01 00:00 UTC");
assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC");
assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
// Past chrono's own year range (262143) the instant is moved into range by whole 400-year
// cycles and the real year restored, so it keeps the old hand-rolled output instead of
// falling back to the epoch.
assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC");
}
}
+15 -3
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 {
@@ -162,7 +162,13 @@ impl Color {
p.parse::<f32>().ok().map(|v| v / 255.0)
}
};
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
let alpha = match parts.get(3) {
Some(a) => parse_alpha(a)?,
None => 1.0,
};
if parts.len() > 4 {
return None;
}
return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha));
}
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
@@ -170,7 +176,13 @@ impl Color {
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
let alpha = match parts.get(3) {
Some(a) => parse_alpha(a)?,
None => 1.0,
};
if parts.len() > 4 {
return None;
}
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
if t == "transparent" {
+19 -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,21 @@ 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));
}
#[test]
fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
assert_eq!(Color::parse_css("hsla(0,0%,0%,abc)"), None);
assert_eq!(Color::parse_css("rgb(1,2,3,4,5)"), None);
assert_eq!(Color::parse_css("hsl(0,0%,0%,0.5,1)"), None);
assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0);
assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5);
}
+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();
+12
View File
@@ -60,6 +60,9 @@ impl LetterSpacing {
}
pub fn trim_num(v: f64) -> String {
if v.abs() >= 9.0e18 {
return format!("{v}");
}
let r = (v * 100.0).round() / 100.0;
if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") }
}
@@ -760,6 +763,15 @@ impl TextData {
mod case_tests {
use super::*;
#[test]
fn trim_num_prints_huge_whole_numbers() {
assert_eq!(trim_num(1e19), "10000000000000000000");
assert_eq!(trim_num(-1e19), "-10000000000000000000");
assert_eq!(trim_num(12.0), "12");
let v = 12345678901234567890.0_f64;
assert_eq!(trim_num(v).parse::<f64>().unwrap(), v);
}
#[test]
fn title_case_respects_context() {
assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray");
+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"));
}
+75 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
fn brand_id(s: &Session) -> String {
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
}
#[test]
fn explicit_paint_edits_detach_the_colour_binding() {
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
// selection colours already do), instead of putting the variable's colour back.
let edits = [
("fill.set", json!({"color": "#00FF00"})),
("node.set", json!({"props": {"fill": "#00FF00"}})),
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
];
for (cmd, mut p) in edits {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p.clone());
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
}
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
let (mut s, r) = setup();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p);
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
assert!(n.bound.is_empty(), "{cmd}");
}
}
#[test]
fn clearing_bound_fills_adds_no_paint_back() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
assert!(bound(&s, r).is_empty());
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
}
#[test]
fn paint_opacity_edits_keep_the_colour_binding() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
// The same colour with another opacity, through node.set, keeps it too.
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
assert_eq!(bound(&s, r).len(), 1);
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
}
#[test]
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
let brand = brand_id(&s);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
}
+1 -1
View File
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
+131
View File
@@ -0,0 +1,131 @@
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
/// 0x80 / 255 and 0x40 / 255.
const HALF: f32 = 128.0 / 255.0;
const QUARTER: f32 = 64.0 / 255.0;
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
let r = NodeId(r["id"].as_u64().unwrap());
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
(s, r)
}
/// The fill's effective alpha (colour alpha times paint opacity).
fn alpha(s: &Session, r: NodeId) -> f32 {
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
}
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
pix.data()[i]
}
fn near(a: f32, b: f32) -> bool {
(a - b).abs() < 2e-3
}
#[test]
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, HALF));
assert_eq!(pixel_alpha(&s, r), 128);
}
#[test]
fn a_bound_variable_alpha_applies_once() {
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
assert_eq!(pixel_alpha(&s, r), 128);
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
// Binding the same variable again changes nothing.
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF));
}
#[test]
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert_eq!(pixel_alpha(&s, r), 64);
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
assert_eq!(pixel_alpha(&s, r), 64);
// Undo restores the binding and its paint.
ex(&mut s, "edit.undo", json!({}));
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
assert_eq!(pixel_alpha(&s, r), 64);
}
#[test]
fn modes_switch_the_alpha_without_accumulating() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
}
// An opacity set while bound multiplies on top and survives mode switches.
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
assert!(near(alpha(&s, r), HALF * 0.5));
}
#[test]
fn node_set_and_style_bindings_apply_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
assert!(near(alpha(&s, r), HALF));
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
}
#[test]
fn a_fig_round_trip_keeps_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
let a = n.fills[0].solid_color().unwrap().a;
assert!(near(a, HALF * 0.5), "{a}");
}
#[test]
fn code_gives_a_translucent_stroke_its_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
}
+3 -1
View File
@@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"),
m!("Text", SEP, ""),
m!("Text", "Missing fonts…", "text.missingFonts"),
// Arrange
m!("Arrange", "Align left", "align.left"),
m!("Arrange", "Align horizontal centers", "align.hcenter"),
@@ -287,7 +289,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+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()
}
+105 -25
View File
@@ -56,11 +56,17 @@ pub fn specs() -> Vec<CommandSpec> {
s.set_selection(out.iter().filter_map(id_of).collect());
Ok(json!({"ids": out}))
}),
spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine),
spec!(
"component.combineVariants",
"Combine as variants",
"{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")",
true,
combine
),
spec!(
"component.addVariant",
"Add variant",
"{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
"{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
true,
add_variant
),
@@ -330,23 +336,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": target}))
}
/// The name a new component set takes from its first variant: the path before the last '/'
/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name
/// ("State=Default, Size=Small") names no set.
fn set_name_from(variant: &str) -> Option<String> {
let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim();
(!base.is_empty() && !base.contains('=')).then(|| base.to_string())
}
/// Wrap components in a new component set (named after the first) placed around them.
fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let cmd = "component.combineVariants";
fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?;
if inside_instance(doc, top) || owning_instance(doc, parent).is_some() {
return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into()));
return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into()));
}
let bounds = doc.bounds_of(ids).unwrap_or_default();
let pw = doc.world_transform(parent);
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0));
let base = ids
.first()
.and_then(|i| doc.node(*i))
.map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string())
.filter(|n| !n.is_empty() && !n.contains(','))
.unwrap_or_else(|| "Component".into());
let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component"));
let set = doc.alloc_id();
let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height());
n.transform = figcraft_geom::Affine::translate((local.x0, local.y0));
@@ -358,22 +366,71 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
Ok(set)
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.combineVariants";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
const INSIDE_INSTANCE: &str = "components inside an instance can't become variants";
/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they
/// can: anything but main components, components that are variants already (combining them would
/// put a set in a set), library copies, components inside an instance, and a component inside
/// another one of `ids`.
fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
return Err(bad(cmd, "select components only"));
Some("select components only")
} else if ids.iter().any(|i| variant_set(doc, *i).is_some()) {
Some("components that are variants already can't be combined again")
} else if ids.iter().any(|i| doc.is_remote(*i)) {
Some(crate::library::READ_ONLY)
} else if ids.iter().any(|i| inside_instance(doc, *i)) {
Some(INSIDE_INSTANCE)
} else if roots(doc, ids).len() != ids.len() {
Some("a component can't be combined with one inside it")
} else {
None
}
let ids = z_sorted(doc, &ids);
let set = wrap_in_set(doc, &ids)?;
}
/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more
/// main components that [`combine_refusal`] lets through.
pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool {
ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::<BTreeSet<_>>().len() == ids.len()
}
/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main
/// component not in a set yet (it becomes one); never a library copy or inside an instance.
pub fn can_add_variant(doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
let local = !doc.is_remote(id) && !inside_instance(doc, id);
match n.kind {
NodeKind::ComponentSet { .. } => {
local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })))
}
NodeKind::Component { .. } => local,
_ => false,
}
}
/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default",
/// "Property 1=VariantN" unless already in prop=value form. Returns the set.
fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let set = wrap_in_set(cmd, doc, ids)?;
for (k, i) in ids.iter().enumerate() {
// Name variants "Property 1=Variant N" unless already in prop=value form.
let node = doc.get_mut(*i)?;
if !node.name.contains('=') {
node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) });
}
}
Ok(set)
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.combineVariants";
let mut seen = BTreeSet::new();
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
let doc = s.doc_mut()?;
if let Some(why) = combine_refusal(doc, &ids) {
return Err(EngineError::Disabled(cmd.into(), why.into()));
}
let ids = z_sorted(doc, &ids);
let set = make_variant_set(cmd, doc, &ids)?;
s.set_selection(vec![set]);
Ok(json!({"id": set}))
}
@@ -397,15 +454,19 @@ fn fit_set(doc: &mut Document, set: NodeId) {
fn add_variant(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.addVariant";
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?;
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?;
let doc = s.doc_mut()?;
let (set, base) = match doc.get(t)?.kind {
NodeKind::ComponentSet { .. } => {
let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?;
(t, last)
}
NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t),
_ => return Err(bad(cmd, "select a component set or variant")),
NodeKind::Component { .. } => match variant_set(doc, t) {
Some(set) => (set, t),
// A main component on its own becomes a set of itself, then gains the new variant.
None => (make_variant_set(cmd, doc, &[t])?, t),
},
_ => return Err(bad(cmd, "select a component set or component")),
};
let options = variant_options(doc, set);
let mut pairs = variant_props(&doc.get(base)?.name);
@@ -472,6 +533,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 +553,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 +683,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 +1114,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")?;
@@ -1084,7 +1164,7 @@ fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
.unwrap_or_else(|| "Default".into());
let set = match owner {
Owner::Set(s) => s,
Owner::Comp(c) => wrap_in_set(doc, &[c])?,
Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?,
};
for c in Owner::Set(set).components(doc) {
let cn = doc.get_mut(c)?;
+60 -9
View File
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
}),
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
s, p, "vector"
)),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
@@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
/// or less on an axis it has is refused rather than flattening it.
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
None => Ok(1.0),
Some(a) if have <= 1e-9 => {
if a == 0.0 {
Ok(1.0)
} else {
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
}
}
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
};
Ok(match (width, height) {
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
(Some(_), None) => {
let s = fit(width, bw, "width")?;
(s, s)
}
(None, Some(_)) => {
let s = fit(height, bh, "height")?;
(s, s)
}
(None, None) => (1.0, 1.0),
})
}
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.create";
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
};
// Vectors: path data or points define the shape and its box.
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
// for, scale the geometry: both sides independently, one side proportionally.
let mut offset = (0.0, 0.0);
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
@@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
}
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
}
}
+45 -11
View File
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
spec!(
"edit.paste",
"Paste",
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
true,
paste
),
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
@@ -261,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
let i = req_index("effect.set", p, "index")?;
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
for id in req_targets("effect.set", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if let Some(slot) = n.effects.get_mut(i) {
@@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
let idx = doc.index_in_parent(i).map(|x| x + 1);
let w = n.width;
let nid = doc.duplicate_into(i, parent, idx)?;
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
(None, Some(dy)) => (0.0, dy),
@@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
nn.transform = Affine::translate((dx, dy)) * nn.transform;
}
}
if rename {
// An instance keeps the name instances get by default.
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
let nn = doc.get_mut(nid)?;
nn.name = bump_name(&nn.name);
}
@@ -515,6 +522,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
s.clipboard.source_doc = uid;
s.clipboard.copied_components = nodes
.iter()
.filter(|(_, sub)| {
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
})
.map(|(root, _)| *root)
.collect();
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
@@ -593,7 +607,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
let (nodes, images, bounds) = copied(&doc, &tops);
let props = s.clipboard.props.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -623,6 +638,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
// From another file: its instances follow the mains this file can have.
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
let copy_local = s.ds()?.uid == clip.source_doc;
let doc = s.doc_mut()?;
for (k, v) in &clip.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
@@ -636,6 +652,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
let mut out = Vec::new();
let mut all = BTreeMap::new();
for (root, nodes) in &clip.nodes {
if copy_local
&& clip.copied_components.contains(root)
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
{
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
out.push(instance);
continue;
}
let mut map = BTreeMap::new();
for n in nodes {
map.insert(n.id, doc.alloc_id());
@@ -661,10 +687,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
let sel = s.selection();
let doc = s.doc()?;
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
let target = id(p, "parent").or_else(|| {
sel.first()
.copied()
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
});
let parent = target.unwrap_or(page);
let at = match (num(p, "x"), num(p, "y")) {
@@ -931,9 +959,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
let digits = req_string("object.opacityKeys", p, "digits")?;
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
for i in req_targets("object.opacityKeys", s, p)? {
s.doc_mut()?.get_mut(i)?.opacity = o;
let ids = req_targets("object.opacityKeys", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
doc.get_mut(i)?.opacity = o;
}
super::styles::detach_edited(doc, &before);
Ok(json!({"opacity": o}))
}
@@ -1181,10 +1213,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 +1376,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
if let Some(b) = blend(p, "blend") {
slot.blend = b;
}
super::lists::remap_stop_bindings(n, fills, at, &old);
super::lists::rebind_edited_paint(n, fills, at, &old);
if fills {
n.styles.fill = None;
} else {
@@ -1376,8 +1410,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
let e: Effect = match get(p, "effect") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
Some(e) => e,
None => {
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
+49 -5
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",
@@ -528,7 +542,8 @@ fn load_with(
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
}
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
if loaded.doc.name.is_empty() {
// Older files saved an untouched default title even after the user chose a filename.
if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" {
loaded.doc.name = file_stem(name_hint);
}
Ok((loaded, false))
@@ -548,6 +563,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
super::styles::unfold_imported_bound_alpha(doc);
crate::style_vars::after_load(doc);
super::styles::apply_paint_bindings_on_load(doc);
let mut insts: Vec<(usize, NodeId)> =
@@ -838,8 +854,14 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
}
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
let bytes = file_bytes(ds)?;
let mut snapshot = FileSnapshot::of(ds);
let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));
if follows_path && ds.path.as_deref() != Some(path.as_str()) {
Arc::make_mut(&mut snapshot.doc).name = file_stem(&path);
}
let bytes = snapshot.encode()?;
write(&path, &bytes)?;
ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
@@ -1373,14 +1395,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`.
+1
View File
@@ -80,6 +80,7 @@ fn registry() -> &'static Registry {
list.extend(page::specs());
list.extend(view::specs());
list.extend(text::specs());
list.extend(crate::fonts::specs());
list.extend(styles::specs());
list.extend(image::specs());
list.extend(variables::specs());
+88 -15
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,
@@ -775,10 +843,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
for (field, var) in o {
match var.as_str() {
Some(vid) => {
if n.bound.get(field).map(String::as_str) != Some(vid) {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
}
n.bound.insert(field.clone(), vid.to_string());
}
None => {
n.bound.remove(field);
if n.bound.remove(field).is_some() {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
}
}
}
+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();
+200 -9
View File
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
))
}),
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
let cmd = "variable.createCollection";
let name = req_string(cmd, p, "name")?.trim().to_string();
if name.is_empty() {
return Err(bad(cmd, "name can't be empty"));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
_ => vec!["Mode 1".into()],
};
if modes.is_empty() {
return Err(bad(cmd, "a collection needs at least one mode"));
}
if modes.iter().any(String::is_empty) {
return Err(bad(cmd, "mode names can't be empty"));
}
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
Ok(json!({"id": id}))
@@ -458,10 +468,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
match var {
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
Some((id, _)) => {
if style.bound.get(&key) != Some(&id)
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
style.bound.insert(key, id);
}
None => {
style.bound.remove(&key);
if style.bound.remove(&key).is_some()
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, false);
}
}
}
// The style takes its default-mode value, and each layer using it its own.
@@ -770,16 +789,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
if n.bound.get(&field) != Some(v) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
}
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
if n.bound.remove(&field).is_some() {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
}
}
}
}
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
let paints = match list {
BoundList::Fills => fills,
BoundList::Strokes => strokes,
_ => return,
};
if let Some(p) = paints.get_mut(i)
&& let PaintKind::Solid { color } = &mut p.kind
{
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
color.a = 1.0;
}
}
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
@@ -916,6 +960,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
apply_bindings_matching(doc, only, |_| true);
}
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
}
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
/// changed drops its binding, which would otherwise write the variable's value back (#809).
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
/// another (proportions, min/max) lose theirs.
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
for old in before {
let Some(n) = doc.node(old.id) else { continue };
let edited: Vec<String> = n
.bound
.keys()
// A binding the edit itself made (`boundVariables`) stays.
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
.filter(|f| bound_field(old, f) != bound_field(n, f))
.cloned()
.collect();
if edited.is_empty() {
continue;
}
if let Ok(n) = doc.get_mut(old.id) {
for f in edited {
n.bound.remove(&f);
}
}
}
}
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
fn bound_field(n: &Node, field: &str) -> Option<String> {
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
_ => None,
};
let layout = n.layout.as_ref();
let cl = &n.child_layout;
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
Some(match field {
"width" => format!("{}", n.width),
"height" => format!("{}", n.height),
"minWidth" => format!("{:?}", cl.min_w),
"maxWidth" => format!("{:?}", cl.max_w),
"minHeight" => format!("{:?}", cl.min_h),
"maxHeight" => format!("{:?}", cl.max_h),
"opacity" => format!("{}", n.opacity),
"visible" => format!("{}", n.visible),
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
"padding" => format!("{:?}", layout.map(|l| l.padding)),
"paddingTop" => format!("{:?}", pad(0)),
"paddingRight" => format!("{:?}", pad(1)),
"paddingBottom" => format!("{:?}", pad(2)),
"paddingLeft" => format!("{:?}", pad(3)),
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
"cornerRadius" => format!("{:?}", n.corners.radii),
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
NodeKind::Ellipse { start, sweep, inner } => format!(
"{}",
if field == "arcStart" {
start
} else if field == "arcSweep" {
sweep
} else {
inner
}
),
_ => return None,
},
"characters" => match &n.kind {
NodeKind::Text(t) => t.characters.clone(),
_ => return None,
},
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
_ => return None,
},
"fontFamily" => text(&|s| s.font_family.clone())?,
"fontStyle" => text(&|s| s.font_style.clone())?,
"fontWeight" => text(&|s| s.weight().to_string())?,
"fontSize" => text(&|s| s.font_size.to_string())?,
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
_ => return None,
})
}
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
use super::lists::{BoundList, list_binding};
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
_ => None,
});
found.collect()
}
fn unfold(p: Option<&mut Paint>, a: f32) {
if let Some(p) = p
&& matches!(p.kind, PaintKind::Solid { .. })
{
p.opacity = (p.opacity / a).min(1.0);
}
}
let pending = doc.take_dirty();
for i in 0..doc.styles.len() {
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
for (_, j, a) in found {
unfold(paints.get_mut(j), a);
}
}
}
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
for id in ids {
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
if found.is_empty() {
continue;
}
let Ok(n) = doc.get_mut(id) else { continue };
for (list, j, a) in found {
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
}
}
doc.take_dirty();
doc.mark_dirty(pending);
}
/// Settle imported paint colours before the document is first shown. Other bindings can affect
/// layout or component structure, which must be resolved through the normal edit pipeline.
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
@@ -930,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
doc.mark_dirty(pending);
}
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
/// node when `None`). Auto layout fields wait while their node has no auto layout.
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
@@ -944,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
let modes = doc.modes_for(id);
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let has_layout = n.layout.is_some();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
@@ -953,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
if !include(&field) {
continue;
}
// A binding never creates an auto layout: without one it waits for the next.
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
continue;
}
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// One list item's value: a paint's colour, an effect's or a layout grid's field.
if let Some((list, i, prop)) = list_binding(&field) {
@@ -1104,7 +1296,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
stack.extend(n.children.iter().copied());
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
if let Some(c) = paint.solid_color() {
let c = c.with_alpha(c.a * paint.opacity);
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
}
}
+48 -6
View File
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
let bound = super::styles::bound_before(doc, &[id]);
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
if a == b {
t.restyle(0, t.char_count(), &f);
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
t.restyle(a, b, &f);
}
}
super::styles::detach_edited(doc, &bound);
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
let before = super::styles::bound_before(doc, &ids);
for d in ids {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
super::styles::detach_edited(doc, &before);
ok()
}
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
super::styles::detach_edited(doc, &before);
ok()
}),
spec!(
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
let ids = req_targets("text.style", s, p)?;
let m = p.as_object().cloned().unwrap_or_default();
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get_mut(i)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::props::apply_text_style("text.style", t, a, b, k, v)?;
}
}
super::styles::detach_edited(doc, &before);
ok()
}
),
@@ -474,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
spec!(
"text.rescanFonts",
"Rescan installed fonts",
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
true,
|s, _| {
let rescan = figcraft_text::rescan_system_fonts();
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
Ok(json!({"added": rescan.added, "refitted": refitted}))
}
),
spec!(
"text.loadFont",
"Load font file",
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
true,
|s, p| {
let cmd = "text.loadFont";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
} else {
return Err(bad(cmd, "pass `path` or `data` (base64)"));
};
let families = figcraft_text::load_font_bytes(bytes);
if families.is_empty() {
return Err(bad(cmd, "not a font file"));
}
let refitted = crate::fonts::refit_open(s, None, Some(&families));
Ok(json!({"families": families, "refitted": refitted}))
}
),
]
}
+5 -1
View File
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
doc.variables.variables.retain(|v| !gone.contains(&v.id));
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
for id in users {
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
let n = doc.get_mut(id)?;
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
n.bound.retain(|_, v| !gone.contains(v));
}
Ok(())
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
true,
import
),
+105 -38
View File
@@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul
Ok(json!({"on": *slot}))
}
/// One of a view's on/off settings.
type Slot = fn(&mut ViewState) -> &mut bool;
/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and
/// whether the preferences keep each: files opened or made later start with the kept ones. Outline
/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one.
const TOGGLES: &[(&str, Slot, bool)] = &[
("view.pixelGrid", |v| &mut v.pixel_grid, true),
("view.layoutGrids", |v| &mut v.layout_grids, true),
("view.rulers", |v| &mut v.rulers, true),
("view.slices", |v| &mut v.show_slices, true),
("view.comments", |v| &mut v.comments, true),
("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true),
("view.outlines", |v| &mut v.outlines, false),
("view.outlinesSelection", |v| &mut v.outlines_selection, false),
("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true),
("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true),
("view.maskOutlines", |v| &mut v.mask_outlines, true),
("view.frameOutlines", |v| &mut v.frame_outlines, true),
("view.proofSrgb", |v| &mut v.proof_srgb, false),
("view.snapGeometry", |v| &mut v.snap_geometry, true),
("view.snapObjects", |v| &mut v.snap_objects, true),
("view.snapPixel", |v| &mut v.snap_pixel, true),
];
/// View toggle `id`: the file's view changes, and the preferences remember a kept one.
fn toggle(s: &mut Session, p: &Value, id: &str) -> Result<Value> {
let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) };
let r = flag(s, p, f)?;
if keep && let Some(on) = r.get("on").and_then(Value::as_bool) {
s.prefs.view.insert(id.to_string(), on);
}
Ok(r)
}
/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are).
pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) {
for (id, f, _) in TOGGLES.iter().filter(|t| t.2) {
if let Some(on) = prefs.view.get(*id) {
*f(v) = *on;
}
}
}
/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command
/// that isn't one.
pub fn checked(s: &Session, id: &str) -> Option<bool> {
let v = &s.ds().ok()?.view;
Some(match id {
"view.pixelGrid" => v.pixel_grid,
"view.layoutGrids" => v.layout_grids,
"view.rulers" => v.rulers,
"view.slices" => v.show_slices,
"view.comments" => v.comments,
"view.multiplayerCursors" => v.multiplayer_cursors,
"view.outlines" => v.outlines,
"view.outlinesSelection" => v.outlines_selection,
"view.outlinesHiddenLayers" => v.outline_hidden,
"view.outlinesObjectBounds" => v.outline_bounds,
"view.maskOutlines" => v.mask_outlines,
"view.pixelPreview" => v.pixel_preview,
"view.frameOutlines" => v.frame_outlines,
"view.proofSrgb" => v.proof_srgb,
"view.snapGeometry" => v.snap_geometry,
"view.snapObjects" => v.snap_objects,
"view.snapPixel" => v.snap_pixel,
_ => return None,
})
let mut v = s.ds().ok()?.view.clone();
if id == "view.pixelPreview" {
return Some(v.pixel_preview);
}
TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v))
}
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
@@ -235,46 +263,46 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")),
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")),
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")),
spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")),
spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")),
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
flag(s, p, |v| &mut v.multiplayer_cursors)
toggle(s, p, "view.multiplayerCursors")
}),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
flag(s, p, |v| &mut v.outlines_selection)
toggle(s, p, "view.outlinesSelection")
}),
spec!(
"view.outlinesHiddenLayers",
"Include hidden layers in outlines",
"{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there",
false,
|s, p| { flag(s, p, |v| &mut v.outline_hidden) }
|s, p| { toggle(s, p, "view.outlinesHiddenLayers") }
),
spec!(
"view.outlinesObjectBounds",
"Include object bounds in outlines",
"{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer",
false,
|s, p| { flag(s, p, |v| &mut v.outline_bounds) }
|s, p| { toggle(s, p, "view.outlinesObjectBounds") }
),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")),
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")),
spec!(
"view.proofSrgb",
"Soft-proof sRGB",
"{on?} mark the colours of a Display P3 file that sRGB screens and files can't show",
false,
|s, p| { flag(s, p, |v| &mut v.proof_srgb) }
|s, p| { toggle(s, p, "view.proofSrgb") }
),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")),
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
@@ -342,7 +370,25 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
"Interface size",
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
None => {}
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
Some(_) => {
let scale = req_num("pref.uiScale", p, "scale")?;
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
}
}
Ok(json!({"scale": s.prefs.ui_scale}))
}
),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
@@ -381,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.language",
"Interface language",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
false,
|s, p| {
let lang = req_string("pref.language", p, "language")?;
@@ -494,3 +540,24 @@ pub fn specs() -> Vec<CommandSpec> {
#[allow(dead_code)]
fn unused(_: NodeId) {}
#[cfg(test)]
mod start_on_home_tests {
use serde_json::json;
use crate::Session;
#[test]
fn start_on_home_is_a_preference_that_persists() {
let mut s = Session::new();
assert!(!s.prefs.start_on_home);
assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true);
assert!(s.prefs.start_on_home);
// Saved with the other preferences and read back; older settings files without it read as off.
let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(saved.start_on_home);
assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false);
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.start_on_home);
}
}
+51 -6
View File
@@ -256,7 +256,26 @@ fn stroke_color(n: &Node) -> Option<Color> {
if !n.has_visible_stroke() {
return None;
}
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
}
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
/// on each side that has a weight.
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
let Some(c) = stroke_color(n) else { return Vec::new() };
let Some(sides) = n.stroke.sides else {
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
};
let color = tok(doc, n, &["stroke"], c.to_css(), t);
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
named
.zip(sides)
.filter(|(_, weight)| *weight > 0.0)
.map(|((side, field), weight)| {
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
})
.collect()
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
}
if let Some(c) = stroke_color(n) {
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
for (key, value) in css_borders(doc, n, t) {
put(&key, value);
}
let shadows: Vec<String> = n
.effects
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
swift_layer(ctx, n, depth, out, extra);
}
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
fn one_line(name: &str) -> String {
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
}
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a view.
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a composable.
@@ -1168,4 +1191,26 @@ mod tests {
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
}
#[test]
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
let mut s = Session::new();
let name = "deep\nevil()\r\u{2028}more()";
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
let root = parent;
for _ in 0..16 {
parent =
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
.unwrap();
}
let doc = s.doc().unwrap();
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
let code = code_for(doc, root, lang);
assert!(code.contains("// deep evil() more() …\n"), "{code}");
for line in code.lines() {
let t = line.trim_start();
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
}
}
}
}
+17
View File
@@ -285,11 +285,28 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.dev_resources.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
// So do the variable modes the main sets (see `Document::inherited_modes`).
root.var_modes.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
}
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
/// has one main per component, so a main component's copy is an instance of it; a variant copied
/// into its set stays a new variant, and every other layer is deep-copied.
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
let n = doc.get(id)?;
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
return doc.duplicate_into(id, parent, index);
}
let transform = n.transform;
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
doc.get_mut(instance)?.transform = transform;
Ok(instance)
}
/// Which part of the carried overrides to apply.
#[derive(Clone, Copy, PartialEq)]
enum Carry {
@@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData {
n.kind.text().unwrap()
}
#[test]
fn copying_a_main_component_pastes_a_linked_instance() {
for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "select.set", json!({"ids": [k.card]}));
ex(&mut s, "edit.copy", json!({}));
if command != "edit.paste" {
ex(&mut s, "select.set", json!({"ids": [k.inst]}));
}
let before = undo_len(&s);
let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card, "{command}");
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
assert_eq!(node(&s, pasted).source, Some(k.card));
assert_eq!(undo_len(&s), before + 1);
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(pasted).is_none());
ex(&mut s, "edit.redo", json!({}));
assert_eq!(component_of(&s, pasted), k.card);
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
healthy(&s);
}
}
#[test]
fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
ex(&mut s, "select.set", json!({"ids": [hover]}));
ex(&mut s, "edit.copy", json!({}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), hover);
assert_eq!(node(&s, pasted).name, node(&s, set).name);
let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
assert_eq!(props["Show dot"], true);
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
ex(&mut s, "select.set", json!({"ids": [set]}));
ex(&mut s, "edit.copy", json!({}));
let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
assert_eq!(kids(&s, copied_set).len(), 2);
for variant in kids(&s, copied_set) {
assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
assert!(!kids(&s, set).contains(&variant));
}
healthy(&s);
}
#[test]
fn cut_components_and_components_copied_to_another_file_remain_components() {
for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, copy, json!({"ids": [k.card]}));
if undo_cut {
ex(&mut s, "edit.undo", json!({}));
}
if new_file {
ex(&mut s, "file.new", json!({}));
}
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
assert_eq!(kids(&s, pasted).len(), 3);
healthy(&s);
}
}
#[test]
fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "edit.copy", json!({"ids": [main]}));
s.undo().unwrap();
s.undo().unwrap();
let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
ex(&mut s, "component.create", json!({"ids": [other]}));
assert_eq!(other, main, "undo makes the original id available again");
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert_eq!(node(&s, pasted).name, "Copied main");
assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
healthy(&s);
}
#[test]
fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
let original = s.doc().unwrap().world_transform(k.card);
let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card);
for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
assert!((actual - expected).abs() < 1e-9);
}
assert_eq!(node(&s, pasted).parent, Some(frame));
let before = s.doc().unwrap().clone();
assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
healthy(&s);
}
#[test]
fn duplicating_a_main_component_makes_a_linked_instance() {
for rename in [false, true] {
let mut s = Session::new();
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
let k = kit(&mut s);
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
let before = s.doc().unwrap().clone();
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
assert_eq!(s.selection(), ids);
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
assert_eq!(component_of(&s, copy), k.card);
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
let other = *ids.iter().find(|i| **i != copy).unwrap();
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
healthy(&s);
}
}
#[test]
fn alt_dragging_a_main_component_copies_an_instance() {
use crate::tools::{PointerEvent, PointerKind};
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
let [copy] = s.selection()[..] else { panic!("one copy selected") };
assert_eq!(component_of(&s, copy), main);
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
healthy(&s);
}
#[test]
fn duplicating_a_variant_adds_a_variant_to_its_set() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
assert_eq!(node(&s, copy).parent, Some(set));
assert_eq!(kids(&s, set).len(), 3);
// A whole set duplicates as a new set of variants.
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
healthy(&s);
}
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
@@ -706,3 +879,35 @@ fn auto_layout_instances_follow_their_main() {
assert_eq!(node(&s, i).child_layout.min_w, Some(80.0));
healthy(&s);
}
#[test]
fn a_new_component_set_is_named_after_its_first_variant() {
// "Button/State=Hover" groups under "Button": the set takes the path before the last '/'.
// A bare property name ("State=Default, Size=Small") names no set, and neither does an empty
// name: the set gets the next numbered default, like a new component. A plain name is kept.
let rows = [
("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"),
("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"),
("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""),
("", "Component 1", "empty name: the numbered default"),
];
for (name, expected, why) in rows {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20})));
ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}}));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name");
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]})));
assert_eq!(node(&s, set).name, expected, "{why}");
healthy(&s);
}
// Add variant on a lone component (a variant property on it) wraps it in a set named after it.
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"})));
assert_eq!(node(&s, set).name, "Button");
assert_eq!(node(&s, c).name, "State=Default");
healthy(&s);
}
@@ -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();
+17 -5
View File
@@ -62,7 +62,7 @@ impl Roots {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
// Why a path can't be resolved says what exists there: only told for paths inside a root.
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
@@ -79,9 +79,10 @@ impl Roots {
}
}
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
@@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
c => lexical.push(c),
}
}
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
let mut rest: Vec<&OsStr> = Vec::new();
let mut at = lexical.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
break base;
}
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
rest.push(name);
at = parent;
};
roots.iter().any(|r| resolved.starts_with(r))
}
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
+17
View File
@@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
outside.push(f.p("outside/dangling.txt"));
// Reached through a link in the root, it is outside all the same.
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
outside.push(f.p("root/escape/dangling.txt"));
}
}
for p in &outside {
let e = refusal(p);
@@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
}
}
#[test]
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
let f = Fixture::new("linked-root");
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
let r = Roots::new(&[], &[f.p("via")]).unwrap();
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
}
#[test]
fn only_resolved_paths_count_as_the_apps_own_folder() {
let f = Fixture::new("own");
+160
View File
@@ -0,0 +1,160 @@
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
//! ranges, text styles and the string variables bound to a font family. Text in such a family
//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is
//! there, [`refit`] lets auto-sizing text measure its box in it again.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{get, req_targets};
use crate::{Result, Session, spec};
/// A family the document names that no installed or built-in font provides.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Missing {
/// As the document spells it.
pub family: String,
/// Text layers that use it (0: only a text style or a variable names it).
pub layers: usize,
}
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
/// installed fonts are indexed in the background: ask once that's done
/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing.
pub fn missing(doc: &Document) -> Vec<Missing> {
// Lower-case family → (spelling, layers).
let mut used: BTreeMap<String, (String, usize)> = BTreeMap::new();
let mut add = |family: &str, layer: bool| {
let family = family.trim();
if family.is_empty() {
return;
}
let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0));
e.1 += usize::from(layer);
};
for n in doc.nodes() {
if let NodeKind::Text(t) = &n.kind {
let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect();
families.push(&t.style.font_family);
families.sort_by_key(|f| f.trim().to_lowercase());
families.dedup_by_key(|f| f.trim().to_lowercase());
for f in families {
add(f, true);
}
}
if let Some(var) = n.bound.get("fontFamily") {
for f in string_values(&doc.variables, var) {
add(&f, false);
}
}
}
for st in &doc.styles {
if let StyleKind::Text { style } = &st.kind {
add(&style.font_family, false);
}
if let Some(var) = st.bound.get("fontFamily") {
for f in string_values(&doc.variables, var) {
add(&f, false);
}
}
}
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
}
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut todo = vec![(id.to_string(), 0)];
while let Some((id, depth)) = todo.pop() {
let Some(var) = vars.get(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
_ => {}
}
}
}
out
}
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
/// layers whose size changed.
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
let ids: Vec<NodeId> = doc
.nodes()
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
.filter(|n| {
let NodeKind::Text(t) = &n.kind else { return false };
let uses = |w: &BTreeSet<String>| {
w.contains(&t.style.font_family.trim().to_lowercase())
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
};
want.as_ref().is_none_or(uses)
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
})
.map(|n| n.id)
.collect();
if !ids.is_empty() {
let before = doc.clone();
doc.mark_dirty(ids.iter().copied());
crate::normalize(doc, &before);
}
ids
}
/// [`refit`] in the open document, if there is one.
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
match s.doc_mut() {
Ok(doc) => refit(doc, under, families),
Err(_) => Vec::new(),
}
}
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
/// whole document.
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
let under = match get(p, "ids") {
None | Some(Value::Null) => None,
Some(_) => Some(req_targets("text.refit", s, p)?),
};
s.doc()?;
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
),
spec!(
"text.refit",
"Fit text to fonts",
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
true,
refit_text
),
]
}
#[cfg(test)]
#[path = "fonts_tests.rs"]
mod tests;
+215
View File
@@ -0,0 +1,215 @@
use serde_json::json;
use super::*;
use crate::params::id_of;
use figcraft_doc::{Style, StyleRun, TextStyle};
fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId {
let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
t.style.font_family = family.into();
}
id
}
fn families(s: &mut Session) -> Vec<(String, u64)> {
let r = s.execute("text.missingFonts", &json!({})).unwrap();
r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect()
}
/// An installed family (built in, or found by the system scan), if this machine has any.
fn installed() -> Option<String> {
figcraft_text::scan_system_fonts();
figcraft_text::family_names().into_iter().next()
}
#[test]
fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
let mut s = Session::new();
text(&mut s, "FigCraft Missing A");
let b = text(&mut s, "figcraft missing a");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind {
let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() };
t.runs.push(StyleRun { start: 0, end: 3, style });
}
let doc = s.doc_mut().unwrap();
doc.styles.push(Style {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap();
s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap();
let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone();
s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var);
assert_eq!(
families(&mut s),
vec![
("FigCraft Missing A".to_string(), 2),
("FigCraft Missing B".to_string(), 1),
("FigCraft Missing C".to_string(), 0),
("FigCraft Missing D".to_string(), 0),
("FigCraft Missing H".to_string(), 0),
]
);
assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts"));
}
#[test]
fn installed_families_are_not_listed() {
let Some(family) = installed() else {
eprintln!("skipped: no fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
text(&mut s, &family.to_uppercase());
text(&mut s, "FigCraft Missing E");
assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]);
}
#[test]
fn a_family_found_only_by_the_system_scan_is_not_missing() {
figcraft_text::scan_system_fonts();
let embedded: Vec<String> = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect();
let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else {
eprintln!("skipped: no system fonts on this machine");
return;
};
let mut s = Session::new();
text(&mut s, &family);
assert!(families(&mut s).is_empty(), "{family} is installed");
}
#[test]
fn blank_families_and_aliases_are_handled() {
let mut s = Session::new();
text(&mut s, " ");
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap();
let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone();
let t = text(&mut s, "FigCraft Missing G");
let doc = s.doc_mut().unwrap();
// A variable that only aliases another, and one aliasing itself.
let mut alias = doc.variables.get(&base).unwrap().clone();
alias.id = "V:alias".into();
alias.name = "Alias".into();
for v in alias.values.values_mut() {
*v = VariableValue::Alias(base.clone());
}
let mut cycle = alias.clone();
cycle.id = "V:cycle".into();
cycle.name = "Cycle".into();
for v in cycle.values.values_mut() {
*v = VariableValue::Alias("V:cycle".into());
}
doc.variables.variables.push(alias);
doc.variables.variables.push(cycle);
doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into());
let other = text(&mut s, "FigCraft Missing G");
s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into());
// The bound layer takes the value its alias resolves to (bindings are applied after the next
// command); the cycle resolves to nothing.
let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() };
assert_eq!(bound.style.font_family, "FigCraft Missing F");
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
let doc = s.doc_mut().unwrap();
let n = doc.get_mut(id).unwrap();
let want = figcraft_text::auto_size(n).unwrap().0;
n.width = want + dw;
doc.take_dirty();
want
}
/// The width of layer `id`.
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
s.doc().unwrap().node(id).unwrap().width
}
/// The ids a refit reports.
fn refitted(r: &Value) -> Vec<u64> {
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
#[test]
fn refit_measures_stale_text_boxes_again() {
let mut s = Session::new();
let a = text(&mut s, "FigCraft Refit A");
let b = text(&mut s, "FigCraft Refit B");
let fixed = text(&mut s, "FigCraft Refit A");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
t.auto_resize = figcraft_doc::TextAutoResize::None;
}
s.execute("select.none", &json!({})).unwrap();
let want_a = stale(&mut s, a, -30.0);
let want_b = stale(&mut s, b, 25.0);
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
s.doc_mut().unwrap().take_dirty();
// Only the families asked for.
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
assert_eq!(only_a, vec![a]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, b), want_b + 25.0);
// Only the layers asked for, then everything; a fixed box keeps its size.
stale(&mut s, a, 12.0);
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, fixed), 7.0);
// Nothing stale: nothing changes.
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
// An `ids` that isn't a list is refused, not taken as the whole document.
stale(&mut s, a, 9.0);
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
assert_eq!(width(&s, a), want_a + 9.0);
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
}
/// Text inside an instance is refitted too.
#[test]
fn refit_reaches_text_in_instances() {
let mut s = Session::new();
let t = text(&mut s, "FigCraft Refit C");
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
let want = stale(&mut s, copy, -20.0);
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
assert_eq!(refitted(&r), vec![copy.0]);
assert_eq!(width(&s, copy), want);
}
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
#[test]
fn fonts_load_from_files_and_rescans_report_new_families() {
let mut s = Session::new();
assert!(s.execute("text.loadFont", &json!({})).is_err());
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
}
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert!(r["added"].is_array());
}
+23 -1
View File
@@ -1,6 +1,7 @@
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
//! hidden, deleted or other pages' frames do nothing.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
let flat = [Point::new(1.0, 1.0); 4];
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
}
#[test]
fn double_clicking_a_label_asks_to_rename_its_frame() {
let (mut s, f, q) = setup();
let c = center(&q);
let before = s.doc().unwrap().world_bounds(f).unwrap();
let undo = s.ds().unwrap().history.undo.len();
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
s.pointer(&ev(kind, c)).unwrap();
}
assert_eq!(s.selection(), vec![f]);
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
// Other tools ignore the label.
s.ui_requests.clear();
s.set_tool(crate::tools::ToolId::Hand);
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
}
+4
View File
@@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("operandOperation".into(), json!(op));
}
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
}
match &n.kind {
NodeKind::Text(t) => {
o.insert("characters".into(), json!(t.characters));
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
}
}
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
/// boxed in, and a side bound to a variable names it.
#[test]
fn css_borders_follow_individual_stroke_sides() {
let mut s = Session::new();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
let c = css(&mut s, r);
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
let col = col["id"].as_str().unwrap().to_string();
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
let c = css(&mut s, r);
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
}
#[test]
fn native_code_for_a_simple_layer() {
let mut s = Session::new();
+128
View File
@@ -0,0 +1,128 @@
//! Variable modes on instances: an instance is on the modes its main component sets, under the
//! ones set on the instance itself, so switching variants or changing the main's mode reaches it.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn fill(s: &Session, n: NodeId) -> String {
s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn set_mode(s: &mut Session, n: NodeId, mode: Value) {
run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode}));
}
/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B.
fn tone(s: &mut Session) {
run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]}));
run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}}));
}
/// A component filled from `c`, on `mode` when one is given.
fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId {
let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name})));
run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"}));
if let Some(m) = mode {
set_mode(s, f, json!(m));
}
id(run(s, "component.create", json!({"ids": [f]})))
}
/// A set "Kind" whose variants A and B each set their own Tone mode.
fn kinds(s: &mut Session) -> (NodeId, NodeId) {
tone(s);
let a = swatch(s, "Kind=A", Some("A"));
let b = swatch(s, "Kind=B", Some("B"));
let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]})));
(set, b)
}
fn instance(s: &mut Session, component: NodeId) -> NodeId {
id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0})))
}
fn switch(s: &mut Session, i: NodeId, kind: &str) {
run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}}));
}
#[test]
fn switching_variants_takes_the_new_variants_mode() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
assert_eq!(fill(&s, i), "FF0000");
switch(&mut s, i, "B");
assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B");
switch(&mut s, i, "A");
assert_eq!(fill(&s, i), "FF0000");
assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own");
}
#[test]
fn instances_follow_a_mode_their_main_sets_later() {
let mut s = Session::new();
tone(&mut s);
let main = swatch(&mut s, "Chip", None);
let i = instance(&mut s, main);
assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default");
set_mode(&mut s, main, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
set_mode(&mut s, main, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "the main back on Auto");
}
#[test]
fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
set_mode(&mut s, i, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
for kind in ["B", "A"] {
switch(&mut s, i, kind);
assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}");
}
set_mode(&mut s, i, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode");
}
#[test]
fn a_main_mode_is_closer_than_the_frame_around_the_instance() {
let mut s = Session::new();
let (_, b) = kinds(&mut s);
let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100})));
set_mode(&mut s, frame, json!("A"));
let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame})));
assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A");
// Nested: an outer component on mode A holding an instance of B.
let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"})));
run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame}));
set_mode(&mut s, outer_frame, json!("A"));
let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]})));
let copy = instance(&mut s, outer);
let nested = s.doc().unwrap().node(copy).unwrap().children[0];
assert_eq!(fill(&s, nested), "0000FF");
}
#[test]
fn detaching_keeps_the_mode_it_took_from_its_main() {
let mut s = Session::new();
let (set, b) = kinds(&mut s);
let i = instance(&mut s, set);
switch(&mut s, i, "B");
run(&mut s, "component.detach", json!({"ids": [i]}));
set_mode(&mut s, b, json!("A"));
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
}
+93 -5
View File
@@ -29,6 +29,7 @@ pub mod distance;
pub mod doc_urls;
pub mod drop;
pub mod file_access;
pub mod fonts;
pub mod guard;
pub mod guides;
pub mod inspect;
@@ -48,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;
@@ -375,6 +377,8 @@ pub struct DocState {
pub guide: Option<(NodeId, usize)>,
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
/// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]).
view_from_prefs: bool,
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
@@ -436,6 +440,7 @@ impl DocState {
focus: None,
guide: None,
rotation_origin: None,
view_from_prefs: false,
last_duplicate: None,
};
st.zoom_to_fit();
@@ -599,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)]
@@ -626,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>,
@@ -664,6 +693,10 @@ pub struct Prefs {
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
/// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id:
/// files opened or made later start with them. Missing: the toggle's default.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub view: BTreeMap<String, bool>,
/// Recently run actions (the Actions palette's Recents), newest first, at most
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
/// `widget:<plugin>`.
@@ -690,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(),
@@ -702,6 +737,7 @@ impl Default for Prefs {
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
view: BTreeMap::new(),
recent_actions: Vec::new(),
}
}
@@ -726,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>,
@@ -854,6 +892,14 @@ impl Session {
s
}
/// Files new to the session start with the View toggles the preferences keep.
pub fn apply_view_prefs(&mut self) {
for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) {
cmd::view::apply_kept(&self.prefs, &mut ds.view);
ds.view_from_prefs = true;
}
}
pub fn ds(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
@@ -895,6 +941,7 @@ impl Session {
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
}
branches::sync(self);
self.apply_view_prefs();
if textpreview::COMMANDS.contains(&id) {
// A live preview is no edit: no undo step, normalisation or rollback here.
let r = guard::run(id, || (spec.run)(self, params));
@@ -920,6 +967,7 @@ impl Session {
match &result {
Ok(_) => {
self.after_command(spec, before);
self.apply_view_prefs();
if spec.history && id != "edit.repeat" {
self.last_command = Some((id.to_string(), params.clone()));
}
@@ -1014,7 +1062,9 @@ impl Session {
};
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
if changed {
// Saving may adopt the chosen filename as the document title. The encoded snapshot is
// already final; normalising it again would detach it from the saved baseline.
if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
normalize(ds.doc_mut(), &bdoc);
}
let edit = changed && ds.interaction.is_none();
@@ -1079,8 +1129,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.
@@ -1147,6 +1200,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)
}
@@ -1441,21 +1501,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)
}
@@ -1535,6 +1609,8 @@ mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod branch_versions_tests;
@@ -1631,6 +1707,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;
@@ -1699,6 +1777,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;
@@ -1725,6 +1805,8 @@ mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod rest_slot_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;
@@ -1797,8 +1879,12 @@ mod variables_behaviour_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod variables_import_shapes_tests;
#[cfg(test)]
mod variables_io_tests;
#[cfg(test)]
mod variants_discovery_tests;
#[cfg(test)]
mod vector_behaviour_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
@@ -1813,5 +1899,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")]
+2 -2
View File
@@ -265,7 +265,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
s.execute("file.new", &json!({})).unwrap();
let offline_path = saved_file(&mut s);
s.execute("file.revert", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().name, "Untitled");
assert_eq!(s.doc().unwrap().name, offline_path.file_stem().unwrap().to_string_lossy());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().versions.len(), 1);
assert_eq!(s.live_of(uid).doc_uid, Some(uid));
@@ -273,7 +273,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
s.execute("doc.switch", &json!({"index": 0})).unwrap();
s.execute("collab.leave", &json!({})).unwrap();
s.execute("file.revert", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().name, "Untitled");
assert_eq!(s.doc().unwrap().name, path.file_stem().unwrap().to_string_lossy());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().versions.len(), 1);
assert_eq!(s.live().doc_uid, None);
+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)));
}
+6 -1
View File
@@ -120,7 +120,12 @@ pub fn same_server(a: &str, b: &str) -> bool {
let s = plain_server(s).to_ascii_lowercase();
let s = s.trim_end_matches('/');
let port = if s.starts_with("wss://") { ":443" } else { ":80" };
s.strip_suffix(port).unwrap_or(s).to_string()
let start = s.find("://").map_or(0, |i| i + 3);
let end = s[start..].find('/').map_or(s.len(), |i| start + i);
match s[start..end].strip_suffix(port) {
Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]),
None => s.to_string(),
}
};
norm(a) == norm(b)
}
+8
View File
@@ -583,3 +583,11 @@ fn debug_output_of_a_record_leaves_the_secrets_out() {
no_secrets("debug", &text);
assert!(text.contains("share.example") && text.contains(".."), "{text}");
}
#[test]
fn same_server_ignores_the_default_port_before_a_path() {
use crate::online::same_server;
assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/"));
assert!(same_server("ws://host:80/x", "ws://host/x"));
assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/"));
}
+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");
}
+91
View File
@@ -0,0 +1,91 @@
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
//! moves of the mains, and through saving.
use crate::Session;
use crate::components::tests::{ex, healthy, idv};
use crate::components::*;
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
fn text(id: &str, s: &str) -> Value {
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
/// holds a text and a chip.
fn file() -> Value {
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]},
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
]}]}})
}
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
}
/// The instance's slot and the names of what it holds.
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
let doc = s.doc().unwrap();
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
(slot, names)
}
#[test]
fn rest_slot_content_survives_main_edits_moves_and_saving() {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
healthy(&s);
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
let (slot, names) = slot_content(&s);
assert_eq!(names, want);
let doc = s.doc().unwrap();
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
assert!(slot_edited(doc, slot), "the instance's own content");
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
assert!(!inside_instance(doc, chip), "content stands alone");
// Edit and move both mains (the container's and the one used in the content).
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
let title = doc.node(main).unwrap().children[0];
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
healthy(&s);
assert_eq!(slot_content(&s).1, want, "kept across syncs");
// Saving and opening again keeps it.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
let mut again = Session::new();
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
healthy(&again);
let (slot, names) = slot_content(&again);
assert_eq!(names, want);
assert!(slot_edited(again.doc().unwrap(), slot));
// Resetting the slot shows the main's (empty) default content.
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
assert!(slot_content(&again).1.is_empty());
}
+40
View File
@@ -89,6 +89,46 @@ fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]
assert_eq!(s.prefs.recent_files, recent);
}
#[test]
fn first_native_save_uses_chosen_filename_for_an_untitled_document() {
let mut files = SaveFiles::new();
let path = files.path("Redis.figcraft");
let copy = files.path("Redis copy.figcraft");
let manual_copy = files.path("Other.figcraft");
let mut s = Session::new();
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.ds().unwrap().doc.name, "Redis");
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis");
let mut reopened = Session::empty();
reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(s.ds().unwrap().doc.name, "Redis copy");
s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap();
assert_eq!(s.ds().unwrap().doc.name, "Chosen title");
}
#[test]
fn opening_an_older_file_uses_its_filename_only_for_the_default_title() {
let mut files = SaveFiles::new();
let old_path = files.path("Redis.figcraft");
let named_path = files.path("Other.figcraft");
let mut original = Session::new();
std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
let mut reopened = Session::empty();
reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap();
assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap();
assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title");
}
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
let bytes = std::fs::read(path).unwrap();
assert_eq!(result["format"], "fig");
+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}))
}
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more