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 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 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
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
John Peter Sa c3fe69c629 Merge branch 'storytold:main' into main 2026-10-19 18:58:22 -03: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 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
119 changed files with 1947 additions and 701 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]"
+4 -4
View File
@@ -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
@@ -75,10 +75,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- 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' }}
+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
@@ -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"
+7
View File
@@ -7,6 +7,10 @@
</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">
@@ -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">
+63 -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,57 @@ 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 |
@@ -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};
@@ -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;
+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;
+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"
-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(())
}
}
+79 -17
View File
@@ -21,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;
@@ -222,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 {
@@ -232,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")]
@@ -244,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,
@@ -274,9 +270,35 @@ 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 {
@@ -288,15 +310,55 @@ 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)]
+4
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
+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);
+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 }
+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",
"",
+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");
}
}
+14 -2
View File
@@ -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" {
+10
View File
@@ -114,3 +114,13 @@ fn infinite_channels_clamp() {
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);
}
+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");
+84 -23
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);
@@ -1103,7 +1164,7 @@ fn add_prop_for(cmd: &str, 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)?;
+11 -4
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",
@@ -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);
}
+9 -2
View File
@@ -542,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))
@@ -853,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;
+15
View File
@@ -292,6 +292,21 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
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 {
@@ -194,6 +194,67 @@ fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
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,
@@ -818,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);
}
+5 -1
View File
@@ -1062,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();
@@ -1881,6 +1883,8 @@ 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;
+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);
+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/"));
}
+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");
+5 -6
View File
@@ -177,20 +177,19 @@ fn vertical_trim_through_node_set() {
#[test]
fn paragraph_indent_only_moves_left_aligned_text() {
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30}));
let first_x = |s: &Session| {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
(l.glyphs.first().map_or(0.0, |g| g.x), l.width)
(l.glyphs.first().map(|g| g.x), l.width)
};
let (x, w) = first_x(&s);
if w <= 0.0 {
return;
}
// Without any font there are no glyphs, but the indent alone still gives the box a width.
let (Some(x), w) = first_x(&s) else { return };
assert!((x - 30.0).abs() < 1e-6, "left: indented");
s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap();
let (x, w2) = first_x(&s);
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.is_some_and(|x| x.abs() < 1e-6), "centered auto-width text: no indent, the box shrinks by it");
}
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
+1 -1
View File
@@ -1300,7 +1300,7 @@ impl Session {
for i in &ids {
let parent = doc.get(*i)?.parent.unwrap_or(page);
let idx = doc.index_in_parent(*i).map(|x| x + 1);
copies.push(doc.duplicate_into(*i, parent, idx)?);
copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?);
}
moving = copies;
*sel = moving.clone();
@@ -0,0 +1,179 @@
//! Combine as variants and Add variant where people look for them (#903): the predicates the
//! context menu and the Design panel offer them by, combining refusing components that are
//! variants already (it used to put a set inside a set), and Add variant on a main component that
//! isn't in a set yet making it one.
use serde_json::json;
use crate::Session;
use crate::cmd::component::{can_add_variant, can_combine_variants};
use crate::components::tests::{ex, healthy, idv, kids, node};
use figcraft_doc::*;
/// A main component named `name` at `x`, made from a 120×40 frame with a "Bg" rectangle.
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 120, "height": 40, "name": name})));
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg"}}));
idv(&ex(s, "component.create", json!({"ids": [f]})))
}
fn undo_len(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
fn sets(s: &Session) -> usize {
s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).count()
}
#[test]
fn combine_is_offered_for_two_or_more_main_components_only() {
let mut s = Session::new();
let a = component(&mut s, "A", 0.0);
let b = component(&mut s, "B", 200.0);
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 400})));
let doc = s.doc().unwrap();
assert!(can_combine_variants(doc, &[a, b]));
assert!(!can_combine_variants(doc, &[]), "nothing selected");
assert!(!can_combine_variants(doc, &[a]), "one component (the command still takes it)");
assert!(!can_combine_variants(doc, &[a, a]), "the same component twice");
assert!(!can_combine_variants(doc, &[a, frame]), "a frame isn't a component");
assert!(!can_combine_variants(doc, &[a, inst]), "an instance isn't a main component");
let c = component(&mut s, "C", 400.0);
let d = component(&mut s, "D", 600.0);
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c, d]})));
let doc = s.doc().unwrap();
assert!(!can_combine_variants(doc, &[c, d]), "variants of one set");
assert!(!can_combine_variants(doc, &[c, a]), "a variant and a lone component");
assert!(!can_combine_variants(doc, &[set, a]), "a set isn't a component");
// Components in different frames still combine.
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 100})));
let e = component(&mut s, "E", 0.0);
ex(&mut s, "node.reparent", json!({"ids": [e], "parent": holder}));
assert!(can_combine_variants(s.doc().unwrap(), &[a, e]));
}
#[test]
fn add_variant_is_offered_for_sets_variants_and_lone_components() {
let mut s = Session::new();
let a = component(&mut s, "A", 0.0);
let b = component(&mut s, "B", 200.0);
let lone = component(&mut s, "Lone", 400.0);
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})));
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": lone, "x": 0, "y": 400})));
let doc = s.doc().unwrap();
assert!(can_add_variant(doc, set));
assert!(can_add_variant(doc, a));
assert!(can_add_variant(doc, lone));
assert!(!can_add_variant(doc, frame));
assert!(!can_add_variant(doc, inst));
assert!(!can_add_variant(doc, kids(&s, inst)[0]), "a layer inside an instance");
assert!(!can_add_variant(doc, NodeId(999_999)));
}
#[test]
fn combining_variants_again_is_refused_instead_of_nesting_sets() {
let mut s = Session::new();
let a = component(&mut s, "State=Default", 0.0);
let b = component(&mut s, "State=Hover", 200.0);
let lone = component(&mut s, "State=Pressed", 400.0);
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})));
let before = s.doc().unwrap().clone();
let steps = undo_len(&s);
for ids in [vec![a, b], vec![a, lone]] {
let err = s.execute("component.combineVariants", &json!({"ids": ids})).unwrap_err();
assert!(err.to_string().contains("variants already"), "{err}");
}
assert_eq!(*s.doc().unwrap(), before, "a refused combine changes nothing");
assert_eq!(undo_len(&s), steps);
assert_eq!(sets(&s), 1);
assert_eq!(kids(&s, set), vec![a, b]);
// One lone component still makes a set of one (other commands rely on it).
ex(&mut s, "component.combineVariants", json!({"ids": [lone]}));
assert_eq!(sets(&s), 2);
healthy(&s);
}
#[test]
fn combining_refuses_nested_components_and_components_inside_instances() {
let mut s = Session::new();
// A "Card" component holding an "Icon" component.
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let icon = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 20, "height": 20, "name": "Icon", "parent": card})));
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon]})));
let card = idv(&ex(&mut s, "component.create", json!({"ids": [card]})));
let other = component(&mut s, "Other", 300.0);
let inst = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300})));
let inner = kids(&s, inst).into_iter().find(|c| matches!(node(&s, *c).kind, NodeKind::Component { .. })).expect("the instance's copy of Icon");
let doc = s.doc().unwrap();
assert!(!can_combine_variants(doc, &[card, icon]), "a component and one inside it");
assert!(can_combine_variants(doc, &[icon, other]), "a nested component with another one is fine");
assert!(!can_combine_variants(doc, &[inner, other]), "a component inside an instance");
let before = s.doc().unwrap().clone();
let err = s.execute("component.combineVariants", &json!({"ids": [card, icon]})).unwrap_err();
assert!(err.to_string().contains("inside it"), "{err}");
assert_eq!(*s.doc().unwrap(), before);
}
#[test]
fn add_variant_on_a_lone_component_makes_it_a_set_in_one_step() {
let mut s = Session::new();
let c = component(&mut s, "Button", 0.0);
let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 0, "y": 300})));
let before = s.doc().unwrap().clone();
let steps = undo_len(&s);
let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c})));
let set = node(&s, c).parent.unwrap();
let set_node = node(&s, set);
assert!(matches!(set_node.kind, NodeKind::ComponentSet { .. }));
assert_eq!(set_node.name, "Button", "the set takes the component's name");
assert_eq!(kids(&s, set), vec![c, v]);
assert_eq!(node(&s, c).name, "Property 1=Default");
assert_eq!(node(&s, v).name, "Property 1=Variant2");
assert_eq!(s.selection(), vec![v]);
assert!(set_node.height >= 40.0 * 2.0 + 20.0, "the set wraps both variants: {}", set_node.height);
assert!(matches!(node(&s, inst).kind, NodeKind::Instance { component, .. } if component == c), "instances keep their main");
assert_eq!(undo_len(&s), steps + 1, "wrapping and adding are one undo step");
healthy(&s);
s.undo().unwrap();
assert_eq!(*s.doc().unwrap(), before, "undo puts the lone component back");
}
#[test]
fn add_variant_on_a_lone_component_keeps_prop_value_names_and_rolls_back_on_errors() {
let mut s = Session::new();
let c = component(&mut s, "Size=Small", 0.0);
let before = s.doc().unwrap().clone();
let steps = undo_len(&s);
assert!(s.execute("component.addVariant", &json!({"id": c, "property": "Nope", "value": "x"})).is_err());
assert_eq!(*s.doc().unwrap(), before, "a refused Add variant leaves no set behind");
assert_eq!(undo_len(&s), steps);
let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c, "property": "Size", "value": "Large"})));
assert_eq!(node(&s, c).name, "Size=Small");
assert_eq!(node(&s, v).name, "Size=Large");
healthy(&s);
}
#[test]
fn add_variant_on_a_lone_component_in_auto_layout_keeps_its_place() {
let mut s = Session::new();
let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "name": "Row"})));
ex(&mut s, "layout.add", json!({"ids": [row]}));
let first = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row})));
let c = component(&mut s, "Chip", 0.0);
ex(&mut s, "node.reparent", json!({"ids": [c], "parent": row}));
let last = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row})));
ex(&mut s, "component.addVariant", json!({"id": c}));
let set = node(&s, c).parent.unwrap();
assert_eq!(kids(&s, row), vec![first, set, last], "the set takes the component's place in the row");
assert_eq!(kids(&s, set).len(), 2);
healthy(&s);
}
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
doc::NodeKind,
@@ -1,5 +1,4 @@
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use figcraft_doc::{NodeId, StyleKind};
use figcraft_engine::Session;
@@ -1,5 +1,4 @@
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
+14
View File
@@ -0,0 +1,14 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod empty_text_box_undo;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
mod library_style_acceptance;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod list_indent_response;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod scope_reset;
#[allow(clippy::unwrap_used)]
mod selection_colors;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod shape_builder_response;
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::json;
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used)]
use figcraft_color::Paint;
use figcraft_doc::NodeId;
use figcraft_engine::{
@@ -1,4 +1,3 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::{Value, json};
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, built as one test binary.
mod distributed_alignment;
mod import_link_contract;
mod range_hyperlink_round_trip;
+3 -1
View File
@@ -284,7 +284,9 @@ impl ReverseSubpaths for BezPath {
kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
out.close_path();
if matches!(seg_path.elements().last(), Some(kurbo::PathEl::ClosePath)) {
out.close_path();
}
}
out
}
+15
View File
@@ -108,3 +108,18 @@ fn set_rotation_keeps_center() {
}
}
}
#[test]
fn reverse_subpaths_preserves_open_and_closed() {
let mut open = BezPath::new();
open.move_to((0.0, 0.0));
open.line_to((10.0, 0.0));
open.line_to((10.0, 10.0));
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&open);
assert!(!r.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)), "{:?}", r.elements());
let mut closed = open.clone();
closed.close_path();
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&closed);
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
@@ -1,8 +1,7 @@
//! End-to-end agent tasks: an in-process MCP client completes realistic design tasks only through
//! MCP tool calls, and checks every result through inspect_document / get_node like an agent would.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use crate::common;
use common::{Client, approx, png_dims};
use serde_json::{Value, json};
@@ -1,6 +1,5 @@
//! The attached backend against a fake app: a loopback control server answering with a headless
//! session, speaking the desktop app's JSON-lines protocol.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
@@ -1,6 +1,5 @@
//! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only
//! inside the read and write roots and writes only inside the write roots.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use std::path::PathBuf;
@@ -1,5 +1,4 @@
//! An in-process MCP client: feeds JSON-RPC lines to `Server::handle_line`.
#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_mcp::Server;
use serde_json::{Value, json};
@@ -1,7 +1,6 @@
//! Hidden top-level layers in an export take no room in the image (issue #447).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use crate::common;
use common::{Client, png_dims};
use serde_json::{Value, json};
@@ -1,5 +1,4 @@
//! Fuzz-ish: no input line may crash the server or leave it unable to answer.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_mcp::{Server, TOOL_NAMES};
use proptest::prelude::*;
+18
View File
@@ -0,0 +1,18 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod agent_tasks;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod attached;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
mod automation_roots;
#[allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_hidden_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod protocol;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod screenshot_pixel_limit;
@@ -1,7 +1,6 @@
//! JSON-RPC / MCP protocol behaviour.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use crate::common;
use common::Client;
use figcraft_mcp::{PROTOCOL_VERSION, Server, TOOL_NAMES};
@@ -1,6 +1,5 @@
//! `screenshot` with `ids` must honour `maxSize` for the rendered paint area (shadows, overflow).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use crate::common;
use common::{Client, png_dims};
use serde_json::{Value, json};
+4 -2
View File
@@ -586,7 +586,8 @@ impl W<'_> {
}
let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
Some(fills) => {
let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
@@ -601,7 +602,8 @@ impl W<'_> {
continue;
}
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\">{}</tspan>", num(x), num(y), esc(&text));
}
+19
View File
@@ -116,6 +116,25 @@ fn text_run_fill_survives_live_text_export() {
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b</tspan>") && svg.contains(">a</tspan>"), "{svg}");
}
#[test]
fn title_case_does_not_restart_words_across_fill_runs_in_live_text() {
use figcraft_doc::{StyleRun, TextCase, TextData};
figcraft_text::scan_system_fonts();
let mut d = Document::new("t");
let page = d.first_page().unwrap();
let mut text = TextData::new("hello");
text.style.font_size = 24.0;
text.style.case = TextCase::Title;
let mut style = text.style.clone();
style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
text.runs.push(StyleRun { start: 2, end: 3, style });
text.normalize();
let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 200.0, 40.0).unwrap();
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap();
assert!(svg.contains(">He</tspan>") && svg.contains(">l</tspan>") && svg.contains(">lo</tspan>"), "{svg}");
}
#[test]
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
use figcraft_doc::{StrokeJoin, VectorNetwork};
+1
View File
@@ -22,6 +22,7 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
egui_extras = { workspace = true }
+6 -4
View File
@@ -1,12 +1,14 @@
# FigCraft interface strings: Georgian.
#
# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard
# glyphs stay as they are. Georgian has no letter case, and a noun after a numeral stays singular, so
# counts read "შრე: {n}" and plural entries give the same text for one and other. See docs/translating.md.
# Written with AI assistance (Claude) from the meaning of each English string, following the glossary
# the Craft apps share, and reviewed by a native Georgian speaker; kept as a draft until a maintainer
# changes its status. Brand names and keyboard glyphs stay as they are. Georgian has no letter case,
# and a noun after a numeral stays singular, so counts read "შრე: {n}" and plural entries give the
# same text for one and other. See docs/translating.md.
[meta]
name = "ქართული"
status = "draft — needs native review"
status = "draft — reviewed by a native speaker, awaiting a maintainer's approval"
[ui]
"% of all commits" = "ყველა კომიტის %"
+114
View File
@@ -1640,6 +1640,120 @@ status = "draft — needs native review"
"⌘ click" = "⌘ clique"
"⌘ scroll" = "⌘ rolar"
"⌥ drag" = "⌥ arrastar"
"A responsive product page: hero, features, figures, a quote and a footer" = "Uma página de produto responsiva: destaque, recursos, dados, depoimento e rodapé"
"A title, an agenda, a big figure, a chart and a closing slide" = "Um título, uma pauta, um número em destaque, um gráfico e um slide de encerramento"
"A to Z" = "De A a Z"
"Adding to {name}" = "Adicionando a {name}"
"All the fonts this file uses are installed" = "Todas as fontes usadas neste arquivo estão instaladas"
"Alphabetical" = "Alfabética"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Uma tela de análise em grade: dados, gráficos vetoriais e uma tabela"
"Apps" = "Aplicativos"
"Apr" = "abr."
"Aug" = "ago."
"Bottom padding" = "Espaçamento interno inferior"
"Bound to {name}. Click to detach the variable." = "Vinculada a {name}. Clique para desvincular a variável."
"Checking the fonts installed on this computer…" = "Verificando as fontes instaladas neste computador…"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de cor e texto, variáveis Claro e Escuro, botões, campos e cartões"
"Convert to frame" = "Converter em quadro"
"Convert to section" = "Converter em seção"
"Created" = "Criado"
"Dashboard" = "Painel"
"Date created" = "Data de criação"
"Dec" = "dez."
"Design system starter" = "Ponto de partida para sistema de design"
"Draw a frame" = "Desenhe um quadro"
"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem."
"Drop to import" = "Solte para importar"
"Drop to open" = "Solte para abrir"
"Edited {when}" = "Editado {when}"
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporte quadros e camadas como PNG, JPG, SVG ou PDF"
"Feb" = "fev."
"File not found" = "Arquivo não encontrado"
"Four screens of a trail app with components and a prototype flow" = "Quatro telas de um aplicativo de trilhas com componentes e um fluxo de protótipo"
"Getting started" = "Primeiros passos"
"Greyscale components and two example screens to sketch a flow with" = "Componentes em escala de cinza e duas telas de exemplo para esboçar um fluxo"
"Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estas dicas (Primeiros passos no menu Ajuda as exibe novamente)"
"Hide when publishing" = "Ocultar ao publicar"
"Icon components on a 24 px grid with keylines and a size preview" = "Componentes de ícone em uma grade de 24 px, com linhas-guia e prévia de tamanho"
"Icon set starter" = "Ponto de partida para conjunto de ícones"
"Individual padding" = "Espaçamento interno individual"
"Interface size" = "Tamanho da interface"
"Jan" = "jan."
"Jul" = "jul."
"Jun" = "jun."
"Kits" = "Kits"
"Landing page" = "Página de apresentação"
"Last modified" = "Modificado por último"
"Left padding" = "Espaçamento interno à esquerda"
"Link frames on the Prototype tab, then present the flow" = "Vincule os quadros na aba Protótipo e apresente o fluxo"
"Make a component" = "Criar um componente"
"Mar" = "mar."
"May" = "mai."
"Missing fonts" = "Fontes ausentes"
"Mobile app" = "Aplicativo para celular"
"Newest first" = "Mais recentes primeiro"
"No files or templates match your search" = "Nenhum arquivo ou modelo corresponde à sua busca"
"No other pages" = "Não há outras páginas"
"No templates match your search" = "Nenhum modelo corresponde à sua busca"
"Nov" = "nov."
"Oct" = "out."
"Oldest first" = "Mais antigos primeiro"
"One announcement in five common sizes: square, portrait, story, link and banner" = "Um anúncio em cinco tamanhos comuns: quadrado, retrato, story, link e banner"
"Opening the file…" = "Abrindo o arquivo…"
"Opening {n} files as new files" = { one = "Abrindo como um novo arquivo", other = "Abrindo {n} arquivos como novos arquivos" }
"Order" = "Ordem"
"Other" = "Outro"
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Selecione a ferramenta Quadro e arraste na tela ou escolha o tamanho de um dispositivo"
"Plugin" = "Plugin"
"Plugins & widgets" = "Plugins e widgets"
"Presentation slides" = "Slides de apresentação"
"Playground" = "Área de testes"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Em relação à escala de exibição do sistema (100% corresponde ao tamanho definido pelo sistema). O zoom do documento é independente."
"Remove from recents" = "Remover dos recentes"
"Reorder selection" = "Reordenar seleção"
"Right padding" = "Espaçamento interno à direita"
"Search templates" = "Buscar modelos"
"See all templates" = "Ver todos os modelos"
"Select layers and they space themselves as their content changes" = "Selecione camadas e elas ajustarão o espaçamento conforme o conteúdo mudar"
"Search files and templates" = "Buscar arquivos e modelos"
"Search results" = "Resultados da busca"
"Search pages" = "Buscar páginas"
"Sep" = "set."
"Share with a code" = "Compartilhar com um código"
"Show all templates" = "Mostrar todos os modelos"
"Show in Explorer" = "Mostrar no Explorador de Arquivos"
"Show in Finder" = "Mostrar no Finder"
"Show in folder" = "Mostrar na pasta"
"Social and slides" = "Redes sociais e slides"
"Social post sizes" = "Tamanhos de publicação para redes sociais"
"Sort by" = "Ordenar por"
"Sort by {column}" = "Ordenar por {column}"
"Start from a template" = "Começar com um modelo"
"Start sharing with the Share button, then pass on the code it gives" = "Comece a compartilhar pelo botão Compartilhar e envie o código gerado"
"Templates" = "Modelos"
"The destination document is unavailable. Nothing was imported." = "O documento de destino está indisponível. Nada foi importado."
"The destination document was closed. Nothing was imported." = "O documento de destino foi fechado. Nada foi importado."
"The destination page was removed. Nothing was imported." = "A página de destino foi removida. Nada foi importado."
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "A janela é pequena demais para {size}%, então a interface é exibida em {drawn}%. Aumente a janela para usar {size}%."
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este arquivo usa fontes que não estão instaladas neste computador. O texto será exibido com uma fonte substituta até que você as instale."
"Top padding" = "Espaçamento interno superior"
"Try another search, or browse templates." = "Tente outra busca ou explore os modelos."
"Try another word, or look at every kind." = "Tente outra palavra ou veja todos os tipos."
"Turn a layer into a component, reuse it, and change every copy at once" = "Transforme uma camada em componente, reutilize-a e altere todas as cópias de uma só vez"
"Use your browser's zoom to change the interface size." = "Use o zoom do navegador para alterar o tamanho da interface."
"Uses variables: each layer shows them in its own mode" = "Usa variáveis: cada camada as exibe em seu próprio modo"
"Websites" = "Sites"
"What you try here changes this preview and the code below, not the file" = "O que você experimentar aqui altera esta prévia e o código abaixo, não o arquivo"
"Widget" = "Widget"
"Wireframe kit" = "Kit de wireframe"
"Z to A" = "De Z a A"
"just now" = "agora mesmo"
"only in a text style or variable" = "somente em um estilo de texto ou variável"
"slot layers without a matching slot property were imported as frames" = "camadas de slot sem uma propriedade de slot correspondente foram importadas como quadros"
"yesterday" = "ontem"
"{month} {day}, {year}" = "{day} de {month} de {year}"
"{n} text layers" = { one = "{n} camada de texto", other = "{n} camadas de texto" }
"{percent}%" = "{percent}%"
[commands]
"align.bottom" = "Alinhar embaixo"
+57
View File
@@ -1,5 +1,6 @@
# FigCraft interface strings: Russian.
# Wording reviewed and approved by VanTan.
# Strings added since that review (2026-10-20) are a machine draft awaiting review.
# Keep Frame, Dev Mode and Smart animate as familiar tool names.
# See docs/translating.md; plural forms follow Russian integer rules.
@@ -16,6 +17,7 @@ name = "Русский"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя."
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма."
"A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование"
"A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд"
@@ -65,6 +67,7 @@ name = "Русский"
"Add variant with a new value" = "Добавить вариант с новым значением"
"Added" = "Добавлено"
"Added the library \"{name}\"" = "Добавлена библиотека «{name}»"
"Adding to {name}" = "Добавление в {name}"
"Adjust" = "Коррекция"
"Adjustment presets" = "Пресеты коррекции"
"Advanced" = "Дополнительно"
@@ -88,6 +91,7 @@ name = "Русский"
"All components" = "Все компоненты"
"All fonts" = "Все шрифты"
"All supported properties" = "Все поддерживаемые свойства"
"All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены"
"All variables" = "Все переменные"
"Alpha" = "Альфа"
"Alphabetical" = "По алфавиту"
@@ -168,6 +172,7 @@ name = "Русский"
"Both directions" = "Оба направления"
"Bottom" = "Снизу"
"Bottom left radius" = "Радиус нижнего левого угла"
"Bottom padding" = "Нижний внутренний отступ"
"Bottom right radius" = "Радиус нижнего правого угла"
"Branch" = "Ветка"
"Branch archived" = "Ветка архивирована"
@@ -194,6 +199,7 @@ name = "Русский"
"C or ⇧C" = "C или ⇧C"
"CMYK colour" = "Цвет CMYK"
"Can view" = "Может просматривать"
"Checking the fonts installed on this computer…" = "Проверка шрифтов, установленных на этом компьютере…"
"Checking the server…" = "Проверка сервера…"
"Clear from this list" = "Убрать из этого списка"
"Code or invite link" = "Код или ссылка приглашения"
@@ -293,6 +299,8 @@ name = "Русский"
"Contributors" = "Участники"
"Contributors: {people} · Commits: {commits}" = "Участники: {people} · Коммиты: {commits}"
"Controller" = "Контроллер"
"Convert to frame" = "Преобразовать во Frame"
"Convert to section" = "Преобразовать в секцию"
"Cool" = "Холодный"
"Copied as CSS" = "Скопировано как CSS"
"Copied as PNG" = "Скопировано как PNG"
@@ -310,8 +318,10 @@ name = "Русский"
"Copy {value}" = "Копировать {value}"
"Corner radius" = "Радиус углов"
"Corner smoothing" = "Сглаживание углов"
"Couldn't download the variables" = "Не удалось загрузить переменные"
"Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере."
"Couldn't read {name}." = "Не удалось прочитать {name}."
"Couldn't save automatically: {error}" = "Не удалось сохранить автоматически: {error}"
"Count" = "Количество"
"Create" = "Создать"
"Create a variable collection first" = "Сначала создайте коллекцию переменных"
@@ -407,7 +417,10 @@ name = "Русский"
"Draw" = "Рисование"
"Draw a frame" = "Нарисовать Frame"
"Drawing tools" = "Инструменты рисования"
"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение."
"Drop shadow" = "Внешняя тень"
"Drop to import" = "Отпустите, чтобы импортировать"
"Drop to open" = "Отпустите, чтобы открыть"
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Двойная лицензия MIT OR Apache-2.0. Создано командой и сообществом ArtCraft."
"Duo" = "Дуо"
"Duplicate" = "Дублировать"
@@ -477,6 +490,7 @@ name = "Русский"
"False" = "Ложь"
"Feb" = "февр."
"Fetching from the server…" = "Загрузка с сервера…"
"FigCraft couldn't open its window: {error}" = "FigCraft не смог открыть своё окно: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется."
"File" = "Файл"
"File name" = "Имя файла"
@@ -598,6 +612,7 @@ name = "Русский"
"Icon components on a 24 px grid with keylines and a size preview" = "Компоненты иконок на сетке 24 px с направляющими и предпросмотром размеров"
"Icon set starter" = "Основа набора иконок"
"If" = "Если"
"If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение."
"If {condition}" = "Если {condition}"
"Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)"
"Image" = "Изображение"
@@ -619,6 +634,7 @@ name = "Русский"
"Included" = "Включено"
"Included in layout" = "Участвует в компоновке"
"Individual corners" = "Углы по отдельности"
"Individual padding" = "Внутренние отступы по отдельности"
"Individual strokes" = "Обводки по отдельности"
"Ink" = "Чернила"
"Inner shadow" = "Внутренняя тень"
@@ -640,6 +656,7 @@ name = "Русский"
"Interaction" = "Взаимодействие"
"Interactions" = "Взаимодействия"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
"Interface size" = "Размер интерфейса"
"Intersect" = "Пересечение"
"Intersects" = "Пересечения"
"Invalid color" = "Недопустимый цвет"
@@ -689,6 +706,7 @@ name = "Русский"
"Leaves" = "Листья"
"Left" = "Слева"
"Left & right" = "Слева и справа"
"Left padding" = "Левый внутренний отступ"
"Left side" = "Левая сторона"
"Left side width" = "Ширина левой стороны"
"Letter case" = "Регистр букв"
@@ -761,6 +779,7 @@ name = "Русский"
"Mirror angle and length" = "Отражать угол и длину"
"Mirroring" = "Зеркальность"
"Missing" = "Отсутствует"
"Missing fonts" = "Отсутствующие шрифты"
"Missing layer" = "Слой отсутствует"
"Missing style" = "Стиль отсутствует"
"Missing variable" = "Переменная отсутствует"
@@ -911,10 +930,13 @@ name = "Русский"
"Open variables ({count})" = "Открыть переменные ({count})"
"Opened as a new document" = "Открыт как новый документ"
"Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке"
"Opening the file…" = "Открытие файла…"
"Opening {n} files as new files" = { one = "Открытие {n} файла как нового", few = "Открытие {n} файлов как новых", many = "Открытие {n} файлов как новых", other = "Открытие {n} файлов как новых" }
"Open|comments" = "Открытые"
"Options" = "Параметры"
"Orange" = "Оранжевый"
"Order" = "Порядок"
"Other" = "Другое"
"Other apps may not open every .fig file exported here." = "Другие приложения могут открыть не каждый экспортированный здесь файл .fig."
"Others lose access and keep their copy; the file stays on the server for you" = "Другие участники потеряют доступ, сохранив свои копии; файл останется на сервере для вас"
"Others type this under Share ▸ Join with a code" = "Другие участники вводят это в «Поделиться» ▸ «Подключиться по коду»"
@@ -974,6 +996,8 @@ name = "Русский"
"Place a shape or frame (its tool chosen)" = "Разместить фигуру или Frame (выбранным инструментом)"
"Place automatically" = "Разместить автоматически"
"Place image" = "Разместить изображение"
"Plugin" = "Плагин"
"Plugins & widgets" = "Плагины и виджеты"
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Плагины работают в песочнице FigCraft без доступа к файлам, сети и часам. Устанавливайте только плагины, которым доверяете."
"Plugins" = "Плагины"
"Plus darker" = "Линейное затемнение"
@@ -1043,6 +1067,7 @@ name = "Русский"
"Reduce motion" = "Уменьшить движение"
"Refraction" = "Преломление"
"Regular expression" = "Регулярное выражение"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Относительно масштаба экрана в системе (100% — собственный размер системы). Масштаб документа задаётся отдельно."
"Remove" = "Удалить"
"Remove all" = "Удалить всё"
"Remove annotation" = "Удалить аннотацию"
@@ -1066,6 +1091,7 @@ name = "Русский"
"Remove the else and its actions" = "Удалить «Иначе» и его действия"
"Remove this condition and its actions" = "Удалить это условие и его действия"
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Удалить файл с сервера? Его больше нельзя будет открывать и публиковать оттуда. Ваша копия останется открытой здесь."
"Remove {name} from session" = "Убрать {name} из сессии"
"Remove {name} from {server}?" = "Удалить {name} с {server}?"
"Removed" = "Удалено"
"Removed from the server" = "Удалено с сервера"
@@ -1080,6 +1106,7 @@ name = "Русский"
"Rename on the server…" = "Переименовать на сервере…"
"Rename page" = "Переименовать страницу"
"Rename to" = "Новое имя"
"Reorder selection" = "Изменить порядок выделения"
"Replace all" = "Заменить всё"
"Replace mixed export settings" = "Заменить разные настройки экспорта"
"Replace with" = "Заменить на"
@@ -1115,6 +1142,7 @@ name = "Русский"
"Review and merge" = "Проверить и объединить"
"Ribbon" = "Лента"
"Right" = "Справа"
"Right padding" = "Правый внутренний отступ"
"Right side" = "Правая сторона"
"Right side width" = "Ширина правой стороны"
"Rotate 90°" = "Повернуть на 90°"
@@ -1134,6 +1162,8 @@ name = "Русский"
"Rulers" = "Линейки"
"Run" = "Запустить"
"Run {label}" = "Запустить {label}"
"Save files automatically" = "Автоматически сохранять файлы"
"Search pages" = "Поиск страниц"
"Search templates" = "Поиск шаблонов"
"See all templates" = "Все шаблоны"
"Select layers and they space themselves as their content changes" = "Выделите слои: интервалы между ними будут подстраиваться при изменении содержимого"
@@ -1278,6 +1308,7 @@ name = "Русский"
"Start from a blank canvas" = "Начать с пустого холста"
"Start from a template" = "Начать с шаблона"
"Start on path" = "Начало на пути"
"Start on the Home screen" = "Начинать с главной"
"Start point" = "Начальная точка"
"Start sharing" = "Включить общий доступ"
"Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код"
@@ -1339,6 +1370,9 @@ name = "Русский"
"Texts" = "Тексты"
"Texture" = "Текстура"
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "На холсте цвета отображаются в sRGB; экспортированные PNG, JPG и WebP помечены профилем Display P3."
"The destination document is unavailable. Nothing was imported." = "Документ назначения недоступен. Ничего не импортировано."
"The destination document was closed. Nothing was imported." = "Документ назначения закрыт. Ничего не импортировано."
"The destination page was removed. Nothing was imported." = "Страница назначения удалена. Ничего не импортировано."
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Файл удаляется с сервера: его больше нельзя будет открыть или подключиться к нему оттуда. Отменить это нельзя. Уже открытые копии остаются у участников."
"The file now holds another library" = "Теперь файл содержит другую библиотеку"
"The file now holds another library (\"{name}\")" = "Теперь файл содержит другую библиотеку («{name}»)"
@@ -1348,6 +1382,7 @@ name = "Русский"
"The selection uses several text styles: click to apply one" = "В выделении несколько текстовых стилей: нажмите, чтобы применить один"
"The server deletes this file if nobody opens it before {date}." = "Сервер удалит файл, если никто не откроет его до {date}."
"The whole file" = "Весь файл"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Окно слишком мало для {size}%, поэтому интерфейс отрисовывается в масштабе {drawn}%. Увеличьте окно, чтобы использовать {size}%."
"Theme" = "Тема"
"There's no destination for that action" = "Для этого действия не задано назначение"
"Thickness" = "Толщина"
@@ -1359,6 +1394,8 @@ name = "Русский"
"This file" = "Этот файл"
"This file has unsaved changes" = "В файле есть несохранённые изменения"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду."
"This file is too large to join here (up to {mb} MB)" = "Этот файл слишком большой, чтобы подключиться здесь (до {mb} МБ)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "В этом файле используются шрифты, не установленные на этом компьютере. Их текст отрисовывается запасным шрифтом, пока вы их не установите."
"This frame is new on the branch." = "Этот Frame добавлен в ветке."
"This frame isn't on the branch." = "Этого Frame нет в ветке."
"This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен."
@@ -1378,6 +1415,7 @@ name = "Русский"
"Top" = "Сверху"
"Top & bottom" = "Сверху и снизу"
"Top left radius" = "Радиус верхнего левого угла"
"Top padding" = "Верхний внутренний отступ"
"Top right radius" = "Радиус верхнего правого угла"
"Transform" = "Трансформация"
"Triangle arrow" = "Треугольная стрелка"
@@ -1415,6 +1453,8 @@ name = "Русский"
"Update from selection" = "Обновить из выделения"
"Updated {components} and {styles}" = "Обновлено: {components} и {styles}"
"Updated {name}" = "Обновлено: {name}"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Часто помогает обновление драйвера видеокарты. Чтобы использовать только OpenGL, запустите FigCraft из командной строки с параметром:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Часто помогает обновление драйвера видеокарты. Переменная WGPU_BACKEND (vulkan, gl, metal) задаёт другой графический API, который можно попробовать."
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра."
"Uppercase" = "Прописные"
"Use a fixed value" = "Использовать фиксированное значение"
@@ -1424,6 +1464,7 @@ name = "Русский"
"Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами"
"Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig."
"Use the layer's fill" = "Использовать заливку слоя"
"Use your browser's zoom to change the interface size." = "Изменяйте размер интерфейса масштабом браузера."
"Used in {files} · {instances}" = "Используется в {files} · {instances}"
"Username" = "Имя пользователя"
"Value" = "Значение"
@@ -1458,6 +1499,7 @@ name = "Русский"
"Visibility" = "Видимость"
"Visible" = "Видимый"
"Vivid" = "Яркий"
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Переменная WGPU_BACKEND задана и указывает используемый графический API; если её убрать, FigCraft выберет сам."
"Warm" = "Тёплый"
"Watch" = "Часы"
"Wavy" = "Волнистый"
@@ -1477,6 +1519,7 @@ name = "Русский"
"White" = "Белый"
"Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов"
"Why it changed" = "Причина изменения"
"Widget" = "Виджет"
"Widgets" = "Виджеты"
"Width" = "Ширина"
"Width point" = "Точка ширины"
@@ -1574,6 +1617,7 @@ name = "Русский"
"nobody else here yet" = "здесь пока больше никого нет"
"non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены"
"now" = "сейчас"
"only in a text style or variable" = "только в текстовом стиле или переменной"
"or" = "или"
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя"
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "ключи предпочтительных компонентов слотов не удалось сопоставить со слоями файла, они пропущены"
@@ -1596,6 +1640,7 @@ name = "Русский"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены"
"slot content assignment target is missing or is not slot content" = "цель назначения содержимого слота отсутствует или не является содержимым слота"
"slot content assignment uses the empty GUID sentinel and was skipped" = "назначение содержимого слота использует пустой GUID-маркер и пропущено"
"slot layers without a matching slot property were imported as frames" = "слои слотов без подходящего свойства слота импортированы как фреймы"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "некоторые векторные сегменты ссылаются на настройки стиля, которых нет в файле; они используют обводку слоя"
"stale or invalid grid track sizing entries were ignored" = "устаревшие или недопустимые записи размеров треков сетки проигнорированы"
@@ -1682,6 +1727,7 @@ name = "Русский"
"{n} replies" = { one = "{n} ответ", few = "{n} ответа", many = "{n} ответов", other = "{n} ответа" }
"{n} rows" = { one = "{n} строка", few = "{n} строки", many = "{n} строк", other = "{n} строки" }
"{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" }
"{n} text layers" = { one = "{n} текстовый слой", few = "{n} текстовых слоя", many = "{n} текстовых слоёв", other = "{n} текстовых слоя" }
"{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" }
"{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" }
"{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" }
@@ -1690,6 +1736,7 @@ name = "Русский"
"{n}m" = "{n} мин"
"{n}w" = "{n} нед"
"{page}, nothing selected" = "{page}, ничего не выделено"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (текущий)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} — значение свойства; {property ? \"a\" : \"b\"} выбирает строку. Другие {…} остаются как есть."
"{server} can't say what became of your files (it is older than this app)." = "{server} не может сообщить, что стало с вашими файлами (версия сервера старее приложения)."
@@ -1732,6 +1779,7 @@ name = "Русский"
"arrange.front" = "На передний план"
"arrange.packH" = "Уплотнить по горизонтали"
"arrange.packV" = "Уплотнить по вертикали"
"arrange.reorder" = "Изменить порядок выделения"
"arrange.roundToPixels" = "Округлить до пикселей"
"arrange.spacing" = "Интервал"
"arrange.tidy" = "Упорядочить"
@@ -1766,6 +1814,7 @@ name = "Русский"
"collab.publish" = "Опубликовать на сервере"
"collab.recoverFile" = "Восстановить онлайн-файл"
"collab.registerLinks" = "Открывать ссылки приглашения в FigCraft"
"collab.removePerson" = "Убрать из сессии"
"collab.report" = "Пожаловаться на файл оператору сервера"
"collab.serverInfo" = "Информация о сервере"
"collab.share" = "Поделиться по коду подключения"
@@ -1944,6 +1993,7 @@ name = "Русский"
"object.batchRename" = "Массовое переименование"
"object.convertToFrame" = "Преобразовать во Frame"
"object.convertToGroup" = "Преобразовать в группу"
"object.convertToSection" = "Преобразовать в секцию"
"object.flipH" = "Отразить по горизонтали"
"object.flipV" = "Отразить по вертикали"
"object.frameSelection" = "Поместить выделение во Frame"
@@ -1990,6 +2040,7 @@ name = "Русский"
"plugin.ui" = "Панель плагина"
"plugins.manage" = "Управление плагинами…"
"plugins.runLast" = "Запустить последний плагин"
"pref.autoSave" = "Автоматически сохранять файлы"
"pref.highlightHover" = "Подсвечивать слои при наведении"
"pref.keepTool" = "Оставлять инструмент выбранным после использования"
"pref.language" = "Язык интерфейса"
@@ -1999,6 +2050,8 @@ name = "Русский"
"pref.reduceMotion" = "Уменьшить движение"
"pref.renameDuplicates" = "Переименовывать дубликаты слоёв"
"pref.showDimensions" = "Показать размеры объектов"
"pref.startOnHome" = "Начинать с главной"
"pref.uiScale" = "Размер интерфейса"
"pref.userName" = "Ваше имя в комментариях"
"proto.action.add" = "Добавить действие прототипа"
"proto.action.move" = "Изменить порядок действия прототипа"
@@ -2104,14 +2157,18 @@ name = "Русский"
"text.listIndent" = "Увеличить отступ списка"
"text.listOutdent" = "Уменьшить отступ списка"
"text.listStyle" = "Стиль списка"
"text.loadFont" = "Загрузить файл шрифта"
"text.maxLines" = "Максимум строк"
"text.missingFonts" = "Отсутствующие шрифты"
"text.numberList" = "Нумерованный список"
"text.onPath" = "Текст на пути"
"text.paragraphIndent" = "Абзацный отступ"
"text.paragraphSpacing" = "Интервал между абзацами"
"text.pathOptions" = "Параметры текста на пути"
"text.preview" = "Предпросмотр изменения текста"
"text.refit" = "Подогнать текст под шрифты"
"text.removeFromPath" = "Снять текст с пути"
"text.rescanFonts" = "Повторно найти установленные шрифты"
"text.set" = "Задать текст"
"text.setFeatures" = "Функции OpenType"
"text.setVariations" = "Оси вариативного шрифта"
+9 -2
View File
@@ -183,7 +183,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
let own_cursor = input(app, ui, &resp);
crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import"));
// A browser names no dragged file until the drop, so documents there still say "import".
let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document)));
crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") });
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
@@ -504,10 +506,15 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]).
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
// a dropped document opens as a file of its own, as on the home screen.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
for f in dropped {
let path = f.path().to_path_buf();
if crate::is_document(&path) {
crate::pending_open::open_dropped(app, &f);
continue;
}
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let (params, read) = if path.exists() {
+30 -1
View File
@@ -7,7 +7,7 @@ use egui::{Event, Key, Modifiers, Pos2, pos2, vec2};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::comments::{cluster, insert_mention, mention_matches, mention_query};
use crate::comments::{cluster, insert_mention, mention_matches, mention_query, start_draft};
use crate::present_tests::{Ui, id};
fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 {
@@ -41,6 +41,35 @@ fn comments(u: &Ui) -> Value {
u.app.inspect()["comments"].clone()
}
#[test]
fn switching_from_comment_to_move_closes_the_unposted_composer() {
let mut u = Ui::new(Session::new());
u.app.run("tool.set", json!({"tool": "comment"}));
start_draft(&mut u.app, 100.0, 80.0);
assert!(comments(&u)["draft"].is_array());
u.app.run("tool.set", json!({"tool": "move"}));
u.frame(vec![]);
assert!(comments(&u)["draft"].is_null());
}
#[test]
fn control_channel_tool_switch_closes_the_unposted_composer() {
let mut u = Ui::new(Session::new());
u.app.run("tool.set", json!({"tool": "comment"}));
start_draft(&mut u.app, 100.0, 80.0);
assert!(comments(&u)["draft"].is_array());
let invalid = json!({"command": "tool.set", "params": {"tool": "not-a-tool"}});
assert!(crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &invalid).is_err());
assert!(comments(&u)["draft"].is_array());
crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &json!({"command": "tool.set", "params": {"tool": "move"}}))
.unwrap();
u.frame(vec![]);
assert!(comments(&u)["draft"].is_null());
}
#[test]
fn mention_helpers() {
// The `@query` before the cursor.
+23 -4
View File
@@ -2,8 +2,9 @@
//!
//! - An instance: its main component (with a swap menu), variant dropdowns, property values,
//! the selected layer's overrides, and Reset / Detach / Go to main / Push changes.
//! - A component or component set: its properties (add, rename, default, delete) and, for a set,
//! the variant property matrix.
//! - A component or component set: its properties (add, rename, default, delete), Add variant
//! (a lone main component becomes a set) and, for a set, the variant property matrix.
//! - Several main components: Combine as variants.
//! - A layer inside a component: bind its visibility / text / instance to a property, or make a
//! frame a slot.
//! - A slot inside an instance (and an instance's slot properties): fill it with components
@@ -11,6 +12,7 @@
use egui::{Id, Sense, Ui, vec2};
use figcraft_doc::*;
use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
use figcraft_engine::components::{
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts,
variant_options, variant_props, variant_set,
@@ -27,7 +29,10 @@ use crate::widgets;
/// Entry point (called from the Design panel header for every selection).
pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let [id] = sel else { return };
let [id] = sel else {
combine_ui(app, ui, doc, sel);
return;
};
let Some(n) = doc.node(*id) else { return };
match &n.kind {
NodeKind::Instance { .. } => instance_ui(app, ui, doc, n),
@@ -57,6 +62,19 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No
}
}
/// Two or more main components selected: a button combines them into a component set.
fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if !can_combine_variants(doc, sel) {
return;
}
row(ui, 32.0, |ui| {
let label = crate::i18n::command("component.combineVariants", "Combine as variants");
if widgets::dropdown(ui, &label, 160.0, Some(Icon::ComponentSet)).clicked() {
app.run("component.combineVariants", json!({"ids": sel}));
}
});
}
/// A component's description: a multi-line field, saved when it loses focus.
fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) {
widgets::separator(ui);
@@ -365,7 +383,8 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
});
}
}
if is_set {
// A lone main component too: Add variant makes it a set of itself and the new variant.
if can_add_variant(doc, owner) {
row(ui, 32.0, |ui| {
if widgets::dropdown(ui, tr("Add variant"), 120.0, Some(Icon::Plus)).clicked() {
app.run("component.addVariant", json!({"id": owner}));
+1
View File
@@ -239,6 +239,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?;
let params = p.get("params").cloned().unwrap_or(json!({}));
let r = app.session.execute(cmd, &params).map_err(|e| e.to_string());
app.clear_comment_draft_after_tool_set(cmd, r.is_ok());
// Programmatic calls never open dialogs.
app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog."));
app.handle_ui_requests();
+2 -10
View File
@@ -180,19 +180,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
fn dropped(app: &mut FigApp, ctx: &egui::Context) {
let files = ctx.input(|i| i.raw.dropped_files.clone());
for f in files {
let path = f.path().to_path_buf();
if !crate::opens(&path) {
if !crate::opens(f.path()) {
app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file."));
continue;
}
// `opens` saw an extension, so there is a file name to tell the format by.
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy()}), None)
} else {
(json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
crate::pending_open::open_dropped(app, &f);
}
}
+50 -11
View File
@@ -10,6 +10,7 @@
use std::collections::HashMap;
use std::path::Path;
use chrono::Datelike;
use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_engine::Prefs;
use figcraft_format::thumbnail::{FileKind, Preview};
@@ -200,18 +201,35 @@ const MONTHS: [&str; 12] = [
tr_noop("Dec"),
];
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026".
/// (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) {
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
}
}
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". 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.
fn date(secs: u64) -> String {
// Days to a civil date (Howard Hinnant's days_from_civil, inverted).
let z = i64::try_from(secs / 86_400).unwrap_or(0) + 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 day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), 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 (year, dt) = utc_parts(days, sod, 0);
let (month, day) = (i64::from(dt.month()), i64::from(dt.day()));
let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m));
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
}
@@ -965,3 +983,24 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
arrows(ui, recents, files, 0, 1);
action
}
#[cfg(test)]
mod tests {
use super::*;
/// `date` keeps the exact "Mon D, YYYY" (UTC) output the hand-rolled days-from-civil code
/// produced. With no catalog loaded the month and format strings pass through in English.
#[test]
fn date_formats_seconds_since_1970_in_utc() {
assert_eq!(date(0), "Jan 1, 1970");
assert_eq!(date(951_782_400), "Feb 29, 2000");
assert_eq!(date(1_709_164_800), "Feb 29, 2024");
assert_eq!(date(1_741_046_400), "Mar 4, 2025");
// A year past chrono's own `NaiveDate` range (262143): the instant is moved into range by
// whole 400-year cycles and the real year restored.
assert_eq!(date(253_402_300_800), "Jan 1, 10000");
// Past chrono's year range, `u64::MAX` keeps the old hand-rolled output instead of falling
// back to the epoch.
assert_eq!(date(u64::MAX), "Nov 9, 584554051223");
}
}
+51 -4
View File
@@ -668,9 +668,8 @@ fn dropping_a_native_file_on_the_home_screen_opens_it() {
#[test]
fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() {
let dir = Temp::new();
let path = dir.path("Canvas import.figcraft");
let mut source = Session::new();
source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
let path = dir.path("Canvas import.svg");
std::fs::write(&path, r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10"/></svg>"#).unwrap();
let mut session = Session::new();
let target_uid = session.ds().unwrap().uid;
@@ -696,7 +695,55 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc
u.frame(vec![]);
assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front");
assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::<Vec<_>>(), [2, 1]);
let layers = |uid: u64| {
let doc = &u.app.session.docs.iter().find(|doc| doc.uid == uid).unwrap().doc;
doc.node(doc.pages[0]).unwrap().children.len()
};
assert_eq!((layers(target_uid), layers(other_uid)), (1, 0), "the drop lands in the tab that received it");
}
#[test]
fn dropping_a_document_on_canvas_opens_it_as_a_new_file() {
let dir = Temp::new();
let path = dir.path("Dropped.figcraft");
let mut saved = Session::new();
saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
let mut u = Ui::new(Session::new());
u.app.ui.home = false;
u.frame(vec![]);
let before = u.app.session.doc().unwrap().clone();
let s = drop_file(&mut u, &path);
assert!(has_text(&s, "Drop to open"), "dragging a document over the canvas says it opens");
let s = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&s, "Opening as a new file"), "the card says so too");
u.frame(vec![]);
assert!(u.app.ui.pending_opens.is_empty());
assert_eq!(u.app.session.docs.len(), 2, "a tab of its own");
assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()), "in front");
assert_eq!(u.app.session.docs[0].doc.as_ref(), &before, "the file that was open gets no pages added");
}
#[test]
fn a_document_dropped_on_canvas_without_a_file_on_disk_opens_as_a_new_file() {
// Every drop in the browser (#916).
let dir = Temp::new();
let saved = dir.path("Saved.figcraft");
Session::new().execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap();
let bytes = std::fs::read(&saved).unwrap();
let mut u = Ui::new(Session::new());
u.app.ui.home = false;
u.frame(vec![]);
let pos = u.app.canvas.rect.center();
let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes("Shared.figcraft".into(), bytes));
u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() });
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.pending_opens.is_empty());
assert_eq!(u.app.ui.last_error, None);
assert_eq!(u.app.session.docs.len(), 2, "a tab of its own");
assert_eq!(u.app.session.docs[0].doc.pages.len(), 1, "no pages added to the open file");
assert_eq!(u.app.session.docs[1].path, None, "with no save path made up for it");
}
#[test]
+2 -1
View File
@@ -27,7 +27,8 @@ pub struct Lang {
pub code: &'static str,
/// The language's own name for itself (the language picker shows it untranslated).
pub name: &'static str,
/// Machine-drafted, waiting for a native speaker's review (`[meta] status`).
/// A draft (`[meta] status` set): waiting for a native speaker's review, or reviewed and waiting for
/// a maintainer's approval.
pub draft: bool,
/// Index into [`LANGS`] of the catalog a regional one overlays ("es" under "es-419": it lists
/// only what differs), else 0 (English).
+4
View File
@@ -26,6 +26,10 @@ pub(crate) struct Harness {
impl Harness {
pub(crate) fn new(s: Session) -> Self {
// The one system font scan bumps the layout generation, which makes the canvas render
// again: scanned here, before the first frame, rather than by whichever test happens to
// run first while this one waits for idle (#333).
figcraft_text::scan_system_fonts();
Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }
}
+12
View File
@@ -229,6 +229,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let mut move_to: Option<(NodeId, String)> = None;
// Pages are dragged up and down the list to reorder them.
let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages");
let page_key = (uid, cur);
let scroll_to_cur = app.ui.pages_seen_page != Some(page_key);
let mut current_row_rendered = false;
// A long list scrolls in its own area, so the layers keep their room below it.
let max_h = pages_list_height(app.ui.pages_height, ui.available_height());
let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| {
@@ -238,6 +241,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
let sense = if filtering { Sense::click() } else { Sense::click_and_drag() };
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense);
if id == cur {
current_row_rendered = true;
if scroll_to_cur {
ui.scroll_to_rect(r, None);
}
}
if !filtering {
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
}
@@ -301,6 +310,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
reorder.finish(ui)
});
if current_row_rendered {
app.ui.pages_seen_page = Some(page_key);
}
// A drop outside the list's visible area (and the row gap at its edges) isn't on a page row,
// even one scrolled out of view.
if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) {
@@ -507,3 +507,59 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() {
// A panel too short for both keeps the three rows.
assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW);
}
/// Selecting a page that is far down the list automatically scrolls it into view.
#[test]
fn selecting_a_distant_page_scrolls_it_into_view() {
let mut s = Session::new();
let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
for _ in 0..40 {
s.execute("page.add", &json!({})).unwrap();
}
let pages = s.doc().unwrap().pages.clone();
let first = pages[0];
let last = *pages.last().unwrap();
s.execute("page.select", &json!({"id": first})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let mut u = Ui::new(s);
let layer = u.row_at(rect);
u.frame(vec![]);
// Select the last page.
u.app.run("page.select", json!({"id": last}));
for _ in 0..15 {
u.frame(vec![]);
}
assert_eq!(u.app.ui.pages_seen_page, Some((u.app.session.ds().unwrap().uid, last)));
let opened = pages_down_to(&mut u, layer.y - 60.0);
let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap();
assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})");
}
/// If the active page is filtered out by search, it is not marked as seen
/// until the filter is cleared and its row actually renders.
#[test]
fn filtered_out_active_page_is_seen_only_after_rendering() {
let mut s = Session::new();
let p1 = s.doc().unwrap().pages[0];
let p2 = id(s.execute("page.add", &json!({})).unwrap());
s.execute("page.rename", &json!({"id": p1, "name": "Alpha"})).unwrap();
s.execute("page.rename", &json!({"id": p2, "name": "Beta"})).unwrap();
s.execute("page.select", &json!({"id": p1})).unwrap();
let uid = s.ds().unwrap().uid;
let mut u = Ui::new(s);
assert_eq!(u.app.ui.pages_seen_page, Some((uid, p1)));
// Filter to only "Alpha", then select "Beta" (p2).
u.app.ui.page_search = true;
u.app.ui.page_filter = "Alpha".into();
u.app.run("page.select", json!({"id": p2}));
u.frame(vec![]);
// Beta was filtered out and not rendered, so pages_seen_page has not recorded it.
assert_ne!(u.app.ui.pages_seen_page, Some((uid, p2)));
// Clear filter: Beta renders now and is recorded.
u.app.ui.page_filter.clear();
u.frame(vec![]);
assert_eq!(u.app.ui.pages_seen_page, Some((uid, p2)));
}
+19
View File
@@ -215,6 +215,8 @@ mod variables_export_ui_tests;
#[cfg(test)]
mod variables_rename_tests;
#[cfg(test)]
mod variants_discovery_tests;
#[cfg(test)]
mod vector_panel_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
@@ -495,6 +497,8 @@ pub struct UiState {
pub page_filter: String,
/// The height asked for the pages list (`None`: a share of the panel).
pub pages_height: Option<f32>,
/// The document and active page last drawn in the pages list.
pub pages_seen_page: Option<(u64, NodeId)>,
pub expanded: BTreeSet<NodeId>,
/// The layer being renamed in the layers panel.
pub rename: Option<layers::Rename>,
@@ -667,6 +671,7 @@ impl Default for UiState {
page_search: false,
page_filter: String::new(),
pages_height: None,
pages_seen_page: None,
expanded: BTreeSet::new(),
rename: None,
rename_page: None,
@@ -831,9 +836,17 @@ impl FigApp {
}
}
/// Dismiss the composer after a successful switch away from the Comment tool.
pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) {
if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment {
self.ui.comments.draft = None;
}
}
/// Run a command, surfacing errors as a toast. Returns the result.
pub fn run(&mut self, id: &str, params: Value) -> Option<Value> {
let r = self.session.execute(id, &params);
self.clear_comment_draft_after_tool_set(id, r.is_ok());
self.handle_ui_requests();
r.map_err(|e| self.failed(id, &e)).ok()
}
@@ -1817,6 +1830,12 @@ pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG:
/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one.
pub(crate) fn is_document(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
+13
View File
@@ -853,6 +853,14 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
None
}
});
// Two or more main components combine into a component set. Judged on the selection
// the command acts on: a layer row right-clicked in a multi-selection passes only itself.
let combine = app
.session
.doc()
.ok()
.filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection()))
.map(|_| i18n::command("component.combineVariants", "Combine as variants"));
let mut entries: Vec<(&str, &str)> = if has_sel {
vec![
(tr("Copy"), "edit.copy"),
@@ -910,6 +918,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
{
entries.insert(at + 1, entry);
}
if let Some(label) = &combine
&& let Some(at) = entries.iter().position(|e| e.1 == "component.create")
{
entries.insert(at + 1, (label, "component.combineVariants"));
}
// A right-clicked ruler guide (selected, with no layers) can be removed.
if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {
entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);
+10
View File
@@ -32,6 +32,16 @@ pub struct ImportTarget {
pub name: String,
}
/// Queues dropped file `f` to open as a document of its own: by path, or by its contents when there
/// is no file on disk to read. Callers checked its extension, so there is a name to tell the format by.
pub(crate) fn open_dropped(app: &mut FigApp, f: &egui::DroppedFileHandle) {
let path = f.path();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let (params, read) =
if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(f, &name))) };
app.ui.pending_opens.push(PendingOpen { label: name, params, command: "file.open", target: None, read });
}
fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
let target = match pending.target {
Some(target) => target,
@@ -0,0 +1,111 @@
//! Combine as variants and Add variant where people look for them (#903), driven through egui
//! headless: the Design panel offers Combine for two or more main components and Add variant for
//! a lone one, and the canvas / layers context menu lists Combine only when it applies.
use egui::Pos2;
use figcraft_doc::{NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::live_tests::{flat, has_text, text_center};
use crate::present_tests::id;
const COMBINE: &str = "Combine as variants";
/// A session with lone main components named A, B and C side by side, and a frame. Returns
/// (session, [a, b, c], frame).
fn doc() -> (Session, [NodeId; 3], NodeId) {
let mut s = Session::new();
let mut comps = Vec::new();
for (k, name) in ["A", "B", "C"].iter().enumerate() {
let f = id(&s.execute("frame.create", &json!({"x": k * 160, "y": 0, "width": 120, "height": 40, "name": name})).unwrap());
comps.push(id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()));
}
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 200, "width": 60, "height": 60})).unwrap());
(s, [comps[0], comps[1], comps[2]], frame)
}
fn select(u: &mut Ui, ids: &[NodeId]) {
u.app.session.execute("select.set", &json!({"ids": ids})).unwrap();
u.frame(vec![]);
}
/// The text painted this frame.
fn shows(u: &mut Ui, s: &str) -> Option<Pos2> {
let shapes = flat(&u.frame_output(vec![]).shapes);
has_text(&shapes, s).then(|| text_center(&shapes, s))
}
fn click(u: &mut Ui, p: Pos2) {
u.button(p, true);
u.button(p, false);
}
fn set_of(u: &Ui, c: NodeId) -> Option<NodeId> {
let doc = u.app.session.doc().unwrap();
doc.node(c).and_then(|n| n.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. })))
}
fn undo_len(u: &Ui) -> usize {
u.app.session.ds().unwrap().history.undo.len()
}
#[test]
fn the_design_panel_combines_two_selected_components() {
let (s, [a, b, _], _) = doc();
let mut u = Ui::new(s);
select(&mut u, &[a, b]);
let at = shows(&mut u, COMBINE).expect("the Design panel offers Combine as variants");
let undo = undo_len(&u);
click(&mut u, at);
let set = set_of(&u, a).expect("combined into a set");
assert_eq!(set_of(&u, b), Some(set));
assert_eq!(u.app.session.selection(), vec![set]);
assert_eq!(undo_len(&u), undo + 1);
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
assert!(shows(&mut u, COMBINE).is_none(), "a set selected: nothing to combine");
}
#[test]
fn the_design_panel_offers_combine_only_when_it_applies() {
// The cases are the engine's (`can_combine_variants`); this pins the panel to it.
let (s, [a, ..], frame) = doc();
let mut u = Ui::new(s);
select(&mut u, &[a, frame]);
assert!(shows(&mut u, COMBINE).is_none(), "a component and a frame");
}
#[test]
fn the_design_panel_adds_a_variant_to_a_lone_component() {
let (s, [a, ..], _) = doc();
let mut u = Ui::new(s);
select(&mut u, &[a]);
let at = shows(&mut u, "Add variant").expect("a lone main component offers Add variant");
click(&mut u, at);
let set = set_of(&u, a).expect("the component became a set");
assert_eq!(u.app.session.doc().unwrap().node(set).unwrap().children.len(), 2);
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn the_context_menu_lists_combine_for_two_selected_components() {
let (s, [a, b, _], _) = doc();
let mut u = Ui::new(s);
// One component: no Combine, Create component as before.
select(&mut u, &[a]);
u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a]));
assert!(shows(&mut u, COMBINE).is_none());
assert!(shows(&mut u, "Create component").is_some());
u.app.ui.context_menu = None;
// Two components, right-clicked on one layer row (the menu gets only that row): judged on
// the selection, which is what the command combines.
select(&mut u, &[a, b]);
u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a]));
u.frame(vec![]);
let at = shows(&mut u, COMBINE).expect("the context menu offers Combine as variants");
click(&mut u, at);
let set = set_of(&u, a).expect("combined into a set");
assert_eq!(set_of(&u, b), Some(set));
assert!(u.app.ui.context_menu.is_none(), "the menu closes");
}
@@ -1,6 +1,5 @@
//! A canvas pan (middle button or Space + primary) ends when its button is released, even when the
//! release happens over a panel: returning to the canvas with no button held must not move the view.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2};
use figcraft_engine::Session;
@@ -1,6 +1,5 @@
//! File > Export frames to PDF: the menu asks where to save and writes the frames' PDF there,
//! one visible top-level frame per page.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
@@ -1,5 +1,3 @@
#![allow(clippy::unwrap_used)]
use egui::{Pos2, RawInput, Rect, Vec2, vec2};
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, NoServices};
+11
View File
@@ -0,0 +1,11 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod canvas_pan_release;
mod comment_open_other_page;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod file_menu_pdf;
#[allow(clippy::unwrap_used)]
mod guide_label_narrow;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod recent_file_names;
@@ -1,5 +1,4 @@
//! Recent file rows keep their full accessible name.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use egui::{Context, Id, Pos2, RawInput, Rect, accesskit::Role, vec2};
use figcraft_engine::Session;
+4 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -16,7 +16,8 @@ accounts).
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints |
| Component sets / variants, add variant, switch | done | |
| Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) |
| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) |
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
@@ -76,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
+4 -3
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (`.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -20,7 +20,7 @@ and prototype interactions are still unchecked there.
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -51,7 +51,7 @@ export limit, not a native document nesting limit.
|---|---|---|---|---|
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
| WebP | — | yes (ours) | | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged |
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
+4 -3
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) |
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) |
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
@@ -178,7 +178,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Impact:** Figma users notice the feel within minutes; it decides whether they stay.
- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md).
### 5. Localization: English, reviewed Russian, plus unreviewed drafts
### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts
- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese,
Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish
overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and
@@ -444,6 +444,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding |
| 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged |
| 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. |
| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. |
+4 -3
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -12,13 +12,13 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
| Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours |
|---|---|---|---|
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots and input fail at once until it is on screen (#313, #332, #720). Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
| Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA |
| Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |
| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged |
| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged |
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
+6 -3
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -37,7 +37,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
only the first preferred language counts, since that list also holds the languages added for a
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the
other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU,
tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
next OS language, never Simplified) or the browser's language on the web.
`pref.language {language}` sets it from the CLI, MCP and the control channel.
@@ -63,7 +64,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
labels of the right panel and the home screen's cards keep a long translation on one line with an
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
(right-to-left) layout of panels. CJK translations are shorter than English; the same ellipsis
(right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts
(no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis
and tooltip cover the few that don't fit (the sizing dropdowns).
- **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open
file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han,
@@ -120,6 +122,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding |
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog |
+1 -1
View File
@@ -1675,7 +1675,7 @@ other key next to `$step` fails the whole batch, which is rolled back. Only an o
value too) is read as a reference. A step that is itself a `command.batch` is passed on as written,
so the references in its steps are to its own steps, not the outer batch's.
The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks
The end-to-end tests in `crates/mcp/tests/it/agent_tasks.rs` are worked examples of realistic tasks
(auto layout, a card built in one batch with step references, components and overrides, gradients
and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten,
export, undo/redo, drawing with tools).
+54 -4
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; 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; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; 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 ~79%) · **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)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -16,8 +16,9 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
40–60 h, parallel by tool.
4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja,
ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language
plus native review.
ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
the entries added since each catalog was written, then the other key languages (Hindi,
Vietnamese, Arabic): 3–7 h per language plus native review.
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@@ -57,7 +58,7 @@ blockers.
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
| M10 Localization | ~39%: the 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 | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 |
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
@@ -73,6 +74,55 @@ blockers.
| 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 | System theme on first run (#262): the first frame follows the system theme, and going back to System from a manual theme works. 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-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). Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more