Compare commits

..
Author SHA1 Message Date
bflatastic fc7e55ad37 M11: a double-click a board object or a widget field takes, in a function of its own (Sonar) 2026-10-26 13:15:10 +07:00
bflatastic aa72206d42 Merge main into feat/widget-input
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
2026-10-26 12:53:17 +07:00
BFlat d3782fdc43 M12: board templates and tables (#1363)
* M12: board templates and tables

Five built-in board templates (retrospective, brainstorm, weekly planner,
flow chart, kanban board) placed as ordinary board objects in one undo step
(board.template, board.templates) from a toolbar picker with pictures, and
board tables made of auto layout frames (board.table, board.tableEdit; up to
50 x 20), typed into with a double-click and edited by row and column from
the bar over the selection. Docs: boards-parity, gap 24, ROADMAP, mcp.

* M12: board templates picker on egui 0.37

Id::new and Ui::id are deprecated in egui 0.37: the templates picker's area uses Id::unique, its buttons' interaction ids come from ui.scope_id(), and its test reads the board bar's area by Id::unique.
2026-10-26 12:52:39 +07:00
BFlat bfd6e5f83a Merge pull request #1359 from Silvertree2010/fix/h2744-color-to-css-rounds-alpha-to-2-d
Keep 8-bit alpha when printing colours with Color::to_css
2026-10-26 12:51:22 +07:00
BFlat ad2fc0c7cb Merge pull request #1362 from peda-cos/fix/srv9-fsync-files-dir
fix: fsync the files directory after creating a new files folder
2026-10-26 12:43:17 +07:00
bflatastic 280c4e3654 Merge main into feat/widget-input
# Conflicts:
#	ROADMAP.md
#	crates/plugin/src/manifest.rs
#	crates/ui-egui/src/canvas.rs
#	docs/gaps.md
#	docs/plugins.md
#	docs/roadmap.md
#	docs/target-app-parity.md
2026-10-26 12:36:07 +07:00
Pedro Monteiro 11cf6fb12a fix: fsync the files directory after creating a new files folder (#1093) 2026-10-26 02:34:04 -03:00
BFlat 4fad29a66e M12: board timer and voting sessions (#1361)
* M12: board timer and voting

A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the
time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on
everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes
show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document
properties and ballots as one entry per voter; neither is an undo step. A pill at the top right
of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs.

* M12: board timer and voting on egui 0.37

Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique.

* M12: board.vote's start and ballot changes in functions of their own (Sonar)
2026-10-26 12:28:59 +07:00
BFlat 26291aff2c M12: boards dot grid, re-attaching connector ends, curved connectors (#1249)
* M12: boards dot grid, re-attaching connector ends, curved connectors

Board pages draw a dot grid in the canvas render, after the page colour
and under the layers (figcraft_render::Dots), thinned out when zoomed out
and coloured from the theme's ink against the page. A selected connector
shows its ends as handles; dragging one onto an object re-attaches it,
onto empty canvas leaves it free (board.reattach, one undo step, also
over MCP). Connectors can be curved, and elbows go around the objects
they join when the short way would cross them.

* M12: boards dot grid: blending a dot in a function of its own (Sonar)
2026-10-26 11:52:16 +07:00
NetterSilverandClaude Haiku 5.5 eb73f021c6 Keep 8-bit alpha when printing colours with Color::to_css
Found while testing crates/color/src/lib.rs:198 (Color::to_css)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 05:37:27 +01:00
BFlat 33061d92fc Merge pull request #1287 from watchakorn-18k/thai-ui-fonts-and-catalog
M10: Thai UI fonts and a Thai draft catalog
2026-10-26 11:31:01 +07:00
BFlat edef2e2cde Merge pull request #1294 from AganzeFelicite/fix/bundled-font-installed-styles-1246
M7: offer the installed styles of an embedded font family (#1246)
2026-10-26 11:25:55 +07:00
bflatastic 245a8cca0d #1287: the Thai code-connect hint names ArkUI too 2026-10-26 11:16:51 +07:00
bflatastic b6d7d0a893 Merge main into #1287
# Conflicts:
#	crates/ui-egui/src/i18n.rs
#	crates/ui-egui/src/i18n_tests.rs
#	docs/localization-parity.md
#	docs/translating.md
2026-10-26 11:14:49 +07:00
bflatastic 573b43d645 #1294: its font test names no PostScript name, a field #1021 added 2026-10-26 11:13:14 +07:00
bflatastic 5ee983ef63 Merge main into #1294
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-26 11:12:25 +07:00
BFlat 8e79ffc3d7 Merge pull request #1021 from sarsonj/feat/sketch-import
M6: .sketch import (Sketch 48 and newer)
2026-10-26 11:10:16 +07:00
BFlat d203f8fdcf Merge pull request #698 from Dubblbindong/firma/fix/plugin-permission-to-save-a-file-86
feat(plugin): add saveFile permission with host-owned save dialog
2026-10-26 10:55:11 +07:00
BFlat 3a641026ad Merge pull request #1202 from culiulemon/main
Vector editing, property copy/paste, dev-mode assets, ArkUI codegen and shape-builder fixes
2026-10-26 10:53:13 +07:00
bflatastic 27d1089847 Merge main into #1021
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-26 10:43:34 +07:00
BFlat b107ddf882 Merge pull request #990 from midasdf/jp/kinsoku-jis
M4: CJK kinsoku completed to JIS X 4051
2026-10-26 10:41:08 +07:00
BFlat bcde334043 Merge pull request #1314 from denisg0mes/keep-images-past-budget
M7: don't save over a file whose images past the budget weren't loaded
2026-10-26 10:41:03 +07:00
bflatastic 334f6e536e M8: widget property menu on egui 0.37
Id::new and Ui::id are deprecated in egui 0.37: the widget bar's area uses Id::unique and its buttons' interaction ids come from ui.scope_id().
2026-10-26 10:37:22 +07:00
bflatastic 1b946a247f Merge main into #698
# Conflicts:
#	README.md
#	crates/ui-egui/locales/fr.toml
#	crates/ui-egui/locales/sv.toml
2026-10-26 10:37:14 +07:00
bflatastic 5bed282751 #1202: the Italian and Vietnamese code-connect hints name ArkUI too 2026-10-26 10:36:00 +07:00
bflatastic f0385ef089 Merge main into #1202 2026-10-26 10:34:05 +07:00
bflatastic 8ffc5e57e8 #1021: the Vietnamese drop hint names .sketch too 2026-10-26 10:33:06 +07:00
bflatastic 76b5c8da6b Merge main into #1021 2026-10-26 10:31:59 +07:00
BFlat 9e993c6a77 Merge pull request #1026 from sonth87/i18n-vi
i18n: add Vietnamese (vi) interface localization
2026-10-26 10:31:47 +07:00
bflatastic 7177f1c042 Merge main into #1021
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-26 10:29:24 +07:00
BFlat 4b33cc17cb Merge pull request #1299 from mojtabajafarzadeh/fix/share-key-and-large-file-publish
ui: server key field in the Share popover (plus a longer publish reply timeout)
2026-10-26 10:27:36 +07:00
bflatastic b1ddb2d5c2 Merge main into feat/widget-input 2026-10-26 10:27:09 +07:00
BFlat 660140ddc1 Merge pull request #984 from kler/swedish-ui-2026-10-19
i18n(sv): translate 38 new strings, use the agreed terms in 93
2026-10-26 10:25:56 +07:00
BFlat f93cdc769e Merge pull request #1340 from Silvertree2010/fix/1331-bug-report-figcraft-a-failed-bat
Name the failing step in batch command errors
2026-10-26 10:25:50 +07:00
BFlat 8b470eceaa Merge pull request #1275 from Ridzzz0Alam/fix/1062-render-bounds-depth
fix: Issue (#1062) render bounds stop at the depth the renderer draws.
2026-10-26 10:25:45 +07:00
BFlat f229d91ff2 Merge pull request #1243 from SusieeTheLinuxUser/fix/1239-wayland-clipboard
UI: Route text clipboard writes through egui (#1239)
2026-10-26 10:25:41 +07:00
bflatastic f291f89fbd Merge main into #990
# Conflicts:
#	ROADMAP.md
2026-10-26 10:24:45 +07:00
BFlat b2ac04bdf8 Merge pull request #1357 from storytold/land/1017
M9: make graphics-startup assertions independent of UI language (#1017)
2026-10-26 10:24:00 +07:00
bflatastic 02f15fa483 #1021: the Italian drop hint names .sketch too 2026-10-26 10:23:48 +07:00
BFlat f93deb965b Merge pull request #661 from AganzeFelicite/fix/adobe-fonts-macos-298
M7: find fonts activated through Adobe Fonts on macOS (#298)
2026-10-26 10:23:17 +07:00
bflatastic 6c4bb00c9e Merge main into #1021
# Conflicts:
#	crates/text/src/fontdb_tests.rs
2026-10-26 10:20:43 +07:00
BFlat e03ebccece Merge pull request #1209 from aryann2611/mobile-layout
M6: mobile layout (phones and portrait tablets)
2026-10-26 10:20:07 +07:00
BFlat 9df8075aeb Merge pull request #1019 from muamerETS/fix/988-toolbar-flyout-carets
M7: toolbar flyout carets get a wider target clear of their tool (#988)
2026-10-26 10:19:53 +07:00
bflatastic d0a165efaa Merge main into #1026
# Conflicts:
#	docs/translating.md
2026-10-26 10:15:28 +07:00
BFlat 3a845b9717 Merge pull request #703 from timrrrr/codex/cmd-resize-image-crop
M1: crop image fills on Command/Ctrl resize
2026-10-26 10:15:04 +07:00
BFlat 8a89d5c20e Merge pull request #607 from MMexicann/fix/outline-stroke-rings
M4: preserve stroke rings when outlining shapes
2026-10-26 10:14:58 +07:00
BFlat 5eefcfaaf6 Merge pull request #623 from ahmedhosnypro/fix/system-theme-linux
Fix the system theme mode always drawing light on Linux and the BSDs
2026-10-26 10:14:53 +07:00
BFlat 6713b0eae3 Merge pull request #921 from YarilaFox/fix/paste-image-into-selected-shape
M7: paste images into selected shapes
2026-10-26 10:14:48 +07:00
BFlat 3a9fed2c6a Merge pull request #681 from leandrodslv/i18n/fr-native-review
i18n(fr): translate the missing strings and review the draft catalog
2026-10-26 10:14:43 +07:00
BFlat 0e7cffb5e7 Merge pull request #1217 from Salah1St/feat/default-font
M7: FIGCRAFT_DEFAULT_FONT names the font new text starts in
2026-10-26 10:14:38 +07:00
BFlat 17596c6996 Merge pull request #1216 from Salah1St/fix/thai-ui-fallback
M7: Noto Sans Thai as a UI fallback when the build embeds it
2026-10-26 10:14:33 +07:00
BFlat 44a21e9b91 Merge pull request #1224 from CosmoNetinfo/add-italian-language
Add Italian (it) interface language
2026-10-26 10:14:10 +07:00
BFlat fea6a52c7d Merge pull request #1215 from Salah1St/fix/thai-text-fallback
M4: Thai faces in the text fallback list
2026-10-26 10:13:56 +07:00
bflatastic d9aa3eda87 Merge main into #1019
# Conflicts:
#	crates/ui-egui/src/toolbar.rs
2026-10-26 10:06:44 +07:00
bflatastic 1aed3d4915 Merge main into #990
# Conflicts:
#	ROADMAP.md
2026-10-26 10:03:33 +07:00
bflatastic 7c25399e4a Merge main into #661
# Conflicts:
#	ROADMAP.md
2026-10-26 10:03:15 +07:00
bflatastic b6c582396b #1287: drop the "Contents only" key main renamed 2026-10-26 10:01:26 +07:00
bflatastic 99435b72d7 Merge main into #1287
# Conflicts:
#	docs/translating.md
2026-10-26 10:01:26 +07:00
bflatastic 454ec26c11 #1299: the server key field's id uses egui 0.37's Id::unique 2026-10-26 09:41:10 +07:00
watchakorn-18kandClaude Sonnet 5.5 c7669d9d6d M10: cover Thai in the no-plural-forms test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015wxxFg3gana4zaUKnyirTC
2026-10-26 09:40:43 +07:00
bflatastic 3b56f18e8e Merge main into #1202
# Conflicts:
#	crates/engine/src/codegen.rs
#	docs/mcp.md
2026-10-26 09:21:24 +07:00
bflatastic eb16585ec2 Merge main into #698 2026-10-26 09:20:18 +07:00
bflatastic 27f3bf6730 Merge main into #703 2026-10-26 09:20:12 +07:00
bflatastic c6d43e7903 Merge main into #607 2026-10-26 09:20:09 +07:00
bflatastic 42ac4a50d2 Merge main into #623 2026-10-26 09:20:06 +07:00
bflatastic 0dd8bf591e Merge main into #921 2026-10-26 09:20:03 +07:00
bflatastic 5a9a45795a Merge main into #1209 2026-10-26 09:19:57 +07:00
bflatastic 6fe1618ed4 Merge main into #1017 2026-10-26 09:19:54 +07:00
bflatastic 2ca0ed9787 Merge main into #681 2026-10-26 09:19:51 +07:00
bflatastic a35dbf184f Merge main into #1217 2026-10-26 09:19:49 +07:00
bflatastic c2ccb7bb2c Merge main into #1216 2026-10-26 09:19:45 +07:00
bflatastic 83d664f26c Merge main into #1026 2026-10-26 09:19:42 +07:00
bflatastic 0f64dd280d Merge main into #1224 2026-10-26 09:19:39 +07:00
bflatastic 5cdd41a79f Merge main into #1215 2026-10-26 09:19:37 +07:00
bflatastic ec2ad1094d Merge main into #1294
# Conflicts:
#	ROADMAP.md
2026-10-26 09:16:31 +07:00
bflatastic 994ddfc47e #1299: the server key field shows dots, as a password field does 2026-10-26 09:15:55 +07:00
bflatastic a288fac725 Merge main into #1299 2026-10-26 09:15:54 +07:00
bflatastic 3bd905fbdf Merge main into #1243
# Conflicts:
#	apps/figcraft/src/services.rs
2026-10-26 09:15:53 +07:00
bflatastic 2fd59da54c Merge main into #1275
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-26 09:15:49 +07:00
bflatastic 8d52baa4de #984: Swedish joining a live session reads ansluta till 2026-10-26 09:15:43 +07:00
bflatastic 2a85a09cf9 Merge main into #984 2026-10-26 09:15:42 +07:00
bflatastic 6f1999c248 #1340: a failed step keeps its kind of error; only its message names the step 2026-10-26 09:14:25 +07:00
bflatastic c4a98342a6 Merge main into #1340
# Conflicts:
#	crates/engine/src/batch_step_ref_tests.rs
#	crates/engine/src/lib.rs
2026-10-26 09:14:13 +07:00
bflatastic 7a1b9d8414 #1021: a named style's optical size follows the text size only for .sketch imports
Existing text keeps the named instance's optical size, so documents render as before; the
Sketch import stores the size as the text's opsz value instead.
2026-10-26 09:12:40 +07:00
bflatastic 07a654959a Merge main into #1021
# Conflicts:
#	docs/file-format-parity.md
2026-10-26 09:12:39 +07:00
bflatastic df8edc6b8c #1314: refuse saving over the file only when save_to has a path (main's download has none) 2026-10-26 09:12:21 +07:00
bflatastic 1ec74401e6 Merge main into #1314 2026-10-26 09:11:52 +07:00
BFlat abc5105111 Merge pull request #1297 from Silvertree2010/fix/h2089-arrowhead-cap-disappears-when-an
Skip zero-length segments when finding arrowhead direction
2026-10-26 09:05:13 +07:00
BFlat 1172cfa444 Merge pull request #1324 from Silvertree2010/fix/h2487-end-arrowhead-missing-on-an-open
Fix missing end arrowhead when a closed subpath follows an open one
2026-10-26 09:05:08 +07:00
BFlat a2d4886b59 Merge pull request #1329 from Silvertree2010/fix/h2481-scale-tool-does-not-scale-per-po
Scale per-point corner radius of vector layers in transform.scale
2026-10-26 09:05:02 +07:00
BFlat f7d60ef460 Merge pull request #1315 from Silvertree2010/fix/h2365-scale-tool-transform-scale-does
Scale the path of text on a path in transform.scale
2026-10-26 09:04:57 +07:00
BFlat 4a48a70910 Merge pull request #1350 from dotCooCoo/fix/png-row-export-options
Docs: the PNG row names ignore overlapping layers and where include bounding box is offered
2026-10-26 09:04:50 +07:00
BFlat 49c2fa1263 Merge pull request #1339 from devius/ka-short-labels
i18n(ka): shorter Georgian for 13 labels that were cut off
2026-10-26 09:04:45 +07:00
BFlat 4fce30ceab Merge pull request #1346 from storytold/codex/egui-037-fig-followup-20261025
Keep synthetic Copy link commands inside the clipboard fence
2026-10-26 09:04:40 +07:00
BFlat 09d2d96588 Merge pull request #1353 from storytold/fix/sonar-new-code-refactor
refactor: clear SonarCloud new-code issues (S3776, S107) and exclude generated coverage tree
2026-10-26 09:04:36 +07:00
BFlat 3de687fe7c Merge pull request #1011 from lucasmartins-ai/feat/tailwind-output
Dev Mode: React + Tailwind page code and Tailwind theme export
2026-10-26 09:04:31 +07:00
BFlat 570348072e Merge pull request #1337 from Silvertree2010/fix/1336-bug-report-figcraft-images-with
Scale down images past 16384 px a side instead of drawing nothing
2026-10-26 09:04:25 +07:00
BFlat 7c16f311bd Merge pull request #1278 from Ridzzz0Alam/fix/1060-dash-count
fix: Issue #1060 Dash patterns too fine for their path draw solid instead of running out of memory
2026-10-26 09:04:21 +07:00
BFlat fdea151735 Merge pull request #1349 from dotCooCoo/fix/controller-buttons-wait-for-link-notice
M5: controller buttons wait while the link notice shows
2026-10-26 09:04:16 +07:00
BFlat 3c9fee8dbd build: one craft-libs rev for every craft-libs crate (495e597) (#1354) 2026-10-26 08:59:52 +07:00
BFlat 42d7c3c45f Merge pull request #1355 from storytold/fix/r3-followups
Review fixes for #1184, #919 and #1134
2026-10-26 08:59:44 +07:00
bflatastic 234a66458a #1026: Vietnamese drops "Contents only", which the export settings no longer use 2026-10-26 08:53:17 +07:00
bflatastic 4a07c6a485 #1224: Italian drops "Contents only", which the export settings no longer use 2026-10-26 08:53:00 +07:00
bflatastic 82ddbc8908 #1202: port the paint and effect row menus and the Inspect assets to egui 0.37 2026-10-26 08:45:41 +07:00
bflatastic d54186c9bf #1209: port the phone layout to egui 0.37 2026-10-26 08:44:25 +07:00
bflatastic 9c57ba1beb Merge main into #1202 2026-10-26 08:43:07 +07:00
bflatastic fa6c1bd3f4 Merge main into #1209 2026-10-26 08:41:46 +07:00
bflatastic 30ea80d541 Merge main into #1026 2026-10-26 08:41:31 +07:00
bflatastic 50a2d08087 Merge main into #1224 2026-10-26 08:41:28 +07:00
bflatastic b162ef884c #1134: the server memory note becomes a revision row and a line in docs/server.md
The paragraph above the ROADMAP stage line moves into the revision history; docs/server-memory-retention.md
is folded into the compaction bullet of docs/server.md.
2026-10-26 08:36:05 +07:00
bflatastic 08ce40fd88 #1134: name the snapshot checksum's place SNAP_HEADER - 4, not 13 2026-10-26 08:35:22 +07:00
bflatastic 3921556427 #919: the windows_slim_errors flag applies to every Windows GNU target, matching the compile check 2026-10-26 08:35:21 +07:00
bflatastic f124d907bc #1184: boolean operands use the rounded per-side strokes too
One side_strokes helper (square or rounded, as #1044 paints them) serves stroke_area and the boolean
operand outline, instead of a second, square-only copy; test for a rounded per-side border.
2026-10-26 08:35:20 +07:00
BFlat 57af418419 Merge pull request #1134 from F3R9/pr/server-memory-retention
M9: avoid unnecessary snapshot rewrites and duplicate buffers
2026-10-26 08:33:59 +07:00
BFlat 23b443978e Merge pull request #919 from YarilaFox/fix/windows-gnu-a11y-crash
M7: avoid Windows GNU UI Automation crash
2026-10-26 08:33:45 +07:00
BFlat 8b8eea2561 Merge pull request #1184 from saturngod/fix/boolean-stroke-outlines
Fix stroke-only boolean operand outlines
2026-10-26 08:33:31 +07:00
BFlat b3f06f6eca Merge pull request #1163 from switch87/fix/linux-symbol-fallback
M9: fall back to the Noto symbol and math fonts on Linux (#1156)
2026-10-26 08:33:05 +07:00
BFlat 2b23477544 Merge pull request #1035 from Nael-Nathanael/fix/752-alias-walk
M7: missing fonts: follow each font variable's aliases once (#752)
2026-10-26 08:32:52 +07:00
BFlat df1bdd0e7c Merge pull request #1010 from Ridzzz0Alam/fix/969-web-save-download
Web build: Save, Save as and Export arrive as downloads
2026-10-26 08:32:39 +07:00
BFlat c853432360 Merge pull request #989 from Ridzzz0Alam/fix/981-fig-layer-cap
Large `.fig` files keep their layers and their library links (#981)
2026-10-26 08:32:26 +07:00
BFlat c5b914ead0 Merge pull request #951 from Hamza0101/fix/fig-note-counts
fig import: a note says how much of the file it covers (#467)
2026-10-26 08:32:09 +07:00
BFlat 378ac56286 Merge pull request #1003 from kkilchrist/feat-994-batch-step-refs
command.batch: named step references (`as` / `$ref`) and paths in `field`
2026-10-26 08:31:53 +07:00
Dylan M. Taylor 831fa599f1 Revert "ci(sonar): exclude the generated coverage-archive tree from analysis"
This reverts commit 887676207a.
2026-10-25 21:25:39 -04:00
Dylan M. Taylor 887676207a ci(sonar): exclude the generated coverage-archive tree from analysis
CI unpacks a coverage archive containing a build-generated locales.rs (the
i18n table from crates/ui-egui/build.rs) into coverage-archive/. Scanning it
made almost all new code duplicated (new_duplicated_lines_density 67.8%).
2026-10-25 21:11:43 -04:00
Dylan M. Taylor 7dc5d3b377 refactor(mcp): group with_marker's cut sizes into a struct (SonarCloud S107)
The depth and wire-size numbers describing a cut subtree are now one Cut
value, so with_marker takes 5 parameters instead of 8. Output is unchanged.
2026-10-25 21:11:43 -04:00
Dylan M. Taylor 4fc0a39304 refactor(ui-egui): split style text_properties into helpers (SonarCloud S3776)
Each logical section of the text style editor (sample, font menu, style and
size row, line height and letter spacing, and the two menus) is its own
helper. UI calls keep their order; no behaviour change.
2026-10-25 21:11:43 -04:00
aiongg 8ae2e206fb chore(deps): safe dependency refresh (#1352) 2026-10-25 20:48:41 -04:00
dotCooCoo 1bac7367ba Docs: the PNG row's include bounding box applies to any layer from the commands
The Export section offers include bounding box for text layers, and
file.export and export.update take it for any layer.
2026-10-25 17:34:16 -07:00
dotCooCoo 7820825ea1 M5: controller buttons pressed with no frame shown are dropped
A controller button pressed while the player has no frame to show,
for example after the frame is deleted, no longer plays the
prototype's gamepad trigger once the frame comes back.
2026-10-25 17:09:48 -07:00
Denis Gomes 03f4e6e070 M7: images-past-budget guard: hard links, and script output over its input
Two gaps CodeRabbit found in the guard:

- `same_file` compared paths, plain and resolved. A second hard link to
  the file is a different path to the same data, so `convert`,
  `run --out` and Save As to the link passed the guard and truncated
  the file. It now asks the file system (same-file: device and inode on
  Unix, volume and file index on Windows). same-file was already in the
  lock through walkdir.

- `run --in X --out X` checked the tab the script left active. A script
  that ends on `file.duplicate` or `file.new` leaves a tab with no count
  of images left in X, so X was written over. The count is now taken
  when X is opened and checked against `--out` whatever tab is active;
  the active tab's own file is still checked as before.

The script check is a separate function so it can be tested without a
file past the 4 GiB budget.
2026-10-25 18:41:58 -04:00
Denis GomesandClaude Opus 5.5 c305e23ab4 M7: don't save over a file whose images past the budget weren't loaded
A native file whose images together pass IMAGE_BUDGET (4 GiB, or 20,000
images) opens without the images past it. They are intact in the file, but
saving it again wrote the archive from the images in memory, so every
save, autosave or `convert` onto itself deleted them for good. A 7.6 GiB
`.fig` converted to a 7.3 GiB `.figcraft` reopened with 1,312 of its 2,337
images: one save would have dropped the other 1,025.

Loading now tells images left out for the budget apart from damaged ones
(`Loaded::images_past_budget`). A tab opened or reverted from such a file
remembers the file, and saving over it is refused with a message that says
how many images would be lost and to save under another name; saving a copy
elsewhere works, and the original keeps its images. Auto-save skips the
tab instead of retrying and failing every minute. `figcraft-cli convert`
and `run --in X --out X` refuse to write over their input the same way.
The same file is matched by its resolved path, so `./a.figcraft` and
`a.figcraft` count as one (`file_access::same_file`).

The note for each image left out for the budget now says it stays in the
file, which can't be saved over without losing it. Damaged and missing
images keep the old wording: they can't be kept, and saving drops them as
before. The doc comment on report_lost_images said saving always drops
the images it reports, which is no longer true for budget ones, so it went.

This stops the data loss; it doesn't make such files editable in place.
Loading images on demand would, and is discussed in the issue this links.

Tests: the count and the note for both budgets; saving over the file
refused with the file unchanged, Save As then working, and saving back over
the original refused again; a snapshot encoded in the background refused
too; a tab without images left out saving as before. Checked by hand on the
7.3 GiB file with file.save, file.saveAs, convert and run --out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:31:47 -04:00
dotCooCoo 2581b0bfd9 Docs: the PNG row names ignore overlapping layers and include bounding box for text layers
The PNG row of docs/file-format-parity.md listed the export settings as
"contents only" and "include bounding box (the layer's own box)". Since
#1307 the Export section labels the first option Ignore overlapping
layers and shows Include bounding box for text layers. The row now uses
those names, says that ignore overlapping layers is on by default, and
gives include bounding box for text layers, as the help page lists
them. The status line gets the new date and topic, and the revision
history gets a row; the percentages and hours are unchanged.
2026-10-25 15:27:32 -07:00
Dylan M. Taylor d27061036f build: pin craft-update to craft-libs main (495e597) (#1348)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:52:27 -04:00
Dylan M. TaylorandCodebuff 0fb1f143cc build: pin craft-update to craft-libs main (495e597) (#1347)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <[email protected]>
2026-10-25 17:37:10 -04:00
Jindřich ŠaršonandClaude Opus 5.5 6258ad2786 M6: Sketch import: merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 22:23:40 +01:00
dotCooCoo 84d782c85b M5: controller buttons wait while the link notice shows
A controller button pressed while the notice before a website link
shows no longer plays the prototype's gamepad triggers, as keys do not.
Buttons play again once the notice closes.
2026-10-25 14:19:01 -07:00
Dylan M. Taylor 106ef69351 feat: startup update prompt modal and direct self-updating (#1328)
* feat: startup update prompt modal and direct self-updating

The update flow (check at start or on request, the startup prompt, the self-update worker) is craft-update's Updater; the app only supplies the suite, its close path and its status window. The check at start is opt-in for the desktop app, an install holds back while documents are unsaved, and the Windows update helper runs before argument parsing.

Ref: storytold/craft-libs#319

* fix(updates): re-add the update hooks to the 0.37 frame loop

* fix(updates): address review feedback

Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.

* fix(updates): show the prompt on Home and presenter frames and close the application, not one window

* fix(updates): add the update strings to the English catalog
2026-10-25 16:23:00 -04:00
skylartaylor b7a0d892d7 M14: cover synthetic Copy link clipboard isolation 2026-10-25 20:39:43 +01:00
skylartaylor abfdaa2436 M-egui037: retain one explicit session switch in the qualified regression 2026-10-25 20:14:50 +01:00
skylartaylor 48bea15abd M-egui037: base clipboard follow-up on the published squash merge 2026-10-25 20:14:42 +01:00
skylartaylor 985b9c1525 M-egui037: preserve clipboard fencing across the published integration 2026-10-25 20:14:25 +01:00
236f3185d6 Migrate FigCraft to egui 0.37 (#1312)
* E21: require release font coverage and stabilize capture deadline tests

* E21: separate capture, IME and field-save regression phases

* M0: correct the migration qualification reference

* M-egui037: document input lifecycle and UI regression contracts

* M0: correct egui 0.37 status docs and stabilize join-code regression

---------

Co-authored-by: Dylan M. Taylor <[email protected]>
Co-authored-by: Codebuff <[email protected]>
2026-10-25 15:04:39 -04:00
Dylan M. Taylor fc41889916 Merge origin/main into the egui 0.37 migration
Reconcile upstream: keep both roadmap entries, main's Copy as submenu marker and
copy-link toast, and the migration's explicit join-order switch in the live
session test.

Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-25 14:49:38 -04:00
NetterSilverandClaude Haiku 5.5 0a4e70fb97 Name the failing step in batch command errors
Fixes #1331

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 18:23:37 +00:00
Patrik LermonandClaude Opus 5.5 408b77e9ec i18n(sv): translate 38 new strings, use the agreed terms in 93
Brings the Swedish catalog up to date with upstream main (35da7b7c).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:17:45 +01:00
Davit MatchakhelidzeandClaude Opus 5.5 35cea3ef8c i18n(ka): shorter wording for labels that were cut off
Decided in the native-speaker review of the screens (all-t01).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-25 22:13:52 +04:00
NetterSilverandClaude Opus 5.5 d7da3f1bb1 Scale down images past 16384 px a side instead of drawing nothing
Fixes #1336

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:01:22 +00:00
skylar taylor-barrick c413651984 egui-037: integrate current main and preserve migration fixes 2026-10-25 18:11:22 +01:00
skylar taylor-barrick 4eb5224f02 M0: correct egui 0.37 status docs and stabilize join-code regression 2026-10-25 17:13:00 +01:00
Brandon Thomas 3fc005c7fe Merge pull request #1295 from Silvertree2010/fix/h2091-inserting-text-at-the-boundary-o
Keep style runs disjoint when inserting text at a run boundary
2026-10-25 12:11:24 -04:00
Brandon Thomas e1d23d8480 Merge pull request #944 from Silvertree2010/fix/h884-color-from-hex-accepts-several-l
Reject multiple leading '#' in Color::from_hex
2026-10-25 12:11:18 -04:00
Brandon Thomas f53001cd93 Merge pull request #931 from lukasz-sklad/community/polish-native-review
i18n(pl): native review, catch-up and terminology fixes
2026-10-25 12:11:13 -04:00
Brandon Thomas f215fc4f95 Merge pull request #954 from MMexicann/fix/cli-option-terminator
M0.9: allow literal dash-prefixed CLI filenames
2026-10-25 12:11:08 -04:00
Brandon Thomas 8436737129 Merge pull request #1310 from dotCooCoo/fix/collab-net-loopback-tests
M9: the hello deadline refuses a connection even when a read times out early
2026-10-25 12:11:03 -04:00
Brandon Thomas 0761a0e07a Merge pull request #1303 from marcoarnulfo/fix/dialog-last-folder
Dialogs start in the folder of the last file picked
2026-10-25 12:10:58 -04:00
Brandon Thomas 31b857433d Merge pull request #1242 from SusieeTheLinuxUser/fix/1220-thai-backspace
Text: Backspace removes Thai and other marks one at a time (#1220)
2026-10-25 12:10:52 -04:00
Brandon Thomas 610fe374c7 Merge pull request #1218 from Salah1St/fix/justify-zwsp
M4: justified text doesn't widen U+200B
2026-10-25 12:10:47 -04:00
Brandon Thomas 15aeff572b Merge pull request #1291 from watchakorn-18k/context-menu-copy-link
M8: Copy link to selection in the layer context menu
2026-10-25 12:10:42 -04:00
Brandon Thomas dfbba05318 Merge pull request #1144 from Silvertree2010/fix/1139-bug-report-figcraft-macos-contex
Keep context menu open after a same-frame trackpad tap
2026-10-25 12:10:37 -04:00
Brandon Thomas 97191e20c7 Merge pull request #1251 from storytold/fix/hover-lines-leave
Canvas: hover distances go when the pointer leaves the canvas
2026-10-25 12:10:32 -04:00
Brandon Thomas 3ec1197a8e Merge pull request #1276 from Ridzzz0Alam/fix/1150-section-labels
fix: Issue #1150 a section's title draws above the labels of frames inside it (#1150)
2026-10-25 12:10:27 -04:00
Brandon Thomas 01c17983fb Merge pull request #1326 from Silvertree2010/fix/h2485-number-field-rejects-a-unit-type
Accept a unit typed after a space in number fields
2026-10-25 12:10:21 -04:00
Brandon Thomas ff056176f8 Merge pull request #1323 from Silvertree2010/fix/h2483-component-code-uses-js-strict-mo
Rename component parameters that are strict-mode reserved words
2026-10-25 12:10:15 -04:00
Brandon Thomas 4b27ae4c77 Merge pull request #1317 from Silvertree2010/fix/h2376-svg-export-rounds-the-scale-of-a
Keep significant digits in SVG matrix scale coefficients
2026-10-25 12:10:09 -04:00
Brandon Thomas 132fdba12f Merge pull request #1296 from Silvertree2010/fix/h2093-code-export-prints-any-whole-num
Fix code export printing whole numbers of 2^63 or more as i64::MAX
2026-10-25 12:10:05 -04:00
Brandon Thomas 7df4ba4900 Merge pull request #1282 from Silvertree2010/fix/h1985-distribute-spacing-measures-the
Measure distribute span to the group's outermost edge
2026-10-25 12:10:00 -04:00
Brandon Thomas 1b8e65fb01 Merge pull request #1274 from Silvertree2010/fix/h1924-non-finite-percent-strings-accep
Reject non-finite percent strings for lineHeight and letterSpacing
2026-10-25 12:09:54 -04:00
Brandon Thomas 8cbdff697f Merge remote-tracking branch 'origin/main' into fix-1295
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-25 11:56:28 -04:00
Brandon Thomas 44a2091b42 Merge remote-tracking branch 'origin/main' into fix-944
# Conflicts:
#	crates/color/src/tests.rs
2026-10-25 11:56:28 -04:00
midasdf b3242d8eaf Merge origin/main into jp/kinsoku-jis 2026-10-26 00:28:52 +09:00
skylar taylor-barrick 69dc49e2d3 M-egui037: reconcile current main and document hidden-frame deadlines 2026-10-25 16:05:48 +01:00
NetterSilverandClaude Haiku 5.5 000c0e3d4e Scale per-point corner radius of vector layers in transform.scale
Found while testing crates/engine/src/cmd/arrange.rs:~560 (scale_node, the Vector branch calling VectorNetwork::scale); crates/doc/src/vector.rs:631 (VectorNetwork::scale)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 14:22:29 +00:00
skylar taylor-barrick b751271c2a M-egui037: document input lifecycle and UI regression contracts 2026-10-25 14:52:12 +01:00
Brandon ThomasandClaude Opus 5.5 d4a303d470 Release 0.11.2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:44:44 -04:00
Brandon ThomasandClaude Opus 5.5 149f59cd14 Web: build the browser wasm with opt-level z to stay under the 24 MiB gate
The 0.11.1 release's Web job failed: the wasm was 25,248,590 bytes, over packaging/web/package.sh's 25,165,824-byte limit. opt-level z brings it to 23,306,645 bytes (measured locally with the release's craft-fonts ref, wasm-bindgen 0.2.129 and wasm-opt -Oz).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:44:13 -04:00
Brandon ThomasandClaude Opus 5.5 22c15e3206 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:14:22 -04:00
Brandon ThomasandClaude Opus 5.5 4b76c407d5 macOS signing: keep the temporary keychain until the build has signed with it
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:14:05 -04:00
Brandon ThomasandClaude Opus 5.5 32b836fbc5 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:04:17 -04:00
NetterSilverandClaude Haiku 5.5 8e8314e273 Accept a unit typed after a space in number fields
Found while testing crates/ui-egui/src/field_math.rs:105 (Parser::factor, unit loop; end() at :109)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 11:35:08 +00:00
Jindřich ŠaršonandClaude Opus 5.5 23e3131e90 M6: Sketch import: .sketch files in recent files show their preview
A .sketch file opened from disk joins the recent files, where it had no
picture and a generic icon. FileKind::Sketch reads the preview.png Sketch
saves under previews/ (within the thumbnail caps) and gets the design-file
mark, like .fig. Test: sketch_files_show_their_preview_png.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:33:22 +01:00
Jindřich ŠaršonandClaude Opus 5.5 b8c120f30c M6: Sketch import: name .sketch in the file.open, file.import and MCP open_file docs
The command parameter docs (CLI `commands`, MCP list_commands, the Actions
palette) and the MCP open_file tool description listed .fig but not
.sketch; docs/mcp.md and docs/cli.md already did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:33:22 +01:00
Jindřich ŠaršonandClaude Opus 5.5 db1bc539b0 M6: Sketch import: offer FigCraft for .sketch on Windows and Linux too
Linux: the shared-mime-info package declares application/x-sketch (a zip,
*.sketch) and the desktop entry and AppStream metadata list it. Windows:
the installer registers a Figcraft.Sketch ProgId under .sketch's
OpenWithProgids and .sketch in figcraft.exe's SupportedTypes, never as
the default. Both hand the path to file.open, as macOS does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:25:07 +01:00
Jindřich ŠaršonandClaude Opus 5.5 c80801391f M6: Sketch import: offer FigCraft in Finder's Open With for .sketch
Info.plist declares Sketch's public type identifier (imported, so it is
known on a Mac without Sketch) and lists it as an Alternate, Viewer
document type: Sketch stays the default, FigCraft is offered. The app
already opened .sketch from an open-documents Apple event via file.open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:22:06 +01:00
Jindřich ŠaršonandClaude Opus 5.5 e712b359fc M6: Sketch import: merge main
Conflicts were only in the Change notes of docs/file-format-parity.md and
docs/target-app-parity.md: main's notes kept, the `.sketch` note added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 12:22:06 +01:00
NetterSilverandClaude Haiku 5.5 a107e62bcc Fix missing end arrowhead when a closed subpath follows an open one
Found while testing crates/pathops/src/points.rs:89-92 (Styled::arrowheads)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 12:13:55 +01:00
NetterSilverandClaude Haiku 5.5 142676191e Rename component parameters that are strict-mode reserved words
Found while testing crates/engine/src/component_codegen.rs:~226 (RESERVED list used by ident at :346)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 12:08:47 +01:00
skylartaylor fc344de33c M0: correct the migration qualification reference 2026-10-25 08:10:37 +01:00
NetterSilverandClaude Haiku 5.5 283c6de2b1 Keep significant digits in SVG matrix scale coefficients
Found while testing crates/svg/src/lib.rs:97 (num, used by matrix at :129 for the image pattern's patternTransform at :243-254)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 06:07:08 +00:00
skylartaylor a63aa1422f M-egui037: reconcile main and serialize screenshot commits with timeouts 2026-10-25 07:03:53 +01:00
aiongg 92682728e9 Follow-ups from the #1311 review: link notice clicks, variable scopes, corner tooltip (#1316)
* Prototype player: no clicks reach the prototype behind the link notice

* Selection colors: scopes filter the variable menu; the command binds as variable.bind does

* Style picker: color variables scoped to every selected layer's fill or stroke

* Corner button: named Corner smoothing on polygons and stars

* docs/gaps.md: known gaps from the #1311 review

* cargo fmt

* Style picker: clippy
2026-10-25 01:09:29 -04:00
skylartaylor 3d9c8279ec E21: reconcile current text, export and Dev Mode changes 2026-10-25 05:49:49 +01:00
skylartaylor d7c7986347 E21: separate capture, IME and field-save regression phases 2026-10-25 05:32:03 +01:00
skylartaylor 2ef3ebb45d E21: require release font coverage and stabilize capture deadline tests 2026-10-25 05:17:15 +01:00
NetterSilverandClaude Haiku 5.5 a6a8d7149c Scale the path of text on a path in transform.scale
Found while testing crates/engine/src/cmd/arrange.rs:~495 (scale_node, the Text branch). Compare crates/layout/src/lib.rs:136, which calls TextPath::scale on resize.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-25 03:52:14 +00:00
aiongg 2a3b7d4fa7 Merge pull request #1311 from aiongg/dotcoocoo-batch
Merge 13 open PRs (tested batch)
2026-10-24 23:34:17 -04:00
skylartaylor 836f1082bd egui037: qualify current figcraft integration 2026-10-25 03:54:01 +01:00
aiongg e44ef587e5 docs/mcp.md: a slice in a frame or group with contentsOnly (#1305) 2026-10-25 02:40:12 +00:00
aiongg d135620b9f Dev Mode: the Assets panel's Publish refuses too (#1277) 2026-10-25 02:40:12 +00:00
aiongg 32199cc8e8 Text on a path tool: carry fill and effect bindings as text.onPath does (#1298, #1301) 2026-10-25 02:40:12 +00:00
aiongg b5ed2913d7 Merge #1307 2026-10-25 02:21:44 +00:00
aiongg 86bcfaddb9 Merge #1305
# Conflicts:
#	docs/file-format-parity.md
2026-10-25 02:21:44 +00:00
aiongg b95a04dc43 Merge #1304
# Conflicts:
#	docs/prototyping-parity.md
2026-10-25 02:21:29 +00:00
aiongg ad40507450 Merge #1277 2026-10-25 02:21:19 +00:00
aiongg 7aab188389 Merge #1301 2026-10-25 02:21:19 +00:00
aiongg d31a43419d Merge #1298
# Conflicts:
#	docs/ui-parity.md
2026-10-25 02:21:19 +00:00
aiongg 855631759f Merge #1288 2026-10-25 02:21:08 +00:00
aiongg c104757f59 Merge #1286 2026-10-25 02:21:08 +00:00
aiongg cdf37f9fc9 Merge #1284 2026-10-25 02:21:08 +00:00
aiongg 90bbd6a6d4 Merge #1281 2026-10-25 02:21:07 +00:00
aiongg e9edc298aa Merge #1283 2026-10-25 02:21:07 +00:00
aiongg cff215a107 Merge #1273 2026-10-25 02:21:07 +00:00
aiongg 996d96580e Merge #1293 2026-10-25 02:21:07 +00:00
dotCooCoo 6e86e26d14 M9: the hello deadline refuses a connection even when a read times out early
On Windows a socket's read timeout counts whole milliseconds, so a read
bounded by the hello deadline can time out a moment before it.
`next_message` then took the timeout for a closed connection: the client
got no "no hello in time" refusal, and the socket closed only after the
2 s linger. `Duplex::read` now reads again until the deadline has passed.

Tests:
- `connections_without_a_hello_are_told_why_they_end` (new): twelve
  connections that never say hello each get the refusal. Failed 5 of 10
  runs without the fix.
- `pre_hello_lifetime` notes the end of the stream on a thread of its own
  instead of reading one message per 300 ms poke, where pongs queued
  ahead of the close delayed it.
- `quiet_guests_stay_and_silent_connections_are_dropped`: the guest takes
  only the host's keepalive. Its 600 ms idle timeout also bounded the wait
  for the welcome.
- `two_files_are_hosted_and_joined_independently`: the two joins race, so
  the file whose welcome arrived last is in front. The test checks that,
  then brings B to the front.
- `a_local_share_is_joined_by_its_code_and_edits_sync`: once the share
  stops, another program's host can take the freed port and refuse the
  code.
2026-10-24 19:00:42 -07:00
Dylan M. TaylorandClaude Sonnet 5.5 9f78875338 security: linear vector import, stroking and rounding, validation, and cyclic dev snapshots (#1306)
* security: linear vector import, stroking and rounding, validation and a cycle check for dev snapshots

Closing a subpath, rounding corners and chaining stroke runs each rescanned the whole
network per step, and Document::validate searched a parent's children per child. They now
update only the current subpath's last segment, rebuild each region loop in one pass, walk
per-vertex adjacency lists, and check edges against a once-built index. A dev snapshot whose
layers contain a cycle under the root is refused instead of reconstructed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(doc): address review findings for snapshot children and 2-segment rounded loops

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-24 21:17:36 -04:00
skylartaylor 99d66401d6 EGUI037: port FigCraft lifecycle, capture and text input to egui 0.37
Preserve current main and pin the reviewed fork dependency family.
Qualify bundled and system-font configurations, plugins and regression tests.
2026-10-25 02:31:42 +02:00
dotCooCoo 4f0649ad52 M7: the export settings name Ignore overlapping layers and offer Include bounding box for text layers only
The export setting's advanced options name the contentsOnly option
"Ignore overlapping layers" and offer Include bounding box only when
every selected layer is a text layer. A setting that already has Include
bounding box on still shows the option, so it can be turned off, and the
export still applies it. The saved keys and the contentsOnly and
includeBoundingBox params of export.add, export.update and file.export
are unchanged.

Every catalog's "Contents only" row becomes an "Ignore overlapping
layers" row.
2026-10-24 17:21:42 -07:00
dotCooCoo 9c20d06abd M7: with Contents only on, a slice in a frame or group exports only that container
A slice exported every layer of its page that showed in its area, whatever
its Contents only setting said. With Contents only on (the default), a slice
inside a frame or group now exports only that frame or group and the layers
in it. With it off, or for a slice on the page or in a section, the slice
exports everything in its area as before. The Export section's preview of a
slice follows its first export setting in the same way.
2026-10-24 15:53:33 -07:00
dotCooCoo ae57bf84d0 M5: a notice before a prototype's Open link leaves for a website
The player opened a prototype's Open link at once, also from an After
delay trigger with no click. The help center's Prototype actions page
says that the viewer is told they are leaving before an external
website opens, with a checkbox that skips this page on the current
device.

An Open link to an http or https address now shows a notice with the
link, Open link, Cancel and "Don't show this again". Open link opens
it; Cancel, the close button or Escape drops it. While the notice
shows, the prototype takes no keys, and the presenter's C, Shift+C and
F keys do nothing. When Open link is pressed with the box ticked, the
desktop app saves the choice with its preferences; the web build keeps
it until the page reloads. proto.linkNotice {show} turns the notice on
or off. A mail link opens at once, as before, also while the notice
shows. A second link to a website that fires while the notice shows is
dropped.
2026-10-24 14:10:41 -07:00
dotCooCoo 0978b44385 M4: text on a path takes a fill binding made through fills
The bindings that move with the path's fills and effects are now found with list_binding, which reads `fills` as the first fill's color, as style_sets in cmd/styles.rs does.
2026-10-24 14:02:50 -07:00
dotCooCoo 2c38768872 M6: a layer drag or eye and padlock sweep that ends in Dev Mode changes nothing
A drag or sweep started in Design and released after switching to Dev Mode leaves the layers as they were and shows the Dev Mode note.
2026-10-24 14:01:22 -07:00
Marco Arnulfo f4d3f796d6 Dialogs start in the folder of the last file picked (fixes #1290) 2026-10-24 23:00:08 +02:00
dotCooCoo e46825b1cb M4: Text on a path checks masks only for layers that give a candidate
The hover search now recurses into each child first and looks up its mask only when the child added a candidate, as layers_at in crates/doc/src/geometry.rs does.
2026-10-24 13:51:38 -07:00
dotCooCoo 949d6c2830 M4: text on a path keeps its effects when the path's effect style has none
A path linked to an effect style with no effects gave the text an empty effect list and the style link. The text now takes the path's effects only when there are any.
2026-10-24 13:51:32 -07:00
dotCooCoo 98d061df10 M4: text put on a path takes the path's fill and effects
text.onPath, and the Design panel's Put text on path button that runs
it, kept the text layer's own fill and effects when the text went onto
a path. The help center's Guide to text says that the vector object's
fill and effects are transferred to the text layer.

The text now takes the path layer's fills, with their fill style and
variable bindings, when one of them shows, and the path layer's effects,
with their effect style and bindings, when it has any. The text's own
bindings on those fields are dropped. When the path has no visible fill
or no effects, the text keeps its own.
2026-10-24 12:37:59 -07:00
Mojtaba Jafarzadeh 241b349d7d Merge branch 'main' of https://github.com/storytold/figcraft into fix/share-key-and-large-file-publish
# Conflicts:
#	contributors/people.toml
2026-10-24 22:50:16 +03:30
Mojtaba JafarzadehandClaude 726700aa2a contributors: add Mojtaba Jafarzadeh's real name
Co-Authored-By: Claude <[email protected]>
2026-10-24 22:35:07 +03:30
Mojtaba JafarzadehandClaude 9a006175c8 ui: server key field in the Share popover
A server with a key rejects publishes without one, but Preferences' server
address never holds a key. Add a "Server key" field to the Share popover's
Advanced section and pass it to collab.share so a keyed server can be used
from the popover without the Publish dialog.

Co-Authored-By: Claude <[email protected]>
2026-10-24 22:35:07 +03:30
Mojtaba JafarzadehandClaude 2a08ad3244 collab-net: wait longer for one-shot server replies
Publishing uploads a whole file, and a large project on a slow link takes
the server several minutes to receive and answer. The one-shot request
connections (hosting, the publish upload, a library, a plugin directory)
waited the session idle timeout (45s), so a large publish timed out with
"no answer from the server" before the server finished. Add a dedicated
request_reply_timeout (5m) for these connections, keeping the session idle
timeout unchanged.

Co-Authored-By: Claude <[email protected]>
2026-10-24 22:35:05 +03:30
NetterSilverandClaude Haiku 5.5 42fbe8f00c Skip zero-length segments when finding arrowhead direction
Found while testing crates/pathops/src/lib.rs:352 (path_end, used by arrowhead)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 18:52:03 +00:00
dotCooCoo d8900e1c16 M4: the Text on a path tool
Text went onto a path only with the Design panel's Put text on path
button (a text layer and a path selected together) or with text.onPath.
The Text on a path tool, in the Text flyout of the toolbar
(tool.textOnPath), shows the I-beam over a shape's or vector's path, and
a click there puts new, empty text on the path, starting at the click,
and starts typing. The text takes the path layer's place, transform,
box, constraints and effects, and its fills when one of them shows; the
path layer is removed. Ending the edit with nothing typed puts the path
back with no undo step.

The tool measures the distance on the page, so a layer scaled more one
way than the other, or shown smaller, is reached where it shows. Shapes
in front whose outline is away from the click do not hide a path behind
them, and paths that a mask or a clipping frame hides at the click are
skipped, as are locked layers and layers inside instances.

A double-click into a text layer that was just made no longer makes it
count as an existing layer, so leaving it empty records no undo step,
with this tool and with the Text tool.
2026-10-24 11:37:47 -07:00
NetterSilverandClaude Haiku 5.5 d9a1de91d3 Fix code export printing whole numbers of 2^63 or more as i64::MAX
Found while testing crates/engine/src/codegen.rs:112 (num)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 17:39:10 +00:00
dotCooCoo 27fc3cec81 Docs: list each file-format status topic once
The status line of docs/file-format-parity.md repeated eight topics,
from SVG/PDF export depth refusal (#208) to "last major". Each topic is
now listed once, and the boards entry closes its parenthesis.
2026-10-24 10:34:29 -07:00
Jindřich ŠaršonandClaude Opus 5.5 1d6382f2ad M6: Sketch import: list .sketch in figcraft-cli --help
The Files paragraph of the CLI usage text now names .sketch (Sketch 48 or
newer, best effort), as docs/cli.md already does (PR review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:30:18 +02:00
NetterSilverandClaude Haiku 5.5 544a29c93b Keep style runs disjoint when inserting text at a run boundary
Found while testing crates/doc/src/text.rs:592 (TextData::replace_range, with normalize at :573)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 17:17:22 +00:00
Jindřich ŠaršonandClaude Opus 5.5 87db9522b4 M6: Sketch import: merge main (Boards M12 and community fixes)
Conflicts resolved keeping both sides: `.sketch` stays in `is_document`
(now via `OsStr::to_str`); ROADMAP and the parity, roadmap and
file-format docs keep main's headers and rows with the `.sketch` notes
and history rows added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:13:03 +02:00
AganzeFelicite 86aad2bb6a M7: offer the installed styles of an embedded font family (#1246)
Release builds embed some families with only a few styles (Poppins
Regular, Italic and Bold), and the first font scan dropped every
installed face of such a family, so an installed Poppins SemiBold or
Medium was never offered. The first scan now keeps the embedded faces,
which still win for the styles they cover, and offers the installed
styles they lack beside them, as a rescan already did.

Closes #1246
2026-10-24 18:39:44 +02:00
AganzeFelicite c743f8ec1e Merge remote-tracking branch 'upstream/main' into fix/adobe-fonts-macos-298
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb.rs
#	docs/roadmap.md
2026-10-24 18:20:00 +02:00
dotCooCoo 954f9036da Docs: parity rows for mouse-trigger delays, outline options and missing fonts
Three parity rows still listed work that has landed on main. The
prototyping Triggers row listed delays on mouse enter, leave, down and
up as missing; they landed on 2026-10-13. Canvas & navigation's
remaining work in target-app-parity listed outline mode's Include
hidden layers and Include object bounds, which View > Outlines has.
The file-format Fonts row said missing fonts give warnings only; the
desktop app lists them in the Missing fonts window, and replacing them
is still missing.

Each file gets its status line and a revision-history row. The
percentages and hours are unchanged.
2026-10-24 09:19:45 -07:00
watchakorn-18k d1d3b3a86c M8: group the copy commands under Copy as in the layer context menu
Copy as CSS, SVG, PNG and Copy link to selection move into a Copy as
submenu, placed under Paste to replace, instead of four flat rows near
the bottom of the menu.
2026-10-24 23:12:11 +07:00
watchakorn-18k 09b28f9b2d M8: Copy link to selection in the layer context menu
Edit > Copy as > Copy link to selection (edit.copyLink) was only in the main
menu. Add it to the right-click menu on a layer, after Copy as PNG, labelled
with the command's own translated label.

The command now says "Link copied" when it copies, and copies nothing when
no layer is selected (it used to put figcraft://node/0 on the clipboard).
2026-10-24 22:34:35 +07:00
dotCooCoo ac399dbe73 M6: Dev Mode says why when a page or layer drag is refused
Page rows and layer rows take drags in Dev Mode again. A drag on either leaves the order as it was and shows the Dev Mode note, as a double click or the row menu already does.
2026-10-24 08:34:13 -07:00
watchakorn-18kandClaude Sonnet 5.5 42c7f89543 M10: keep Hangul first in a Korean UI when Thai is also needed
samples() reversed the whole script list for a Korean UI, which put the
Bengali and Thai entries ahead of Hangul. Swap Hangul and Kana instead,
so the order is Hangul, Han, Kana, then Bengali and Thai. A test covers
the mixed case.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cs5R9irCMt9rwfhpkwxvSn
2026-10-24 22:09:09 +07:00
dotCooCoo 41fe7d95d8 M1: the iOS button next to Corner smoothing sets 60%
The Corner smoothing row showed "iOS: 60%" as plain text. The help
page on corner radius and smoothing says to click iOS to set corner
smoothing to 60%. The row now has an iOS button that sets
cornerSmoothing to 0.6 on the selection in one undo step, with the
hover text "Set corner smoothing to 60%".
2026-10-24 08:01:53 -07:00
dotCooCoo 6fa00af2be Docs: components parity status line for the text style variables 2026-10-24 07:32:51 -07:00
watchakorn-18kandClaude Sonnet 5.5 f0764be987 M10: Thai UI fonts and a Thai draft catalog
Layer, page and frame names in Thai drew as tofu squares in the panels and
on the canvas labels: the UI loaded system faces for CJK and Bengali only.

- cjk.rs: Thai is a fifth script (U+0E00..U+0E7F). When a name, a typed text
  or the UI language needs it, the desktop app adds an installed Thai face
  (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya,
  Sarabun, Loma, Garuda, Waree). English users still load nothing; the web
  build leaves Thai out, like Bengali.
- locales/th.toml: machine-drafted Thai catalog (every UI string, 589
  command labels, the menu wordings), marked as a draft needing native
  review. i18n.rs: Thai has no plural forms.
- docs/translating.md and docs/localization-parity.md updated.

cargo test -p figcraft-ui-egui passes (662); cargo xtask i18n-report,
assets and layers are ok. cargo xtask wasm and clippy were not run
(no wasm32 target here; clippy -D warnings already fails on other files
with Rust 1.95).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cs5R9irCMt9rwfhpkwxvSn
2026-10-24 21:25:22 +07:00
dotCooCoo 811d599960 M4: corner smoothing on polygons and stars
Polygon and star outlines did not use their corner smoothing: the value
was stored, imported and exported, but `shape_path` drew their corners
as plain circular arcs, and the Design panel showed the Corner
smoothing field only for rectangles and frames. Rounded polygon and
star corners now take smoothing the way rectangle corners do. Each
corner keeps a circular arc over the unsmoothed part of its turn and
eases into both edges, and it starts (1 + smoothing) times the plain
corner's length from the vertex. When a smoothed corner does not fit
in half of its shorter edge, smoothing is reduced first, then the
radius, as for rectangles. The Individual corners button opens the
panel for polygons and stars too, with the Corner smoothing row and no
field per corner.
2026-10-24 07:22:36 -07:00
dotCooCoo 268c0ead11 M3: apply variables to a text layer's font family and font style
The Design panel's font family and font style menus offered no way to
bind a string variable; these bindings could be made only by commands
such as variable.bind. Each menu now starts with Apply variable, which
opens the variable picker on string variables for the selected text
layers. A bound family or style shows its variable's chip in place of
the menu. A click on the chip picks another variable, and its detach
button unbinds it. While a text is being edited (a caret or selected
characters), the menus have no Apply variable item and show the plain
dropdown. A pick applies to the selected characters, or to the whole
layer when no characters are selected, as before.
2026-10-24 06:08:17 -07:00
dotCooCoo 9f280cb2e0 M3: style variable pickers offer only variables scoped to the field
The variable picker opened from a style's field listed every variable of
the field's type, and the Fill and Stroke style picker's Color variables
listed every color variable, whatever their scopes. Both now offer only
variables scoped to the property, as the Design panel's pickers do. A
paint style's colors take variables scoped to a fill or to strokes, an
effect style's fields take effect variables, and a text style's fields
take the variables of their font property. Layout grid fields still take
any number variable. style.bind still binds a variable outside its
scopes, as variable.bind does.
2026-10-24 05:24:51 -07:00
NetterSilverandClaude Haiku 5.5 598a4a2867 Measure distribute span to the group's outermost edge
Found while testing crates/engine/src/cmd/arrange.rs:210 (distribute, also used by arrange.packH/packV)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 11:51:08 +00:00
dotCooCoo 337f9d72e0 M3: bind variables to a text style's fields from the style editor
The style editor bound variables to paint colors, shadow colors, blur
radii and grid sizes, but a text style's fields took variables only
through style.bind. The font and font style menus now start with Apply
variable, which opens the picker on string variables, and the size, line
height and letter spacing fields show a variable button on hover for
number variables, as the Design panel's number fields do. A bound field
shows its variable's chip in place of the value, as in the Design
panel. A click on the chip picks another variable, and its detach
button unbinds it.
2026-10-24 04:11:03 -07:00
Ridzzz0Alam 2baeff3452 M1: dash limit counts the pattern restarting at each subpath (#1060)
kurbo restarts the dash pattern at every MoveTo, so many short subpaths
with a pattern of tiny dashes before a long gap made ~1M dashes (5M path
elements) while the length-based estimate allowed them. stroke_center
now adds one pattern's length per subpath to the estimate: an upper
bound on the dashes kurbo makes.

Test: each_subpath_restarting_the_dash_pattern_counts_toward_the_limit
(5M elements before).

Reported by CodeRabbit on #1278.
2026-10-24 12:44:55 +02:00
Ridzzz0Alam 494387a6a8 M1: a dash pattern too fine for its path strokes solid (#1060)
stroke_center handed any dash pattern to kurbo::stroke, which makes one
outline per dash: a 0.01/0.01 pattern on a 10,000-unit line made 2.5M
path elements, and a long line with a tiny pattern ran out of memory.

The variable width stroke already drew solid past MAX_DASHES (20,000);
that estimate is now width::dashes_fit and stroke_center uses it too, so
both strokes follow one rule. The path length is only measured for
dashed strokes.

Test: a_dash_pattern_too_fine_for_its_path_strokes_solid (2.5M elements
on the old code).
2026-10-24 12:13:40 +02:00
dotCooCoo 29ab17c222 M6: Dev Mode refuses edits from dropped files, the Pages list and the Assets panel
In Dev Mode the menus, shortcuts, clipboard, canvas and layers panel
refuse design edits, but an image or SVG dropped on the canvas was
still imported. The Pages list still added, renamed, reordered,
duplicated and deleted pages. The Assets panel still placed instances,
swapped them on an Alt-drop, applied styles, changed Hide when
publishing, accepted library updates and added, removed or swapped
libraries.

All of these now go through dev_mode_refuses with the command they
would run, and show its note. A dropped document still opens as a file
of its own, and the drop overlay shows the note instead of "Drop to
import". A click in the Pages list still opens a page, and page rows
take no drags in Dev Mode.
2026-10-24 02:57:11 -07:00
Ridzzz0Alam 1cb5f403fc M1: a section's title draws above the labels of frames inside it (#1150)
boards() listed a section before its contents, so the label of a frame
near the section's top-left was painted over the section's title pill,
and label_at (last label wins) gave clicks on the pill to that frame.
A section now comes after everything inside it: its pill draws on top
and takes the click.

Test: a_sections_pill_is_above_the_labels_of_frames_inside_it (fails on
the old order).
2026-10-24 11:52:57 +02:00
skylartaylor 9825a4926a M2.1: migrate FigCraft to egui 0.37 and reconcile current main 2026-10-24 11:43:04 +02:00
Ridzzz0Alam 4da6a11bad M1: render bounds stop at the depth the renderer draws (#1062)
world_render_bounds and children_render_bounds recursed once per level
with no limit, so a crafted chain of ~20,000 nested groups overflowed the
stack and aborted the app on PNG/JPG/WebP export, rasterize, copy as PNG
and thumbnails. A stack overflow is an abort, not a panic, so the guard
can't catch it.

The walk now carries a depth and stops descending at 256, the depth
draw_layer draws to (MAX_EXPORT_DEPTH): bounds match what is drawn and
shallower trees are unchanged.

Test: render_bounds_stop_where_drawing_stops_on_very_deep_chains (aborts
on the old code).
2026-10-24 11:26:27 +02:00
NetterSilverandClaude Opus 5.5 10702311f3 Require a per-client secret to resume a collab client id (#1193)
Fixes #1054

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:26:27 -04:00
NetterSilverandClaude Opus 5.5 a80df8ed18 Count scalar kiwi fields against the decoded-value budget (#1188)
Fixes #1057

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:26:06 -04:00
NetterSilverandClaude Opus 5.5 35f5faa2e6 Check SVG nesting depth after DTD entity expansion (#1177)
Fixes #1081

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:25:43 -04:00
NetterSilverandClaude Haiku 5.5 8ab0de2690 Keep wrong join-code streak when a correct code is tried (#1174)
Fixes #1088

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:25:17 -04:00
NetterSilverandClaude Haiku 5.5 7bc594632d Cap layout.setTrack track count at MAX_TRACKS (#1168)
Fixes #1098

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:24:53 -04:00
NetterSilverandClaude Opus 5.5 9c0d1c19fe Restore the clipboard when a command fails and is rolled back (#1167)
Fixes #1100

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:24:29 -04:00
NetterSilverandClaude Haiku 5.5 8864e42dce Clear plugin settings when a plugin id is re-registered (#1159)
Fixes #1116

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:24:05 -04:00
NetterSilverandClaude Haiku 5.5 98298a3519 Validate image member names when reading .figcraft archives (#1148)
Fixes #1122

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:23:43 -04:00
NetterSilverandClaude Opus 5.5 7df328d1ca Clip Inside/Outside strokes by the shape's even-odd fill rule (#1253)
Found while testing crates/pathops/src/lib.rs:326-333 (stroke_outline_of, StrokeAlign::Inside and Outside arms calling binary(), which uses FillRule::NonZero at :82)

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-24 05:23:23 -04:00
NetterSilverandClaude Haiku 5.5 3a1ddf339c Convert text decoration colour in colour profile conversion (#1247)
Found while testing crates/doc/src/colors.rs:30 (TextStyle::for_each_color_mut, used by TextData, Style and Document::map_colors / set_color_profile)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-24 05:22:48 -04:00
43a255fe16 Clamp control-channel document.inspect depth to 64 (#1162)
Fixes #1115

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-24 05:17:25 -04:00
NetterSilverandClaude Haiku 5.5 2d1fabda1f Reject non-finite percent strings for lineHeight and letterSpacing
Found while testing crates/engine/src/cmd/props.rs:145 (line_height) and :153 (letter_spacing)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 11:10:23 +02:00
vetlucasmartins cd606ef8cd Merge remote-tracking branch 'upstream/main' into feat/tailwind-output 2026-10-24 10:05:51 +01:00
Dylan M. Taylor cb38f68c0c fix(security): linear docprops reordering and deduplicate restored deletion nodes (#1258)
* fix(security): linear docprops reordering and deduplicate restored deletion nodes

* style: format if-let condition
2026-10-24 04:45:01 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 3739b2863a fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity (#1264)
* chore: explicit test imports in engine, part 1 (Sonar S2208)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-24 04:41:31 -04:00
Dylan M. Taylor 0cca56984f fix(security): bound decoration steps, text-on-path elements, and rename expansions (#1257) 2026-10-24 04:39:12 -04:00
Dylan M. Taylor 25d48530a9 fix(security): owner-only recovery permissions, linear cli export suffixing, and web noopener (#1256) 2026-10-24 04:37:38 -04:00
Dylan M. Taylor 996bf871cd fix(security): prevent symlink traversal in asset walker, safe cert tmpdir, and atomic icon copy (#1255) 2026-10-24 04:36:46 -04:00
Dylan M. TaylorandClaude Sonnet 5.5 fdc4f74034 chore: drop redundant 'static lifetimes in consts (Sonar S8863) (#1254)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-24 04:28:06 -04:00
dotCooCoo b2cb613ef0 Contributors: add dotCooCoo (Robert Lee) 2026-10-24 01:10:25 -07:00
dotCooCoo ce8e9a7d60 M3: apply and detach color variables from Selection colors
A Selection colors row could take a color style but not a color
variable, and a row linked to a style or variable could not be
unlinked from there.

selection.bindColorVariable binds a color variable to every solid fill
and stroke slot a row stands for. It replaces a color style on that
list and any earlier binding of the slot. A text range with fills of
its own takes the variable's value in the layer's modes. Gradients and
slots outside the variable's scopes are refused.
selection.detachColor unlinks the style or variable of a row's slots
and keeps their colors.

In the Design panel, the Styles menu of an unlinked row lists the
document's color variables after its color styles, and a linked row
shows a Detach button in the Styles button's place.
2026-10-24 01:10:25 -07:00
bflatastic dbf57d30c2 Merge main into #1003 2026-10-24 14:21:15 +07:00
BFlat 70227e30fc Merge pull request #1009 from lucasmartins-ai/feat/data-fill
data.fill and text.fit: fill copies of a frame from rows of data
2026-10-24 14:17:14 +07:00
BFlat 127247433a Merge pull request #1004 from kkilchrist/feat-1001-vector-path
node.set: accept `path` to replace a vector's geometry
2026-10-24 14:17:06 +07:00
BFlat 448019c43a Merge pull request #1005 from kkilchrist/fix-992-reparent-index
node.reparent: index is the layer's final position
2026-10-24 14:16:56 +07:00
BFlat 6d91528d2a Merge pull request #915 from Hamza0101/fix/mcp-inspect-bound
MCP: inspect_document answers within what a client can read
2026-10-24 14:16:48 +07:00
BFlat 62e1e57626 Merge pull request #1006 from kkilchrist/fix-993-boolean-default-main
Show a boolean property's default on the main component's bound layers
2026-10-24 14:16:37 +07:00
BFlat a111638378 Merge pull request #1008 from lucasmartins-ai/feat/css-export-px
variable.export css: number lengths in px
2026-10-24 14:16:29 +07:00
bflatastic 0b3ce29eb3 Canvas: hover distances go when the pointer leaves the canvas 2026-10-24 14:14:37 +07:00
bflatastic 9f766dcbf7 Merge main into #1009 2026-10-24 13:47:22 +07:00
Muamer Mustafic 340d24955a M7: re-run CI (#988) 2026-10-24 08:37:51 +02:00
Muamer MustaficandCursor 7f18940c1d M7: toolbar flyout carets get a wider target clear of their tool (#988)
Co-authored-by: Cursor <[email protected]>
2026-10-24 08:37:51 +02:00
BFlat e3e39bbc4d M6: Dev Mode measurements kept on the canvas, a read-only layers panel (#1248)
* M6: Dev Mode measurements kept on the canvas, a read-only layers panel

Measurements placed between two layers, along a side or to the layer
around one (devMode.addMeasurement / removeMeasurement / measurements),
stored on the layer they start from (saved, synced, undoable; old files
open without them), drawn in Dev Mode as labelled redlines that follow
the layers and listed, placed and removed in the Inspect tab. In Dev Mode
the layers panel only selects and expands.

* M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar)
2026-10-24 13:33:26 +07:00
bflatastic 80bf75526b #1026: the localization page's revision history lists the Vietnamese catalog 2026-10-24 13:04:28 +07:00
bflatastic 0168d25d85 #1026: the intensity-width test skips labels egui's own font can't draw 2026-10-24 13:04:14 +07:00
bflatastic 030dffc2eb #1026: Vietnamese drops two strings the close dialog no longer uses 2026-10-24 13:02:42 +07:00
bflatastic a5afa004b4 #1224: Italian drops two strings the close dialog no longer uses 2026-10-24 13:02:41 +07:00
bflatastic a6949ec2e9 #1010: a zip export stops at 256 MB of files, which are all in memory at once 2026-10-24 13:02:34 +07:00
bflatastic b6489aba3b #989: .fig pages keep room for their top-level layers past large library copies 2026-10-24 13:02:25 +07:00
bflatastic 9b78ff8c4a #989: explicit imports, the .fig test helper named main_component 2026-10-24 13:02:24 +07:00
bflatastic d9d9e1d978 #1009: Dev Mode refuses data.fill, a design edit recorded as one grouped step 2026-10-24 13:02:11 +07:00
bflatastic a7b7dfe1c4 #1009: data.fill refused before it copies or writes in a read-only live file 2026-10-24 13:01:55 +07:00
bflatastic 35fada1161 #1009: data.fill and text.fit in smaller helpers (Sonar) 2026-10-24 13:01:48 +07:00
bflatastic 5376e4d25d #1009: explicit imports 2026-10-24 13:01:47 +07:00
bflatastic bc0f61eeee #1004: a size given with a new path still fixes its auto layout axis 2026-10-24 13:01:46 +07:00
bflatastic b00d12b623 #915: get_node refuses a layer too large for maxBytes on its own; a window only an offset made says it is paging 2026-10-24 13:01:33 +07:00
bflatastic b6f5c29d19 #915: MCP get_node pages past a child too large for the limit and keeps the truncation marker 2026-10-24 12:58:10 +07:00
bflatastic 91af3782e0 #915: the inspect response-limit tests join the MCP crate's one test binary (tests/it, as on main since #1208) 2026-10-24 12:57:58 +07:00
bflatastic 647e5f17d9 #1011: single-call Tailwind variables, rem only where it converts 2026-10-24 12:57:52 +07:00
bflatastic 9361b230b5 #1011: explicit imports, a method reference 2026-10-24 12:57:43 +07:00
bflatastic e1117d2a3c #1008: token_roles names its imports 2026-10-24 12:57:42 +07:00
bflatastic 6fd4194240 Merge main into #681
# Conflicts:
#	contributors/people.toml
#	crates/ui-egui/locales/fr.toml
2026-10-24 12:54:57 +07:00
bflatastic 565e0e8266 Merge main into #1217 2026-10-24 12:54:49 +07:00
bflatastic 7a7d71f904 Merge main into #1216 2026-10-24 12:54:42 +07:00
bflatastic 3e99f489e1 Merge main into #1026
# Conflicts:
#	docs/localization-parity.md
#	docs/translating.md
2026-10-24 12:54:30 +07:00
bflatastic 0c549a4147 Merge main into #1224 2026-10-24 12:54:27 +07:00
bflatastic c31f886c05 Merge main into #1215 2026-10-24 12:54:15 +07:00
bflatastic 82568faf3b Merge main into #1010 2026-10-24 12:54:07 +07:00
bflatastic 48a5e03348 Merge main into #954
# Conflicts:
#	ROADMAP.md
2026-10-24 12:54:04 +07:00
bflatastic a3509895d4 Merge main into #989 2026-10-24 12:54:02 +07:00
bflatastic 61855d362b Merge main into #951 2026-10-24 12:54:01 +07:00
bflatastic a0bd65f7b4 Merge main into #1009 2026-10-24 12:53:58 +07:00
bflatastic 13eba6a3ea Merge main into #1003
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-24 12:53:57 +07:00
bflatastic ede3c634ad Merge main into #1004 2026-10-24 12:53:55 +07:00
bflatastic 6a172e4e52 Merge main into #1005 2026-10-24 12:53:53 +07:00
bflatastic 1b41d129e6 Merge main into #915 2026-10-24 12:53:52 +07:00
bflatastic 7cd4c5216d Merge main into #1006 2026-10-24 12:53:50 +07:00
bflatastic be8991d6a8 Merge main into #1011 2026-10-24 12:53:49 +07:00
bflatastic ce32e07fe0 Merge main into #1008 2026-10-24 12:53:48 +07:00
bflatastic 3b384769e9 M8: widgets take text input and a property menu
Plugin API 4 (gap 6). A widget's text layer can be an editable field:
a double-click edits it like any text, and when editing ends the value
(cleaned, at most 1,000 characters) goes into the widget's state under
the field's name and its plugin redraws, as one undo step. A widget can
also carry a property menu of up to 8 actions, toggles and colour
choices, shown in a small bar over it while it's selected.

- widget.setText and widget.menu drive the same paths from scripts,
  the CLI and MCP; widget.insert/list also report fields and menu
- descriptions, values and stored menus are validated as untrusted;
  an API 3 plugin drawing a field or menu is refused, older hosts
  refuse API 4 manifests; no new host imports
- SDK reads the new events; the Counter example gains a label field
  and a menu (reset, steps of ten, a colour)
- tests for fields, menus, canvas editing, rollback, forged data and
  live sync; junk sweep extended; labels in every command catalog
- docs: plugins, mcp, gaps, target-app-parity, ROADMAP, roadmap
2026-10-24 12:29:28 +07:00
Salah1StandClaude Opus 5.5 84ca850d5f M4: justified text doesn't widen U+200B
Thai, Lao, Khmer and Burmese are written without spaces between words.
Tools that know the words insert U+200B ZERO WIDTH SPACE so lines can
break between them, and line breaking already treats it as a space. But
justification counted every space-class char as a gap, so a justified
paragraph got extra room at every U+200B: visible gaps between all its
words, which is not how these scripts are justified.

Item::stretches() (a space other than U+200B) is used for the justify
gap count, the trailing width and the pen advance. Line breaking is
unchanged. Test: on a justified line of words joined by U+200B and one
real space, the extra room goes to the real space only; with no real
space the line is laid out as if left-aligned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:15:57 +07:00
SusieeTheFemboyandGPT-6 Luna 8989e7b253 Text: Backspace removes Thai and other marks one at a time (#1220)
Co-Authored-By: GPT-6 Luna <[email protected]>
2026-10-24 06:27:49 +02:00
BFlat 3476f4b1e7 Merge pull request #1244 from storytold/batch/r2-1
Community batch r2-1: 63 reviewed PRs
2026-10-24 11:15:53 +07:00
bflatastic 9f60f5470d Batch r2-1 review: status lines and rows in ROADMAP, prototyping and UI parity 2026-10-24 10:31:31 +07:00
bflatastic 8af2efd1b9 Batch r2-1 review: U+2007 glues beside ideographs too 2026-10-24 10:31:29 +07:00
bflatastic d0db758684 Batch r2-1 review: SVG ids unique as written; a filled open curve closes for its fill 2026-10-24 10:31:26 +07:00
bflatastic 31d4c6eba4 Batch r2-1 review: per-side strokes follow smoothed corners for every alignment 2026-10-24 10:31:24 +07:00
bflatastic c45c693278 Batch r2-1 review: a report reason blank after cleaning is refused 2026-10-24 10:31:22 +07:00
bflatastic d114c6ed92 Batch r2-1 review: CLI export file names stay within 200 bytes 2026-10-24 10:31:19 +07:00
bflatastic 46b02d44e6 Batch r2-1 review: paste_nodes refuses two nodes mapped to one new id 2026-10-24 10:31:17 +07:00
bflatastic 5d7eb04d44 Batch r2-1 review: the hello and close-drain deadlines bound every read
Duplex::read_until gives a reader a deadline: each socket read waits at most what's left of it.
The server reads the handshake and hello under the hello deadline, so a hello trickled in
fragments can't outlast it; manage's close drains under CLOSE_DRAIN, so a silent server can't
hold it for the idle timeout.
2026-10-24 10:31:15 +07:00
bflatastic aac76ebeca Batch r2-1 review: Save on close waits a frame for a panel field to commit
A click on Save takes the keyboard from a focused panel field, which commits next frame: the files
save then, as the field-save shortcut does. OsStr::to_str as a method reference (SonarCloud).
2026-10-24 10:31:13 +07:00
bflatastic 3334ec6c16 Batch r2-1 review: vector_up in smaller helpers (SonarCloud cognitive complexity) 2026-10-24 10:31:10 +07:00
bflatastic 8dcba0684b Batch r2-1 review: .fig export grid sizing in helpers; instance layers clear inherited track sizes
auto_layout's grid branch moves to grid_layout and grid_sizing (SonarCloud cognitive complexity).
unset() clears gridColumnsSizing and gridRowsSizing, so an instance layer with equal tracks no
longer reads back its main layer's track sizes.
2026-10-24 10:31:08 +07:00
bflatastic bd80829a27 Batch r2-1 review: a paint keeps its own opacity through unbind and rebind
color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag:
binding a colour variable takes only that part out, unbinding puts the variable's alpha back in,
and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not.
2026-10-24 10:30:49 +07:00
a1a842af9a chore: resolve SonarCloud maintainability issues (#1241)
* chore: resolve SonarCloud maintainability issues

- Define SHA256_FMT constant in packaging script to remove duplicated string literal (shelldre:S1192)
- Remove redundant 'static lifetime annotations from const items (rust:S8863)
- Replace closures with method and function pointers (rust:S1612)
- Extract helper functions in synthetic document generation, bezier polyline flattening, and component restoration to reduce cognitive complexity (rust:S3776)

* fix(packaging): use verify_sha256 helper function to avoid shellcheck SC2059

* fix(engine): remove duplicate closing brace in large_document

* fix(engine): take &mut Document in restore_id_map; keep 'static on associated consts

restore_id_map allocates ids, so it needs a mutable document. Elided
lifetimes in associated consts trip elided_lifetimes_in_associated_constant,
so those annotations are restored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Assign positional parameters to locals in sha256 verification

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Dylan Taylor <[email protected]>
2026-10-23 23:28:13 -04:00
bflatastic 3b39f7cb3b #698: installing an API 4 plugin from a directory says it may offer files to save; the save strings in every catalog 2026-10-24 10:18:12 +07:00
bflatastic 551d2b9db6 #698: with plugin API 4 here, "4.0.0" is a valid api version string; the refused one is 5.0.0 2026-10-24 10:12:40 +07:00
bflatastic b35a4e85ab Merge main and the community batch into #698
# Conflicts:
#	README.md
#	crates/engine/src/cmd/file.rs
#	crates/plugin/src/manifest.rs
2026-10-24 10:12:35 +07:00
bflatastic 4babe5a04a #1209: everything that opens or closes the left panel does so on a phone too
UiState::open_left_panel / close_left_panel set the narrow window's panel along with left_collapsed:
the Assets panel (Shift+I), libraries, file and layer rename, page select, version history, the rail
tabs, the panel's Collapse button and panel.leftSidebar (which toggles what a narrow window shows).
Test: view.assets opens the left panel in a phone-sized window.
2026-10-24 10:11:02 +07:00
bflatastic a44bba56a0 #703: Cmd/Ctrl-resize crops a layer whose size was bound to a variable as well: the resize detaches the binding (#809), so the new size sticks 2026-10-24 10:09:28 +07:00
bflatastic caeab4e89e #703: an explicit size edit detaches a binding made under the short names w and h too, as it does width and height 2026-10-24 10:09:27 +07:00
bflatastic 8f51875700 #1209: the open panel button's icon uses accent_on, and frame() keeps its doc comment
The icon was painted accent_text on the accent fill (green on green); the inserted fn had taken
frame()'s doc line.
2026-10-24 10:08:50 +07:00
bflatastic ad6af0c489 Merge main and the community batch into #1209
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/toolbar.rs
2026-10-24 10:08:32 +07:00
bflatastic 86e39cc9c0 #703: a Cmd/Ctrl-resize crop whose coefficients aren't finite leaves the image fill as it is 2026-10-24 10:04:32 +07:00
bflatastic ea77e7c38f Merge main and the community batch into #703
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-24 10:03:58 +07:00
bflatastic 2912222c53 #607: the outline-stroke tests join the engine's one integration test binary (tests/it) 2026-10-24 10:02:53 +07:00
bflatastic 07b3daa738 Merge main and the community batch into #607
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
2026-10-24 10:01:40 +07:00
bflatastic 839e9fa4bc #623: the colour scheme watcher wakes the window on a change, reads GNOME's 0 (no preference) as light, and falls back to Read on portals before 1.17 2026-10-24 09:59:26 +07:00
bflatastic 92db01a808 Merge main and the community batch into #1017
# Conflicts:
#	ROADMAP.md
2026-10-24 09:57:35 +07:00
bflatastic e596272d5a Merge main and the community batch into #623
# Conflicts:
#	docs/target-app-parity.md
2026-10-24 09:54:39 +07:00
bflatastic 6595974a52 #921: an image layer copied in FigCraft pastes as a layer; another app's image fills a selected layer only when it is newer than our copy 2026-10-24 09:52:54 +07:00
bflatastic 77540d8c73 #921: a pasted image is added on top of the selected layer's fills; the others keep their variables 2026-10-24 09:52:53 +07:00
bflatastic 0f50e60e35 Merge main and the community batch into #921
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-24 09:50:13 +07:00
bflatastic 5186d83418 #1202: fi and nl drop the code-connect hint's old wording (its ArkUI wording is there) 2026-10-24 09:49:26 +07:00
bflatastic 1ed6447797 #1202: a double-click inside an open path's or a line's box doesn't edit it (only a closed shape has an inside) 2026-10-24 09:47:34 +07:00
aiongg c668e21759 PDF: write with craft-libs' krilla, which bounds decoded PNGs (#1240)
* PDF: write with craft-libs' krilla, which bounds decoded PNGs

* PDF: pin craft-libs' krilla at 9f75d3dc
2026-10-23 22:46:21 -04:00
bflatastic 20ffb1f844 #1202: an asset's chosen export format stays when the document changes 2026-10-24 09:43:07 +07:00
bflatastic a8363f062b #1202: look for empty canvas under the pointer only on a triple click 2026-10-24 09:39:53 +07:00
bflatastic 7fad345e48 #1202: builder_poly_tests.rs without a byte-order mark 2026-10-24 09:39:53 +07:00
bflatastic e52be58d46 #1202: drop the editor rules file (.trae) from the change 2026-10-24 09:39:53 +07:00
bflatastic 62a3072134 Merge main and the community batch into #1202
# Conflicts:
#	crates/engine/src/component_codegen.rs
#	crates/engine/src/tools.rs
#	crates/ui-egui/locales/fi.toml
#	crates/ui-egui/locales/nl.toml
#	crates/ui-egui/src/dev_mode_tests.rs
#	crates/ui-egui/src/shortcuts.rs
#	docs/dev-mode-parity.md
#	docs/file-format-parity.md
#	docs/ui-parity.md
2026-10-24 09:39:52 +07:00
bflatastic 1f9effd25d #660: a file saved as Proto.figcraft presents as Proto (titles follow file names on main) 2026-10-24 09:34:40 +07:00
Susiee The Linux UserandGPT-6 Luna c6dd115e78 UI: Route text clipboard writes through egui (#1239)
Co-Authored-By: GPT-6 Luna <[email protected]>
2026-10-24 04:28:02 +02:00
bflatastic f00b62ba2d Merge fix: the server store tests keep each test closed 2026-10-24 09:26:44 +07:00
bflatastic 9fb88b6668 Merge #904: M1: write grid track sizes in .fig export (#465) (@xiaou61)
# Conflicts:
#	docs/file-format-parity.md
2026-10-24 09:24:04 +07:00
bflatastic 489da09905 Merge #740: engine: fill rules as vector.fillRule and vector.reverseLoop (@yelintong) 2026-10-24 09:23:50 +07:00
bflatastic 550c3e8bbb Merge #905: M1: say that the automation roots reach only the headless side (#835) (@xiaou61) 2026-10-24 09:23:48 +07:00
bflatastic fac42785c1 Merge #1158: FreeBSD: package the aarch64 cross build (craft-common-actions#39) (@zuujiscom) 2026-10-24 09:23:45 +07:00
bflatastic ba8b608673 Merge #660: M5: one presentation tab per page of a file (@JimKnoxx)
# Conflicts:
#	docs/prototyping-parity.md
2026-10-24 09:23:43 +07:00
bflatastic 21a334b3ef Merge #976: fix(ui): keep the style picker open while editing a style's colour (@kollakek1) 2026-10-24 09:23:29 +07:00
bflatastic 075f5bfc98 Merge #1197: Regression test for #237 (@skylartaylor) 2026-10-24 09:23:26 +07:00
bflatastic 0a99f0fb69 Merge #1032: M8: save-format tests keep their temp folders apart (@MortenHusted) 2026-10-24 09:23:24 +07:00
bflatastic 2577f53e2c Merge #1212: Control server: wait after a failed accept instead of retrying at once (@oracola91) 2026-10-24 09:23:21 +07:00
bflatastic 53b8b531ce Merge #1031: Treat U+2007 FIGURE SPACE as non-breaking in line breaking (@Silvertree2010) 2026-10-24 09:23:18 +07:00
bflatastic 771a0060b6 Merge #1038: Keep literal true/false distinct from variables named true/false (@Silvertree2010)
# Conflicts:
#	crates/doc/src/proto_expr_parse_tests.rs
2026-10-24 09:23:15 +07:00
bflatastic 60e1ef9976 Merge #1041: Keep not <var> from reading back as a variable named 'not …' (@Silvertree2010) 2026-10-24 09:23:02 +07:00
bflatastic a5e8d256e6 Merge #1052: Drop XML-forbidden control characters from live-text SVG export (@Silvertree2010) 2026-10-24 09:23:00 +07:00
bflatastic 3610a7cb12 Merge #1142: Write a final Greek sigma in lower-case text layout (@Silvertree2010) 2026-10-24 09:22:57 +07:00
bflatastic 378c015eb3 Merge #1145: Remove all leftover temp files at server startup (@Silvertree2010)
# Conflicts:
#	apps/figcraft-server/src/store_tests.rs
2026-10-24 09:22:54 +07:00
bflatastic 53e61e2a5d Merge #1146: Guard file generation increments against u64 overflow (@Silvertree2010)
# Conflicts:
#	apps/figcraft-server/src/store_tests.rs
2026-10-24 09:22:42 +07:00
bflatastic 3774f9e51d Merge #1149: Bound the library/plugins close drain with a deadline (@Silvertree2010) 2026-10-24 09:22:28 +07:00
bflatastic 75c1586011 Merge #1153: Match widget data check depth to serde_json's recursion limit (@Silvertree2010)
# Conflicts:
#	crates/engine/src/plugins.rs
2026-10-24 09:22:12 +07:00
bflatastic 52afac49fe Merge #1164: Reject non-finite coordinates in control-channel ui.input (@Silvertree2010) 2026-10-24 09:21:48 +07:00
bflatastic f4541d8b24 Merge #1147: Restrict a pre-existing data directory to 0700 (@Silvertree2010) 2026-10-24 09:21:45 +07:00
bflatastic 70536a6265 Merge #948: Keep a solid paint's own opacity when binding a colour variable (@Silvertree2010) 2026-10-24 09:21:42 +07:00
bflatastic d93b806323 Merge #1151: Never drop Sync events when the live inbox is full (@Silvertree2010) 2026-10-24 09:21:39 +07:00
bflatastic ef6a6c49b2 Merge #1185: Make is_ancestor fail closed on deep or looping parent chains (@Silvertree2010)
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-24 09:21:37 +07:00
bflatastic 82004c6e9c Merge #1187: Cap the issue list returned by Document::validate (@Silvertree2010) 2026-10-24 09:21:24 +07:00
bflatastic 15fd9fe1bf Merge #1192: Strip bidi control characters from names and report reasons (@Silvertree2010)
# Conflicts:
#	apps/figcraft-server/src/store_tests.rs
2026-10-24 09:21:04 +07:00
bflatastic 6b37d779e0 Merge #1195: Accept a version string for the plugin manifest api field (@Silvertree2010) 2026-10-24 09:20:03 +07:00
bflatastic fed124fa77 Merge #1238: Fix duplicate SVG ids when a layer name matches a generated id (@Silvertree2010)
# Conflicts:
#	crates/svg/src/tests.rs
2026-10-24 09:20:00 +07:00
bflatastic f109945c47 Merge #1165: Make SVG unique_id linear for repeated layer names (@Silvertree2010) 2026-10-24 09:19:41 +07:00
bflatastic 404916a949 Merge #1169: Refuse library versions that leave no newer one to publish (@Silvertree2010) 2026-10-24 09:19:38 +07:00
bflatastic b5a8dcb059 Merge #1230: Honour even-odd fill rule when hit testing vectors (@Silvertree2010)
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-24 09:19:18 +07:00
bflatastic fcace33059 Merge #1178: Enforce per-address storage caps per IPv6 /64, /56 and /48 (@Silvertree2010) 2026-10-24 09:18:28 +07:00
bflatastic 45a3069dce Merge #1175: Keep the hello size limit until a connection is admitted (@Silvertree2010) 2026-10-24 09:18:25 +07:00
bflatastic f0a7037a53 Merge #1183: Enforce an absolute pre-hello deadline against ping slowloris (@Silvertree2010) 2026-10-24 09:18:22 +07:00
bflatastic d40ec98ef6 Merge #1182: Shut kicked and removed connections when their close can't be queued (@Silvertree2010) 2026-10-24 09:18:19 +07:00
bflatastic 00074d0041 Merge #1186: Stop stale next_id from making pastes overwrite existing nodes (@Silvertree2010) 2026-10-24 09:18:16 +07:00
bflatastic dc0ce6c176 Merge #1181: Refuse text.loadFont path for plugins (@Silvertree2010) 2026-10-24 09:18:13 +07:00
bflatastic 2a20a4de0a Merge #1190: Refuse non-local library paths in the library Cache (@Silvertree2010) 2026-10-24 09:18:10 +07:00
bflatastic e3c9d3f644 Merge #1191: Deny library.publishToServer to plugins (@Silvertree2010) 2026-10-24 09:18:07 +07:00
bflatastic 243728b758 Merge #1196: Don't run the expiry sweep from offline CLI commands (@Silvertree2010) 2026-10-24 09:18:03 +07:00
bflatastic c7062db387 Merge #1171: Never overwrite a file's log when it can't be read (@Silvertree2010) 2026-10-24 09:17:59 +07:00
bflatastic ad5d502200 Merge #1014: M7: keep keyboard focus inside the Interface size menu (@YarilaFox) 2026-10-24 09:17:56 +07:00
bflatastic f3782e6bc4 Merge #1023: fix(engine): show the Pen's Shift 45° step in its preview line (#970) (@vxMacDev) 2026-10-24 09:17:11 +07:00
bflatastic eada8b0ff3 Merge #1042: M3: style links given in a call apply after its values (@MortenHusted)
# Conflicts:
#	crates/engine/src/cmd/props.rs
2026-10-24 09:16:54 +07:00
bflatastic 90e5e03b23 Merge #1160: M0.9: figcraft-cli export names files as the app's export does (#1154) (@switch87) 2026-10-24 09:16:12 +07:00
bflatastic adc8e81826 Merge #1161: M0.9: --help and -h after a figcraft-cli subcommand print the usage (#1155) (@switch87) 2026-10-24 09:16:07 +07:00
bflatastic 15321a56ff Merge #1198: fill/stroke props: accept 'none' and null as no paint (@lileeei)
# Conflicts:
#	ROADMAP.md
2026-10-24 09:16:04 +07:00
bflatastic 1e4a31a303 Merge #1199: M3: instance-swap properties in the Dev Mode component playground (@marcoarnulfo) 2026-10-24 09:15:41 +07:00
bflatastic 6063d3e9a9 Merge #1189: M4: vector edit transform box around points of several layers (@marcoarnulfo)
# Conflicts:
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-24 09:15:11 +07:00
bflatastic 8f9e2fedc7 Merge #1047: M7: Type settings keeps its width with long labels (#1029) (@AganzeFelicite)
# Conflicts:
#	ROADMAP.md
2026-10-24 09:14:10 +07:00
bflatastic 07191c881d Merge #1030: M1: individual corner fields sit at their corners, each with its own icon (#1002) (@muamerETS) 2026-10-24 09:13:48 +07:00
bflatastic 8eb5624460 Merge #986: fix: = zooms in, matching - zooming out (#973) (@Ridzzz0Alam) 2026-10-24 09:13:43 +07:00
bflatastic f31199d4ca Merge #1018: M1: a selected unfilled shape drags from inside its box (#910) (@muamerETS) 2026-10-24 09:13:38 +07:00
bflatastic 370faa52da Merge #1033: M3: a variant switch takes the new variant's resizing unless the instance set its own (@MortenHusted) 2026-10-24 09:13:33 +07:00
bflatastic ab5a62387b Merge #1034: M6: SVG import fills open subpaths the way SVG does (@MortenHusted) 2026-10-24 09:13:28 +07:00
bflatastic 98040b48a5 Merge #1037: M3: a variable mode set on a layer inside an instance is an override (@MortenHusted) 2026-10-24 09:13:23 +07:00
bflatastic 83f840f802 Merge #1043: M3: a swapped nested instance keeps its outer main's overrides (@MortenHusted) 2026-10-24 09:13:18 +07:00
bflatastic d394e952c6 Merge #1044: M4: individual stroke weights follow the corner radius like CSS borders (@MortenHusted) 2026-10-24 09:13:13 +07:00
bflatastic ac5783d0cd Merge #1135: M5: prototype actions lay out what they change like the canvas does (@MortenHusted)
# Conflicts:
#	docs/prototyping-parity.md
2026-10-24 09:13:08 +07:00
bflatastic 293ab33eaa Merge #1166: M7: Cmd/Ctrl+S saves while a panel field has the keyboard (#1157) (@switch87) 2026-10-24 09:12:49 +07:00
bflatastic 90f401d73c Merge #1173: M8: the unsaved-changes question on close offers Save (#1170) (@switch87) 2026-10-24 09:12:45 +07:00
bflatastic 2053098e8e Merge #1200: The colour picker's eyedropper applies the colour to the paint the picker edits (Reported in storytold/vectorcraft#3342) (@marcoarnulfo)
# Conflicts:
#	crates/engine/src/tools.rs
2026-10-24 09:12:42 +07:00
bflatastic 2c9f5267a9 Merge #1232: M5: a top-level frame taller than the device scrolls without an overflow setting (@seltzdesign) 2026-10-24 09:11:46 +07:00
bflatastic 703d55cc67 Merge #983: Fix invisible dropdown options in the light theme (@Ridzzz0Alam) 2026-10-24 09:11:41 +07:00
midasdf 18832c620e Merge remote-tracking branch 'origin/main' into jp/kinsoku-jis
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-24 09:44:03 +09:00
zuujiscomandClaude Opus 5.5 5d9f8a53a3 Merge main into freebsd/aarch64-cross-package
Resolves the package.sh conflicts with main's SonarCloud cleanup: the cross-build changes are kept
and use main's style, with [[ ]] tests and release_arch taking its argument as a named local and
ending in an explicit return.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:06:00 -05:00
vetlucasmartins 7a2568fe55 tailwind: emit @theme static; fix assertion precedence in tests
Tailwind v4 drops theme variables no utility uses, but page code also
reads tokens through var() and (--name) utilities, so the export keeps
them all. Two test assertions mixed && and || without parentheses.
2026-10-24 00:50:53 +01:00
vetlucasmartins 788d1df7be Merge remote-tracking branch 'origin/feat/css-export-px' into feat/tailwind-output 2026-10-24 00:47:30 +01:00
vetlucasmartins 90455cc429 token roles: follow alias chains to their end
variable.set does not cap alias chains, so propagate until a pass adds
nothing instead of a fixed eight rounds.
2026-10-24 00:43:59 +01:00
vetlucasmartins 3734965cce Merge remote-tracking branch 'upstream/main' into feat/css-export-px 2026-10-24 00:41:08 +01:00
vetlucasmartins 3a3746d45d Merge upstream/main into feat/data-fill 2026-10-24 00:33:14 +01:00
vetlucasmartins 601a9701a5 text.fit: keep sizes that fit, respect the floor, skip one-word paragraphs
- Text that already fits keeps its exact size; shrunk sizes round to a
  tenth but never below minSize (per run, its share of it).
- A last line after a hard break is not an orphan, and the no-break space
  is only placed inside the last paragraph.
- docs: data.fill documents formats? and errors?.
2026-10-24 00:32:24 +01:00
vetlucasmartins cf4119dd0c Merge remote-tracking branch 'upstream/main' into feat/data-fill 2026-10-24 00:25:24 +01:00
NetterSilverandClaude Haiku 5.5 6494743b6e Fix duplicate SVG ids when a layer name matches a generated id
Found while testing crates/svg/src/lib.rs:549 (unique_id) with :162 (id) and :387

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 22:55:35 +00:00
ArminandClaude Opus 5.5 95db91def9 M5: a top-level frame taller than the device scrolls without an overflow setting
On a device, a presented frame bigger than the screen now scrolls through
its whole size whatever its overflow, as Figma documents for top-level
frames. Before, "No scrolling" frames only clipped, so the landing page
template's long Phone frame couldn't be scrolled in Present. Content past
a frame's own edges still scrolls only on the axes its overflow allows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:04:28 +02:00
BFlat 8167f13a6a M12: Boards: a collaborative whiteboard file kind (#1229)
Board files (File > New board) with sticky notes, shapes with text, connectors that follow what they join (straight or elbow, arrowheads, labels), marker, highlighter and eraser, and stamps; a board toolbar and a contextual bar; the Design panel hidden on boards, Prototype and Dev Mode refused there. Boards are ordinary FigCraft files built from ordinary layers, so saving, undo, export, MCP and live sessions work unchanged. File tabs, recent files and Open files show a pen nib for design files and a note for boards (original icons drawn in code).
2026-10-24 04:18:55 +07:00
Dylan M. Taylor a4f25b0733 ci(sonar): don't cancel superseded runs on main, so coverage is published (#1231) 2026-10-23 17:01:52 -04:00
BFlat 17ed21d128 M6: Dev Mode tail: imports in layer trees, compare with versions, an inspect-only canvas (#538)
Import lines for connected components inside generated layer trees; Compare changes against any saved version; a Dev Mode behaviour suite with an inspect-only canvas (menus, shortcuts, paste and Revert to saved refuse design edits; a drag under way is dropped on entering), Changed in the ready list, statuses only on top-level frames and sections; read-only plugins' panels from the Inspect tab.
2026-10-24 02:56:55 +07:00
NetterSilverandClaude Haiku 5.5 068634b586 Honour even-odd fill rule when hit testing vectors
Found while testing crates/doc/src/geometry.rs:75 (hits_node_itself, the `_` arm, using path.winding(lp) != 0); compare VectorNetwork::fill_contains at crates/doc/src/vector.rs:321

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 21:53:25 +02:00
aiongg 70f702ca17 i18n: a missing translation fails no check; cargo xtask i18n-report lists them (#1228) 2026-10-23 15:43:21 -04:00
midasdf 04e60e23bc Merge remote-tracking branch 'origin/main' into jp/kinsoku-jis
# Conflicts:
#	ROADMAP.md
#	docs/target-app-parity.md
2026-10-24 04:11:13 +09:00
BFlat d6e8a6e874 Docs: roadmap entries for the community PRs in #507, #510, #529 and #530 (#587)
Roadmap, milestone and parity rows for the 93 community PRs landed in batches #507, #510, #529 and #530.
2026-10-24 02:02:19 +07:00
aiongg 87e0c8a7da CI: shared gate workflow; FreeBSD nightly and on release (#1206)
* CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release

ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.

* CI: build the shared gate without craft-fonts, as before

* CI: build the shared gate with release.yml's craft-fonts pin
2026-10-23 14:31:08 -04:00
saturngod 95d4a1e6ce Merge commit '6ba02236367320fef0b373b461f6e9796bfc6de0' into fix/boolean-stroke-outlines 2026-10-23 13:02:40 -05:00
saturngod 690f31719d Merge commit 'c48102350b1ef00614b105ac07451ff0ca8a5141' into fix/boolean-stroke-outlines 2026-10-23 12:51:13 -05:00
CosmoNetinfoandClaude Sonnet 5.5 98b90b3c56 Add Italian (it) interface language
Adds crates/ui-egui/locales/it.toml: all 1747 UI strings (42 plural entries,
one/other), 574 command labels and 31 menu wordings, marked as a draft awaiting
native review like the other machine-drafted catalogs. Adds a resolve/plural
test for Italian locales and lists Italian in docs/translating.md.

Not compiled locally; the catalog was validated with a script (same keys as
en.toml, identical placeholders and ellipses per entry).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fr2xzM8A8RiUwZWddELBCt
2026-10-23 16:49:31 +02:00
Salah1StandClaude Opus 5.5 f728b8f9fe M7: test that text.create and the Text tool start in DEFAULT_FONT
Review: the doc test serialised TextStyle directly. The engine test makes
text with text.create (no fontFamily) and with a Text tool click, and
checks each starts in figcraft_doc::DEFAULT_FONT and writes it with the
text. Passes with FIGCRAFT_DEFAULT_FONT unset and set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:07:40 +07:00
Salah1StandClaude Opus 5.5 dd37c1763c M4: the Thai fallback test checks the characters it draws
Review: the test chose its Thai face, and checked each glyph's face, by
U+0E01, which the sample doesn't contain. It now asks for a face that has
every Thai character of the sample, and checks that each glyph's face has
the characters of its cluster.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:36:24 +07:00
Salah1StandClaude Opus 5.5 c4be9c1a34 M7: say what a default font family in files does and doesn't promise
Review: a saved family renders only where that family is available. The
docs and the test's comment now say the variable changes only what new
text starts in, and that a file keeps its fonts in a build with another
default, falling back like any missing family elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:34:43 +07:00
oracola91 42502a81d7 Control server: wait after a failed accept instead of retrying at once
The accepting thread read listener.incoming().flatten(), which drops a
failed accept and asks again at once. While accepting keeps failing, for
example when the process has run out of file descriptors, that thread
kept a core busy. It now waits 100 ms after a failed accept. The loop goes
through a small accepted helper so the wait can be tested.
2026-10-23 11:59:36 +03:00
Marco Arnulfo 2a6e460a7b Let the box of another layer's points win over a region of the active one
With points selected only on another edited layer, a press inside the
combined box over an unselected filled region of the active layer picked
the region instead of dragging the box. Other layers' selections now count
in the same priority check; a press on an already selected region still
drags the regions.
2026-10-23 10:17:56 +02:00
aryans2611andClaude Sonnet 5.5 ea03f90aaf M6: mobile: test that the toolbar pill fits a phone canvas; rustfmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 13:09:25 +05:30
Salah1StandClaude Opus 5.5 b649035695 M7: FIGCRAFT_DEFAULT_FONT names the font new text starts in
TextStyle::default() hard-coded Inter, which has no Thai (nor Hebrew,
Devanagari, ...). In a build for users who mostly write such a script,
every new text layer started in a font that can't draw what they type:
fallback faces hide the boxes, but the layer still says Inter and gets
Inter's metrics and line height.

Like FIGCRAFT_DEFAULT_SERVER, a build variable read with option_env!
names another family; unset or empty means Inter. font_family is always
written, so files open in the same font whichever build reads them.
docs/releasing.md gets a "The default font" section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:28:29 +07:00
Salah1StandClaude Opus 5.5 a51d3eed53 M7: Noto Sans Thai as a UI fallback when the build embeds it
In the web build, Thai layer names, frame labels on the canvas, text in
property fields and the layer search were drawn as missing-glyph boxes,
even when the build embedded Noto Sans Thai: the UI's built-in fallback
list (BUILT_IN in theme::install_fonts) didn't name it, and the browser
has no system fonts to fall back on.

Noto Sans Thai joins BUILT_IN. It is used only when the face is
embedded, so builds without it are unchanged. Test: with it embedded,
the UI fonts draw Thai, marks included (skips without it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:27:21 +07:00
Salah1StandClaude Opus 5.5 1340e32b16 M4: Thai faces in the text fallback list
Thai typed into a layer whose font has no Thai glyphs (Inter, which new
text starts in) was drawn as missing-glyph boxes, on the desktop and on
the web, even with a Thai face embedded or installed: FALLBACK_FAMILIES
listed Latin, CJK, Hangul, Arabic and symbol families but no Thai one.

Noto Sans Thai, Noto Sans Thai UI, Leelawadee UI, Thonburi and Tahoma go
after Noto Sans Arabic; they are only consulted for characters the
layer's own font lacks. Tests: the list has a Thai face for each
platform, and with one installed or embedded every Thai cluster in a
Latin layer, marks included, is drawn from it (skips without one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:26:37 +07:00
aryans2611andClaude Sonnet 5.5 ab375119ad M6: mobile: submenus stay on screen, toolbar hides while a side panel is open
A submenu with no room beside its row (a phone) opens over its parent menu instead of off the
right edge. The toolbar pill is not drawn while a narrow window shows a side panel, where it was
squeezed into the strip left beside the panel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:36:32 +05:30
aryans2611andClaude Sonnet 5.5 0289890197 M6: mobile layout: one side panel at a time on phones, two-finger pan, scrolling toolbar
Windows narrower than 760 pt show the Layers or Design panel over the canvas, opened from two
touch-sized buttons. Two fingers pan as well as pinch. The main toolbar pill is capped to the
canvas width and scrolls sideways (drag or wheel) when its buttons don't fit; the help button
moves above it when they would overlap. The web page's viewport covers the safe area and stops
touch scrolling and overscroll.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 11:36:31 +05:30
Nicek 261b8c5146 fix: 处理 PR 评审意见(剪贴板、图案填充、ArkUI 生成、资产缩略图)
粘贴其他应用的设计图层时保留已复制的填充/描边/效果行;粘贴重复图案前校验来源,拒绝自引用;ArkUI 生成补上变体枚举导出、对齐文本的宽度、auto layout 内绝对定位子节点,并用容器包裹带定位的 instance swap builder;资产列表只为可见行生成缩略图;Shift+Del 检查所有编辑图层的选中点;文档把 ArkUI 标为 FigCraft 自有能力并更新状态与修订历史。
2026-10-23 13:20:31 +08:00
Nicek 1ad244d9e9 Merge remote-tracking branch 'origin/main' into local/vector-edit 2026-10-23 10:34:37 +08:00
Marco Arnulfo 410c1f10e2 The colour picker's eyedropper applies the colour to the paint the picker edits (Reported in storytold/vectorcraft#3342) 2026-10-23 04:08:48 +02:00
Nicek 5b97d03701 fix: 修复异形描边生成重叠区域的问题
通过将自相交路径简化为简单轮廓,解决了中心描边在异形尖角处产生重叠区域的问题。新增clean_poly函数清理多边形中的重复点和亚像素细条,修改路径处理逻辑确保生成的区域不重叠,同时添加相关测试验证正确性。
2026-10-23 09:49:32 +08:00
Marco Arnulfo af0b150736 M3: instance-swap properties in the Dev Mode component playground 2026-10-23 03:28:55 +02:00
skylar taylor-barrick 3b440d01e6 #237: test that wrapped colour picker swatches stay inset inside the picker
The "On this page" swatches were indented by a single add_space, so once they wrapped the later rows started at the picker's left edge and ran past its right edge. The test fills a page with 40 distinct colours, opens the picker and checks every painted swatch lies at least 16 pt inside both sides of the picker window. Without the fix the rightmost swatch ends 3 pt inside the edge instead of 16.
2026-10-22 18:39:53 -06:00
NetterSilverandClaude Haiku 5.5 49c0d4c789 Don't run the expiry sweep from offline CLI commands
Fixes #1091

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:17:35 +00:00
NetterSilverandClaude Haiku 5.5 ffe61d1880 Accept a version string for the plugin manifest api field
Fixes #1040

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 00:13:46 +02:00
NetterSilverandClaude Haiku 5.5 375001983c Strip bidi control characters from names and report reasons
Fixes #1126

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:07:07 +00:00
NetterSilverandClaude Haiku 5.5 b57c67782f Deny library.publishToServer to plugins
Fixes #1055

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:54:59 +02:00
NetterSilverandClaude Haiku 5.5 b3e65c89f9 Refuse non-local library paths in the library Cache
Fixes #1056

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:46:52 +02:00
Marco Arnulfo 1f2a8104bf M4: vector edit transform box around points of several layers
With points selected in more than one edited layer the transform box now
wraps them all, in the page's axes since the layers can be turned
differently. Its corners and edges scale them, outside a corner rotates
them and inside drags them; each layer's points are mapped in its own
coordinates and the whole drag is one undo step. A click inside clears
every layer's points.
2026-10-22 23:37:49 +02:00
NetterSilverandClaude Haiku 5.5 c857f8d0f3 Cap the issue list returned by Document::validate
Fixes #1058

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:30:11 +02:00
NetterSilverandClaude Opus 5.5 8aa456b198 Stop stale next_id from making pastes overwrite existing nodes
Fixes #1064

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:23:47 +02:00
NetterSilverandClaude Haiku 5.5 5a53425f51 Make is_ancestor fail closed on deep or looping parent chains
Fixes #1066

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:14:57 +02:00
NetterSilverandClaude Haiku 5.5 d5de4be1ab Enforce an absolute pre-hello deadline against ping slowloris
Fixes #1068

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:08:25 +02:00
saturngod 6cdc36f6c3 Fix stroke-only boolean operand outlines 2026-10-22 16:07:38 -05:00
zuujiscom 03354ec7d3 FreeBSD package.sh: accept only the two supported FreeBSD targets
Review follow-up. CARGO_BUILD_TARGET must be aarch64-unknown-freebsd or x86_64-unknown-freebsd,
the two the release builds; anything else, including made-up triples such as
aarch64-foo-unknown-freebsd, stops with exit 2.

Part of storytold/craft-common-actions#39.
2026-10-22 16:06:19 -05:00
NetterSilverandClaude Haiku 5.5 c80d1e7ae7 Shut kicked and removed connections when their close can't be queued
Fixes #1070

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 23:04:22 +02:00
NetterSilverandClaude Haiku 5.5 b8741162cd Refuse text.loadFont path for plugins
Fixes #1071

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:57:58 +02:00
NetterSilverandClaude Opus 5.5 d7d6bc8429 Enforce per-address storage caps per IPv6 /64, /56 and /48
Fixes #1073

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:49:58 +02:00
Gert Pellin d77e002170 M8: one Save goes through every file needing Save as; Enter answers Save (#1170)
Review: after a Save as, closing called request_close, which asked the
question again while another file still had changes. It now carries on
with save_then_close. Save is the default answer: Enter picks it when no
button has the keyboard.
2026-10-22 22:35:42 +02:00
zuujiscom 30149d765d FreeBSD package.sh: never run a FreeBSD target's binary on another OS
Review follow-up. With CARGO_BUILD_TARGET set on a host that is not FreeBSD (say an x86_64 Mac
building x86_64-unknown-freebsd), the architectures match but the binary still cannot run, so
the --version run is skipped there and the file(1) check is used instead.

Part of storytold/craft-common-actions#39.
2026-10-22 15:31:37 -05:00
NetterSilverandClaude Opus 5.5 4f39406fcd Keep the hello size limit until a connection is admitted
Fixes #1087

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:30:07 +02:00
Gert Pellin 83dac86bc5 M8: the unsaved-changes question on close offers Save (#1170)
Closing a window with unsaved changes asked "Close the window and lose
them?" with only Close without saving and Cancel. It now asks
Don't Save / Cancel / Save, as PhotoCraft and VectorCraft do. Save saves
each file of the window with unsaved changes and closes; a file without
a path (or saved as .fig) gets Save as first, and closing goes on once
that saved it. A failed save or a cancelled Save as keeps the window
open with its changes. The two changed strings are updated in every
catalog.
2026-10-22 22:23:11 +02:00
NetterSilverandClaude Opus 5.5 eafa1a1d06 Never overwrite a file's log when it can't be read
Fixes #1090

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:20:49 +02:00
NetterSilverandClaude Haiku 5.5 51a4509c87 Refuse library versions that leave no newer one to publish
Fixes #1092

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 22:16:23 +02:00
Gert Pellin d9cb5cd216 M7: the save-from-field test answers Save as and checks both files (#1157) 2026-10-22 22:05:36 +02:00
zuujiscom 477b25e63f FreeBSD package.sh: accept only executables in the cross-build check
Review follow-up. The file(1) check for binaries that cannot run here now needs an executable
(file says "executable" or "pie executable") or an ELF with an interpreter, which is how an older
file(1) still shows a PIE it calls a shared object. FreeBSD libraries, object files and core files
for the target architecture are refused.

Part of storytold/craft-common-actions#39.
2026-10-22 14:49:35 -05:00
NetterSilverandClaude Haiku 5.5 b5e5c2100e Make SVG unique_id linear for repeated layer names
Fixes #1106

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:48:28 +02:00
Gert Pellin 8f0982b5bd M7: Cmd/Ctrl+S saves while a panel field has the keyboard (#1157)
With the keyboard in a Design panel field (X, W, a colour…), the
shortcut handler returned before looking at any key, so Cmd/Ctrl+S and
Shift+Cmd/Ctrl+S did nothing: no save, no message, the tab kept its
unsaved dot. Those two now work from a field: the field lets go of the
keyboard, which commits what was typed as clicking away does, and the
save runs the next frame.
2026-10-22 21:46:32 +02:00
Gert Pellin c7d2a5f019 M9: the symbol test also looks for the Noto Sans Symbols2 spelling (#1156) 2026-10-22 21:46:12 +02:00
Gert Pellin bb2e56a7ea M0.9: --help wins over an earlier bad option (#1155)
Review: `mcp --bogus --help` stopped at --bogus with exit 2. The parser
now keeps the first bad option and reports it only when no --help or -h
follows.
2026-10-22 21:45:29 +02:00
Gert Pellin eb07717783 M0.9: trim mixed edge spaces and dots, document the name limits (#1154)
Review: " . .name. . " kept inner dots at the edges; trim spaces and dots
in one pass. docs/cli.md and safe_file_name's comment give the 100 and
200 character limits.
2026-10-22 21:43:52 +02:00
NetterSilverandClaude Haiku 5.5 c6dba8c8f3 Reject non-finite coordinates in control-channel ui.input
Fixes #1112

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:34:58 +02:00
zuujiscom e366f61e8f FreeBSD package.sh: accept only FreeBSD targets, check cross-built binaries
Review follow-up. CARGO_BUILD_TARGET must now be a *-unknown-freebsd triple, so a Linux or macOS
target can no longer be packaged under a FreeBSD name, and it is read before uname -m, so an
unsupported host does not block a valid cross build. When the binaries are for another
architecture and cannot run --version, file(1) must report each as a FreeBSD ELF executable
for the target architecture.

Part of storytold/craft-common-actions#39.
2026-10-22 14:32:12 -05:00
Gert Pellin 5a3dddc46f M9: fall back to the Noto symbol and math fonts on Linux (#1156)
Signs such as U+2316 and U+2912 are in no text face. The fallback list
had the macOS and Windows symbol fonts (Apple Symbols, Segoe UI Symbol)
but nothing a Linux install has, so they drew as empty boxes there even
with Noto Sans Symbols 2 or Noto Sans Math installed. Adds Noto Sans
Symbols, Noto Sans Symbols 2 (and the Symbols2 spelling some packages
use) and Noto Sans Math after them.
2026-10-22 21:29:58 +02:00
Gert Pellin 40315a123d M0.9: --help and -h after a figcraft-cli subcommand print the usage (#1155)
`figcraft-cli mcp --help` failed with "unknown option --help" and
exit code 2; only a leading --help worked. The argument parser now
returns CliError::Help for --help or -h among a subcommand's options
(not as an option's value), and main prints the usage on stdout with
exit code 0. docs/cli.md says so.
2026-10-22 21:25:24 +02:00
Gert Pellin ca30791fd7 M0.9: figcraft-cli export names files as the app's export does (#1154)
export into a folder replaced every character outside letters, digits,
'-', '_', space and '.' with '_', so a frame called
"Homepage - desktop (live + verbeteringen)" became
"Homepage - desktop _live _ verbeteringen_.png". The app's
file.exportLayers keeps those names and only replaces what file systems
refuse. The CLI now uses the engine's safe_file_name, and docs/cli.md
says which characters are replaced.

Also adds my own entry to contributors/people.toml.
2026-10-22 21:22:20 +02:00
zuujiscom 27ea0d4bdc FreeBSD package.sh: package the aarch64 cross build
The release workflow now cross-compiles FreeBSD aarch64 inside the x86_64 VM
(storytold/craft-common-actions#50) and sets CARGO_BUILD_TARGET=aarch64-unknown-freebsd.
package.sh now reads the binaries from target/<triple>/release when CARGO_BUILD_TARGET
is set, names the archive after the target instead of uname -m, and skips the --version
run when the binary is for another architecture than the machine. x86_64 packages are
unchanged.

Part of storytold/craft-common-actions#39.
2026-10-22 14:14:09 -05:00
NetterSilverandClaude Haiku 5.5 67ae35b155 Match widget data check depth to serde_json's recursion limit
Fixes #1118

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 21:11:15 +02:00
NetterSilverandClaude Opus 5.5 7e114c3420 Never drop Sync events when the live inbox is full
Fixes #1119

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:03:10 +02:00
NetterSilverandClaude Haiku 5.5 57e0872943 Bound the library/plugins close drain with a deadline
Fixes #1120

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:56:58 +02:00
NetterSilverandClaude Haiku 5.5 8c46b733e6 Restrict a pre-existing data directory to 0700
Fixes #1127

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:43:04 +02:00
NetterSilverandClaude Haiku 5.5 82779ec0f2 Guard file generation increments against u64 overflow
Fixes #1128

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:39:39 +02:00
NetterSilverandClaude Haiku 5.5 21d666465f Remove all leftover temp files at server startup
Fixes #1129

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:34:42 +02:00
NetterSilverandClaude Haiku 5.5 b8a5f76471 Keep context menu open after a same-frame trackpad tap
Fixes #1139

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 20:29:04 +02:00
NetterSilverandClaude Opus 5.5 83b9f74665 Write a final Greek sigma in lower-case text layout
Found while testing crates/text/src/layout.rs:1070 (shape_segment calls TextCase::apply_after one char at a time; crates/doc/src/text.rs:124 apply_after)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 19:04:10 +02:00
AganzeFelicite be6a547ff4 M7: the Type settings test checks it stays open at 264 px every frame (#1029) 2026-10-22 18:01:15 +02:00
Yarila 2cd88c0f46 Merge branch 'main' into fix/interface-size-menu-focus 2026-10-22 20:05:21 +05:00
lileeei 2437a65d1b M1: fill/stroke props accept "none" and null (#1036)
node.create, node.set and the other prop appliers map fill/stroke
"none" (either case) and null to an empty fills/strokes list, as
fills: [] already did (undocumented), so an outline-only shape is one
call - agents write "none" for no paint. KEYS and docs/mcp.md
document it; the error names the accepted forms. fill_none_tests
covers create, set, undo, text and the error.
2026-10-22 23:04:57 +08:00
MortenHusted ad9116ff5c M5: the prototyping parity status line and history name the layout fix 2026-10-22 17:01:21 +02:00
MortenHusted 3d5f3e7946 M5: prototype actions lay out what they change like the canvas does
Change to rebuilt the instance from the new variant, and set variable
and set variable mode re-applied bindings, but the player never laid the
result out: no text sizing, no auto layout. A hugging button with a
longer label than its main showed the other variant at the main's width,
its label clipped, and a text bound to a variable kept its old size.

These actions now run the engine's normalize on the player's document
afterwards, the pass that follows every edit on the canvas: text resizes
to what it shows, instances follow and auto layout reflows.
2026-10-22 16:41:30 +02:00
F3R9 0f85c2294e M9: avoid read-only snapshot rewrites and duplicate buffers
Validated formatting, workspace Clippy, workspace tests excluding the two pre-existing localized startup assertion failures tracked in #1017, layers, assets, WASM and plugins. The startup tests are unchanged in this independent fix.
2026-10-22 17:40:52 +03:00
NetterSilverandClaude Opus 5.5 6d748536b6 Drop XML-forbidden control characters from live-text SVG export
Found while testing crates/svg/src/lib.rs:153 (esc, used for tspan text at :598, :608 and :613)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 13:01:37 +00:00
Nicek 7e28d58b3f feat: 新增 ArkUI 代码生成和代码连接支持
添加 ArkUI 作为新的代码语言支持,包括:
- 代码生成:支持生成 ArkTS 组件代码,包括 `@Component` 结构体、`@Prop` 参数、`@BuilderParam` 构建器等
- 代码连接:支持将组件连接到 ArkTS 代码,包括导入路径和模板配置
- 测试:添加 ArkUI 相关测试用例,验证代码生成和连接功能的正确性
- 更新文档:更新开发模式 parity 文档、文件格式一致性文档等,添加 ArkUI 相关说明
- 更新国际化:在所有支持的语言中添加 ArkUI 选项提示
2026-10-22 20:36:45 +08:00
Nicek 37921f48ce feat(inspect): 为资产列表添加格式选择下拉菜单和预览缩略图 2026-10-22 19:51:25 +08:00
MortenHusted f1c206509f M3: files from before the modes override keep the modes set inside their instances
A file or library saved before "modes" was an override group has a mode
chosen on a layer inside an instance without its marker, so the next
rebuild or library import still put the main's mode back.

Copies follow their main layer's modes when the main changes them, so a
layer inside an instance on other modes chose them itself.
mark_layer_modes marks those when a file opens and when a published
library is read from a file or a server. Layers on their main's modes
and top-level instances stay unmarked.
2026-10-22 13:41:56 +02:00
MortenHusted 6394e96005 M3: a variable mode set on a layer inside an instance is an override
A variable mode chosen on a layer inside an instance (the icon inside a
Close button, recoloured through an icon colour collection) wasn't an
override group. A full rebuild of the instance starts each layer over
from its main and carries only override groups, so the mode fell back
to the main's. Library import always rebuilds, as do main edits such as
a new auto layout direction, so a modal's recoloured close icon came
into other files in its default colour.

"modes" is now an override group: setting a mode inside an instance
records it, rebuilds carry it, rebasing a nested instance finds it, the
component panel lists it, and resetting it brings back the main's mode.
A top-level instance's own modes stay its own and record no override,
like its resizing.
2026-10-22 13:41:56 +02:00
Nicek 785dc437fa fix(vectoredit): 编辑模式结束时移除无任何线段的空图层
新增空图层检测函数,在结束矢量编辑模式时自动删除没有任何线段的图层,避免留下空图层或空框。同时更新相关测试用例和文档,确保该行为符合预期。
2026-10-22 19:11:39 +08:00
AganzeFelicite 73a8bb83d6 M7: test Type settings' width through the whole app in Georgian and French (#1029) 2026-10-22 12:24:24 +02:00
MortenHusted c141dee771 M3: a swapped nested instance keeps its outer main's overrides
A Button main's override on its icon instance's glyph (a red fill) was
lost when an instance swapped the icon: the outer rebuild re-copies the
glyph from the main's layer with no overrides of its own, so the nested
rebuild matched it by name and carried nothing. Before that rebuild, each
old layer of the swapped instance now takes the overrides held along the
source chain it had before this rebuild, through layers inside instances
in the mains that place the swapped instance. Geometry, slot content and
groups the layer overrides itself are left out. Matched by name, the
values carry onto the new main's layer as the instance's own: inherited
at the swap, frozen after, and back to the icon main's value on reset.
An icon main's own overrides stay with it.
2026-10-22 11:26:04 +02:00
MortenHusted 6db58d32cb M4: individual stroke weights follow the corner radius like CSS borders
stroke_area used rect_side_strokes only for square corners; a rounded
rect-like layer with individual weights fell through to a uniform ring at
stroke.weight (canvas, SVG, PDF, shadows, shape builder, outline stroke).
pathops::rounded_side_strokes now draws them as CSS does: the outer edge is
the rounded rect (the fill outline for inside strokes), the inner edge is
inset by each side's weight with elliptical corners max(0, R - weight), so
border-l-4 rounded-r-lg is a straight bar and a weight that differs across
a rounded corner tapers through it. Center and outside grow the outer box
and each non-zero radius by half or all of the weight (CSS has only
inside). The inner edge runs against the outer one, so the ring is a ring
under non-zero too; where it could leave the outer edge (radii shrunk to
fit a narrow inner box, smoothed outer corners) it is cut out instead.
2026-10-22 11:20:24 +02:00
NetterSilverandClaude Opus 5.5 93c0bb9ce2 Keep not <var> from reading back as a variable named "not …"
Found while testing crates/doc/src/proto_expr.rs:172 (display writes `not {x}` with a bare var_ref) with crates/doc/src/proto_expr_parse.rs:262 (not() checks variable names before the `not` keyword)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:20:19 +00:00
MortenHusted 24a5855cab M3: style links given in a call apply after its values
apply_order gave fillStyle, strokeStyle, effectStyle, gridStyle and
textStyle the rank of plain values, so the JSON map's byte order decided:
fills, strokes, effects, grids, layoutGrids and variationSettings sort
after their link and overrode it (node.set then unlinked the style;
node.create kept a link its values contradicted). Style links now apply
after the values and x/y, just before boundVariables. A deviation set in a
later call still unlinks.
2026-10-22 11:11:59 +02:00
NetterSilverandClaude Opus 5.5 79e722d340 Keep literal true/false distinct from variables named true/false
Found while testing crates/doc/src/proto_expr_parse.rs:462 (variable lookup before the true/false literals at :466) with crates/doc/src/proto_expr.rs:151/186 (display writes the literal as bare `true` and var_ref writes such a variable bare)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:56:12 +00:00
MortenHusted e8c4742874 M6: the open-subpath fixture covers stroke-first paint order
A third row in open_subpaths.svg: a filled and stroked path that is only
0.001 short stays one layer, and an open triangle with
paint-order="stroke" puts its stroke layer under its fill layer.
2026-10-22 10:27:34 +02:00
RealtaNuaandClaude Opus 5.5 85010e491a M7: missing fonts: follow each font variable's aliases once (#752)
A file whose font family variables alias each other in several modes made
the missing-fonts walk branch in every mode, billions of steps before the
depth limit stopped it, freezing the app on open and `figcraft-cli info`.
The walk now visits each variable once.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 15:19:11 +07:00
MortenHusted a713897d87 M6: SVG import fills open subpaths the way SVG does
SVG fills every subpath as if it were closed; the import filled only
subpaths that close themselves (a z, or an end within 1e-6 of the
start). Logos exported from PDF round relative coordinates to three
decimals, so their outlines end about 0.001 short of where they started
and most of their letters came in empty.

A filled path now has its open subpaths closed before it becomes a
vector network: an end within 1/1000 of the path's size snaps onto the
start, any other open subpath of two or more segments gets a closing
segment. Stroke-only paths are unchanged. A filled and stroked path
with a really open subpath becomes a fill layer (closed) under a stroke
layer (open), so the stroke stays open as SVG draws it.
2026-10-22 09:54:27 +02:00
MortenHusted 0449edf645 M8: save-format tests keep their temp folders apart
SaveFiles named each test's folder after the process id and the
clock in nanoseconds. The tests run in parallel and the macOS clock
counts microseconds, so two of them often got the same folder and one
failed with AlreadyExists (14 of 30 runs here). A counter now keeps
the names apart.
2026-10-22 09:37:35 +02:00
NetterSilverandClaude Haiku 5.5 857762e65c Treat U+2007 FIGURE SPACE as non-breaking in line breaking
Found while testing crates/text/src/breaks.rs:42

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 07:36:21 +00:00
MortenHusted 8ee0218d19 M3: a variant switch takes the new variant's resizing unless the instance set its own
Switching an instance to another variant copied the old root's sizing
and min/max back over the new variant's on the rebuild, so an instance
switched from a hugging variant to a fixed-width one whose children
fill kept hugging at the old width. The next edit then turned those
children Fixed, as a child can't fill an axis its parent hugs.

Resizing still equal to the old variant's was never the instance's own,
so on a sibling-variant switch it now follows the new variant; sizing or
min/max the instance set itself stays.
2026-10-22 09:33:12 +02:00
AganzeFelicite e6067549bc M7: Type settings keeps its width with long labels (#1029) 2026-10-22 09:21:24 +02:00
Muamer MustaficandCursor 4b71d2c69e M1: individual corner fields sit at their corners, each with its own icon (#1002)
Co-authored-by: Cursor <[email protected]>
2026-10-22 09:13:19 +02:00
Jindřich ŠaršonandClaude Opus 5.5 7cb4168e13 M6: Sketch import: fixes from the PR review
- --no-system-fonts holds for .sketch imports too: the CLI opts the
  process out, so the import's font matching doesn't scan.
- Shared text styles get the installed family and platform tracking
  their linked layers get.
- Each text run writes its letter spacing, line height and font axes,
  neutral when it has none, instead of taking the first run's.
- The unsupported-drop hint and its catalogs list .sketch.
- A test sends damaged .sketch data through file.open and file.import.
- Stack items are matched to their source layers through a map.
- Docs: .sketch is a convert input; the Carbon kit's Sketch 47 edition
  is refused (the 68 and 83 ones open).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:03:17 +02:00
AganzeFelicite b13e5c4ff3 M7: doc comments on the font scan's folders and scan_dir (#298) 2026-10-22 09:00:37 +02:00
Nicek a7e4096ba3 Merge remote-tracking branch 'origin/main' into local/vector-edit 2026-10-22 14:16:37 +08:00
Nicek 90004dc7cd fix: 修复多选矢量点时变换框的交互逻辑
修复了多选矢量点时,变换框角点会误判为可拖动的点的问题,同时更新了相关注释和测试用例,确保未被点占据的角点和边缘仍可用于缩放操作。
2026-10-22 13:52:06 +08:00
Muamer Mustafic aa5cc840e5 M1: re-run CI (#910) 2026-10-22 07:46:23 +02:00
Tống Huy Sơn 80b9b49109 docs(i18n): reconcile catalog entry counts to 2,352 in localization-parity.md 2026-10-22 12:02:08 +07:00
Nicek d3a1538614 fix: 修复矢量编辑模式下点击空画布的行为逻辑 2026-10-22 13:00:27 +08:00
Tống Huy Sơn e3bf9b7270 i18n: add Vietnamese (vi) interface localization 2026-10-22 11:33:34 +07:00
Nicek 6bb1f3a456 feat: 实现矢量编辑模式下的剪切命令逻辑并添加测试
在矢量编辑模式中,剪切命令会处理选中的顶点并自动修复路径,而非直接删除整个图层。新增了专门的cut函数处理该逻辑,同时添加了vector_keys_tests测试文件,覆盖了不同场景下的剪切行为,包括Windows系统的Shift+Delete映射、选中顶点时的剪切、无选中项时的剪切以及非编辑模式下的剪切。
2026-10-22 12:03:19 +08:00
Nicek fa53463ddd feat: 为矢量编辑添加线段中点添加点的交互和提示 2026-10-22 11:01:42 +08:00
vxMacDev 3d41302f23 fix(engine): show the Pen's Shift 45° step in its preview line (#970)
A Shift-click with the Pen already put the next point at a 45° step
from the last one, but the line drawn to the pointer while moving
ignored Shift and followed the pointer, so holding Shift looked like it
did nothing. The line now ends where the click will put the point. Both
use one helper, at_45_degrees.
2026-10-21 19:47:01 -05:00
Jindřich ŠaršonandClaude Opus 5.5 03c5324305 M6: Sketch import: count it in the parity assessment
Figma imports Sketch files from its file browser (desktop app too); the
area table hadn't listed it. Import/export breadth 86% → 87%, file
formats 60% → 61%; headline numbers unchanged after rounding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 054d53900f M6: Sketch import: note embedded fonts as waiting for .figcraft font support
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 3e92904cba M6: Sketch import: review fixes
Texts an auto layout places keep the layout's position (the next layout
pass would take the baseline move back). A faded layer's group takes the
layer's id, so stacks, links and overrides still find it; faded masks
stay masks. figcraft-format no longer depends on figcraft-layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 40c7cb3029 M6: Sketch import: tint copies and handed overrides count as work
Tinting copies a group's or master's layers; the copy is charged to the
import's work budget (past it, tints are skipped with a note). An
override handed to a nested instance costs its value's size, so deep
chains of large overrides can't be copied without end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 79894cfad2 M6: Sketch import: junk tints and colour overrides can't panic
A tint or fill that isn't a JSON object is left alone instead of being
indexed. A structural fuzz replaces each value of a rich file with values
of other types and imports every variant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 5f5b802127 M6: Sketch import: roadmap and parity rows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 de0650e3f7 M6: Sketch import: layout grids come in hidden
Sketch draws an artboard's layout and square grids only while its layout
view is on; they now import hidden (still there, shown with the eye in
the Layout grid section).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 a8bade1446 M6: Sketch import: alpha masks, Fade, linear gradient geometry
Alpha masks only mask (no drawn copy of their fill); a progressive
opacity (Fade) becomes an alpha mask of its gradient over the layer;
linear gradients run square in pixels as Sketch draws them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 f02557e8f9 M6: Sketch import: fonts by PostScript name, Sketch's natural line
The font database knows each face's PostScript name; the Sketch import
finds the installed face a file's font names (CourierNewPSMT is Courier
New Regular) and reads the family from the name only when it isn't
installed. Text without a fixed line height takes the line Sketch draws:
ascent and descent rounded to whole pixels, the line gap left out, a
fifth more for classic Mac fonts, the baseline where Sketch puts it
(measured on sketchtool renders of 80 fonts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 41ff790d77 M6: Sketch import: variable font axes; no false embedded-font note
Sketch keys a font's variation axes by the tag read as a number; they map
onto the text style's variations (a fontVariations key in the REST
mapping). Font references without font data only name the fonts a
document uses and no longer raise the embedded-font note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 c639cc2b76 M6: Sketch import: Sketch 2025 frames, stacks, tints and corners
Groups as frames and graphics, stacks as auto layout (instances laid out
by their stack), corners and per-side borders from the style, pins and
sizing, tints (layeringType, fillColor overrides), text colour and
visibility overrides, blur lists (progressive, glass), inner shadows in
the shadow list, angular gradients starting on the right, text sizing,
and texts an override changed measured again on open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 ad78ee94c3 M6: Sketch import: instances, artboards and Apple fonts like Sketch
Compared with Sketch's renders of a Sketch 64 file. Resized instances lay
out their layers the way Sketch does (measured: pins share the change in
proportion, a fixed size keeps its centre's share, and files before format
110 just scale); masters' backgrounds show in instances only when Sketch
includes them; swaps to a missing symbol keep the original; artboards
don't draw a style they carry. Apple's system fonts are found under the
names designs use (SF Pro, SF UI Text and Display, SF Compact), the
optical size follows the text size for named styles too, and imported
text keeps the platform's trak tracking as letter spacing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 5b9e18317d M6: Sketch import: match Sketch's own rendering
Compared artboard by artboard with Sketch's renders of a real Sketch 50
file (sketchtool, black-box). Shape groups whose children have no
operation are one compound path filled by the group's winding rule (read
from the layer), not a union: rings, map streets and icons keep their
holes. Filled open paths close, as Sketch fills them. Flips apply after
rotation. Text with a fixed line height sits where Sketch's line spacing
behaviour puts it. Edited ovals and rectangles keep their points. Bitmap
colour adjustments become image filters. scale 0 means unscaled. A local
test compares sample files with sketchtool's renders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 6640498195 M6: Sketch import: hostile files can't panic, run out of memory or hang
From the branch review: overrides that point at junk (shared styles that
aren't objects, text runs that aren't) no longer panic; symbol expansion
spends a work budget (JSON copied, overrides handed on to nested
instances, grouped once by their first path segment), so small files
can't expand into gigabytes; far path points stay in range; notes are
capped and de-duplicated with a set; shared styles are looked up by
index; a styled second mask's drawn copy isn't clipped by the first;
JSON members are refused past 100 times their packed size or 2 GB in
all, and an unreadable document.json keeps the pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 233c8e9708 M6: Sketch import: local corpus check and docs
FIGCRAFT_SKETCH_SAMPLES runs every .sketch file of a folder through
file.open (opens with linked instances and finite geometry, or is
refused as too old). File format parity gains a .sketch row; CLI, MCP,
roadmap and ROADMAP mention the import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 31698164ac M6: Sketch import: prototype links, export settings, layout grids
Layers linked to artboards navigate there on click (back links go back,
slide animations slide), hotspots become transparent frames with their
link, flow-home artboards start a flow; export options become export
settings (scale, width or height, suffix); artboard layout and square
grids become layout grids, which instances show like their masters.
Smart Layout, stacks and locked overrides are noted once per file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 4be7017ed1 M6: Sketch import: shared styles and colour variables
Shared layer styles become a paint style for their fills, a paint style
for their border colours (linked to strokes) and an effect style; shared
text styles become text styles (colour stays on the layers); local and
library copies alike, unused ones listed too. Layers link to them, keeping
their own values where they differ. Color Variables (swatches, also
library ones) become colour variables in a Color Variables collection,
with fills, borders and text colours bound to them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 4437d49a72 M6: Sketch import: symbols as components, instances with overrides
Symbol masters become components, also those copied from libraries (on a
page of their own); instances carry their master's layers with their
overrides applied (text, nested symbol swaps and hiding, images, layer
and text styles, nested paths) and resize them by their constraints, so
editing a master still updates every instance. A symbol inside itself
stops with a note; instances of missing symbols are empty frames.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 60cc184ab7 M6: Sketch import: PingFang families keep their own names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 a0542e581c M6: Sketch import: text with styled runs and font names
Text layers keep their string and styled runs (font, size, colour, line
height, letter spacing, decorations, text transform; ranges in UTF-16
units, clamped), paragraph alignment and spacing, vertical alignment and
how the box sizes. Fonts named by PostScript name become family and
style; the engine then matches families to installed ones ignoring
spaces (PingFangSC → PingFang SC). Fonts embedded in newer files are
named in the import report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 ff9d966ea6 M6: Sketch import: masks that show their own style draw it
Sketch draws a clipping mask with its fills, borders and effects under
what it clips; a FigCraft mask only clips. A plain copy of such a mask
goes underneath it, so a light avatar circle with a white icon in it no
longer vanishes on a white artboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 8f32e4f504 M6: Sketch import: shape groups that are clipping masks clip
Sketch 51 and older files make masks from shape groups; the mask flag was
lost when the group became its shape or boolean operation, so a masked map
spilled out of its card.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 d77d57912b M6: Sketch import: fills, borders, effects, opacity, masks
Solid, gradient (linear, radial with its ellipse, angular) and image
pattern fills (tile, fill, stretch, fit); borders as strokes with the
first enabled border's width and position, dashes, caps, joins and arrow
ends; shadows, inner shadows, layer and background blurs; layer and paint
opacity and blend modes; clipping masks, wrapped in a group of their own
where Sketch breaks the mask chain; Display P3 documents. Shape groups
pass their style to the shape or boolean operation they become. Noise
fills, motion and zoom blurs are listed in the import report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 72c7eae868 M6: Sketch import: editable paths, shape groups, booleans, stars, polygons
Paths become editable vector networks (handles, per-point corner radius,
open and closed, winding rule). A shape group with one shape becomes that
shape in the group's place; several combine as boolean operations, mixed
operations nesting from the left as Sketch applies them. Stars and
polygons stay stars and polygons while their points are the generated
ones, edited ones and triangles become vectors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 5805cea9b4 M6: Sketch import: layer tree, transforms, constraints, basic shapes
Artboards (and Sketch 2025 frames) become frames with their background
(white by default, as Sketch shows them), groups and graphics groups,
rectangles with their corner radii, ovals ellipses, bitmaps rectangles
with the image, slices slices. Positions accumulate through groups,
rotation and flips keep their direction, resizingConstraint bits become
constraints. Not yet mapped layer kinds are listed in the import report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Jindřich ŠaršonandClaude Opus 5.5 a30f846ee3 M6: open Sketch files (48+): detection, pages, version cut, wiring
.sketch archives (document.json, meta.json, pages/*.json) open through
file.open, file.import, drag and drop, the CLI and MCP; files saved by a
Sketch older than 48 are refused with a message saying how to convert
them. Layers come in the next commits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:27:20 +02:00
Muamer MustaficandCursor 84b48bc797 M1: the drag-from-inside fallback tests the selection's turned box (#910)
Co-authored-by: Cursor <[email protected]>
2026-10-22 00:19:13 +02:00
F3R9 6c0ffe6677 M9: make graphics-startup assertions independent of UI language 2026-10-22 00:42:48 +03:00
Muamer MustaficandCursor 795567f016 M1: a selected unfilled shape drags from inside its box (#910)
Co-authored-by: Cursor <[email protected]>
2026-10-21 23:37:09 +02:00
Ridzzz0Alam 82b05803b1 M1: a refused browser download keeps the unsaved changes (#969)
`file.saveAs {data: true}` marked the file saved before the UI handed the
bytes to `Services::download`. When the download was refused, the toast
said "Couldn't download" but the document was clean, so closing it no
longer warned and the edits could be lost.

The data result now leaves the save state alone. A new `file.markSaved`
(the bookkeeping a native save already did: the file and its branch tabs)
runs from `save_as_dialog` only once the download was delivered. It ends a
version preview like the other commands that act on the document itself.

Tests: the engine save test checks the file stays dirty until
`file.markSaved`; `a_refused_download_says_so` checks the document is
still dirty.
2026-10-21 22:48:51 +02:00
YarilaFox 5ab748d207 M7: keep interface size menu focus inside popup 2026-10-22 01:27:13 +05:00
vetlucasmartins 9f2470b5bd Tailwind theme names: resolve collisions in linear time
Clash detection compared every variable with every other, and suffixes
rescanned the taken list, so a file with very many variables made the
export quadratic. Counts in a HashMap, taken names in a HashSet and a next
suffix per base keep it linear.
2026-10-21 21:00:02 +01:00
Ridzzz0Alam f0e75e183b M1: swap defaults past the layer cap say so (#981)
Instance swap defaults that name an in-file main skipped by the layer cap
were reported as "isn't in the file": only instance targets fed the
capped-mains list. Defaults now feed it too.
2026-10-21 21:06:59 +02:00
vetlucasmartins 2118480e7a Dev Mode: React + Tailwind page code and Tailwind theme export
- inspect.code {language: "tailwind"}: a layer tree as a React component
  styled with Tailwind CSS v4 utilities, no stylesheet. Auto layout maps to
  flex utilities on the spacing scale; fixed-width auto layout frames, text
  and wide layers become fluid (w-full max-w-[…]); instances are inlined
  with their overrides, except those connected to code (codeConnect), which
  are their snippet with one import each; bound variables are theme
  utilities (bg-accent, rounded-card).
- mobile: <frame id> (same layer names): mobile-first code, desktop classes
  that differ behind lg:, resets for desktop defaults, padding compared per
  side; layers only on desktop are hidden below lg, those only on mobile are
  listed in the header.
- variable.export {format: "tailwind"}: @import "tailwindcss" and an @theme
  block (--color-*, --radius-*, --text-*, --spacing-*, --font-*), names
  unique per file and shared with page code.

Builds on the token roles of the CSS export px fix (#1008).
2026-10-21 20:03:46 +01:00
vetlucasmartins 196e3432dd data.fill and text.fit: fill copies of a frame from rows of data
- data.fill {id, rows, gap?, formats?, dir?, format?, scale?}: repeats a
  frame once per row in one undo step; each row key names layers in the
  copy (text, or {text?, fill?, visible?, fit?}). `formats` makes each row
  once per size (feed, story, square), the copy resized so its auto layout
  reflows. With `dir`, each copy is exported under a unique file name; a
  failed export is reported in `errors` and keeps the copies.
- text.fit {ids?, maxLines?, minSize?, orphans?}: the largest font size,
  down to a floor, at which fixed-width text stays within N lines and its
  box; avoids a one-word last line; `fits` comes from the final text.
- Plugins can't call data.fill: it runs other commands inside, past the
  plugin policy, like command.batch.
2026-10-21 19:54:47 +01:00
vetlucasmartins 40fd169bec variable.export css: number lengths in px
A radius exported as `--radius: 12` was invalid where CSS reads it as a
length (`border-radius: var(--radius)` ignores a bare number). Number
values now carry px unless the layers bound to them, their scopes, or the
variables aliasing them make them an opacity or a font weight, which stay
bare. The roles come from a small token_roles module.

Updates the variables_io_tests expectation for the radius (4 → 4px,
6.5 → 6.5px), and adds a test for a bound radius, a bound opacity and an
opacity alias's target.
2026-10-21 19:38:30 +01:00
Ridzzz0Alam 09ff20ffd6 M1: several exported layers arrive as one zip in the browser (#969)
Cause: in the browser, exporting several layers started one download
per file. Chrome holds back every download after the first until the
site is allowed automatic downloads, so the other files didn't arrive.

Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the
files together as one base64 zip `data`, written by a new
figcraft_format::zip_files (the workspace's existing zip dependency).
The UI asks for it when the platform saves by downloads and hands over
one `<document name>.zip`.

Test: ui-egui several_layers_download_as_one_zip (one download, a zip
holding both layers). It fails on the previous commit (two downloads).
2026-10-21 20:35:13 +02:00
KameronandClaude Opus 5.5 014098b6c9 fix(components): a boolean property's default is its bound layers' visibility on the main (#993)
component.addProperty {type: "boolean", default: false, layer} hid the
bound layer in new instances but left it showing in the main component,
so the main didn't show its own default.

On the main, a boolean property's value is now the visibility of the
layers bound to it:

- addProperty, bindProperty {field: "visible"} and editProperty {default}
  show or hide the main's bound layers to match the default (every
  variant of a set for editProperty). Without a default, addProperty
  takes the bound layer's current visibility instead of always `true`.
- Showing or hiding a bound layer on the main sets the default, and the
  property's other bound layers (in every variant) follow. Edits inside
  instances stay overrides.
- Existing instances keep their stored values; documents open as saved
  (no normalisation on load).

Fixes #993

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:27:46 -07:00
KameronandClaude Opus 5.5 afdc31b727 Let node.set replace a vector's path
node.set took no `path`, so redrawing a vector meant deleting and recreating it and
losing its id, place in the layer list, paints, styles, bindings and step references.

`path` now takes SVG path data for vector layers and replaces the geometry through
the same reader node.create uses (path_network, shared with create). width/height in
the same call scale it as create does. The path's own box goes at the layer's current
origin, so rotation and flips stay; with x/y in the same call it goes at x/y plus the
box's offset, which is where create puts it.

On several layers, layers that aren't vectors are skipped with a warning, as auto
layout keys already are (the skip logic is shared now). A single non-vector is
refused, and so is a vector inside an instance: there is no geometry override, so
the next sync would undo it. Data that doesn't parse, draws nothing or has
non-finite bounds is refused cleanly; create now refuses non-finite bounds too.

Tests cover keeping the layer, placement and scaling against create, rotation,
undo/redo, invalid data, non-vectors, instances, boolean operands, edit mode, and a
junk-path sweep. The engine-wide junk sweep has node.set path entries.

Fixes #1001

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:21:24 -07:00
KameronandClaude Opus 5.5 9315e5e014 command.batch: named steps ($ref) and slash paths in field
A batch step can name itself with "as" and later steps refer to it with
{"$ref": "name"}, alongside positional {"$step": n}. "field" now takes a
slash path into the result as well as a key, so {"$ref": "copy",
"field": "ids/0"} reaches the first id edit.duplicate returned (the
convention variable.bind uses for fills/0/color; a leading "/" as in
JSON Pointer also works, and a key holding "/" still matches first).

Resolution moves to crates/engine/src/batch.rs (BatchStep, BatchScope),
shared by command.batch, the CLI script runner, the control channel and
the MCP batch tool, which now passes "as" through. Duplicate names fail
before any step runs; unknown, same-step or later names, bad paths and
out-of-range indexes fail the batch with the step, name and full path.
$ref is reserved like $step; nested batches keep their own names.

Fixes #994.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:19:47 -07:00
KameronandClaude Opus 5.5 3146ad2b2f node.reparent: index is the layer's final position (#992)
`node.reparent {ids, parent, index}` read `index` against the parent's
children as they were before the move, so within the same parent moving a
layer up one place did nothing and asking for two places moved it one.
`index` now counts the parent's children after the move, the way
`node.reorder` already does, in the same parent or a new one; several
layers still land together in their stacking order.

The layers panel was the only caller passing an index other than 0. It
drops on a gap between rows counted before the move, so it now subtracts
the dragged layers below that gap and drags behave as before.

Documents `index` in the command's params and in docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:03:08 -07:00
Ridzzz0Alam 5e2957a901 M1: Save, Save as and Export arrive as downloads in the browser (#969)
Cause: in the web build no file has a path, so Save goes to Save as, and
Save as and every Export go through Services::pick_save / pick_folder.
WebServices keeps the trait's defaults there, which return no path, so
the callback never ran: no download, no message. Only Variables ▸ Export
used the platform's downloads (#368).

Fix: where services.downloads() is true, those routes run their command
without a path and hand the bytes to services.download():
- engine: file.saveAs takes `data: true` and returns the .figcraft file
  as base64 `data` with its `name`, marking it saved (no path, nothing
  remembered). Programmatic Save as without either still asks for a path.
- ui: FigApp::download decodes a result's `data` and says when nothing
  arrived; Save as, one-off export (PNG/JPG/WebP/SVG/PDF), .fig export,
  frames to PDF and multi-layer export (one download per file) use it.

Tests: engine save_as_data_hands_back_the_file_and_marks_it_saved;
ui-egui web_save_tests (save, export, refused download) with the fake
browser Services from variables_export_ui_tests. The UI tests fail
without the fix.
2026-10-21 19:15:58 +02:00
midasdf 42bdfcce34 M4: CJK kinsoku completed to JIS X 4051
JIS X 4051's line-start (行頭禁則) and line-end (行末禁則) sets were mostly
there — closing punctuation and brackets, small kana, the prolonged sound
mark, iteration marks and opening brackets already never start or end a line —
but a few categories fell through:

- the halfwidth small kana ァ–ッ (U+FF67..U+FF6F) and the halfwidth voicing
  marks ゙゚ (U+FF9E/U+FF9F) were not line-start prohibited, so a wrapped line
  could begin with them;
- the spacing (semi-)voiced sound marks ゛゜ (U+309B/U+309C) and the kana
  iteration marks 〱〲〳〴〵 (U+3031..U+3035) likewise;
- the halfwidth parentheses ⦅⦆ were missing from the line-end/line-start
  sets.

Add them (ranges in `class`, the two parentheses in the OPEN and CLOSE
strings), and pin every JIS category in the behaviour suite: the existing 22
cases stay and 9 join them (halfwidth small kana and small tsu, halfwidth and
spacing voicing marks, kana and vertical kana iteration marks, both halfwidth
parentheses), plus a wrap case where a halfwidth small kana moves down with
its base (キャ) and a case showing a manual newline may still start a line with
a prohibited character (禁則 is for automatic wrapping only).
`break_opportunities` fails on the old sets at the first new halfwidth
small-kana case.

Latin wrapping, ideograph breaks and the rest of the behaviour suite are
unchanged, and the headline numbers stay as they are.

Verified: cargo test -p figcraft-text --lib 92 passed; cargo clippy
-p figcraft-text --all-targets -- -D warnings clean; rustfmt clean on the
touched files.

Docs: docs/target-app-parity.md text behaviour row and ROADMAP progress log,
revision history and header.
2026-10-22 01:37:55 +09:00
Ridzzz0Alam e211bf5476 M1: large .fig files keep their layers and their library links (#981)
A design-system .fig file (54 pages) stopped at 244 248 layers, the rest
of its pages skipped, and most of its instances came in as frames "of
components that aren't in the file", with the cap note shown twice.

- The layer budget follows the compressed canvas at 8 bytes a layer, not
  32: design systems are mostly instances, which pack tightly. Files under
  about 1.6 MB keep the 200 000 floor and the 2 million ceiling stays
  (measured on synthetic files: about 5.4 KB and 19 µs a layer).
- The library copies the pages use (the file's hidden internal canvas) are
  mapped before the pages: at the cap the pages' last layers are skipped,
  not the mains their instances link to. The REST stage keeps building
  pages first (its ids are first-wins) and allows for the wrapper frame of
  each library copy, the one layer the mapper doesn't count; that layer
  tripped the REST stage's own cap and repeated the note.
- Instances and swap defaults whose main is in the file but past the cap
  say so instead of "aren't in the file" (en and es catalogs).
- The .fig import gives a note the mapper already gave only once.

Tests in fig/layer_cap_tests.rs: the budget for a #981-sized canvas, library
mains ahead of the pages at the cap, the note for a capped local main, and
a whole 210 000-layer file with one cap note and its library links kept.
All four fail on the previous code.
2026-10-21 18:22:16 +02:00
Nicek 2a3c5b4689 feat: 改进双击行为,编辑形状点时更符合平台习惯 2026-10-22 00:16:44 +08:00
Ridzzz0Alam f619dca716 M1: = zooms in as - zooms out (#973)
Plain `-` zoomed out but plain `=` (the `+` key without Shift) did nothing,
so a canvas zoomed out with `-` couldn't be zoomed back in the same way;
held down, `-` reaches the 2% floor and `=` leaves you there. Ctrl+Shift+=
zoomed in while Ctrl+Shift+- did nothing either.

Found with a trace of the input on GNOME Wayland: Ctrl+wheel, Ctrl+=,
Ctrl+-, Ctrl+Shift+= and `-` all reach the canvas and zoom; `=` and
Ctrl+Shift+- arrive too but no binding takes them.

`view.zoomIn` gains `=` and `view.zoomOut` gains `Shift+Cmd+-`, pinned in
the shortcut parity table; neither key was bound elsewhere and the menus
still show Ctrl/Cmd and `+`/`-`. A keymap test checks each zoom-in key
has its zoom-out twin on both platforms.
2026-10-21 17:41:30 +02:00
Ridzzz0Alam 12bb6503bd M1: readable dropdown rows in the light theme (#972, #955)
Panel dropdowns (prototype device, Preferences, blend modes, versions…)
draw their rows with `right::menu_row` on the theme's popup fill, which is
white in the light theme. Since 5a4b7c27 those rows painted their labels in
`menu_text`, white in both themes because it belongs to the dark menu bar,
so the options were invisible until hovered.

Rows now take the theme's text (`t.text`, shortcuts `t.text_secondary`) and
white only on the green hover highlight, as before that commit. The menu
bar, tool flyout and style/variable pickers paint their own rows on
`t.menu` and are unchanged.

Regression test: a dropdown opened headless in both themes keeps its labels
at 4.5:1 and shortcuts at 3:1 against the popup fill.
2026-10-21 17:00:21 +02:00
Nicek 12e0cdc147 feat: 为填充、描边和效果添加右键复制粘贴功能
实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。
2026-10-21 22:18:30 +08:00
kolla be0fcb5795 fix(ui): keep the style picker open while editing a style's colour
The colour picker's Window area is keyed by egui as Id::new(Some(title)), but
the style picker looked it up as Id::new("color_picker"), so the lookup always
missed and any click in the picker counted as an outside click that closed the
editor (and the picker with it). Give the window a fixed id and query it through
colorpicker::window_id(), with a regression test.
2026-10-21 15:15:14 +03:00
AganzeFelicite d68a2c54c5 Merge remote-tracking branch 'upstream/main' into fix/adobe-fonts-macos-298
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb.rs
#	crates/text/src/fontdb_tests.rs
#	docs/roadmap.md
2026-10-21 05:36:34 +02:00
Hamza 1aa1d61bd4 MCP: get_node keeps to the limit too, and windows a wide node
inspect_document sends a shallower tree when the document's is too large
and points at get_node for the rest, so get_node was the way out of that
limit -- and it had none of its own. Measured against a document with a
frame of 15,000 children, through figcraft-cli mcp:

    inspect_document depth 64, allPages   14.29 MiB   (8.00 MiB cap not
                                                       applied before #915)
    get_node depth 64                     10.51 MiB

Both are over the 10 MiB buffer the report is about, and for get_node no
depth makes it smaller: the width is at depth 1, so depth 1 is already
9.12 MiB of text and depth 0 has no children at all.

get_node now takes the same maxBytes, cuts the subtree by depth as the
document's tree is cut, and then takes a window over the children when
even depth 1 is too large. The marker says how many children were shown
of how many, and the offset to ask for next; `offset` is a new argument
for reading the rest. After this:

    get_node depth 64                     8.00 MiB, children 11427 of
                                          15000 from 0, next 11427

The hint in inspect_document's marker no longer promises that get_node
has no size limit, since that was the hole: it now says a wide node
answers with a window and an offset.

The test pages through a windowed node and asserts every child is
reported exactly once, in order, with every page inside the limit.
Unbounded, the first page is 23,003 bytes against a 7,667-byte limit.
2026-10-21 00:29:14 +05:00
Mex df5ffec87b M0.9: allow literal dash-prefixed CLI filenames 2026-10-20 21:25:49 +02:00
Hamza f3e5d2abc4 .fig import: a note says how much of the file it covers
A .fig import's notes were deduplicated and left uncounted, so each kind
was named once with no sign of how much of the file it applied to: a
file whose every text layer lost a setting read exactly like one where a
single layer did. That is what made the loss in #467 invisible -- the
reporter saw one line while three kinds of data went, one of them on all
83 text layers.

The mapper now collects its notes through the same `Warnings` the REST
importer already uses, which keeps the first-seen order and the cap and
appends `(×n)` where a note was met more than once. Nothing else
changes: the note text stays the catalog key it was, and the import
report already reads a `(×n)` suffix off before translating the base
(`ui-egui`'s `note_text`), so a counted note is still translated.
`figcraft-cli info` prints these notes, so it now names each kind with
its count.

With the count dropped again, the test reports the two notes plain:

    ["connector layers import as static frame/vector snapshots ...",
     "connector label was skipped: depth limit prevents nested ..."]

Part of #467.
2026-10-21 00:06:57 +05:00
NetterSilverandClaude Haiku 5.5 b788b55286 Keep a solid paint's own opacity when binding a colour variable
Fixes #923

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 20:43:51 +02:00
NetterSilverandClaude Haiku 5.5 570144ee90 Reject multiple leading '#' in Color::from_hex
Found while testing crates/color/src/lib.rs:125

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 17:49:45 +00:00
Hamza 3df5673627 MCP: maxBytes is the whole message, and depth 0 says when it is over
Two things the limit got wrong, both reported by CodeRabbit on #915.

maxBytes was compared with the escaped text block alone, but what goes
on the wire is that block inside a content object, inside the JSON-RPC
result, with the request id and the newline the transport writes: a
block measured at exactly maxBytes left as a larger message than the
caller said it could read. A kilobyte of the caller's figure is now kept
for that wrapping. About ninety bytes of it is fixed and the rest is the
request id, so the figure holds for any id up to roughly 900 bytes --
every id in practice, a number or a UUID. Sizing the real envelope here
would mean handing the transport's request id to every tool, and the
assumption is written down instead, in the schema and at the constant.

A depth: 0 request over the limit returned early, so the one response
that cannot be made any smaller was the one sent without the truncated
marker -- while a bisection that walked down to depth 0 sent the same
tree with it. Depth 0 now takes the same path as the bisection: the tree
goes out, over the limit, with the marker saying so.

Without the first fix the test reports a limit at the exact size of the
tree as no truncation; without the second the depth-0 marker is absent
(left: (None, None), right: (Some(0), Some(0))).
2026-10-20 20:56:08 +05:00
YarilaFox 6165d2ec85 M7: preserve bindings and design layers when pasting images 2026-10-20 20:12:47 +05:00
Lucas 728f5c48ef i18n(pl): add Polish labels for remaining 7 engine commands
- Add Polish command labels for arrange.reorder, file.moveTab, pref.startOnHome,
  pref.uiScale, text.loadFont, text.refit, and text.rescanFonts.
- Ensures 100% command coverage with zero fallback to English in the Actions palette.
2026-10-20 16:46:58 +02:00
Lucas cabe2e3cfb i18n(pl): native review, catch-up and terminology fixes
- Mark Polish catalog as reviewed and approved by native speaker (@lukasz-sklad)
- Replace "Lekki" with "Jasny" for Light mode
- Translate disambiguated context keys (Fill|sizing, Join|stroke, Light|glass, Open|comments)
- Catch up with 15 UI strings added in recent main commits
- Update docs/translating.md to list Polish as reviewed
2026-10-20 15:58:55 +02:00
YarilaFox a559638419 M7: avoid Windows GNU UIA crash 2026-10-20 17:57:10 +05:00
YarilaFox 31da698dd5 Fill selected layer when pasting an image 2026-10-20 17:55:08 +05:00
Hamza b1f0eafbf7 MCP: inspect_document answers within what a client can read
MCP stdio is newline-delimited JSON, so one tool result is one message
and has to fit the client's read buffer whole; there is no streaming of
part of one. The TypeScript SDK's stdio transport defaults to 10 MiB and
hosts rarely raise it, so a document whose tree went past that killed
the server: the client stopped reading, the pipe broke, and every later
call in the session failed.

The tree is now reported at the deepest depth that fits, found by
bisection, with a `truncated` object giving the depth asked for, the
depth used, both sizes, the limit and a pointer to get_node for reading
one subtree deeply. `maxBytes` lets a caller that knows its own buffer
say so; the default is 8 MiB, leaving the envelope 2 MiB of the usual
10.

The size is measured as the transport sends it -- pretty-printed and
then escaped inside the text block, which adds about a tenth (342,136
bytes of tree became 379,954 on the wire in the fixture). A kilobyte is
reserved for the marker itself, which is added after the measurement.

A limit below even the shallowest tree still answers, at depth 0, with
the marker saying so: there is nothing smaller to send, and an error
would leave the caller with nothing at all.

The tool description now says that each level of depth multiplies the
response and points at get_node, which the report notes would have been
enough on its own to avoid the crash.

Fixes #912.
2026-10-20 16:30:03 +05:00
xiaou61 beb87d8696 M1: describe the fallback the export actually takes (#465)
The warning still said fixed and content-sized tracks were written as equal
tracks, which the export no longer does. It now reports only the tracks whose
size the format can't hold, and only for the tracks an exported grid has.

Co-authored-by: xiaou61 <[email protected]>
2026-10-20 11:14:02 +08:00
xiaou61 ea2fa7c367 M1: say that the automation roots reach only the headless side (#835)
The CLI help and docs/mcp.md described the roots as confining whatever the run
touches, which reads as if they also applied to `mcp --attach`. They don't: an
attached server runs the commands inside the app, and the app confines them
from its own flags. VectorCraft documents the same split, and the missing
sentence is what made the difference confusing, as #835 reports.

Co-authored-by: xiaou61 <[email protected]>
2026-10-20 11:08:21 +08:00
xiaou61 414aa1b63c M1: write grid track sizes in .fig export (#465)
The import has read `gridColumnsSizing` / `gridRowsSizing` since #630, but the
export wrote only equal tracks and warned about it, so a grid with a fixed
column beside two weighted ones came back equal after a save and reload.

Write the sizing map next to the order map, keyed by the same track ids: fixed
px, fractional fill and content-sized tracks keep their sizes. A track the
format has no name for (negative or out-of-range size, or a document track the
frame doesn't list) keeps its id and reads back equal, with the existing
warning.

Co-authored-by: xiaou61 <[email protected]>
2026-10-20 11:04:13 +08:00
JimKnoxxandClaude Opus 5.5 4a73289a91 M5: presentation tabs: keep them off the window controls, close X on every frame
Presentation tabs now take at most half the free strip while file tabs share it.
They shrink to the minimum tab width first, then the rest go into an overflow
menu, so they never reach the new-tab button, theme toggle or window buttons.
The close X has its own hit rect next to the tab body and is registered every
frame, so a click on it can no longer select the tab instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:34:27 +02:00
JimKnoxxandClaude Opus 5.5 bbe691cb43 M5: presentation tabs: test both owner guards, document the tab functions
Review follow-up. The closed-owner test now picks a device and switches
Responsive in the presenter and checks that the file in front keeps its
prototype settings, so removing either `live` guard in present::show fails
it. The present harness gets frame_output for the AccessKit tree. The new
tab functions get doc comments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:15:25 +02:00
JimKnoxxandClaude Opus 5.5 8d5a29dfda M5: one presentation tab per page of a file
Main plays a presentation in a single "Present" tab. Now each page of a
file gets its own tab, named "file - page", and the name follows
renames. Play on a page that has a tab shows that tab as it was; from
another start frame it starts again. A tab keeps its own copy of the
prototype: it plays on after its file closes, and a saved file opened
again gets its tabs back. A tab whose file is closed or not in front
never reads or changes the file in front (device, responsive,
comments). Escape or the tab's X closes that tab and goes back to its
file, or Home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:15:13 +02:00
timrrrr 1ca3a5ca2d M1: merge main and resolve crop progress conflicts 2026-10-19 13:54:10 +03:00
DubblbindongandClaude Opus 5.5 a370211b48 fix(plugin): keep the plugin directory's wire format; refuse oversized save_file data
- PluginEntry no longer carries save_file: postcard is positional and existing
  messages keep their fields (docs/collab-protocol.md), so mixed app/server
  versions keep reading the directory. Manage plugins still shows "can save files".
- save_file checks the data length before reading it and refuses with status 1
  and the reason, as documented, instead of failing the run with a limit error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:11:59 +02:00
Dubblbindong 38034a4c4a Merge main into saveFile branch
Resolve README (four example plugins) and the de/en/es catalogs (keep both sides, sorted).
2026-10-18 16:50:01 +02:00
leandrodslvandClaude Sonnet 5 4223031c5c docs(contributors): credit leandrodslv by the name on their GitHub profile
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-18 14:37:56 +02:00
leandrodslvandClaude Sonnet 5 31d6566f3b i18n(fr): land the native-speaker wording fixes from the draft review
#783 completed the French catalog to every current string, superseding
the translations added in #681. This keeps only the ~50 UI strings and
8 command labels whose wording a native French speaker corrected during
that review (keyboard focus, "Off" as "Désactivé", blend modes, stroke
join, spotlight, the no-break space before : ; ? !…), applied on top of
#783. The draft status is unchanged.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-18 14:37:56 +02:00
Mex 9f00703d8f Merge branch 'main' into fix/outline-stroke-rings 2026-10-18 11:21:59 +02:00
叶林橦 d489235d2c engine: short loops in vector.reverseLoop really turn round
reverseLoop reversed the list and skipped shared segments, which only turns a
loop of three or more round: a one-segment loop has no order at all, and a
two-segment ring reads the same whichever way its list is turned, so both
answered reversed: true without moving.

Three or more segments: reverse the list and leave every stored end alone (a
shared one belongs to the neighbour too). One or two: store one segment the
other way round, skipping any segment another short loop lists; when none is
left to swap, refuse with a clear error and leave the document as it was.

Tests: two 2-segment areas over one line that share it, the shared segment
listed first in one area and last in the other; each reversal flips that area's
winding sign and leaves the shared segment and the neighbour alone. A shared
one-segment loop is refused. The 4-segment squares stay as the long-loop case.

Drop the two catalog.rs menu lines: they aren't in the reference app's menus,
and the Vector menu runs commands without parameters, so both would fail there.
2026-10-17 22:35:50 +08:00
叶林橦 57e455dfa7 engine: fill rules as vector.fillRule and vector.reverseLoop
Two vector commands behind the fill-rule panel, split out so they can land
on their own (the panel is held for a maintainer design decision):

- vector.fillRule {ids? | id?, region?, rule: nonZero|evenOdd} sets the
  winding rule a filled area fills by (the whole layer when region is left out).
- vector.reverseLoop {ids? | id?, region, loop} turns one loop round, which is
  how a hole is made and unmade.

reverseLoop no longer swaps the stored ends of a segment a loop shares with a
neighbour: reversing the loop's list already flips this loop's traversal of the
shared segment, so flipping its segment would turn the neighbour's area round
too (bug called out in review). A new test builds two areas that share a segment
and checks the shared segment and the neighbour's fill are left alone.

Adds the commands to the Vector menu (catalog/parity), their locale labels, and
vector_fill_rule_tests (rule per area or whole layer, junk refused, shared segment).
2026-10-17 20:57:26 +08:00
timrrrr e409f9efc0 M1: retain GitHub username for contributor credit 2026-10-17 01:48:50 +03:00
timrrrr 8a77ef6759 M1: preserve image fills with variable-bound resize dimensions 2026-10-17 01:18:07 +03:00
timrrrr 9397d2ca03 M1: crop image fills during Command/Ctrl resize (#702) 2026-10-16 23:16:26 +03:00
DubblbindongandClaude Sonnet 5.5 ed74fc67d0 feat(plugin): add saveFile permission with host-owned save dialog
Plugins can hand the host a file name, MIME type and bytes through the new
`save_file` import (plugin API 4, `permissions.saveFile`). The host asks the
user where to save (browser download on the web), so the plugin never sees a
path. Includes the SDK wrapper and the `export-names` example plugin.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Assisted-by: Claude (Anthropic)
2026-10-16 20:12:54 +02:00
AganzeFelicite 66dd31ad9f M7: find fonts activated through Adobe Fonts on macOS
Fonts activated through Adobe Fonts rendered in the fallback font without
a warning: Creative Cloud keeps them in its own folder as hidden files
named by number, which the font scan never read. The macOS scan now
includes ~/Library/Application Support/Adobe/CoreSync/plugins/livetype,
and files without an extension are indexed when they start with a font
signature. Fonts are read in place.

Closes #298
2026-10-16 11:07:33 +02:00
ahmedhosnypro d55781e56e Read the portal's colour scheme as the D-Bus variant it is
ReadOne replies with a variant body (signature v); deserializing it
straight to u32 (signature u) is a signature mismatch that zvariant
rejects, so the initial colour scheme never arrived and system mode
kept its light fallback. Take the Value and read the uint32 out of it.
2026-10-16 10:58:06 +03:00
ahmedhosnypro 4c494b804f Fix the system theme always light on Linux and the BSDs
winit 0.30 reports no system theme there (EventLoop::system_theme is
None), so the "system" preference resolved to light every time. When
the windowing stack gives no theme, the interface now follows the
freedesktop colour scheme instead: read once and watched live through
the settings portal (org.freedesktop.appearance / color-scheme) in the
desktop services (Services::system_dark), so GNOME's and KDE Plasma 6's
appearance setting switches the app without a restart. Where the OS
theme is reported (macOS, Windows, web) it still wins, and without any
answer the light fallback stands.
2026-10-16 03:16:41 +03:00
Mex 2795d9fdcb Merge branch 'main' into fix/outline-stroke-rings 2026-10-16 00:11:34 +02:00
Mex 9cb1dba1e2 M4: normalize contribution files to LF 2026-10-16 00:07:13 +02:00
Mex a0f6410397 M4: preserve stroke rings when outlining shapes 2026-10-16 00:03:04 +02:00
490 changed files with 51954 additions and 3986 deletions

No files matched your search

+8
View File
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
# The Windows UI Automation adapter can be asked about a node that disappeared in the
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
[target.'cfg(all(windows, target_env = "gnu"))']
rustflags = ["--cfg=windows_slim_errors"]
[alias]
xtask = "run -q -p xtask --"
+15 -73
View File
@@ -1,9 +1,7 @@
# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and
# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only
# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md),
# and Windows/FreeBSD packaging have their own workflows.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -13,76 +11,20 @@ on:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links, and a smaller target dir.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every
# main state that gets a run is checked to the end.
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
timeout-minutes: 35
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- 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@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at
# a sibling checkout that CI doesn't have): everything must build and pass without it.
- name: Test
run: cargo test --workspace --locked
- name: Dependency layering
run: cargo run --locked -q -p xtask -- layers
- name: Asset attribution
run: cargo run --locked -q -p xtask -- assets
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
# The engine, the UI crate and apps/figcraft-web must keep compiling for the browser.
- name: Check wasm-safe crates
run: cargo run --locked -q -p xtask -- wasm
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
+6 -6
View File
@@ -4,12 +4,10 @@
# before it can fail a release. Nothing is uploaded and no secrets are used.
name: FreeBSD
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
push:
branches: [release]
pull_request:
@@ -17,6 +15,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "17 2 * * *"
workflow_dispatch:
concurrency:
@@ -32,4 +32,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+4 -5
View File
@@ -24,13 +24,12 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
# Re-pin once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
# crates/text/build.rs keeps the web build to Inter.
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+11 -2
View File
@@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
## egui dependency font icons
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
Generated
+496 -168
View File
File diff suppressed because it is too large. Load diff
+36 -8
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,11 +32,15 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# 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" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# 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"] }
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -45,6 +49,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
same-file = "1.0.6"
# 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"
@@ -79,9 +84,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -122,9 +127,32 @@ codegen-units = 4
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
opt-level = "s"
# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
opt-level = "z"
lto = "fat"
codegen-units = 1
debug = false
strip = true
panic = "abort"
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+17
View File
@@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2 -2
View File
@@ -234,8 +234,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
+46 -8
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 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; 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
@@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
@@ -65,7 +65,7 @@ native speakers' reviews of the nine language drafts need people.
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-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) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
@@ -86,13 +86,14 @@ native speakers' reviews of the nine language drafts need people.
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
@@ -109,11 +110,11 @@ native speakers' reviews of the nine language drafts need people.
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
| Vietnamese | vi | 100% | draft (needs native review) |
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -131,6 +132,21 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
(`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
65%.
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
- **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.
@@ -139,9 +155,16 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **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-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
Binary regression coverage opens five such native files and checks their JSON info.
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -324,6 +347,17 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
| 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 |
@@ -373,6 +407,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 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-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
| 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 |
@@ -384,10 +420,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
+36 -8
View File
@@ -1,6 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors.
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
use std::collections::BTreeMap;
@@ -16,8 +16,14 @@ pub struct Args {
impl Args {
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
let mut a = Args::default();
// The first bad option, reported once the rest of the line is checked for `--help`.
let mut bad: Option<CliError> = None;
let mut it = argv.iter();
while let Some(arg) = it.next() {
if arg == "--" {
a.positional.extend(it.cloned());
break;
}
let name = if let Some(n) = arg.strip_prefix("--") {
n
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
@@ -30,19 +36,27 @@ impl Args {
Some((n, v)) => (n, Some(v.to_string())),
None => (name, None),
};
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
if inline.is_none() && matches!(name, "help" | "h") {
return Err(CliError::Help);
}
if valued.contains(&name) {
let v = match inline {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
a.values.entry(name.to_string()).or_default().push(v);
match inline.or_else(|| it.next().cloned()) {
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
None => {
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
}
}
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
return Err(CliError::Usage(format!("unknown option `{arg}`")));
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
}
}
Ok(a)
match bad {
Some(e) => Err(e),
None => Ok(a),
}
}
pub fn value(&self, k: &str) -> Option<String> {
@@ -135,4 +149,18 @@ mod tests {
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
assert_eq!(a.positional, ["-5"]);
}
#[test]
fn help_is_asked_for_anywhere_but_as_a_value() {
// Also after a bad option: help wins over the usage error (#1161 review).
for argv in
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
{
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
}
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
// `--help=x` is no request for help, just an unknown option.
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
}
}
+125 -28
View File
@@ -1,10 +1,11 @@
//! The file subcommands: info, export, convert, render and run.
use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
@@ -19,7 +20,7 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
}
fn ext(path: &str) -> String {
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
fn image_format(e: &str) -> Option<ExportFormat> {
@@ -207,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
fn sanitize(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
let s = s.trim().trim_matches('.').to_string();
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
let s = figcraft_engine::cmd::file::safe_file_name(name);
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
}
/// What `export` exports and how.
@@ -268,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
return Err(CliError::Failed("the page is empty: nothing to export".into()));
}
let mut used = std::collections::BTreeSet::new();
let mut next_suffix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for id in ids {
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base} {k}");
k += 1;
}
let base_lower = base.to_lowercase();
let next = next_suffix.entry(base_lower).or_insert(1);
let name = loop {
let k = *next;
*next += 1;
let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
if used.insert(candidate.to_lowercase()) {
break candidate;
}
};
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
write(&path, &bytes_of(&[id])?)?;
println!("{path}");
@@ -283,8 +292,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
return Err(CliError::Failed(format!(
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
)));
}
Ok(())
}
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
if let Some((input, images_left_out)) = input {
refuse_dropping_images(input, out, images_left_out)?;
}
if let Some(images_left_out) = active.images_lost_saving_to(out) {
refuse_dropping_images(out, out, images_left_out)?;
}
Ok(())
}
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
let doc = load(input)?;
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
refuse_dropping_images(input, out, loaded.images_past_budget)?;
let doc = loaded.doc;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
@@ -309,7 +339,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
@@ -334,7 +364,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
Ok((cmd.to_string(), params))
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
})
.collect()
}
@@ -343,43 +374,44 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
let (mut s, input_images_left_out) = match input {
Some(path) => {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
s
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
(s, left_out)
}
None => Session::new(),
None => (Session::new(), 0),
};
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
// steps before it, and a step that fails takes the whole script back instead of leaving the
// earlier steps applied. The result lines are held back until the script is through, so none of
// them reports work that was rolled back.
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
for (i, step) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.batch_step(&mut scope, &step.command, &step.params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
@@ -411,6 +443,14 @@ mod tests {
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
assert_eq!(sanitize(" .hidden. "), "hidden");
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
}
#[test]
@@ -494,6 +534,43 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
#[test]
fn a_script_refers_to_named_steps_and_paths() {
let dir = temp_dir("run-ref");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
assert_eq!(names, ["Card", "Copy"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// Two steps with one name fail before anything runs.
#[test]
fn a_script_with_a_name_twice_runs_nothing() {
let dir = temp_dir("run-dup-name");
let script = dir.join("script.json");
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
@@ -513,4 +590,24 @@ mod tests {
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
let dir = temp_dir("script-images-left-out");
let input = dir.join("big.figcraft");
std::fs::write(&input, b"kept").unwrap();
let link = dir.join("link.figcraft");
std::fs::hard_link(&input, &link).unwrap();
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
let duplicate = Session::new();
let active = duplicate.ds().unwrap();
for out in [input, link] {
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
}
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
std::fs::remove_dir_all(dir).unwrap();
}
}
+42 -23
View File
@@ -27,16 +27,16 @@ USAGE:
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
@@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
as the user who started it, as before. A running app confines itself: start it with
`figcraft --control PORT` and the same flags (`--attach` refuses them).
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
confined by the app it attaches to, not by flags passed to the CLI: start that app with
`figcraft --control PORT` and the same roots (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
@@ -74,6 +75,10 @@ fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
Ok(code) => code,
Err(CliError::Help) => {
println!("{USAGE}");
ExitCode::SUCCESS
}
Err(CliError::Usage(msg)) => {
eprintln!("error: {msg}\n\n{USAGE}");
ExitCode::from(2)
@@ -87,6 +92,8 @@ fn main() -> ExitCode {
#[derive(Debug)]
pub enum CliError {
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
Help,
/// Bad arguments: usage is printed, exit code 2.
Usage(String),
/// The operation failed: exit code 1.
@@ -101,15 +108,27 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
if system_fonts {
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
let flags = [flags, &["no-system-fonts"]].concat();
let args = Args::parse(argv, valued, &flags)?;
if !args.flag("no-system-fonts") {
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
} else {
// Nothing scans them then, not even a `.sketch` import matching its fonts.
figcraft_text::opt_out_of_system_fonts();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
Ok(args)
}
fn run(argv: &[String]) -> CliResult<ExitCode> {
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
return Err(CliError::Usage("missing subcommand".into()));
};
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
@@ -120,13 +139,13 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
@@ -134,7 +153,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
@@ -142,19 +161,19 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"render" => {
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
let a = parse_args(&rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
@@ -163,7 +182,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"mcp" => {
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
@@ -178,7 +197,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
let a = parse_args(&rest, &["server"], &[])?;
match a.positional.as_slice() {
[verb, folder] if verb == "publish" => {
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
@@ -188,10 +207,10 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
}
"perf" => {
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let a = parse_args(&rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, system_fonts)
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
+90
View File
@@ -13,6 +13,38 @@ fn tmp(name: &str) -> std::path::PathBuf {
d
}
#[test]
fn option_terminator_allows_dash_prefixed_filenames() {
let dir = tmp("option-terminator");
let doc = figcraft_doc::Document::new("Dash-prefixed file");
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] {
std::fs::write(dir.join(name), &bytes).unwrap();
let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["file"], name);
assert_eq!(info["name"], "Dash-prefixed file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn global_font_flag_respects_option_values() {
for filter in ["--", "--no-system-fonts"] {
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
}
}
#[test]
fn global_font_flag_can_precede_the_subcommand() {
let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(!serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
}
#[test]
fn bad_arguments_exit_2_without_panicking() {
for args in [
@@ -39,6 +71,40 @@ fn bad_arguments_exit_2_without_panicking() {
assert_eq!(out.status.code(), Some(1));
}
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
/// (#1155), whatever else is on the line; nothing runs.
#[test]
fn help_after_a_subcommand_prints_the_usage() {
for args in [
&["--help"][..],
&["-h"],
&["help"],
&["mcp", "--help"],
&["mcp", "-h"],
&["info", "--help"],
&["export", "--help"],
&["export", "x.figcraft", "-o", "out", "--help"],
&["convert", "-h"],
&["render", "--help"],
&["run", "--help"],
&["commands", "--help"],
&["plugin", "publish", "--help"],
&["perf", "--help"],
&["mcp", "--attach", "notaport", "--help"],
&["mcp", "--bogus", "--help"],
] {
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
}
// A value that happens to be `-h` is still a value, not a request for help.
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
assert_eq!(out.status.code(), Some(0));
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
}
#[test]
fn run_export_info_convert() {
let d = tmp("run");
@@ -80,6 +146,30 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`,
/// `%`, `#` and commas stay, what file systems refuse becomes `_`.
#[test]
fn export_file_names_keep_what_the_app_keeps() {
let d = tmp("names");
let script = d.join("s.json");
let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"];
let steps: Vec<serde_json::Value> = names
.iter()
.enumerate()
.map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}}))
.collect();
std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap();
let file = d.join("doc.figcraft");
assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success());
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] {
assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::<Vec<_>>());
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
+26
View File
@@ -66,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
/// A synthetic `.sketch` file: one page with one Helvetica text.
const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
/// `--no-system-fonts` too: the run still knows only the built-in families.
#[test]
fn sketch_imports_keep_to_no_system_fonts() {
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("open.json");
let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("run")
.arg(&script)
.arg("--no-system-fonts")
.env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
let fonts: Vec<String> = serde_json::from_value(lines[1]["result"].clone()).unwrap();
assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
}
+3 -3
View File
@@ -120,15 +120,15 @@ impl Abuse {
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
/// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong
/// ones before it (they only decay with time); enough wrong ones lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::store::Store;
use crate::test_util::{TempDir, config};
fn args(s: &[&str]) -> Vec<String> {
s.iter().map(|a| a.to_string()).collect()
s.iter().map(ToString::to_string).collect()
}
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
+12 -8
View File
@@ -262,17 +262,21 @@ fn the_limits_on_codes_over_time() {
let t0 = Instant::now();
let me = ip("198.51.100.9");
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
// A right code forgives the wrong ones before it.
// A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones.
let a = ip("2001:db8:1:2::1");
let b = ip("2001:db8:1:2::2");
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(a, false, t0);
}
abuse.code_tried(b, true, t0);
abuse.code_tried(a, true, t0);
assert_eq!(abuse.code_locked(a, t0), None);
abuse.code_tried(a, false, t0);
assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT));
// Wrong codes: locked for the lockout, then free again.
for _ in 0..WRONG_CODES {
abuse.code_tried(me, false, t0);
}
abuse.code_tried(me, true, t0);
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
assert_eq!(abuse.code_locked(me, t0), None);
// One more: locked for the lockout, then free again.
abuse.code_tried(me, false, t0);
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
+7 -5
View File
@@ -13,6 +13,7 @@
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
//! first damaged record (a write cut short).
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
@@ -49,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option<u64> {
/// `path` with `.tmp` added: where its next version is written first.
pub fn temp_of(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
@@ -127,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
let mut out = Vec::with_capacity(SNAP_HEADER);
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&0u32.to_le_bytes());
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
let sum = crc(&out[SNAP_HEADER..]);
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
@@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() {
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
#[test]
fn a_library_publish_cannot_claim_the_highest_version() {
let dir = TempDir::new("libmax");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
s.execute("component.create", &json!({"ids": [f]})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone();
let mut archive = |version: u64| {
lib.manifest.version = version;
figcraft_engine::library_server::archive(&lib).unwrap()
};
let high = 1u64 << 32;
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version");
assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in");
assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it");
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused");
assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in");
}
+74
View File
@@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() {
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn caps_per_address_count_the_wider_ipv6_blocks() {
let dir = TempDir::new("block-caps");
let mut c = open(dir.path());
c.max_files_per_ip = 1;
c.publishes_per_hour = 1000;
let mut store = Store::open(dir.path(), c.store()).unwrap();
// A /48 keeps 4 times one address's files: /64s in different /56s of it.
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
// A /56 keeps twice one address's files.
store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap();
store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
drop(store);
// Bytes: one address keeps one file's worth, so a /48 keeps 4 files.
let probe = TempDir::new("block-caps-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-caps-bytes");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
s.hosting.max_bytes_per_ip = size;
let mut store = Store::open(dir.path(), s).unwrap();
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
}
#[test]
fn edits_count_the_wider_ipv6_blocks_too() {
use figcraft_collab_net::hub::Backend;
let probe = TempDir::new("block-room-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-room");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
// One address keeps 2.25 files' worth, so its /56 keeps 4.5.
s.hosting.max_bytes_per_ip = size * 2 + size / 4;
let mut store = Store::open(dir.path(), s).unwrap();
let mut ids = Vec::new();
for i in 0..4 {
ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id);
}
let server = Backend::open(&mut store, &ids[0]).unwrap();
// Its own /64 has room for this, but its /56 doesn't.
let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err();
assert!(e.contains("have reached their"), "{e}");
Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap();
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
@@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() {
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
}
#[test]
fn offline_commands_do_not_sweep_idle_files() {
let dir = TempDir::new("offline-sweep");
let mut c = open(dir.path());
c.idle_days = 30.0;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
store.age(&public.id, 31 * 86_400);
drop(store);
let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap();
drop(store);
assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file");
let _serving = Store::open(dir.path(), c.store()).unwrap();
assert!(!dir.path().join("files").join(&public.id).exists());
}
#[test]
fn rate_limits_and_blocks_refuse_with_a_reason() {
let dir = TempDir::new("limits");
+131 -29
View File
@@ -42,7 +42,7 @@ use figcraft_collab::message::{
PluginReply, PluginReq, ReportInfo, Usage,
};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key};
use figcraft_collab_net::token;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -291,7 +291,18 @@ fn valid_id(s: &str) -> bool {
/// A name others may see: trimmed, no control characters, at most 256 characters.
fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect()
}
/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters.
fn clean_reason(s: &str) -> String {
let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect();
s.trim().chars().take(MAX_REASON).collect()
}
/// Bidirectional format characters, which could reorder what a terminal shows.
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
/// Take the data directory's lock: `None` when someone else holds it.
@@ -302,6 +313,10 @@ pub fn lock(root: &Path) -> Result<Option<File>, String> {
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700);
dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
// `DirBuilder` only applies the mode to directories it creates; tighten an existing one too.
#[cfg(unix)]
fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700))
.map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?;
let f = OpenOptions::new()
.create(true)
.truncate(false)
@@ -334,16 +349,27 @@ fn write_key(root: &Path) -> Result<String, String> {
Ok(key)
}
/// The highest version a file's first library publish may claim. Publishing counts up by one (a
/// library carried over from another server keeps its number), so no real history gets near it.
/// Later publishes may go one past the version on the server, so there is always a next one.
const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32;
impl Store {
/// Use the data directory `root` (made if missing). Fails when another server or command holds
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))
let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?;
store.sweep();
Ok(store)
}
/// [`open`](Self::open), or `None` when someone else holds the directory.
/// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or
/// `None` when someone else holds the directory.
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result<Option<Store>, String> {
let Some(lock) = lock(root)? else { return Ok(None) };
for name in [KEY, BLOCKED, REPORTS] {
let _ = fs::remove_file(disk::temp_of(&root.join(name)));
}
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
@@ -374,7 +400,6 @@ impl Store {
store.scan(&dir);
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
store.sweep();
Ok(Some(store))
}
@@ -425,12 +450,20 @@ impl Store {
/// A file folder's description and links, after checking its snapshot and log. Leftover
/// temporary files (writes cut short) are removed.
fn check(&self, path: &Path) -> Result<Entry, String> {
for name in [SNAPSHOT, LOG, META, LINKS] {
for name in [SNAPSHOT, LOG, META, LINKS, USAGE] {
let tmp = disk::temp_of(&path.join(name));
if tmp.exists() {
let _ = fs::remove_file(&tmp);
}
}
if let Ok(entries) = fs::read_dir(path) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with("library-") && name.ends_with(".bin.tmp") {
let _ = fs::remove_file(e.path());
}
}
}
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
@@ -514,6 +547,34 @@ impl Store {
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
}
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
/// address that doesn't parse counts as itself only, ×1, by its exact text.
fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
let kept: Vec<(&str, Vec<_>, u64)> = self
.files
.iter()
.filter_map(|(id, e)| {
let from = e.meta.ip.as_deref()?;
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
})
.collect();
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
let blocks = blocks_of(ip);
if blocks.is_empty() {
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
return vec![(1, files, bytes)];
}
blocks
.into_iter()
.map(|(block, times)| {
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
(times, files, bytes)
})
.collect()
}
/// Keep a document published without the key from `ip`, within the caps on what one address
/// and everyone may keep: the file, its links and its owner token.
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
@@ -525,19 +586,21 @@ impl Store {
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
return Err("too many files published from your address lately: try again in an hour".into());
}
let ip_text = ip.to_string();
let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect();
if mine.len() >= max_files {
return Err(format!(
"your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires",
mine.len()
));
let blocks = self.per_block(&ip.to_string());
for &(times, files, _) in &blocks {
if files >= max_files.saturating_mul(times as usize) {
return Err(format!(
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
));
}
}
let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum();
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
let fits = |n: u64| {
if used_ip.saturating_add(n) > max_ip {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20));
for &(times, _, used) in &blocks {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
}
}
if used_all.saturating_add(n) > max_all {
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
@@ -595,7 +658,10 @@ impl Store {
let dir = self.dir(&id);
let written = fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()))
.and_then(|()| {
disk::sync_dir(Some(&self.root.join(FILES)));
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
})
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
// Last: a folder without its description is an upload cut short.
@@ -748,7 +814,7 @@ impl Store {
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect();
let reason = clean_reason(reason);
if reason.is_empty() {
return Err("say what is wrong with the file".into());
}
@@ -973,6 +1039,12 @@ impl Store {
if m.version <= old.version {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
// A jump would let a client use up the numbers and leave no newer version to publish.
if m.version > old.version.saturating_add(1) {
return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
}
} else if m.version > MAX_FIRST_LIBRARY_VERSION {
return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
}
// Each version has a file of its own, written before the description names it: a failed
// description write or a crash in between leaves the previous version served whole, never
@@ -1090,9 +1162,11 @@ impl Store {
let h = &self.cfg.hosting;
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum();
if used_ip.saturating_add(n) > max_ip {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20));
for (times, _, used) in self.per_block(&ip) {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
if used_all.saturating_add(n) > max_all {
@@ -1110,7 +1184,24 @@ impl Store {
let mut issues = Vec::new();
let mut fresh_log = true;
let mut kept = 0;
match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) {
let log_path = dir.join(LOG);
// A log that's there but can't be read (an IO error, or over a lowered cap) may hold
// confirmed edits: the file isn't opened, and the log is left as it is for another try.
let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
r => r,
};
// One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
let read = match read.map(|b| disk::read_log(&b)) {
Ok(Err(why)) => {
let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
Err(format!("{why}; it was moved aside to {}", aside.display()))
}
Ok(Ok(log)) => Ok(log),
Err(why) => Err(why),
};
match read {
Ok(log) if log.generation == generation => {
fresh_log = false;
for entry in log.entries {
@@ -1167,7 +1258,10 @@ impl Store {
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
_ => {
let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
self.rewrite(file, next, &server).map_err(|e| e.why)?
}
};
self.entry(file)?.log = Some(log);
Ok(server)
@@ -1243,7 +1337,11 @@ impl Store {
fn compact(&mut self, file: &str, server: &Server) {
self.compactions += 1;
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
match self.rewrite(file, generation + 1, server) {
let Some(next) = generation.checked_add(1) else {
log::error!("file {file}: compaction failed: the file's generation can't go any higher");
return;
};
match self.rewrite(file, next, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
@@ -1385,14 +1483,18 @@ impl Backend for Store {
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
let _ = flush_log(file, log);
log.dirty
let compact = self.files.get_mut(file).is_some_and(|entry| {
let snapshot_seq = entry.meta.seq;
entry.log.as_mut().is_some_and(|log| {
// Issued client ids and refusals are durable in the log; only edits need a
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
})
});
if dirty {
if compact {
self.compact(file, server);
}
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
// It closes anyway, with its state in the snapshot and durable log.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
+178
View File
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
let dir = TempDir::new("read-only-reconnects");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, original) = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Read only");
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
(id, bytes)
};
let mut last_client = 0;
for _ in 0..6 {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let mut server = store.open(&id).unwrap();
let client = join(&mut server, Access::View);
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
last_client = client.id();
store.changed(&id, &mut server);
// Idle must sync even if no periodic flush ran before the connection closed.
assert!(store.idle(&id, &mut server));
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert!(!log.entries.is_empty());
assert_eq!(server.seq(), 0);
}
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
@@ -229,6 +257,17 @@ fn one_server_per_data_directory() {
assert_eq!(again.key(), key, "the key is kept");
}
#[cfg(unix)]
#[test]
fn an_existing_data_directory_is_made_private() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("perms");
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
assert!(crate::store::lock(dir.path()).unwrap().is_some());
let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn temporary_directories_are_removed() {
let path = {
@@ -241,6 +280,19 @@ fn temporary_directories_are_removed() {
assert!(!path.exists());
}
#[test]
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
let body = figcraft_collab::message::encode(&state).unwrap();
let mut legacy = b"FCSN\x01".to_vec();
legacy.extend_from_slice(&37u64.to_le_bytes());
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
legacy.extend_from_slice(&body);
let encoded = disk::encode_snapshot(37, &state).unwrap();
assert_eq!(encoded, legacy);
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
@@ -313,3 +365,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
#[test]
fn a_log_that_cant_be_read_is_never_overwritten() {
let dir = TempDir::new("unreadlog");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let log_path = dir.path().join(FILES);
let last = "Confirmed page name number 39 with some padding";
let id = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..40 {
rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding"));
store.changed(&id, &mut server);
}
assert!(store.flush().is_empty());
id
};
let log_path = log_path.join(&id).join(LOG);
let before = fs::read(&log_path).unwrap();
// The cap lowered just under the log: it can't be read now, but it must still be there after.
let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() };
{
let mut store = Store::open(dir.path(), lowered).unwrap();
let opened = store.open(&id);
assert!(opened.is_err(), "the file isn't opened without its log");
assert!(!store.problems().is_empty(), "the problem is reported");
assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was");
}
let mut store = Store::open(dir.path(), c).unwrap();
let server = store.open(&id).unwrap();
assert_eq!(page_name(&server), last, "every confirmed edit comes back");
}
#[test]
fn a_log_that_isnt_one_is_moved_aside() {
let dir = TempDir::new("badlog");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let folder = dir.path().join(FILES).join(&id);
let junk = b"not a log at all, but maybe worth keeping".to_vec();
fs::write(folder.join(LOG), &junk).unwrap();
drop(store);
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
store.open(&id).unwrap();
assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems());
let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk));
assert!(kept, "the old log's bytes are kept beside the file");
}
const BIDI: [char; 12] =
['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
#[test]
fn names_and_report_reasons_lose_bidi_controls() {
let dir = TempDir::new("bidi");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
for (n, m) in BIDI.into_iter().enumerate() {
let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap();
let (id, _, _) = publish(&mut store, &format!("a{m}b"));
assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32);
store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap();
assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32);
store.remove(&id).unwrap();
}
// A reason that is only control characters says nothing.
let (id, _, _) = publish(&mut store, "blank");
for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] {
assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}");
}
}
#[test]
fn a_maxed_out_generation_does_not_wrap() {
let dir = TempDir::new("maxgen");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Max");
let path = dir.path().join(FILES).join(&id);
let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
// Loading: no log goes with the snapshot, so the next generation would overflow.
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap();
fs::remove_file(path.join(LOG)).unwrap();
assert!(store.open(&id).is_err());
// Compacting: the open log is at the largest generation.
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap();
let _ = fs::remove_file(path.join(LOG));
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..4 {
rename(&mut server, &mut a, &format!("P{i}"));
store.changed(&id, &mut server);
store.flush();
}
let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(snap, u64::MAX);
}
#[test]
fn every_leftover_temporary_file_is_removed_at_startup() {
let dir = TempDir::new("leftovers");
let id = {
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let id = publish(&mut store, "A").0;
store.flush();
id
};
let folder = dir.path().join(FILES).join(&id);
let leftovers = [
dir.path().join("blocked.json.tmp"),
dir.path().join("reports.json.tmp"),
dir.path().join("server.key.tmp"),
folder.join("usage.json.tmp"),
folder.join("library-1.bin.tmp"),
];
for path in &leftovers {
fs::write(path, b"half").unwrap();
}
let _store = Store::open(dir.path(), cfg(dir.path())).unwrap();
for path in &leftovers {
assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display());
}
}
+3 -3
View File
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
@media (prefers-color-scheme: dark) {
+10 -2
View File
@@ -69,7 +69,15 @@ struct WebShell {
}
impl eframe::App for WebShell {
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.ui(ui);
}
@@ -117,7 +125,7 @@ impl Services for WebServices {
}
fn open_url(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") {
log::error!("couldn't open {url}: {e:?}");
}
}
+8 -1
View File
@@ -21,7 +21,7 @@ figcraft-text = { workspace = true }
# 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"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -33,8 +33,15 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
# accesskit already; this only makes it direct.
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
+1
View File
@@ -1,6 +1,7 @@
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
+1 -1
View File
@@ -3,7 +3,7 @@
use crate::args::{Args, file_url_path, is_invite, parse};
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
a.iter().map(ToString::to_string).collect()
}
#[test]
+49 -7
View File
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -16,6 +16,38 @@ const MAX_LINE: usize = 32 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 8;
const MAX_QUEUED_REQUESTS: usize = 8;
const TIMEOUT: Duration = Duration::from_secs(60);
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
@@ -38,7 +70,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in listener.incoming().flatten() {
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
}
});
@@ -47,6 +79,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
}
}
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
@@ -60,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
}
}
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
@@ -87,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
@@ -95,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(timeout) {
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
+64 -2
View File
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
@@ -131,6 +178,18 @@ fn idle_and_partial_frame_connections_expire() {
}
}
/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
/// does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
@@ -215,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
@@ -250,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
+41 -15
View File
@@ -15,6 +15,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
@@ -62,6 +67,9 @@ struct App {
}
impl eframe::App for App {
/// Native eframe logic callback: store the first context, drain control requests, run
/// guarded background work, poll macOS open events, handle close requests and title
/// changes, and schedule recovery repaints on the app clock.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -71,7 +79,9 @@ impl eframe::App for App {
}
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, ctx);
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
// It runs on logic-only callbacks too, so it never reads UI input.
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
@@ -86,8 +96,14 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
// Recovery deadlines use the same monotonic clock as the app's background work: a
// logic-only tick leaves `ctx.input(i.time)` stale.
if let Some(wait) = with_app(|fig| {
let now = fig.now_secs();
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
})
.flatten()
{
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
@@ -117,6 +133,11 @@ impl eframe::App for App {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
@@ -149,21 +170,23 @@ fn main() -> eframe::Result {
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
let fonts_slot = ctx_slot.clone();
// Wakes the window (once it is there) from another thread.
let wake = |slot: &Arc<Mutex<Option<egui::Context>>>| {
let slot = slot.clone();
move || {
if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}
};
let fonts_wake = wake(&ctx_slot);
std::thread::spawn(move || {
figcraft_text::scan_system_fonts();
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
fonts_wake();
});
let mut session = Session::empty();
// Live sessions: the network threads wake the window when something arrives.
let wake_slot = ctx_slot.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}))));
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot)))));
for f in &args.files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");
@@ -175,6 +198,7 @@ fn main() -> eframe::Result {
// The preferences first: they hold the online files a recovered document may be one of.
let recovery = recovery::Shared::default();
let svc = services::Desktop::load(recovery.clone());
svc.follow_system_dark(wake(&ctx_slot));
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
@@ -199,6 +223,7 @@ fn main() -> eframe::Result {
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.set_startup_update_check(true);
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
@@ -244,14 +269,15 @@ fn main() -> eframe::Result {
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
apple_events: apple_events.connect(&cc.egui_ctx),
}))
}),
);
+26 -4
View File
@@ -206,10 +206,12 @@ impl Recovery {
let doc = d.doc.clone();
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
// Which online file it is and what of the server's this very copy has (a line each:
// the server, the file's id, then the session, client id and last change), while it
// the server, the file's id, then the session, client id, last change and the secret the
// client id resumes with), while it
// is one. Written after the copy, never before: a note must not promise more of the
// server's changes than its copy holds.
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
let online =
s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret));
let job = move || {
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
@@ -312,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
// Absent in notes written before ids had secrets.
let secret = at.next().flatten().unwrap_or(0);
let params =
json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret});
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
@@ -338,7 +343,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
let Ok(mut f) = opts.open(&tmp) else { return false };
if std::io::Write::write_all(&mut f, &bytes).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
}
#[cfg(not(unix))]
{
if std::fs::write(&tmp, bytes).is_err() {
return false;
}
}
std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
+1 -1
View File
@@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
let left = crash(&s, &root);
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
let at = s.online_of(s.docs[0].uid).unwrap().2;
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had");
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
let (mut next, script, mut r, opened) = restart(&s, &root);
assert_eq!(opened, 0, "nothing comes back as a plain copy");
+153 -9
View File
@@ -2,8 +2,8 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
@@ -11,15 +11,20 @@ use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
egui_ctx: Option<egui::Context>,
/// Where the preferences live ([`config_dir`]).
dir: Option<PathBuf>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
dark: Arc<AtomicU8>,
/// The system's interface language (read at startup).
language: Option<String>,
/// Recovery copies of unsaved files (written at once after a panic).
recovery: crate::recovery::Shared,
/// The folder of the last file or folder a dialog picked, where the next one starts.
last_folder: Arc<Mutex<Option<PathBuf>>>,
}
pub fn config_dir() -> Option<PathBuf> {
@@ -54,7 +59,33 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN));
Self {
clipboard: arboard::Clipboard::new().ok(),
egui_ctx: None,
dir,
prefs,
reduce_motion,
dark,
language: system_language(),
recovery,
last_folder: Arc::default(),
}
}
/// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme
/// answers instead, read once and then watched in the background, calling `wake` when it
/// changes (an idle window draws no frames). Elsewhere this does nothing.
pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) {
#[cfg(all(unix, not(target_os = "macos")))]
if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({
let dark = self.dark.clone();
move || watch_system_dark(&dark, &wake)
}) {
log::info!("colour scheme: can't start the watcher: {e}");
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
let _ = wake;
}
}
@@ -136,6 +167,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light.
const DARK_UNKNOWN: u8 = 0;
const DARK_DARK: u8 = 1;
const DARK_LIGHT: u8 = 2;
/// The desktop's colour scheme through the freedesktop settings portal
/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is
/// light), read once and then watched for changes, each one calling `wake`. Without a session bus
/// or a portal the scheme stays unknown and the UI keeps its light fallback.
#[cfg(all(unix, not(target_os = "macos")))]
fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) {
use zbus::zvariant::Value;
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
// `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant.
let scheme = |v: &Value| match v {
Value::U32(x) => *x,
Value::Value(inner) => match **inner {
Value::U32(x) => x,
_ => 0,
},
_ => 0,
};
let store = |v: u32| {
let new = match v {
1 => DARK_DARK,
2 => DARK_LIGHT,
_ => DARK_UNKNOWN,
};
if dark.swap(new, Ordering::Relaxed) != new {
wake();
}
};
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else {
log::info!("colour scheme: no session bus: the system theme isn't followed");
return;
};
let read = |method: &str| -> zbus::Result<u32> {
let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?;
let body = reply.body();
Ok(scheme(&body.deserialize::<Value>()?))
};
match read("ReadOne").or_else(|_| read("Read")) {
Ok(v) => store(v),
Err(e) => log::info!("colour scheme: can't read the system setting: {e}"),
}
let watch = || -> zbus::Result<()> {
let rule =
zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build();
for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? {
let msg = msg?;
let body = msg.body();
let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue };
if namespace == APPEARANCE && key == "color-scheme" {
store(scheme(&value));
}
}
Ok(())
};
if let Err(e) = watch() {
log::info!("colour scheme: can't watch the system setting: {e}");
}
}
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
fn reveal(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
@@ -262,25 +358,66 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
}
}
/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder
/// picked itself.
pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option<PathBuf> {
let folder = if is_folder { Some(path) } else { path.parent() };
folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf)
}
/// The remembered folder, unless it is gone since: then the system's own choice.
pub(crate) fn start_folder(last: Option<PathBuf>) -> Option<PathBuf> {
last.filter(|f| f.is_dir())
}
impl Desktop {
/// A dialog that starts in the last folder used. The Linux portal does not remember it between
/// dialogs as macOS and Windows do.
fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog {
let dialog = dialog(title);
match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) {
Some(folder) => dialog.set_directory(folder),
None => dialog,
}
}
}
/// `pick`, with the folder of what it picks kept in `last` for the next dialog.
pub(crate) fn remembering(last: Arc<Mutex<Option<PathBuf>>>, pick: DialogFuture, is_folder: bool) -> DialogFuture {
Box::pin(async move {
let path = pick.await?;
if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) {
*last = Some(folder);
}
Some(path)
})
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
let mut dialog = dialog(title).set_file_name(name);
let mut dialog = self.dialog_in_last_folder(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
let pick = picked(dialog.save_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
let pick = picked(self.dialog_in_last_folder(title).pick_folder());
remembering(self.last_folder.clone(), pick, true)
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
let _ = c.set_text(s.to_string());
if let Some(ctx) = &self.egui_ctx {
ctx.copy_text(s.to_string());
}
}
fn set_egui_context(&mut self, ctx: &egui::Context) {
self.egui_ctx = Some(ctx.clone());
}
fn clipboard_get_text(&mut self) -> Option<String> {
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
@@ -324,6 +461,13 @@ impl Services for Desktop {
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn system_dark(&self) -> Option<bool> {
match self.dark.load(Ordering::Relaxed) {
DARK_DARK => Some(true),
DARK_LIGHT => Some(false),
_ => None,
}
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
+25
View File
@@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself.
#[test]
fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
use crate::services::{folder_to_remember, remembering, start_folder};
use std::path::PathBuf;
let dir = root("last-folder");
let file = dir.join("Poster.figcraft");
assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder");
assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself");
assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder");
let last = Arc::new(Mutex::new(None::<PathBuf>));
let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file)));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered");
let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false);
assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder");
assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
+11 -4
View File
@@ -2,8 +2,15 @@
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
use figcraft_ui_egui::i18n::tr;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
const DRIVER_HINT: &str = if cfg!(windows) {
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
} else {
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
};
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
@@ -15,9 +22,9 @@ fn the_message_carries_the_error_and_where_to_report_it() {
#[test]
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
let text = message(ERROR, false);
assert!(text.contains("graphics driver"), "{text}");
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
}
#[cfg(windows)]
@@ -30,6 +37,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() {
#[test]
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
let text = message(ERROR, true);
assert!(text.contains("unsetting"), "{text}");
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
}
+15
View File
@@ -71,3 +71,18 @@ sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
[people.mojtabajafarzadeh]
real_name = "Mojtaba Jafarzadeh"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.leandrodslv]
sync_github_name = true
+44
View File
@@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() {
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
assert!(hub.barred.contains_key("other"), "another file's stay");
}
#[test]
fn a_kicked_connection_with_a_full_queue_is_shut() {
use std::io::Read;
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc::sync_channel;
use std::time::Duration;
use super::{Conn, Kind, Line, Out};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server_side, _) = listener.accept().unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
// Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone.
let _writer = server_side.try_clone().unwrap();
let (tx, _rx) = sync_channel(2);
while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {}
let out = Line {
tx,
queued: Arc::default(),
block: Arc::new(AtomicUsize::new(0)),
all: Arc::new(AtomicUsize::new(0)),
max: 1 << 20,
max_block: usize::MAX,
max_all: usize::MAX,
};
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
let mut hub = Hub {
in_flight: InFlight(Default::default()),
backend: Closes,
rooms: HashMap::new(),
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
local: None,
outbox: Vec::new(),
barred: HashMap::new(),
};
hub.kick(&[1], "kicked");
assert!(hub.conns.is_empty());
let mut buf = [0u8; 1];
assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)");
}
+8 -2
View File
@@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let mut late = session("Cy");
late.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
// The port is free again: another program's host may have taken it, and that host refuses
// the code's secret.
let label = late.live().status.label();
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
}
#[test]
@@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
assert!(host.live().peers.is_empty());
// A typing mistake is caught before anything connects.
let mut typo: Vec<char> = local.code().chars().collect();
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
// The last symbol contains only checksum bits. Changing payload bits can collide with
// the eight-bit checksum, so it does not guarantee a malformed code.
let last = typo.last_mut().unwrap();
*last = if *last == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
+55 -16
View File
@@ -402,6 +402,8 @@ enum HubIn {
sock: TcpStream,
/// Its size (in flight until handled).
bytes: usize,
/// Told when the hub takes the connection in (dropped unsent when it doesn't).
admitted: SyncSender<()>,
},
Msg {
conn: u64,
@@ -811,17 +813,22 @@ fn encode(msg: &ServerMsg) -> Option<Bytes> {
/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps).
fn refuse(out: &Line, why: &str) {
refused(out, why);
}
/// [`refuse`], and whether the close was queued (it isn't when the queue is full).
fn refused(out: &Line, why: &str) -> bool {
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
let _ = out.tx.try_send(Out::Bytes(b, None));
}
let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why)));
out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok()
}
/// What the server tells a client when it is too busy to take more from it.
const BUSY: &str = "the server is busy: try again in a moment";
/// Read the next binary message (skipping pings) and its size, or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<(ClientMsg, usize)>, String> {
/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => {
@@ -830,6 +837,13 @@ fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<(Client
message::decode::<ClientMsg>(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string())
}
Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()),
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and
// a message trickled in is cut off by it (`Duplex::read_until`).
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_))
if deadline.is_some_and(|d| Instant::now() >= d) =>
{
Err("no hello in time".into())
}
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
Ok(Message::Close(_)) => Ok(None),
Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)),
@@ -851,6 +865,7 @@ fn too_large(max: usize) -> String {
/// One connection's reader (the writer runs on its own thread).
fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, open: Open, shared: &Shared) {
let _ = stream.set_nodelay(true);
let hello_deadline = Instant::now() + limits.hello_timeout;
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
@@ -859,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
}
let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };
let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer);
let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return };
let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return };
// The handshake and the hello must be in by the deadline, however slowly they trickle in.
rd.read_until(Some(hello_deadline));
let cfg = config(limits.max_client_message);
let mut forwarded = None;
// The handshake callback's type is tungstenite's (its error is an HTTP response).
@@ -923,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
log::info!("event=refused ip={ip} reason=connections_per_address");
end(Some("too many connections from your address: close some and try again"));
} else {
match next_message(&mut ws, limits.max_hello) {
let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline));
// In time or not, the hello's deadline is over: what's read next has timeouts of its own.
ws.get_mut().read_until(None);
match hello {
Ok(Some((
first @ (ClientMsg::Hello(_)
| ClientMsg::Manage(_)
@@ -946,9 +966,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
end(Some(BUSY));
} else {
ws.set_config(|c| *c = config(max));
let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok());
if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
let (admitted, verdict) = sync_channel(1);
let opened =
sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok());
// Nothing more is read until the hub took the connection in (its token checked):
// only then may it send messages of the full size.
if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
ws.set_config(|c| *c = config(max));
read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end);
let _ = hub.send(HubIn::Gone { conn });
}
@@ -962,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
drop(counted);
// The block's counters go with the last handle on them (the table forgets it then).
drop(block);
ws.get_mut().read_until(None);
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
@@ -981,7 +1006,7 @@ fn read_loop(
let mut bucket = Bucket::new(limits.rate, limits.burst);
let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST);
loop {
match next_message(ws, max) {
match next_message(ws, max, None) {
Ok(Some((msg, bytes))) => {
let now = Instant::now();
if !bucket.take(now) {
@@ -1074,6 +1099,8 @@ struct Pending {
out: Line,
sock: TcpStream,
ip: IpAddr,
/// Told when it is taken in.
admitted: SyncSender<()>,
}
/// A connection the hub took in.
@@ -1195,7 +1222,7 @@ impl<B: Backend> Hub<B> {
fn handle(&mut self, m: HubIn) {
match m {
HubIn::Open { conn, ip, first, out, sock, bytes } => {
HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => {
self.in_flight.give(bytes);
// The operator (with the server key) gets in from anywhere; everyone else is
// checked (blocked addresses, connection rates).
@@ -1204,7 +1231,7 @@ impl<B: Backend> Hub<B> {
log::info!("event=refused ip={ip} reason={why:?}");
return refuse(&out, &why);
}
let c = Pending { out, sock, ip };
let c = Pending { out, sock, ip, admitted };
match first {
ClientMsg::Hello(hello) => self.join(conn, hello, c),
ClientMsg::Manage(m) => self.manage(conn, &m, c),
@@ -1399,6 +1426,7 @@ impl<B: Backend> Hub<B> {
if let Some(c) = old.and_then(|old| self.conns.remove(&old)) {
shutdown(&c.sock);
}
let _ = c.admitted.try_send(());
self.conns
.insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip });
self.queue(&file, more);
@@ -1429,7 +1457,9 @@ impl<B: Backend> Hub<B> {
barred.insert(c.ip);
}
log::info!("event=removed file={file} client={id} ip={}", c.ip);
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() {
shutdown(&c.sock);
}
self.gone(conn);
Ok(())
}
@@ -1444,6 +1474,7 @@ impl<B: Backend> Hub<B> {
/// Take in connection `c` as `kind` and send it `first`.
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
let _ = c.admitted.try_send(());
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
self.answer(conn, first);
}
@@ -1543,8 +1574,10 @@ impl<B: Backend> Hub<B> {
// People in the file are told the session is over for them, as when a host stops (they
// keep their copy; nothing retries); a library request is refused.
for conn in &people {
if let Some(c) = self.conns.get(conn) {
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
if let Some(c) = self.conns.get(conn)
&& c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err()
{
shutdown(&c.sock);
}
self.gone(*conn);
}
@@ -1593,8 +1626,10 @@ impl<B: Backend> Hub<B> {
/// Refuse connections with `why` and drop them at once (nothing more they send counts).
fn kick(&mut self, conns: &[u64], why: &str) {
for conn in conns {
if let Some(c) = self.conns.get(conn) {
refuse(&c.out, why);
if let Some(c) = self.conns.get(conn)
&& !refused(&c.out, why)
{
shutdown(&c.sock);
}
self.gone(*conn);
}
@@ -1747,3 +1782,7 @@ mod queue_tests;
#[cfg(test)]
#[path = "barred_tests.rs"]
mod barred_tests;
#[cfg(test)]
#[path = "preauth_tests.rs"]
mod preauth_tests;
+81 -6
View File
@@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence};
pub type Waker = Arc<dyn Fn() + Send + Sync>;
/// Most discrete events kept between two drains (joins, leaves, status); the oldest go first.
/// Document sync events don't count and are never dropped: the sync client treats a missing
/// batch as a fatal sequence gap, so losing one would end the session.
const MAX_EVENTS: usize = 10_000;
#[derive(Default)]
struct State {
events: VecDeque<Event>,
/// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order.
next: u64,
/// [`Event::Sync`] events, never dropped.
syncs: VecDeque<(u64, Event)>,
/// Every other event, capped at [`MAX_EVENTS`].
others: VecDeque<(u64, Event)>,
presence: BTreeMap<u32, Presence>,
}
@@ -43,11 +50,17 @@ impl Inbox {
if let Event::PeerLeft(id) = &e {
s.presence.remove(id);
}
if s.events.len() >= MAX_EVENTS {
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
s.events.pop_front();
let n = s.next;
s.next += 1;
if matches!(e, Event::Sync(_)) {
s.syncs.push_back((n, e));
return;
}
s.events.push_back(e);
if s.others.len() >= MAX_EVENTS {
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
s.others.pop_front();
}
s.others.push_back((n, e));
});
}
@@ -62,8 +75,70 @@ impl Inbox {
/// peer.
pub fn drain(&self) -> Vec<figcraft_engine::live::Event> {
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let mut out: Vec<Event> = s.events.drain(..).collect();
let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable();
let mut others = std::mem::take(&mut s.others).into_iter().peekable();
let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len());
loop {
let sync_first = match (syncs.peek(), others.peek()) {
(Some((a, _)), Some((b, _))) => a < b,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => break,
};
let next = if sync_first { syncs.next() } else { others.next() };
out.extend(next.map(|(_, e)| e));
}
out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p)));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use figcraft_collab::message::ServerMsg;
fn reject(batch_id: u64) -> Event {
Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() })
}
fn sync_ids(events: &[Event]) -> Vec<u64> {
events
.iter()
.filter_map(|e| match e {
Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id),
_ => None,
})
.collect()
}
#[test]
fn a_full_inbox_never_drops_sync_events() {
let inbox = Inbox::new(Arc::new(|| {}));
inbox.push(reject(1));
for _ in 0..MAX_EVENTS {
inbox.push(Event::PeerLeft(7));
}
inbox.push(reject(2));
for _ in 0..MAX_EVENTS {
inbox.push(Event::PeerLeft(7));
}
let out = inbox.drain();
assert_eq!(sync_ids(&out), vec![1, 2]);
assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len());
}
#[test]
fn sync_events_past_the_cap_are_all_kept_in_order() {
let inbox = Inbox::new(Arc::new(|| {}));
let n = MAX_EVENTS as u64 + 5;
for id in 0..n {
inbox.push(reject(id));
}
inbox.push(Event::PeerLeft(7));
let out = inbox.drain();
let ids = sync_ids(&out);
assert_eq!(ids.len() as u64, n, "Sync events dropped");
assert!(ids.iter().copied().eq(0..n), "Sync events out of order");
}
}
+2 -2
View File
@@ -189,9 +189,9 @@ pub(crate) enum Taken {
/// in answer must go out on this connection.
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
match m {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => {
if let Ok(mut r) = resume.lock() {
*r = Some(Resume { session, client_id, seq });
*r = Some(Resume { session, client_id, seq, secret });
}
// The sync client opens the document (and fills in its name and pages).
let peers = peers.clone();
+20
View File
@@ -24,6 +24,12 @@ pub struct NetLimits {
pub hello_timeout: Duration,
/// A connection with no traffic for this long is dead.
pub idle_timeout: Duration,
/// How long a one-shot request connection (hosting, a library, a plugin directory — and, most
/// importantly, publishing a file) waits for the server's reply before giving up. Publishing
/// uploads a whole file and a large one on a slow link takes the server a while to receive and
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
/// easily need several minutes).
pub request_reply_timeout: Duration,
/// A quiet client pings this often (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
@@ -68,6 +74,7 @@ impl Default for NetLimits {
burst: 400.0,
hello_timeout: Duration::from_secs(10),
idle_timeout: Duration::from_secs(45),
request_reply_timeout: Duration::from_secs(300),
keepalive: Duration::from_secs(15),
queue: 1024,
write_timeout: Duration::from_secs(10),
@@ -85,3 +92,16 @@ impl Default for NetLimits {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_reply_wait_is_longer_than_idle() {
let d = NetLimits::default();
// Publishing uploads a whole file; a slow link must not cut it off at the session idle
// timeout. Keep the reply wait comfortably above it.
assert!(d.request_reply_timeout > d.idle_timeout);
}
}
+73 -2
View File
@@ -2,7 +2,7 @@
//! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and
//! reconnecting. Every test stops its host and closes its sockets.
use std::io::Read;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -488,6 +488,21 @@ fn silent_connections_time_out() {
host.stop();
}
#[test]
fn connections_without_a_hello_are_told_why_they_end() {
// A socket's read timeout can end a moment before the hello's deadline; the connection still
// gets the refusal. Twelve at once, so that happens to some of them.
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
std::thread::scope(|s| {
let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect();
for w in waits {
assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time"));
}
});
host.stop();
}
#[test]
fn guests_reconnect_after_a_network_failure() {
let (mut host, mut hp, invite) = start();
@@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
let (mut host, mut hp, invite) = start_with(limits, Limits::default());
let probe = Probe::new();
let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into()));
cfg.limits = limits;
// The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds
// the wait for the welcome, which a loaded machine can take longer than 600 ms to send.
cfg.limits.keepalive = limits.keepalive;
let _g = Guest::start(cfg, probe.inbox.clone()).unwrap();
let mut gp = probe;
gp.wait("welcome", welcomed);
@@ -670,3 +687,57 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
assert!(!welcomed(&gp.events));
host.stop();
}
/// How long a connection that never says hello stays open while `poke` sends it something every
/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own
/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close
/// and a slow poke loop don't delay it.
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut r = Raw::connect(addr);
let mut watch = r.0.get_ref().try_clone().unwrap();
let t0 = Instant::now();
let (ended_tx, ended) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
let mut buf = [0u8; 1024];
let until = Instant::now() + WAIT;
loop {
match watch.read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {}
Err(_) => break,
}
}
let _ = ended_tx.send(Instant::now());
});
let (mut closed, mut first) = (None, true);
while t0.elapsed() < Duration::from_secs(6) {
if !poke(&mut r, std::mem::take(&mut first)) {
closed = Some(t0.elapsed());
break;
}
if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) {
closed = Some(at.saturating_duration_since(t0));
break;
}
}
host.stop();
let _ = reader.join();
closed
}
#[test]
fn pings_do_not_keep_a_connection_without_a_hello_alive() {
let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok());
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}");
}
#[test]
fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() {
// A binary message begun and never finished: one masked byte per frame, each in time for the
// read timeout.
let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok());
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}");
}
+60 -4
View File
@@ -196,19 +196,27 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits,
Ok(r)
}
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
/// server a while to keep).
/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload
/// takes the server a while to keep).
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
let (stream, _, _) = open(server, timeout, limits)?;
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
// A publish uploads a whole file; a large project on a slow link can take several minutes to
// receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`.
ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?;
Ok(ws)
}
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost.
/// How long `close` waits for the server's close.
const CLOSE_DRAIN: Duration = Duration::from_secs(2);
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost,
/// but no longer than [`CLOSE_DRAIN`] (a server that keeps pinging, or goes quiet, would pin the
/// loop).
fn close(mut ws: Ws) {
let _ = ws.close(None);
ws.get_mut().read_until(Some(std::time::Instant::now() + CLOSE_DRAIN));
while ws.read().is_ok() {}
}
@@ -240,3 +248,51 @@ fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<
};
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whether `close` returns within 3 s against a server that never answers the close and does
/// `serve` instead, with reads waiting up to `idle`.
fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket<std::net::TcpStream>) + Send + 'static) -> bool {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
if let Ok(ws) = tungstenite::accept(s) {
serve(ws);
}
});
let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() };
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
close(ws);
let _ = tx.send(());
});
rx.recv_timeout(Duration::from_secs(3)).is_ok()
}
/// A server that keeps pinging and never answers the close must not pin `close` forever.
#[test]
fn close_returns_against_a_server_that_keeps_pinging() {
let pinging = closes_in_time(Duration::from_millis(300), |mut ws| {
while ws.send(Message::Ping(Bytes::new())).is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
});
assert!(pinging, "close() kept draining a pinging server");
}
/// Nor one that goes quiet, however long the idle timeout.
#[test]
fn close_returns_against_a_silent_server() {
let silent = closes_in_time(Duration::from_secs(10), |ws| {
std::thread::sleep(Duration::from_secs(5));
drop(ws);
});
assert!(silent, "close() waited out the idle timeout");
}
}
+29 -6
View File
@@ -33,14 +33,16 @@ pub struct Duplex {
tls: Option<Tls>,
/// What the bytes read count against (a server's reader), if anything.
meter: Option<Meter>,
/// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]).
deadline: Option<Instant>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None };
Ok((a, b, stream))
}
@@ -49,6 +51,12 @@ impl Duplex {
self.meter = Some(meter);
}
/// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in
/// time (a message trickled in can't outlast it). `None`: only the read timeout applies.
pub fn read_until(&mut self, deadline: Option<Instant>) {
self.deadline = deadline;
}
/// What was read so far became a message (handed on): it no longer counts as being read.
pub fn read_done(&mut self) {
if let Some(m) = self.meter.as_mut() {
@@ -187,10 +195,25 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
let n = loop {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
self.read.set_read_timeout(Some(left))?;
}
let read = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
};
match read {
// The socket's timeout can run out a moment before the deadline (it counts whole
// milliseconds): wait out the rest, so a read times out only once it has passed.
Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
read => break read?,
}
};
if let Some(m) = self.meter.as_mut() {
m.add(n)?;
}
+72
View File
@@ -0,0 +1,72 @@
//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it
//! can't have a message of the full size read while its token is unchecked.
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal};
use figcraft_collab::{Client, Server};
use tungstenite::{Bytes, Message};
use super::{Admission, Backend, Closing, Hosting};
use crate::net::NetLimits;
/// A backend that refuses every hello, once it is told to.
struct Slow(Receiver<()>);
impl Backend for Slow {
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
let _ = self.0.recv();
Err("wrong token".into())
}
fn open(&mut self, _file: &str) -> Result<Server, String> {
Err("no".into())
}
fn closing(&self) -> Closing {
Closing::Ended
}
}
fn connect(addr: SocketAddr) -> tungstenite::WebSocket<TcpStream> {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0
}
#[test]
fn an_unadmitted_connection_cant_send_a_large_message() {
let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() };
let (release, wait) = channel();
let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap();
let addr = hosting.addr();
let sender = std::thread::spawn(move || {
let mut ws = connect(addr);
let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap();
ws.send(Message::Binary(Bytes::from(hello))).unwrap();
let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) });
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap())));
let mut refused = None;
while let Ok(m) = ws.read() {
if let Message::Binary(b) = m
&& let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20)
{
refused = Some(reason);
}
}
refused
});
let mut peak = 0;
let until = Instant::now() + Duration::from_secs(2);
while Instant::now() < until {
peak = peak.max(hosting.in_flight_bytes());
std::thread::sleep(Duration::from_millis(2));
}
release.send(()).unwrap();
let refused = sender.join().unwrap();
hosting.stop();
assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked");
assert_eq!(refused.as_deref(), Some("wrong token"));
}
+10 -1
View File
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
/// Asserts two files are hosted and joined independently: each has its own port, session and
/// edits, presence stays in its file's session, and closing one file's session leaves the
/// other live.
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
@@ -123,8 +126,14 @@ fn two_files_are_hosted_and_joined_independently() {
host.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(host.live_summary()["link"], a["link"]);
assert_eq!(host.live_summary()["document"], 0);
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
// whose welcome came last is in front.
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
// Concurrent joins may complete in either order. Put B in front explicitly so the following
// assertions exercise A's background session independent of network timing.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
let in_b = rect(&mut host);
+45
View File
@@ -0,0 +1,45 @@
//! Boards sync: the file kind reaches joiners in their snapshot, and a layer's board metadata (a
//! connector's ends, a sticky's author) syncs as one property, through both encodings.
use figcraft_doc::{BoardItem, Connector, Document, End, FileKind, NodeKind, Routing, Side};
use crate::change::{Change, DocProp, Prop};
use crate::replica::{Mode, Replica};
use crate::{diff, message};
#[test]
fn a_boards_kind_and_its_objects_metadata_sync() {
let mut doc = Document::new("Board");
doc.kind = FileKind::Board;
let page = doc.first_page().unwrap();
let a = doc.create(page, NodeKind::Frame, "Sticky", 0.0, 0.0, 200.0, 200.0).unwrap();
doc.node_mut(a).unwrap().board = Some(BoardItem::Sticky { author: "Ada".into() });
let host = Replica::new(doc);
// A joiner's copy, built from the snapshot, is a board with the sticky's metadata.
let snap = host.snapshot().unwrap();
assert!(snap.iter().any(|c| matches!(c, Change::SetDoc { prop: DocProp::Kind, .. })));
let joiner = Replica::from_snapshot(&snap).unwrap();
assert_eq!(joiner.document().kind, FileKind::Board);
assert_eq!(joiner.document().node(a).unwrap().board, host.document().node(a).unwrap().board);
// Re-attaching a connector's end is one property write, the same after the wire.
let mut edited = host.document().clone();
let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap();
edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
from: End::Node { node: a, side: Side::Auto },
to: End::Point { x: 5.0, y: 5.0 },
routing: Routing::Elbow,
}));
let mut r = host.clone();
r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap();
let mut moved = r.document().clone();
moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
from: End::Node { node: a, side: Side::Top },
to: End::Point { x: 9.0, y: 1.0 },
routing: Routing::Straight,
}));
let d = diff(&r, &moved).unwrap();
assert!(matches!(d.changes.as_slice(), [Change::Set { prop: Prop::Board, .. }]), "{:?}", d.changes);
let wire: Vec<Change> = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap();
r.apply(&wire, Mode::Strict).unwrap();
assert_eq!(r.document(), &moved);
}
+13 -2
View File
@@ -181,6 +181,8 @@ node_props! {
Annotations => annotations,
CodeLinks => code_links,
DevResources => dev_resources,
Measurements => measurements,
Board => board,
}
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,
@@ -219,6 +221,12 @@ pub enum DocProp {
Variables,
/// Variable ids in order.
VariableOrder,
/// A design file or a board.
Kind,
/// A board's countdown (`null`: none).
Timer,
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
Vote,
}
/// A document-level entry, by id.
@@ -238,6 +246,8 @@ pub enum Entry {
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
}
/// One synced value a change writes: a node property, a document-level property or a
@@ -311,7 +321,7 @@ impl Entry {
Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)),
Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)),
Entry::Comment(id) => Some(space_of(*id)),
Entry::DevSnapshot(_) | Entry::Image(_) => None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
@@ -324,6 +334,7 @@ impl Entry {
Entry::Variable(id) => format!("variable {id}"),
Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"),
Entry::Image(hash) => format!("image {hash}"),
Entry::Ballot(voter) => format!("ballot of {voter}"),
}
}
@@ -334,7 +345,7 @@ impl Entry {
Entry::Comment(_) => Some(Family::Comments),
Entry::Collection(_) => Some(Family::Collections),
Entry::Variable(_) => Some(Family::Variables),
Entry::DevSnapshot(_) | Entry::Image(_) => None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
}
+10 -5
View File
@@ -41,6 +41,8 @@ pub struct Client {
/// The session joined (see [`ServerMsg::Welcome`]).
session: u64,
id: u32,
/// What this client resumes its id with (see [`Resume::secret`]).
secret: u64,
/// The server's state as of `seq`.
confirmed: Replica,
seq: u64,
@@ -84,7 +86,7 @@ impl Client {
/// Start from the server's `Welcome` (which must carry a snapshot).
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else {
return Err(Error::Protocol("expected a welcome with the document".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
@@ -93,6 +95,7 @@ impl Client {
Ok(Client {
session,
id: client_id,
secret,
view: confirmed.clone(),
confirmed,
seq,
@@ -415,9 +418,10 @@ impl Client {
ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello })
}
/// What a reconnect resumes with: this client's session, id and last sequence number.
/// What a reconnect resumes with: this client's session, id, last sequence number and the
/// secret its id was given with.
pub fn resume(&self) -> Resume {
Resume { session: self.session, client_id: self.id, seq: self.seq }
Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret }
}
/// Handle a message from the server. Returns messages to send: batches that were waiting for
@@ -458,10 +462,11 @@ impl Client {
Ok(Vec::new())
}
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => {
if session != self.session || client_id != self.id {
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access });
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret });
}
self.secret = secret;
self.access = access;
self.rejoin(seq, catch_up, &processed, peers, recompute)
}
+13 -1
View File
@@ -11,6 +11,16 @@ use crate::message::{
use crate::sim::Rng;
use crate::{Error, Replica, diff};
#[test]
fn encoding_into_a_prefix_keeps_the_wire_body() {
let msg = moved_layer();
let prefix = b"container header".to_vec();
let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap();
assert_eq!(&bytes[..prefix.len()], prefix);
assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap());
assert_eq!(decode::<ClientMsg>(&bytes[prefix.len()..], bytes.len()).unwrap(), msg);
}
/// A typical edit: one layer dragged (its transform).
fn moved_layer() -> ClientMsg {
let mut doc = Document::new("f");
@@ -38,7 +48,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
name: "Ada".into(),
color: "#FF8800".into(),
client_version: "0.2.0".into(),
resume: Some(Resume { session: 77, client_id: 4, seq: 10 }),
resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }),
}),
moved_layer(),
cursor(),
@@ -102,6 +112,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
processed: Processed::default(),
peers: vec![peer.clone()],
access: Access::Edit,
secret: 0x5EC2E7,
},
ServerMsg::Welcome {
session: 77,
@@ -111,6 +122,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
processed: Processed { last_batch: 7, refused: vec![3, 6] },
peers: vec![],
access: Access::View,
secret: 1,
},
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
+16 -7
View File
@@ -132,18 +132,15 @@ impl<T: Keyed> List<T> {
let keys: Vec<T::Key> = value.read("order")?;
let list = (self.get_mut)(doc);
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
let mut at: BTreeMap<T::Key, Vec<usize>> = BTreeMap::new();
let mut at: BTreeMap<T::Key, std::collections::VecDeque<usize>> = BTreeMap::new();
for (i, e) in slots.iter().enumerate() {
if let Some(e) = e {
at.entry(e.key()).or_default().push(i);
at.entry(e.key()).or_default().push_back(i);
}
}
for k in keys {
let Some(idx) = at.get_mut(&k) else { continue };
if idx.is_empty() {
continue;
}
let i = idx.remove(0);
let Some(i) = idx.pop_front() else { continue };
if let Some(e) = slots.get_mut(i).and_then(Option::take) {
list.push(e);
}
@@ -213,6 +210,9 @@ doc_scalars! {
ImagePresets => image_presets,
AnnotationCategories => annotation_categories,
Libraries => libraries,
Kind => kind,
Timer => timer,
Vote => vote,
}
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
@@ -252,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|k| Entry::Image(k.clone()),
image_json,
out,
)
)?;
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
}
/// Apply a document-level change. Returns whether it can change which pages are listed (the
@@ -288,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
let img = image_of(value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
doc.votes.insert(k.clone(), value.read("ballot")?);
}
},
Change::Remove { entry } => match entry {
Entry::Style(k) => STYLES.remove(doc, k),
@@ -300,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
Entry::Image(k) => {
doc.images.remove(k);
}
Entry::Ballot(k) => {
doc.votes.remove(k);
}
},
_ => return Err(Error::Invalid("not a document-level change".into())),
}
@@ -336,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result<Option<Json>,
Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of),
Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())),
Entry::Image(k) => doc.images.get(k).map(image_json),
Entry::Ballot(k) => doc.votes.get(k).map(Json::of),
};
value.transpose()
}
@@ -349,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool {
Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k),
Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k),
Entry::Image(k) => doc.images.contains_key(k),
Entry::Ballot(k) => doc.votes.contains_key(k),
}
}
+2
View File
@@ -123,6 +123,8 @@ impl Recompute for NoRecompute {
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
}
#[cfg(test)]
mod board_sync_tests;
#[cfg(test)]
mod client_tests;
#[cfg(test)]
+8 -5
View File
@@ -6,11 +6,12 @@
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by
//! item: both sides changing different items of the list (or one adding items at its end) is no
//! conflict, only the same item changed both ways or the list shortened on a side while the other
//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for
//! one side, and the other side's changes to it are left out. Everything else from both sides is
//! kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -91,6 +92,7 @@ impl fmt::Display for Key {
Entry::Variable(k) => write!(f, "variable/{k}"),
Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"),
Entry::Image(k) => write!(f, "image/{k}"),
Entry::Ballot(k) => write!(f, "ballot/{k}"),
},
}
}
@@ -176,6 +178,7 @@ const PER_ITEM: &[Prop] = &[
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
Prop::Measurements,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
+15 -6
View File
@@ -94,6 +94,10 @@ pub struct Resume {
pub client_id: u32,
/// The last sequence number it applied.
pub seq: u64,
/// The secret the server gave with this client id ([`ServerMsg::Welcome`]'s `secret`): only
/// the client the id was given to can resume it (0: none).
#[serde(default)]
pub secret: u64,
}
/// Someone in the session.
@@ -635,7 +639,8 @@ pub enum ServerMsg {
/// Joined: the session's identity (a server that restarted without its saved state is a new
/// session), this connection's client id, the current sequence number, the document (or the
/// batches missed since a reconnecting client's last sequence number), what the server did
/// with this client's batches so far, who else is here and what this client may do.
/// with this client's batches so far, who else is here, what this client may do and the
/// secret it resumes its id with ([`Resume::secret`]; only this client is told).
Welcome {
session: u64,
client_id: u32,
@@ -644,6 +649,8 @@ pub enum ServerMsg {
processed: Processed,
peers: Vec<Peer>,
access: Access,
#[serde(default)]
secret: u64,
},
/// Accepted changes, in order, to everyone (the author's own come back as confirmation).
Changes(Accepted),
@@ -732,11 +739,13 @@ pub fn utc_date(secs: u64) -> String {
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, Error> {
let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?;
let mut out = Vec::with_capacity(body.len() + 1);
out.push(WIRE_VERSION);
out.extend(body);
Ok(out)
encode_into(msg, Vec::new())
}
/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer.
pub fn encode_into<T: Serialize>(msg: &T, mut prefix: Vec<u8>) -> Result<Vec<u8>, Error> {
prefix.push(WIRE_VERSION);
postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string()))
}
/// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most
+19
View File
@@ -253,3 +253,22 @@ fn random_drops_and_reconnects_converge() {
}
assert!(snapshots > 0, "no reconnect needed a snapshot");
}
#[test]
fn resume_needs_the_issued_secret() {
let mut s = Server::new(base(), "tok", Limits { max_peers: 2, ..Limits::default() });
let mut host = join(&mut s);
let guest = join(&mut s);
// A participant who knows the (public) session and the host's id can't take that id over,
// whether the session is full or not, and with view-only access or not.
let forged = message::Resume { client_id: host.id(), ..guest.resume() };
assert_eq!(forged.session, host.resume().session);
let refused = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View);
assert!(matches!(refused, Err(ServerMsg::Refused { .. })), "a forged resume of a live id is refused: {refused:?}");
s.leave(guest.id());
let (id, _) = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View).unwrap();
assert_ne!(id, host.id(), "a forged resume joins as new");
// The real client still comes back as itself.
let resend = come_back(&mut s, &mut host);
assert!(resend.is_empty());
}
+27 -7
View File
@@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals};
use crate::replica::{Mode, Replica, create_change};
use crate::{Error, Limits, docprops, same_secret};
@@ -61,6 +61,9 @@ pub struct Server {
/// Every client id handed out (a reconnecting client keeps its id) and what was done with
/// its batches.
issued: BTreeMap<u32, Processed>,
/// The secret each client id resumes with (sent only in that client's welcome). Not saved:
/// after a restart, the first to come back for an id that isn't connected sets it.
secrets: BTreeMap<u32, u64>,
/// The latest accepted batches, oldest first (a reconnecting client gets what it missed).
log: VecDeque<Accepted>,
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
@@ -116,6 +119,7 @@ impl Server {
deleted: BTreeSet::new(),
removed: BTreeSet::new(),
issued: BTreeMap::new(),
secrets: BTreeMap::new(),
log: VecDeque::new(),
log_changes: 0,
log_bytes: 0,
@@ -219,6 +223,7 @@ impl Server {
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
{
self.issued.remove(&old);
self.secrets.remove(&old);
}
}
@@ -266,7 +271,7 @@ impl Server {
if !same_secret(&hello.token, &self.token) {
return Err(refuse("wrong session token"));
}
let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id));
let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id) && self.may_resume(r));
if self.peers.len() >= self.limits.max_peers && resume.is_none_or(|r| !self.peers.contains_key(&r.client_id)) {
return Err(refuse("the session is full"));
}
@@ -282,15 +287,17 @@ impl Server {
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let id = match resume {
Some(r) => r.client_id,
let (id, secret) = match resume {
Some(r) => (r.client_id, *self.secrets.entry(r.client_id).or_insert(r.secret)),
None => {
let id = self.next_client;
self.next_client += 1;
self.issued.insert(id, Processed::default());
let secret = new_session_id().max(1);
self.secrets.insert(id, secret);
self.forget_oldest();
self.note(Journal::Issued { client: id });
id
(id, secret)
}
};
let processed = self.issued.get(&id).cloned().unwrap_or_default();
@@ -298,7 +305,7 @@ impl Server {
let signals = reads_signals(&hello.client_version);
let peer = Peer { client_id: id, name: hello.name, color: hello.color };
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access };
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret };
self.peers.insert(id, peer.clone());
if access == Access::View {
self.viewers.insert(id);
@@ -318,6 +325,16 @@ impl Server {
Ok((id, outs))
}
/// Whether `r` may take its client id back: it carries the secret that id was given with, or
/// (after a restart, which forgets secrets) a secret for an id nobody is connected as.
fn may_resume(&self, r: &Resume) -> bool {
r.secret != 0
&& match self.secrets.get(&r.client_id) {
Some(s) => *s == r.secret,
None => !self.peers.contains_key(&r.client_id),
}
}
/// Client `id`'s connection closed.
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
self.presence.remove(&id);
@@ -541,11 +558,14 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if let Some(n) = doc.node(d) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ impl BlendMode {
/// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come
/// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`).
pub fn from_name(s: &str) -> Option<Self> {
let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let key: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
Self::ALL
.into_iter()
.find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key)
+38 -7
View File
@@ -122,7 +122,8 @@ impl Color {
/// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits
/// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
@@ -194,7 +195,14 @@ impl Color {
/// CSS `rgba(...)` / hex text for copy-as-CSS.
pub fn to_css(self) -> String {
let [r, g, b, _] = self.to_rgba8();
if self.a >= 0.999 { self.to_css_hex() } else { format!("rgba({r}, {g}, {b}, {:.2})", self.a) }
if self.a >= 0.999 {
return self.to_css_hex();
}
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
let short = format!("{:.2}", self.a);
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
format!("rgba({r}, {g}, {b}, {alpha})")
}
/// Linear interpolation in sRGB (as gradients render).
@@ -355,7 +363,7 @@ impl ImageFilters {
}
/// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity.
pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [
pub const PRESETS: [(&str, &str, ImageFilters); 7] = [
("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }),
("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }),
("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }),
@@ -549,7 +557,7 @@ pub enum PaintKind {
}
/// One fill or stroke paint.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Paint {
#[serde(flatten)]
pub kind: PaintKind,
@@ -559,19 +567,41 @@ pub struct Paint {
pub visible: bool,
#[serde(default)]
pub blend: BlendMode,
/// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]),
/// the rest being an opacity set on its own: a colour variable bound to the paint replaces
/// this part only. 1: all of it is the paint's own.
#[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")]
pub color_alpha: f32,
}
/// Paints compare by what they paint; where the opacity came from doesn't matter.
impl PartialEq for Paint {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend
}
}
fn yes() -> bool {
true
}
fn is_one(x: &f32) -> bool {
*x == 1.0
}
impl Paint {
pub fn solid(color: Color) -> Self {
Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal }
Self {
kind: PaintKind::Solid { color: color.with_alpha(1.0) },
opacity: color.a,
visible: true,
blend: BlendMode::Normal,
color_alpha: color.a,
}
}
pub fn gradient(g: Gradient) -> Self {
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
pub fn image(hash: impl Into<String>, mode: ImageScaleMode) -> Self {
@@ -587,12 +617,13 @@ impl Paint {
opacity: 1.0,
visible: true,
blend: BlendMode::Normal,
color_alpha: 1.0,
}
}
/// Layer `source` (a node id) tiled at its own size.
pub fn pattern(source: u64) -> Self {
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal }
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
/// The solid colour (with the paint opacity folded into alpha), if solid.
+17
View File
@@ -115,6 +115,15 @@ fn infinite_channels_clamp() {
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
#[test]
fn hex_allows_at_most_one_leading_hash() {
assert!(Color::from_hex("##ff0000").is_none());
assert!(Color::from_hex("###fff").is_none());
assert!(Color::from_hex_or_name("##ff0000").is_none());
assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000");
assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000");
}
#[test]
fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
@@ -124,3 +133,11 @@ fn malformed_alpha_or_extra_args_rejected() {
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);
}
#[test]
fn to_css_round_trips_every_alpha() {
for a in 0..=255u8 {
let c = Color::from_rgba8(10, 20, 30, a);
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css());
}
}
+452
View File
@@ -0,0 +1,452 @@
//! Boards: a file kind for whiteboarding, and the small piece of metadata board objects carry.
//!
//! Board objects are ordinary nodes (frames, vectors, text) with a [`BoardItem`] on them, so
//! rendering, hit testing, undo, saving, export, copy and paste and live sessions treat them like
//! any other layer. The metadata says what the object is for: which text a sticky note fits, what
//! a connector joins, who placed a stamp.
use figcraft_color::Color;
use figcraft_geom::kurbo::{Ellipse, RoundedRect};
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
use serde::{Deserialize, Serialize};
use crate::NodeId;
/// What a file is for: designing screens, or a board to think together on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileKind {
#[default]
Design,
Board,
}
impl FileKind {
pub fn is_design(&self) -> bool {
*self == FileKind::Design
}
pub fn id(self) -> &'static str {
match self {
FileKind::Design => "design",
FileKind::Board => "board",
}
}
}
/// What a board object is.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum BoardItem {
/// A sticky note: a frame whose first text layer is the note (its size shrinks as the note
/// grows) and, below it, who wrote it.
Sticky {
#[serde(default, skip_serializing_if = "String::is_empty")]
author: String,
},
/// A shape with text: a frame holding the outline (a vector) and a centred text.
Shape { shape: BoardShape },
/// A line between two objects or points (a vector, re-routed when they move).
Connector(Connector),
/// The label in the middle of connector `connector` (a frame holding its text).
Label { connector: NodeId },
/// A stamp, and who placed it.
Stamp {
stamp: Stamp,
#[serde(default, skip_serializing_if = "String::is_empty")]
author: String,
},
/// A marker or highlighter stroke (what the eraser takes).
Ink,
/// A table: a frame of row frames holding cell frames with text (its column lines are thin
/// rectangles laid over it).
Table,
}
impl BoardItem {
/// Objects selected and moved as a whole: a click on their text or outline picks them, and
/// they never take other layers in.
pub fn is_atom(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table)
}
/// Objects whose first text layer is their text (a double-click or Enter edits it).
pub fn has_text(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. })
}
/// Who made it (stickies and stamps; empty when unknown).
pub fn author(&self) -> &str {
match self {
BoardItem::Sticky { author } | BoardItem::Stamp { author, .. } => author,
_ => "",
}
}
/// Point the layers this object refers to (a connector's ends, a label's connector) at their
/// copies in `map`; the others stay.
pub fn remap(&mut self, map: &std::collections::BTreeMap<NodeId, NodeId>) {
match self {
BoardItem::Connector(c) => {
for end in [&mut c.from, &mut c.to] {
if let End::Node { node, .. } = end
&& let Some(m) = map.get(node)
{
*node = *m;
}
}
}
BoardItem::Label { connector } => {
if let Some(m) = map.get(connector) {
*connector = *m;
}
}
_ => {}
}
}
pub fn kind_id(&self) -> &'static str {
match self {
BoardItem::Sticky { .. } => "sticky",
BoardItem::Shape { .. } => "shape",
BoardItem::Connector(_) => "connector",
BoardItem::Label { .. } => "label",
BoardItem::Stamp { .. } => "stamp",
BoardItem::Ink => "ink",
BoardItem::Table => "table",
}
}
}
/// The colours stickies, shapes and connectors come in: soft tints that dark text reads on.
pub const BOARD_COLORS: [(&str, Color); 8] = [
("yellow", Color::from_rgb8_const(0xFF, 0xE7, 0x8A)),
("orange", Color::from_rgb8_const(0xFF, 0xC9, 0x94)),
("red", Color::from_rgb8_const(0xFF, 0xAE, 0xA6)),
("pink", Color::from_rgb8_const(0xF7, 0xB9, 0xDA)),
("violet", Color::from_rgb8_const(0xD4, 0xC2, 0xFC)),
("blue", Color::from_rgb8_const(0xAC, 0xD3, 0xFA)),
("green", Color::from_rgb8_const(0xC2, 0xE8, 0xA6)),
("gray", Color::from_rgb8_const(0xE3, 0xE1, 0xDD)),
];
/// A board colour by name ("yellow") or as hex ("#FFE78A").
pub fn board_color(s: &str) -> Option<Color> {
let s = s.trim();
BOARD_COLORS.iter().find(|(n, _)| n.eq_ignore_ascii_case(s)).map(|(_, c)| *c).or_else(|| Color::from_hex(s))
}
/// The page colour boards start with: a warm light grey the colours stand out on.
pub const BOARD_PAGE: Color = Color::from_rgb8_const(0xF4, 0xF3, 0xF0);
/// The outlines shapes with text come in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BoardShape {
#[default]
Rectangle,
RoundedRectangle,
Ellipse,
Diamond,
Triangle,
SpeechBubble,
Chevron,
}
impl BoardShape {
pub const ALL: [BoardShape; 7] = [
BoardShape::Rectangle,
BoardShape::RoundedRectangle,
BoardShape::Ellipse,
BoardShape::Diamond,
BoardShape::Triangle,
BoardShape::SpeechBubble,
BoardShape::Chevron,
];
pub fn id(self) -> &'static str {
match self {
BoardShape::Rectangle => "rectangle",
BoardShape::RoundedRectangle => "roundedRectangle",
BoardShape::Ellipse => "ellipse",
BoardShape::Diamond => "diamond",
BoardShape::Triangle => "triangle",
BoardShape::SpeechBubble => "speechBubble",
BoardShape::Chevron => "chevron",
}
}
pub fn from_id(s: &str) -> Option<BoardShape> {
BoardShape::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
BoardShape::Rectangle => "Rectangle",
BoardShape::RoundedRectangle => "Rounded rectangle",
BoardShape::Ellipse => "Ellipse",
BoardShape::Diamond => "Diamond",
BoardShape::Triangle => "Triangle",
BoardShape::SpeechBubble => "Speech bubble",
BoardShape::Chevron => "Chevron",
}
}
/// The outline in a `w` × `h` box at the origin.
pub fn path(self, w: f64, h: f64) -> BezPath {
let poly = |pts: &[(f64, f64)]| {
let mut p = BezPath::new();
for (i, (x, y)) in pts.iter().enumerate() {
let q = Point::new(x * w, y * h);
if i == 0 { p.move_to(q) } else { p.line_to(q) }
}
p.close_path();
p
};
let r = w.min(h);
match self {
BoardShape::Rectangle => Rect::new(0.0, 0.0, w, h).to_path(0.1),
BoardShape::RoundedRectangle => RoundedRect::new(0.0, 0.0, w, h, r * 0.16).to_path(0.1),
BoardShape::Ellipse => Ellipse::from_rect(Rect::new(0.0, 0.0, w, h)).to_path(0.1),
BoardShape::Diamond => poly(&[(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)]),
BoardShape::Triangle => poly(&[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)]),
BoardShape::Chevron => poly(&[(0.0, 0.0), (0.72, 0.0), (1.0, 0.5), (0.72, 1.0), (0.0, 1.0), (0.28, 0.5)]),
BoardShape::SpeechBubble => {
// A rounded body over the bottom fifth, with a tail down to the left.
let (b, c) = (h * 0.8, r * 0.14);
let mut p = BezPath::new();
p.move_to((c, 0.0));
p.line_to((w - c, 0.0));
p.quad_to((w, 0.0), (w, c));
p.line_to((w, b - c));
p.quad_to((w, b), (w - c, b));
p.line_to((w * 0.42, b));
p.line_to((w * 0.18, h));
p.line_to((w * 0.24, b));
p.line_to((c, b));
p.quad_to((0.0, b), (0.0, b - c));
p.line_to((0.0, c));
p.quad_to((0.0, 0.0), (c, 0.0));
p.close_path();
p
}
}
}
/// Where the text goes, as insets of the box (top, right, bottom, left, in fractions of its
/// size): inside the outline's widest part.
pub fn text_insets(self) -> [f64; 4] {
match self {
BoardShape::Rectangle | BoardShape::RoundedRectangle => [0.08, 0.08, 0.08, 0.08],
BoardShape::Ellipse => [0.16, 0.16, 0.16, 0.16],
BoardShape::Diamond => [0.25, 0.25, 0.25, 0.25],
BoardShape::Triangle => [0.45, 0.22, 0.06, 0.22],
BoardShape::SpeechBubble => [0.08, 0.08, 0.28, 0.08],
BoardShape::Chevron => [0.12, 0.3, 0.12, 0.3],
}
}
}
/// The stamps: round badges with a mark.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Stamp {
#[default]
ThumbsUp,
Heart,
PlusOne,
Star,
Question,
Check,
}
/// One part of a stamp's mark: SVG path data on the badge's 40-unit box, filled or stroked.
pub struct MarkPart {
pub d: &'static str,
pub filled: bool,
}
/// A stamp badge's size.
pub const STAMP_SIZE: f64 = 40.0;
impl Stamp {
pub const ALL: [Stamp; 6] = [Stamp::ThumbsUp, Stamp::Heart, Stamp::PlusOne, Stamp::Star, Stamp::Question, Stamp::Check];
pub fn id(self) -> &'static str {
match self {
Stamp::ThumbsUp => "thumbsUp",
Stamp::Heart => "heart",
Stamp::PlusOne => "plusOne",
Stamp::Star => "star",
Stamp::Question => "question",
Stamp::Check => "check",
}
}
pub fn from_id(s: &str) -> Option<Stamp> {
Stamp::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
Stamp::ThumbsUp => "Thumbs up",
Stamp::Heart => "Heart",
Stamp::PlusOne => "Plus one",
Stamp::Star => "Star",
Stamp::Question => "Question",
Stamp::Check => "Check",
}
}
/// The badge's colour (the mark is white on it).
pub fn color(self) -> Color {
match self {
Stamp::ThumbsUp => Color::from_rgb8_const(0x3B, 0x82, 0xE8),
Stamp::Heart => Color::from_rgb8_const(0xE5, 0x48, 0x5F),
Stamp::PlusOne => Color::from_rgb8_const(0x2E, 0xA0, 0x63),
Stamp::Star => Color::from_rgb8_const(0xEE, 0x9B, 0x1A),
Stamp::Question => Color::from_rgb8_const(0x84, 0x5E, 0xD8),
Stamp::Check => Color::from_rgb8_const(0x14, 0x96, 0x8C),
}
}
/// The mark, drawn for FigCraft from lines, arcs and simple outlines.
pub fn mark(self) -> &'static [MarkPart] {
match self {
// A cuff and a raised hand with its thumb up.
Stamp::ThumbsUp => &[
MarkPart { d: "M10.5 19 H15 V29 H10.5 Z", filled: true },
MarkPart {
d: "M16.5 19 L20.6 11.4 C21.6 9.8 24.6 10.6 24.2 13 L23.4 17 H28.2 C29.8 17 30.9 18.5 30.4 20 L28.4 27.3 C28 28.4 27.1 29 25.9 29 H16.5 Z",
filled: true,
},
],
// Two round lobes meeting in a point.
Stamp::Heart => &[MarkPart {
d: "M20 29.5 C12.5 24.5 9.5 20.6 10.4 16.4 C11.3 12.2 16.8 11.2 20 15.4 C23.2 11.2 28.7 12.2 29.6 16.4 C30.5 20.6 27.5 24.5 20 29.5 Z",
filled: true,
}],
Stamp::PlusOne => &[MarkPart { d: "M10 20 H20 M15 15 V25", filled: false }, MarkPart { d: "M24.5 15 L27.5 12.5 V27.5", filled: false }],
Stamp::Star => &[MarkPart {
d: "M20 10.5 L22.8 16.7 L29.5 17.4 L24.5 21.9 L25.9 28.6 L20 25.2 L14.1 28.6 L15.5 21.9 L10.5 17.4 L17.2 16.7 Z",
filled: true,
}],
Stamp::Question => &[
MarkPart { d: "M15.6 16.2 C15.6 10.6 24.4 10.6 24.4 16 C24.4 19.6 20 19.9 20 23.6", filled: false },
MarkPart { d: "M18.2 28 A1.8 1.8 0 1 0 21.8 28 A1.8 1.8 0 1 0 18.2 28 Z", filled: true },
],
Stamp::Check => &[MarkPart { d: "M12.5 20.5 L18 26 L28 14.5", filled: false }],
}
}
}
/// How a connector runs between its ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Routing {
#[default]
Straight,
/// Right angles only: out of each end along its side's normal, turning once or twice, around
/// the objects it joins when the short way would cross them.
Elbow,
/// One smooth curve, leaving and arriving along the sides' normals.
Curved,
}
impl Routing {
pub fn id(self) -> &'static str {
match self {
Routing::Straight => "straight",
Routing::Elbow => "elbow",
Routing::Curved => "curved",
}
}
pub fn from_id(s: &str) -> Option<Routing> {
[Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
}
}
/// The side of an object a connector end is attached to (at its middle).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Side {
/// The side facing the other end.
#[default]
Auto,
Top,
Right,
Bottom,
Left,
}
impl Side {
pub fn from_id(s: &str) -> Option<Side> {
Some(match s.to_ascii_lowercase().as_str() {
"auto" => Side::Auto,
"top" => Side::Top,
"right" => Side::Right,
"bottom" => Side::Bottom,
"left" => Side::Left,
_ => return None,
})
}
/// The side's middle on `b`.
pub fn point(self, b: Rect) -> Point {
let c = b.center();
match self {
Side::Top => Point::new(c.x, b.y0),
Side::Right => Point::new(b.x1, c.y),
Side::Bottom => Point::new(c.x, b.y1),
Side::Left => Point::new(b.x0, c.y),
Side::Auto => c,
}
}
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
/// The way out of the side (y down; none for [`Side::Auto`]).
pub fn normal(self) -> Vec2 {
match self {
Side::Top => Vec2::new(0.0, -1.0),
Side::Right => Vec2::new(1.0, 0.0),
Side::Bottom => Vec2::new(0.0, 1.0),
Side::Left => Vec2::new(-1.0, 0.0),
Side::Auto => Vec2::ZERO,
}
}
}
/// One end of a connector: attached to a layer, or a free point on the page.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum End {
Node {
node: NodeId,
#[serde(default)]
side: Side,
},
Point {
x: f64,
y: f64,
},
}
impl End {
pub fn node(&self) -> Option<NodeId> {
match self {
End::Node { node, .. } => Some(*node),
End::Point { .. } => None,
}
}
}
/// What a connector joins and how it runs (its arrowheads are its stroke's caps).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Connector {
pub from: End,
pub to: End,
#[serde(default)]
pub routing: Routing,
}
+3
View File
@@ -35,6 +35,9 @@ impl Effect {
impl TextStyle {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f));
if let Some(c) = self.decoration_look.as_deref_mut().and_then(|l| l.color.as_mut()) {
f(c);
}
}
}
+8
View File
@@ -134,3 +134,11 @@ fn profile_and_presets_serialize_only_when_set() {
assert_eq!(back.color_profile, ColorProfile::DisplayP3);
assert_eq!(back.image_presets, d.image_presets);
}
#[test]
fn text_decoration_colour_is_visited() {
let mut s = TextStyle::default();
s.set_look(|l| l.color = Some(RED));
s.for_each_color_mut(&mut |c| *c = Color::BLACK);
assert_eq!(s.look().color, Some(Color::BLACK), "decoration colour not visited");
}
+111
View File
@@ -0,0 +1,111 @@
//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote.
//!
//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works
//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting
//! at once change different entries.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{BoardItem, Document, NodeId};
/// The longest a timer runs (24 hours, in milliseconds).
pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000;
/// The most votes one person gets in a vote.
pub const VOTES_MAX: u32 = 99;
/// A board's countdown.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum BoardTimer {
/// Counting down to `ends` (milliseconds since the Unix epoch).
Running { ends: u64 },
/// Stopped with `left` milliseconds to go.
Paused { left: u64 },
}
impl BoardTimer {
/// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out.
pub fn left(&self, now: u64) -> u64 {
match *self {
BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS),
BoardTimer::Paused { left } => left.min(TIMER_MAX_MS),
}
}
}
/// What a vote is on.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum VoteScope {
/// Any object on the board.
#[default]
All,
/// Sticky notes only.
Stickies,
/// The objects that were selected when it started.
Only { ids: Vec<NodeId> },
}
impl VoteScope {
/// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is
/// on (`All`: `whole`, the object a click there picks).
pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option<NodeId> {
if matches!(self, VoteScope::All) {
return self.takes(doc, whole).then_some(whole);
}
std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id))
}
/// Whether object `id` takes votes.
pub fn takes(&self, doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
if doc.pages.contains(&id) || n.parent.is_none() {
return false;
}
match self {
VoteScope::All => true,
VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })),
VoteScope::Only { ids } => ids.contains(&id),
}
}
}
/// A vote: how many votes each person has, what they go on, and whether it has ended (then
/// everyone's votes show). The ballots are [`Document::votes`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vote {
pub per_person: u32,
#[serde(default)]
pub scope: VoteScope,
#[serde(default)]
pub ended: bool,
/// Who started it.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub by: String,
}
/// Every voter's ballot: the objects they put their votes on (once per vote).
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
impl Vote {
/// The votes of `ballot` that count: on objects that are still there, up to the cap.
pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator<Item = NodeId> + 'a {
ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize)
}
/// The votes each object got, most first (ties in layer id order).
pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> {
let mut counts: BTreeMap<NodeId, u32> = BTreeMap::new();
for ballot in ballots.values() {
for id in self.counted(doc, ballot) {
*counts.entry(id).or_default() += 1;
}
}
let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
out
}
}
+27 -4
View File
@@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
| NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing),
NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner),
NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]),
NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]),
NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing),
NodeKind::Star { count, inner_ratio } => {
figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing)
}
NodeKind::Line => {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
@@ -72,7 +74,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)),
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
}
}
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
match &n.kind {
NodeKind::Vector { network } => network.fill_contains(lp),
_ => path.winding(lp) != 0,
}
}
@@ -117,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
/// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there.
fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
if !doc.has_masks() {
return false;
}
@@ -255,6 +266,18 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo
if deep {
return leaf;
}
let target = level_target(doc, leaf, context);
// A board object is picked whole (a sticky, not its text), unless a layer inside it is selected.
let mut chain = doc.ancestors(target);
chain.reverse();
chain.push(target);
chain
.into_iter()
.find(|c| doc.node(*c).is_some_and(Node::is_board_atom) && !context.iter().any(|s| s != c && doc.is_ancestor(*c, *s)))
.unwrap_or(target)
}
fn level_target(doc: &Document, leaf: NodeId, context: &[NodeId]) -> NodeId {
// Chain from the page's child down to the leaf.
let mut chain: Vec<NodeId> = doc.ancestors(leaf);
chain.reverse();
+30
View File
@@ -71,8 +71,38 @@ impl Document {
if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) {
Arc::make_mut(r).parent = now.parent;
}
// A damaged file can give a historical layer itself (or an ancestor) as a child; a copy
// with a cycle under the root would send every walk of it round forever.
if !d.is_tree_under(root) {
return None;
}
d.dev_snapshots.clear();
d.dirty = crate::Dirty::default();
Some(d)
}
/// Whether every layer under `root` is reached once (no layer is its own descendant or listed
/// by two parents), walking iteratively and stopping at the first repeat.
fn is_tree_under(&self, root: NodeId) -> bool {
let mut seen = BTreeSet::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
if !seen.insert(id) {
return false;
}
let Some(n) = self.nodes.get(&id) else {
return false;
};
for &c in &n.children {
let Some(child) = self.nodes.get(&c) else {
return false;
};
if child.parent != Some(id) {
return false;
}
stack.push(c);
}
}
true
}
}
+51
View File
@@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() {
doc.dev_snapshots.clear();
assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none());
}
#[test]
fn a_snapshot_with_a_layer_inside_itself_is_refused() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let mut snap = doc.snapshot_subtree(f).unwrap();
// A damaged file: the historical frame lists itself as a child.
let mut bad = (*snap.nodes[0]).clone();
bad.children.push(f);
snap.nodes[0] = Arc::new(bad);
doc.dev_snapshots.insert(f, Arc::new(snap));
assert!(doc.with_dev_snapshot(f).is_none());
}
#[test]
fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() {
use crate::NodeId;
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap();
let snap = doc.snapshot_subtree(f).unwrap();
let mut missing = snap.clone();
let mut bad_root = (*missing.nodes[0]).clone();
bad_root.children.push(NodeId(99999));
missing.nodes[0] = Arc::new(bad_root);
doc.dev_snapshots.insert(f, Arc::new(missing));
assert!(doc.with_dev_snapshot(f).is_none());
let mut nonrecip = snap.clone();
let mut bad_child = (*nonrecip.nodes[1]).clone();
bad_child.parent = Some(NodeId(88888));
nonrecip.nodes[1] = Arc::new(bad_child);
doc.dev_snapshots.insert(f, Arc::new(nonrecip));
assert!(doc.with_dev_snapshot(f).is_none());
}
#[test]
fn validate_is_linear_in_a_wide_parent() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
for i in 0..5_000 {
doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap();
}
assert!(doc.validate().is_empty());
let kid = doc.node(page).unwrap().children[0];
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
+82 -18
View File
@@ -8,8 +8,10 @@
//! live in higher crates.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod board;
pub mod colors;
pub mod comments;
pub mod facilitation;
pub mod geometry;
pub mod handoff;
pub mod ids;
@@ -23,8 +25,10 @@ pub mod vector;
pub mod vector_faces;
pub mod vector_regions;
pub use board::*;
pub use colors::*;
pub use comments::*;
pub use facilitation::*;
pub use figcraft_color as color;
pub use figcraft_geom as geom;
pub use handoff::*;
@@ -42,6 +46,10 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
/// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws
/// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062).
const MAX_RENDER_DEPTH: usize = 256;
/// Stable node identifier, unique within a document.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
@@ -157,6 +165,9 @@ pub struct ImageData {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub name: String,
/// A design file or a board (files from before boards are design files).
#[serde(default, skip_serializing_if = "FileKind::is_design")]
pub kind: FileKind,
/// Page ids in order.
pub pages: Vec<NodeId>,
nodes: imbl::OrdMap<NodeId, Arc<Node>>,
@@ -199,6 +210,15 @@ pub struct Document {
/// [`handoff`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dev_snapshots: BTreeMap<NodeId, Arc<DevSnapshot>>,
/// A board's countdown (see [`facilitation`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timer: Option<BoardTimer>,
/// A board's vote, running or ended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vote: Option<Vote>,
/// The vote's ballots, by voter.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub votes: Ballots,
}
impl Default for Document {
@@ -221,6 +241,7 @@ impl Document {
pub fn without_pages(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: FileKind::Design,
pages: Vec::new(),
nodes: imbl::OrdMap::new(),
dirty: Dirty::default(),
@@ -237,6 +258,9 @@ impl Document {
image_presets: Vec::new(),
annotation_categories: None,
dev_snapshots: BTreeMap::new(),
timer: None,
vote: None,
votes: BTreeMap::new(),
}
}
@@ -269,7 +293,16 @@ impl Document {
/// A fresh unused id (in this document's client space during a collaboration session; see
/// [`set_client_id`](Self::set_client_id)).
pub fn alloc_id(&mut self) -> NodeId {
self.next_id.alloc()
// A file's saved `next_id` can be stale (behind ids already in use): skip taken ids.
let mut id = self.next_id.alloc();
while self.nodes.contains_key(&id) {
let next = self.next_id.alloc();
if next == id {
break;
}
id = next;
}
id
}
/// The id [`alloc_id`](Self::alloc_id) returns next.
@@ -453,8 +486,9 @@ impl Document {
return true;
}
guard += 1;
if guard > 10_000 {
return false;
// More hops than nodes means the parent chain loops; fail closed so callers reject the move.
if guard > self.nodes.len() {
return true;
}
cur = self.node(c).and_then(|n| n.parent);
}
@@ -687,12 +721,16 @@ impl Document {
/// a clipping frame's own area, else that and what its visible children show (masked
/// children only within their mask). A group is what its children show, grown by its effects.
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
self.render_bounds_at(id, 0)
}
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
let n = self.node(id)?;
let own = n.own_paint_bounds(self.world_transform(id));
if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() {
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
return Some(own);
}
Some(match (self.children_render_bounds(n), &n.kind) {
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
(Some(kids), _) => own.union(kids),
(None, _) => own,
@@ -700,15 +738,15 @@ impl Document {
}
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
fn children_render_bounds(&self, n: &Node) -> Option<Rect> {
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
let mut out: Option<Rect> = None;
for run in self.paint_runs(n) {
let ids = n.children.get(run).unwrap_or(&[]);
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
let r = match mask {
Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
None => r,
};
out = match (out, r) {
@@ -821,6 +859,11 @@ impl Document {
return Err(DocError::NotContainer(parent));
}
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
// Never overwrite an existing node, or another copy, with a copy.
let mut copied = std::collections::BTreeSet::new();
if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) {
return Err(DocError::Invalid(format!("node {taken} already exists")));
}
for n in nodes {
let Some(new_id) = map.get(&n.id) else { continue };
let mut c = n.clone();
@@ -835,6 +878,16 @@ impl Document {
{
*component = *m;
}
// A measurement copied with the layer it measures to measures to the copy; one to a
// layer left behind isn't copied.
c.measurements.retain_mut(|m| match m.to {
Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(),
None => true,
});
// A connector (or label) copied with what it joins joins the copies.
if let Some(b) = c.board.as_mut() {
b.remap(map);
}
// A pattern tiling a layer of the copy tiles the copy's layer.
for p in c.fills.iter_mut().chain(c.strokes.iter_mut()) {
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
@@ -984,39 +1037,50 @@ impl Document {
/// Check structural invariants (used by tests and after loading untrusted files).
pub fn validate(&self) -> Vec<String> {
// Hostile files can break an invariant per child; keep only the first few reports.
const MAX_ISSUES: usize = 100;
fn note(issues: &mut Vec<String>, msg: impl FnOnce() -> String) {
if issues.len() < MAX_ISSUES {
issues.push(msg());
}
}
let mut issues = Vec::new();
// Every (parent, child) edge and the page list, indexed once so each node's checks are
// lookups: a wide parent or a long page list doesn't make validation quadratic.
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (id, n) in self.nodes.iter() {
if n.id != *id {
issues.push(format!("node key {id} has id {}", n.id));
note(&mut issues, || format!("node key {id} has id {}", n.id));
}
let mut seen = std::collections::BTreeSet::new();
for c in &n.children {
if !seen.insert(*c) {
issues.push(format!("child {c} listed twice in {id}"));
note(&mut issues, || format!("child {c} listed twice in {id}"));
}
match self.node(*c) {
Some(cn) if cn.parent == Some(*id) => {}
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
None => issues.push(format!("child {c} of {id} missing")),
Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")),
None => note(&mut issues, || format!("child {c} of {id} missing")),
}
}
if let Some(p) = n.parent
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
&& !(self.node(p).is_some() && edges.contains(&(p, *id)))
{
issues.push(format!("{id} not listed in parent {p}"));
note(&mut issues, || format!("{id} not listed in parent {p}"));
}
// The library store's root is a page that is never listed.
let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
if n.is_page() != listed {
issues.push(format!("page list mismatch for {id}"));
note(&mut issues, || format!("page list mismatch for {id}"));
}
if n.is_page() && n.parent.is_some() {
issues.push(format!("page {id} has a parent"));
note(&mut issues, || format!("page {id} has a parent"));
}
}
let reachable = self.reachable();
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
issues.push(format!("{id} is not under a page (cycle or detached)"));
note(&mut issues, || format!("{id} is not under a page (cycle or detached)"));
}
issues
}
+29
View File
@@ -237,6 +237,17 @@ pub struct DevResource {
pub name: String,
}
/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another
/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers'
/// current bounds, so it follows them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Measurement {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<NodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side: Option<crate::Side>,
}
/// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its
/// callouts are drawn in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -386,6 +397,11 @@ impl NodeKind {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
pub fn has_corner_smoothing(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
}
pub fn text(&self) -> Option<&TextData> {
match self {
NodeKind::Text(t) => Some(t),
@@ -518,6 +534,12 @@ pub struct Node {
/// Dev resources: links for developers (docs, code, tickets…), in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dev_resources: Vec<DevResource>,
/// Dev Mode measurements that start from this layer, in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurements: Vec<Measurement>,
/// On a board: what this object is (a sticky note, a connector, a stamp…).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board: Option<crate::BoardItem>,
}
pub(crate) fn yes() -> bool {
@@ -628,6 +650,8 @@ impl Node {
annotations: Vec::new(),
code_links: Vec::new(),
dev_resources: Vec::new(),
measurements: Vec::new(),
board: None,
}
}
@@ -653,6 +677,11 @@ impl Node {
matches!(self.kind, NodeKind::Page { .. })
}
/// A board object selected and moved as a whole (a sticky, a shape with text, a stamp…).
pub fn is_board_atom(&self) -> bool {
self.board.as_ref().is_some_and(crate::BoardItem::is_atom)
}
/// A section whose contents are hidden: only its name and outline show; its children are
/// skipped when rendering and hit testing.
pub fn hides_contents(&self) -> bool {
+2 -2
View File
@@ -475,7 +475,7 @@ impl WidthPreset {
}
pub fn from_id(s: &str) -> Option<WidthPreset> {
let s: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let s: String = s.chars().filter(char::is_ascii_alphanumeric).collect();
WidthPreset::ALL
.into_iter()
.find(|p| p.id().eq_ignore_ascii_case(&s) || p.label().replace(' ', "").eq_ignore_ascii_case(&s))
@@ -795,7 +795,7 @@ fn yes() -> bool {
impl Effect {
/// Effect types in the order the Design panel lists them: (label, type name).
pub const TYPES: [(&'static str, &'static str); 7] = [
pub const TYPES: [(&str, &str); 7] = [
("Drop shadow", "dropShadow"),
("Inner shadow", "innerShadow"),
("Layer blur", "layerBlur"),
+1 -1
View File
@@ -1003,7 +1003,7 @@ impl PrototypeSettings {
];
/// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it).
pub const CUSTOM_DEVICE: &'static str = "Custom size";
pub const CUSTOM_DEVICE: &str = "Custom size";
/// A new custom size.
pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0];
/// The largest side a custom size may have (points).
+6 -1
View File
@@ -169,7 +169,12 @@ impl Expr {
})
.collect();
match (op, parts.as_slice()) {
(ExprOp::Not, [x]) => format!("not {x}"),
// `not done` reads as a variable named "not done" if there is one: then
// `not (done)`.
(ExprOp::Not, [x]) => {
let bare = format!("not {x}");
if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") }
}
(ExprOp::Negate, [x]) => format!("-{x}"),
_ => parts.join(&format!(" {} ", op.symbol())),
}
+8 -5
View File
@@ -459,16 +459,19 @@ impl Parser<'_> {
if self.at_keyword("mode") && self.next_after(4) == Some('(') {
return self.mode_of();
}
if let Some((id, n)) = self.variable_here() {
self.i += n;
return self.in_mode(id);
}
let var = self.variable_here();
// A bare `true`/`false` is the literal, also when a variable has that name (it reads
// as `{true}`); longer names that start with it ("true value") stay variables.
for (word, v) in [("true", true), ("false", false)] {
if self.at_keyword(word) {
if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) {
self.i += word.len();
return Ok(Expr::Value(VariableValue::Boolean(v)));
}
}
if let Some((id, n)) = var {
self.i += n;
return self.in_mode(id);
}
let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect();
if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) }
}
+46
View File
@@ -195,3 +195,49 @@ fn names_that_look_like_literals_read_back_as_variables() {
assert_eq!(parse("1"), n(1.0));
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
}
#[test]
fn not_of_a_variable_reads_back_when_a_name_starts_with_not() {
use ExprOp::*;
let mut v = vars();
let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone();
for (id, name) in [("done", "done"), ("nd", "not done")] {
let mut x = b.clone();
x.id = id.into();
x.name = name.into();
v.variables.push(x);
}
let not_done = Expr::op(Not, vec![Expr::var("done")]);
for e in [
not_done.clone(),
Expr::op(And, vec![Expr::var("b"), not_done.clone()]),
Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]),
Expr::op(Not, vec![not_done.clone()]),
] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}");
}
assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare");
}
#[test]
fn literal_true_false_survive_variables_named_true_false() {
use ExprOp::*;
let lit = |x: bool| Expr::Value(VariableValue::Boolean(x));
for name in ["true", "false", "TRUE"] {
let mut v = vars();
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") {
var.name = name.into();
}
for e in [
Expr::op(Equals, vec![Expr::var("b"), lit(true)]),
Expr::op(Equals, vec![Expr::var("b"), lit(false)]),
Expr::op(Not, vec![lit(false)]),
Expr::var("b"),
] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
}
assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b")));
}
}
+180
View File
@@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() {
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn validate_caps_the_issue_list() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]);
let bad: Document = serde_json::from_value(v).unwrap();
let issues = bad.validate();
assert!(!issues.is_empty());
assert!(issues.len() <= 100, "{}", issues.len());
}
#[test]
fn auto_layout_loads_files_without_grid_tracks() {
// Saved before grid tracks, cells and spans existed.
@@ -464,3 +478,169 @@ fn text_style_empty_range_links_and_unlinks_all() {
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
#[test]
fn stale_next_id_never_overwrites_existing_nodes() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
v["next_id"] = serde_json::json!(1);
let mut bad: Document = serde_json::from_value(v).unwrap();
let before: Vec<(NodeId, String, Option<NodeId>)> =
[p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect();
let n = bad.node_count();
let copy = bad.duplicate_into(f, p, None).unwrap();
assert!(![p, f, a].contains(&copy));
assert_eq!(bad.node_count(), n + 2);
let copies = bad.duplicate_all_into(&[f], p).unwrap();
assert_eq!(bad.node_count(), n + 4);
assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c)));
for (i, name, parent) in &before {
let node = bad.node(*i).unwrap();
assert_eq!((&node.name, node.parent), (name, *parent));
}
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
// A paste whose id map points at an existing node fails and changes nothing.
let nodes: Vec<Node> = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect();
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
map.insert(a, p);
let snapshot = bad.clone();
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
assert_eq!(bad, snapshot);
assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name);
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
// So does one that maps two nodes to the same new id.
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
map.insert(a, map[&f]);
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
assert_eq!(bad, snapshot);
}
#[test]
fn hit_test_even_odd_vector_hole_misses() {
let ring = |rule| {
let sq = |o: f64, s: f64, base: usize| {
(
[(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)),
[Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)],
)
};
let (v1, s1) = sq(0.0, 200.0, 0);
let (v2, s2) = sq(60.0, 80.0, 4);
VectorNetwork {
vertices: v1.into_iter().chain(v2).collect(),
segments: s1.into_iter().chain(s2).collect(),
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
}
};
let mut d = Document::new("x");
let p = page(&d);
let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo));
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled");
let mut d = Document::new("x");
let p = page(&d);
let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz));
}
#[test]
fn is_ancestor_holds_for_very_deep_chains() {
let mut d = Document::new("x");
let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap();
let mut leaf = root;
for _ in 0..10_005 {
leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap();
}
assert!(d.is_ancestor(root, leaf));
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
}
#[test]
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
// it's within the renderer's depth and is left out past it, as the renderer leaves it out.
let chain = |depth: usize| {
let mut d = Document::new("x");
let mut parent = page(&d);
for _ in 0..depth {
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
}
d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap();
let top = d.node(page(&d)).unwrap().children[0];
d.world_render_bounds(top).unwrap()
};
assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0);
assert_eq!(chain(20_000).x1, 10.0);
}
#[test]
fn text_insert_at_run_boundary_keeps_runs_disjoint() {
let mut t = TextData::new("0123456789");
t.restyle(0, 5, |s| s.font_size = 20.0);
t.restyle(5, 10, |s| s.font_size = 30.0);
t.replace_range(5, 5, "xx");
let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect();
assert_eq!(runs, vec![(0, 7), (7, 12)]);
}
#[test]
fn many_small_vector_subpaths_build_and_stroke_without_rescans() {
// Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334)
// and a big rounded polygon (#F-333): each used to scan the whole network per step.
let mut p = figcraft_geom::kurbo::BezPath::new();
for i in 0..3_000 {
let x = f64::from(i) * 10.0;
p.move_to((x, 0.0));
p.line_to((x + 5.0, 0.0));
p.line_to((x + 5.0, 5.0));
p.line_to((x, 0.0));
p.close_path();
}
let net = VectorNetwork::from_path(&p);
assert_eq!(net.vertices.len(), 3 * 3_000);
assert_eq!(net.regions[0].loops.len(), 3_000);
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
assert_eq!(net.stroke_runs().len(), 3_000);
let pts: Vec<Point> = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect();
let chain = VectorNetwork::from_points(&pts, false);
let runs = chain.stroke_runs();
assert_eq!(runs.len(), 1);
assert!(!runs[0].closed);
let ring: Vec<Point> = (0..4_000)
.map(|i| {
let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU;
Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 })
})
.collect();
let mut poly = VectorNetwork::from_points(&ring, true);
for v in &mut poly.vertices {
v.radius = 0.5;
}
let rounded = poly.rounded().unwrap();
assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides");
assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len());
}
#[test]
fn two_segment_closed_loop_rounded_corners() {
let mut v0 = Vertex::new(0.0, 0.0);
v0.radius = 2.0;
let mut v1 = Vertex::new(10.0, 0.0);
v1.radius = 2.0;
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
let path = net.fill_path();
assert!(!path.elements().is_empty());
}
+28 -2
View File
@@ -253,10 +253,17 @@ pub struct TextStyle {
pub style_id: Option<String>,
}
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
Some(family) if !family.is_empty() => family,
_ => "Inter",
};
impl Default for TextStyle {
fn default() -> Self {
Self {
font_family: "Inter".into(),
font_family: DEFAULT_FONT.into(),
font_style: "Regular".into(),
font_size: 12.0,
line_height: LineHeight::Auto,
@@ -579,7 +586,14 @@ impl TextData {
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
for mut r in std::mem::take(&mut self.runs) {
// Runs never overlap: a later run starts where the previous one ends at the earliest.
if let Some(prev) = merged.last() {
r.start = r.start.max(prev.end);
if r.start >= r.end {
continue;
}
}
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
@@ -783,6 +797,18 @@ mod case_tests {
assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}"));
}
#[test]
fn new_text_starts_in_the_builds_default_font() {
// Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter).
let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty());
assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter"));
let st = TextStyle::default();
assert_eq!(st.font_family, DEFAULT_FONT);
// The family is always written, so a saved file keeps it in a build with another default.
let saved = serde_json::to_value(&st).unwrap();
assert_eq!(saved["font_family"], DEFAULT_FONT);
}
#[test]
fn variations_are_backward_compatible_and_imply_weight() {
// Files written before variable fonts load with no custom axes, and stay byte-identical.
+64 -30
View File
@@ -134,6 +134,16 @@ pub struct VectorNetwork {
pub regions: Vec<Region>,
}
/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`,
/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths.
fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) {
if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i))
&& s.b == from
{
s.b = to;
}
}
impl VectorNetwork {
/// A network from a polyline / polygon.
pub fn from_points(points: &[Point], closed: bool) -> Self {
@@ -169,11 +179,7 @@ impl VectorNetwork {
if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() {
return;
}
for s in net.segments.iter_mut() {
if s.b == a {
s.b = f;
}
}
redirect_last_segment(net, loop_segs, a, f);
net.vertices.pop();
all_loops.push(std::mem::take(loop_segs));
};
@@ -219,11 +225,7 @@ impl VectorNetwork {
let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6);
if same && a != f {
// Merge the duplicate end vertex into the first one.
for s in net.segments.iter_mut() {
if s.b == a {
s.b = f;
}
}
redirect_last_segment(&mut net, &loop_segs, a, f);
if a + 1 == net.vertices.len() {
net.vertices.pop();
}
@@ -385,6 +387,7 @@ impl VectorNetwork {
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
}
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
for c in corners {
if let Some(v) = out.vertices.get_mut(c.v) {
(v.x, v.y) = (c.a.x, c.a.y);
@@ -400,15 +403,28 @@ impl VectorNetwork {
}
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
let arc = out.segments.len() - 1;
// The arc goes between the two segments wherever a loop walks through the point.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let n = lp.len();
let between =
|k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1));
if let Some(k) = (0..n).find(|k| between(*k)) {
lp.insert(k + 1, arc);
by_vertex.insert(c.v, arc);
}
// The arc goes between the two segments wherever a loop walks through the point. Each loop
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()));
let mut cur = start_v;
for &x in lp.iter() {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
if let Some(&arc) = by_vertex.get(&cur) {
rebuilt.push(arc);
}
}
*lp = rebuilt;
}
Some(out)
}
@@ -547,25 +563,40 @@ impl VectorNetwork {
}
}
let degree = |v: usize| degrees.get(v).copied().unwrap_or(0);
let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
self.segments.iter().enumerate().find_map(|(i, s)| {
if used.get(i).copied().unwrap_or(true) {
return None;
// Each vertex's segments in segment order, and how far down that list the used ones reach,
// so finding the next unused segment never rescans the whole network.
let mut adjacent: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
for (i, s) in self.segments.iter().enumerate() {
if let Some(l) = adjacent.get_mut(s.a) {
l.push((i, false));
}
if s.b != s.a
&& let Some(l) = adjacent.get_mut(s.b)
{
l.push((i, true));
}
}
let mut cursor = vec![0usize; self.vertices.len()];
let mut remaining = self.segments.len();
let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
let list = adjacent.get(v)?;
let at = cursor.get_mut(v)?;
while let Some(&(i, rev)) = list.get(*at) {
if !used.get(i).copied().unwrap_or(true) {
return Some((i, rev));
}
if s.a == v {
Some((i, false))
} else if s.b == v {
Some((i, true))
} else {
None
}
})
*at += 1;
}
None
};
let stop = |v: usize| degree(v) != 2 || breaks(v);
// Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops).
let mut starts: Vec<usize> = (0..self.vertices.len()).filter(|v| stop(*v)).collect();
starts.extend((0..self.vertices.len()).filter(|v| !stop(*v)));
for v0 in starts {
if remaining == 0 {
break;
}
while let Some((si, rev)) = next_from(v0, &used) {
let Some(start) = self.vertices.get(v0) else { break };
let mut path = BezPath::new();
@@ -574,8 +605,11 @@ impl VectorNetwork {
let mut closed = false;
let mut seg = Some((si, rev));
while let Some((i, r)) = seg {
if let Some(u) = used.get_mut(i) {
if let Some(u) = used.get_mut(i)
&& !*u
{
*u = true;
remaining = remaining.saturating_sub(1);
}
let Some(s) = self.segments.get(i) else { break };
self.push_seg(&mut path, s, r);
+2
View File
@@ -28,8 +28,10 @@ thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
image = { workspace = true }
+12
View File
@@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
s.undo().unwrap();
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
}
#[test]
fn a_size_given_with_a_new_path_fixes_that_axis() {
let mut s = Session::new();
let f = al_frame(&mut s, json!({}));
let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap());
set(&mut s, v, json!({"sizingH": "fill"}));
assert_eq!(sizing(&s, v).0, Sizing::Fill);
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
+1 -1
View File
@@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() {
};
let ready = ex(&mut s, "inspect.readyForDev", json!({}));
assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order");
assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"}));
assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false}));
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]);
assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err());
+192
View File
@@ -0,0 +1,192 @@
//! Step references in `command.batch`: a step's params can use what an earlier step returned.
//!
//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}`
//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
use std::collections::HashMap;
use serde_json::{Map, Value};
use crate::{Result, bad};
/// Deepest params nesting the resolver walks before giving up.
const MAX_DEPTH: usize = 64;
/// One step of a batch: a command, its params and the name later steps can call it by.
#[derive(Clone, Debug, PartialEq)]
pub struct BatchStep {
pub command: String,
pub params: Value,
/// The step's `as`, for `{"$ref": name}` in later steps.
pub name: Option<String>,
}
impl BatchStep {
/// A step without a name.
pub fn new(command: impl Into<String>, params: Value) -> Self {
Self { command: command.into(), params, name: None }
}
}
/// A step's `as` (`step` is its index, for the error): `None` when it has none.
pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
match json.get("as") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())),
Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))),
}
}
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
pub struct BatchScope {
names: HashMap<String, usize>,
results: Vec<Value>,
}
impl BatchScope {
/// The scope for running `steps`. Two steps with the same name fail here, before any step runs.
pub fn new(steps: &[BatchStep]) -> Result<Self> {
let mut names = HashMap::new();
for (i, step) in steps.iter().enumerate() {
let Some(name) = &step.name else { continue };
if let Some(first) = names.insert(name.clone(), i) {
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
}
}
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
}
/// The results of the steps run so far, in order.
pub fn results(&self) -> &[Value] {
&self.results
}
pub fn into_results(self) -> Vec<Value> {
self.results
}
/// The index of the step that runs next.
pub fn next_step(&self) -> usize {
self.results.len()
}
pub(crate) fn push(&mut self, result: Value) {
self.results.push(result);
}
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
}
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
let step = self.next_step();
if depth > MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
Value::Object(o) => {
let mut out = Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
let step = self.next_step();
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
if let Some(k) = o.keys().find(|k| *k != key && *k != "field") {
return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`")));
}
let (n, who) = if key == "$step" {
let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?;
let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
fail(format!("`$step` {n} must be an earlier step ({range})"))
})?;
(n, format!("step {n}"))
} else {
let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?;
let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?;
if n >= step {
return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step")));
}
(n, format!("step {n} (\"{name}\")"))
};
let path = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(fail("`field` must be a string".into())),
};
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
}
}
/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in
/// JSON Pointer is allowed). The error is the detail after "has no `path`".
fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
// A key as written first, so a key holding `/` still works as it did before paths.
if let Some(x) = v.get(path) {
return Ok(x);
}
let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" };
walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}"))
}
fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
let mut cur = v;
let mut walked = String::new();
for seg in path.split('/') {
let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") };
cur = match cur {
Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?,
Value::Array(a) => match seg.parse::<usize>() {
Ok(i) => a.get(i).ok_or_else(|| {
let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) };
format!(": {here} has {items}")
})?,
Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")),
},
_ => return Err(format!(": {here} is {}", kind(cur))),
};
if !walked.is_empty() {
walked.push('/');
}
walked.push_str(seg);
}
Ok(cur)
}
fn kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
+170
View File
@@ -110,3 +110,173 @@ fn a_nested_batch_refers_to_its_own_steps() {
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
}
/// Runs `commands` as a batch and returns the error message, which must be `BadParams`.
fn batch_error(s: &mut Session, commands: serde_json::Value) -> String {
match s.execute("command.batch", &json!({"commands": commands})) {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn a_named_step_is_referred_to_by_its_name() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}},
{"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}},
{"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}},
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}}
]}),
)
.unwrap();
let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3]));
let doc = s.doc().unwrap();
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch");
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
}
/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them.
#[test]
fn a_field_path_indexes_into_a_list_in_the_result() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}},
{"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}},
{"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}},
{"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}}
]}),
)
.unwrap();
let copies = results[2]["ids"].as_array().unwrap().clone();
let doc = s.doc().unwrap();
assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too");
assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy");
}
#[test]
fn a_field_path_walks_nested_objects_and_takes_whole_values() {
let mut scope = crate::BatchScope::default();
scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"}));
let resolved = scope
.resolve(&json!({
"x": {"$step": 0, "field": "bounds/x"},
"r": {"$step": 0, "field": "bounds/corners/0/r"},
"all": {"$step": 0, "field": "ids"},
"key": {"$step": 0, "field": "a/b"},
"pointer": {"$step": 0, "field": "/ids"}
}))
.unwrap();
assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key");
}
#[test]
fn bad_names_and_paths_fail_and_roll_back() {
let mut s = Session::new();
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}});
let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}});
let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}});
let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}});
for (commands, needle) in [
(json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"),
(json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"),
(json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"),
(json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"),
(
json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]),
"is step 1, not an earlier step",
),
(json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"),
(json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"),
(json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"),
(json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"),
(json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"),
(
json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]),
"step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item",
),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"),
(json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"),
] {
let msg = batch_error(&mut s, commands);
assert!(msg.contains(needle), "{msg:?} should contain {needle:?}");
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
}
}
#[test]
fn text_that_looks_like_a_named_reference_is_left_alone() {
let mut s = Session::new();
let text = "{\"$ref\": \"card\"} $ref";
let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap();
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text));
}
/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the
/// outer batch can't see its.
#[test]
fn a_nested_batch_has_its_own_names() {
let mut s = Session::new();
let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
let results = s
.execute(
"command.batch",
&json!({"commands": [
frame("Outer"),
{"command": "command.batch", "as": "inner", "params": {"commands": [
frame("Inner"),
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}}
]}),
)
.unwrap();
let inner = &results[1];
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one");
assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list");
let msg = batch_error(
&mut s,
json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}}]),
);
assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}");
}
#[test]
fn a_failing_command_names_its_step_and_rolls_back() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
let undo = s.ds().unwrap().history.undo.len();
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
let msg = batch_error(
&mut s,
json!([
{"command": "text.set", "params": {"ids": [text.0], "text": "novo"}},
{"command": "text.set", "params": {"ids": [frame.0], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]),
);
// Still the command's own kind of error, its message saying which step.
assert!(msg.starts_with("step 1: ") && msg.contains("is not text"), "{msg}");
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
+1 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
File diff suppressed because it is too large. Load diff
+179
View File
@@ -0,0 +1,179 @@
//! Board connectors, continued: moving an end onto another object or off into the canvas (the
//! command and a drag of the selected connector's end), curved connectors, and elbows that go
//! around the objects they join.
use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side};
use figcraft_geom::{Point, Rect};
use serde_json::json;
use crate::Session;
use crate::board::route;
use crate::board_tests::{board, connector, drawn, id, near, node};
use crate::tools::{PointerEvent, PointerKind, handle_at};
/// A board with three 100 × 100 shapes in a row 300 apart, and an elbow connector from the
/// first to the second: (a, b, c, connector).
fn three() -> (Session, [figcraft_doc::NodeId; 4]) {
let mut s = board();
let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
let (a, b, c) = (shape(0.0), shape(300.0), shape(600.0));
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id");
(s, [a, b, c, k])
}
#[test]
fn reattach_moves_one_end_and_undoes() {
let (mut s, [a, b, c, k]) = three();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top });
assert_eq!(connector(&s, k).from.node(), Some(a));
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step");
// Off into the canvas: the end stays there, free. The selection is the default connector.
s.execute("select.set", &json!({"ids": [k]})).unwrap();
s.execute("board.reattach", &json!({"end": "from", "target": {"x": -200, "y": 400}})).unwrap();
assert_eq!(connector(&s, k).from, End::Point { x: -200.0, y: 400.0 });
assert!(near(drawn(&s, k)[0], Point::new(-200.0, 400.0)));
// A label stands for its connector.
let label = id(&s.execute("board.label", &json!({"id": k, "text": "next"})).unwrap(), "label");
s.execute("board.reattach", &json!({"id": label, "end": "from", "target": c})).unwrap();
assert_eq!(connector(&s, k).from.node(), Some(c));
}
#[test]
fn reattach_refuses_what_it_cant_join() {
let (mut s, [a, b, _, k]) = three();
let refused = |s: &mut Session, p| s.execute("board.reattach", &p).is_err();
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": a})), "both ends on one layer");
assert!(refused(&mut s, json!({"id": k, "end": "middle", "target": b})));
assert!(refused(&mut s, json!({"id": k, "end": "to"})), "no target");
assert!(refused(&mut s, json!({"id": a, "end": "to", "target": b})), "not a connector");
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": k})), "a connector isn't an end");
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": {"x": "a", "y": 1}})));
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": b, "side": "inside"})));
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
assert!(d.execute("board.reattach", &json!({"end": "to", "target": {"x": 0, "y": 0}})).is_err(), "boards only");
assert_eq!(connector(&s, k).to.node(), Some(b), "nothing changed");
}
#[test]
fn dragging_a_selected_connectors_end_reattaches_it() {
let (mut s, [a, b, c, k]) = three();
s.execute("select.set", &json!({"ids": [k]})).unwrap();
let doc = s.doc().unwrap();
assert_eq!(handle_at(doc, &[k], Point::new(100.0, 50.0), 1.0), None, "no box handles on a connector");
let end = *drawn(&s, k).last().unwrap();
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
// Onto c.
s.pointer(&ev(PointerKind::Down, end)).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(500.0, 50.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(650.0, 50.0))).unwrap();
assert_eq!(s.tool.overlay.drop_target, Some(c), "the object it would attach to shows");
assert!(s.tool.overlay.rubber.is_some());
s.pointer(&ev(PointerKind::Up, Point::new(650.0, 50.0))).unwrap();
assert_eq!(connector(&s, k).to.node(), Some(c));
assert_eq!(connector(&s, k).from.node(), Some(a));
assert_eq!((s.tool.overlay.drop_target, s.tool.overlay.rubber), (None, None));
assert_eq!(s.selection(), vec![k], "still selected");
// The start into empty canvas: free where it was let go.
let start = drawn(&s, k)[0];
s.pointer(&ev(PointerKind::Down, start + figcraft_geom::Vec2::new(3.0, 0.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(100.0, 400.0))).unwrap();
s.pointer(&ev(PointerKind::Up, Point::new(100.0, 400.0))).unwrap();
assert_eq!(connector(&s, k).from, End::Point { x: 100.0, y: 400.0 });
// A click on an end, or a drop on the other end's object, changes nothing.
let before = connector(&s, k);
let end = *drawn(&s, k).last().unwrap();
s.pointer(&ev(PointerKind::Down, end)).unwrap();
s.pointer(&ev(PointerKind::Up, end)).unwrap();
assert_eq!(connector(&s, k), before);
s.execute("board.reattach", &json!({"id": k, "end": "from", "target": b})).unwrap();
let before = connector(&s, k);
s.pointer(&ev(PointerKind::Down, *drawn(&s, k).last().unwrap())).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(350.0, 50.0))).unwrap();
assert_eq!(s.tool.overlay.drop_target, None, "not onto the other end's object");
s.pointer(&ev(PointerKind::Up, Point::new(350.0, 50.0))).unwrap();
assert_eq!(connector(&s, k), before);
}
#[test]
fn curved_connectors_leave_and_arrive_along_the_sides() {
let (mut s, [a, _, c, k]) = three();
s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})).unwrap();
assert_eq!(connector(&s, k).routing, Routing::Curved);
let n = node(&s, k);
let NodeKind::Vector { network } = &n.kind else { panic!("a vector") };
assert_eq!((network.vertices.len(), network.segments.len()), (2, 1));
assert!(!network.segments[0].is_straight(), "one curve");
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
// Side to side in a line: the curve's control points lie on that line, so its box is flat.
assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height);
// From a's bottom to c's top: out downwards and in from above; the box holds the whole curve.
let k2 = id(
&s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(),
"id",
);
let pts = route(s.doc().unwrap(), &connector(&s, k2)).unwrap();
assert_eq!(pts.len(), 4);
assert!(pts[1].y > pts[0].y && pts[2].y < pts[3].y, "{pts:?}");
let b = s.doc().unwrap().world_bounds(k2).unwrap();
assert!(b.y0 <= 0.0 + 1e-9 && b.y1 >= 100.0 - 1e-9, "{b:?}");
// The label sits on the curve's middle (by length): here, by symmetry, the middle of the box.
let label = crate::board::label_of(s.doc().unwrap(), k2).unwrap();
let lc = s.doc().unwrap().world_bounds(label).unwrap().center();
assert!((lc - b.center()).hypot() < 0.5, "{lc:?} vs {:?}", b.center());
assert!(s.execute("board.setStyle", &json!({"ids": [k], "style": "wavy"})).is_err());
s.execute("tool.connector", &json!({"style": "curved"})).unwrap();
assert_eq!(s.tool.board.routing, Routing::Curved);
}
/// Whether `pts` (a right-angled route) runs through the inside of `r`.
fn through(pts: &[Point], r: Rect) -> bool {
pts.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
a.x.min(b.x) < r.x1 - 0.5 && a.x.max(b.x) > r.x0 + 0.5 && a.y.min(b.y) < r.y1 - 0.5 && a.y.max(b.y) > r.y0 + 0.5
})
}
#[test]
fn elbows_go_around_the_objects_they_join() {
let (mut s, [a, b, _, k]) = three();
// Out of a's left and b's right: the short way runs through both, so it goes over them.
s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "left", "toSide": "right", "style": "elbow"})).unwrap();
let k2 = s.selection()[0];
let pts = drawn(&s, k2);
let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0));
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0)));
assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0)));
assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}");
assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}");
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only");
// Facing each other, the short way stays.
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
// An end at the bottom of b leaving towards a above it: around b's side and up.
s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap();
let pts = drawn(&s, k2);
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0)));
}
#[test]
fn reattach_survives_junk() {
let (mut s, [_, _, _, k]) = three();
for p in [
json!({"id": k, "end": "to", "target": {"x": 1e308, "y": -1e308}}),
json!({"id": k, "end": "from", "target": {"x": f64::MAX, "y": 0}, "side": 5}),
json!({"id": [k], "end": ["to"], "target": [1]}),
json!({"ids": "x", "end": null, "target": null}),
json!({"id": k, "end": "to", "target": 99_999}),
] {
let _ = s.execute("board.reattach", &p);
let _ = s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"}));
let _ = s.execute("board.reattach", &p);
}
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
+300
View File
@@ -0,0 +1,300 @@
//! Board tables: a frame of row frames holding cell frames with text, all ordinary auto layout
//! layers. The table hugs its rows' height, the rows share its width and the cells share their
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
//! a line under itself and the column lines are thin rectangles over the table that stretch with
//! it (they are rebuilt after every edit, like the first row's header tint).
use figcraft_color::{Color, Paint};
use figcraft_doc::{
AutoLayout, BoardItem, Constraints, Document, HConstraint, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, TextAlignH, VConstraint,
};
use figcraft_geom::Affine;
use serde_json::{Value, json};
use crate::board::{INK, add_text, require};
use crate::cmd::CommandSpec;
use crate::params::{get, id, num, num_or, opt_index, req_string};
use crate::{EngineError, Result, Session, bad, spec, textedit};
/// The most rows and columns a table has.
pub const MAX_ROWS: usize = 50;
pub const MAX_COLUMNS: usize = 20;
/// A new column's width, and a cell's height before its text is measured.
const CELL_W: f64 = 160.0;
const CELL_H: f64 = 40.0;
/// Room around a cell's text: [top, right, bottom, left].
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
const TEXT_SIZE: f64 = 14.0;
const LINE_W: f64 = 1.0;
const GRID: Color = Color::from_rgb8_const(0xCF, 0xCB, 0xC4);
const HEADER: Color = Color::from_rgb8_const(0xF1, 0xEF, 0xEB);
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.table",
"Table",
"{rows?, columns? (default 3 × 3, or the size of `cells`; at most 50 × 20), cells? ([[text…]…] row by row), x?, y? (its top-left on the page; default centred in the view)} → {id, rows, columns, cells: [[text id…]…]}",
true,
table_cmd
),
spec!(
"board.tableEdit",
"Edit table",
"{id? (a table or a layer in one; default the selection), action: addRow|addColumn|removeRow|removeColumn, index? (where the new one goes, or which one goes; default the last)} → {id, rows, columns, cells}",
true,
edit_cmd
),
]
}
/// A count from 1 to `max` (`default` when missing).
fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result<usize> {
match num(p, key) {
None if get(p, key).is_none() => Ok(default),
Some(v) if v.fract() == 0.0 && (1.0..=max as f64).contains(&v) => Ok(v as usize),
_ => Err(bad(cmd, format!("`{key}` is a whole number from 1 to {max}"))),
}
}
/// A frame for the table at `index` in `parent`, made by `f`.
fn frame(doc: &mut Document, parent: NodeId, index: Option<usize>, name: &str, size: (f64, f64), f: impl FnOnce(&mut Node)) -> Result<NodeId> {
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Frame, name, size.0, size.1);
n.fills.clear();
n.clip = false;
f(&mut n);
doc.insert(n, parent, index)?;
Ok(id)
}
fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64, text: &str) -> Result<NodeId> {
let cell = frame(doc, row, index, "Cell", (width, CELL_H), |n| {
n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: CELL_PAD, ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.child_layout.min_h = Some(CELL_H);
})?;
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
Ok(cell)
}
fn make_row(doc: &mut Document, table: NodeId, index: Option<usize>, width: f64, texts: &[&str]) -> Result<NodeId> {
let row = frame(doc, table, index, "Row", (width, CELL_H), |n| {
n.layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.stroke.weight = LINE_W;
n.stroke.align = StrokeAlign::Inside;
n.stroke.sides = Some([0.0, 0.0, LINE_W, 0.0]);
})?;
let cw = width / texts.len().max(1) as f64;
for t in texts {
make_cell(doc, row, None, cw, t)?;
}
Ok(row)
}
/// A table of `texts` (rows of cell texts, all as long) at `at` on `parent`.
pub(crate) fn make_table(doc: &mut Document, parent: NodeId, at: (f64, f64), texts: &[Vec<&str>]) -> Result<NodeId> {
let columns = texts.first().map_or(1, Vec::len).max(1);
let w = CELL_W * columns as f64;
let table = frame(doc, parent, None, "Table", (w, CELL_H * texts.len() as f64), |n| {
n.transform = Affine::translate(at);
n.fills = vec![Paint::solid(Color::WHITE)];
n.strokes = vec![Paint::solid(GRID)];
n.stroke.weight = LINE_W;
n.stroke.align = StrokeAlign::Inside;
n.corners.radii = [4.0; 4];
n.clip = true;
// The outline draws over the rows' edges rather than taking room from them.
n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: [0.0; 4], strokes_included: false, ..AutoLayout::default() });
n.child_layout.sizing_v = Sizing::Hug;
n.board = Some(BoardItem::Table);
})?;
for row in texts {
make_row(doc, table, None, w, row)?;
}
restyle(doc, table)?;
Ok(table)
}
/// Table `table`'s rows (its laid-out children) and column lines (the others).
fn parts(doc: &Document, table: NodeId) -> (Vec<NodeId>, Vec<NodeId>) {
let kids = doc.node(table).map(|n| n.children.clone()).unwrap_or_default();
kids.into_iter().partition(|c| doc.node(*c).is_some_and(|n| !n.child_layout.absolute))
}
fn cells(doc: &Document, row: NodeId) -> Vec<NodeId> {
doc.node(row).map(|n| n.children.clone()).unwrap_or_default()
}
/// The first row tinted, a line under every row but the last, and the column lines over it all.
fn restyle(doc: &mut Document, table: NodeId) -> Result<()> {
let (rows, lines) = parts(doc, table);
for l in lines {
doc.remove(l)?;
}
let last = rows.len().saturating_sub(1);
for (i, r) in rows.iter().enumerate() {
let n = doc.get_mut(*r)?;
n.fills = if i == 0 { vec![Paint::solid(HEADER)] } else { Vec::new() };
n.strokes = if i < last { vec![Paint::solid(GRID)] } else { Vec::new() };
}
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
let (w, h) = doc.get(table).map(|t| (t.width, t.height))?;
for i in 1..columns {
let x = w * i as f64 / columns as f64 - LINE_W / 2.0;
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Rectangle, "Column line", LINE_W, h);
n.fills = vec![Paint::solid(GRID)];
n.transform = Affine::translate((x, 0.0));
n.child_layout.absolute = true;
n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::TopBottom };
n.locked = true;
doc.insert(n, table, None)?;
}
Ok(())
}
/// Each cell's text layer, row by row (null for a cell without one).
fn cell_texts(doc: &Document, table: NodeId) -> Value {
let text = |cell: NodeId| {
doc.node(cell).and_then(|n| n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))))
};
let (rows, _) = parts(doc, table);
Value::Array(rows.iter().map(|r| Value::Array(cells(doc, *r).into_iter().map(|c| json!(text(c))).collect())).collect())
}
fn answer(doc: &Document, table: NodeId) -> Value {
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
json!({"id": table, "rows": rows.len(), "columns": columns, "cells": cell_texts(doc, table)})
}
fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.table";
require(s, cmd)?;
let given: Vec<Vec<String>> = match get(p, "cells") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(rows)) => rows
.iter()
.map(|r| match r {
Value::Array(c) => c.iter().map(|t| t.as_str().map(str::to_string).ok_or_else(|| bad(cmd, "`cells` holds text"))).collect(),
_ => Err(bad(cmd, "`cells` is a list of rows, each a list of texts")),
})
.collect::<Result<_>>()?,
Some(_) => return Err(bad(cmd, "`cells` is a list of rows, each a list of texts")),
};
let widest = given.iter().map(Vec::len).max().unwrap_or(0);
let rows = count(cmd, p, "rows", if given.is_empty() { 3 } else { given.len() }, MAX_ROWS)?;
let columns = count(cmd, p, "columns", if widest == 0 { 3 } else { widest }, MAX_COLUMNS)?;
if given.len() > rows || widest > columns {
return Err(bad(cmd, format!("`cells` doesn't fit in {rows} × {columns}")));
}
let texts: Vec<Vec<&str>> =
(0..rows).map(|r| (0..columns).map(|c| given.get(r).and_then(|row| row.get(c)).map_or("", String::as_str)).collect()).collect();
let (w, h) = (CELL_W * columns as f64, CELL_H * rows as f64);
let view = &s.ds()?.view;
let at = (num_or(p, "x", view.cx - w / 2.0), num_or(p, "y", view.cy - h / 2.0));
if !(at.0.is_finite() && at.1.is_finite()) {
return Err(bad(cmd, "`x` and `y` are finite numbers"));
}
let page = s.page()?;
let doc = s.doc_mut()?;
let table = make_table(doc, page, at, &texts)?;
let out = answer(doc, table);
s.set_selection(vec![table]);
Ok(out)
}
/// The table `id` is or is in.
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))
}
fn edit_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.tableEdit";
require(s, cmd)?;
let target = id(p, "id").or_else(|| s.selection().first().copied()).ok_or_else(|| EngineError::Disabled(cmd.into(), "select a table".into()))?;
let doc = s.doc()?;
doc.get(target)?;
let table = table_of(doc, target).ok_or_else(|| bad(cmd, "that isn't a table"))?;
let action = req_string(cmd, p, "action")?;
let index = opt_index(cmd, p, "index")?;
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len()).max(1);
let width = doc.get(table)?.width;
let doc = s.doc_mut()?;
// `n` things: `index` (default `last`) must be at most `end`.
let at = |end: usize, last: usize| match index {
Some(i) if i > end => Err(bad(cmd, format!("`index` is from 0 to {end}"))),
Some(i) => Ok(i),
None => Ok(last),
};
match action.as_str() {
"addRow" => {
if rows.len() >= MAX_ROWS {
return Err(bad(cmd, format!("a table has at most {MAX_ROWS} rows")));
}
let i = at(rows.len(), rows.len())?;
// Rows come before the column lines among the table's layers.
let child = rows.get(i).or(rows.last()).and_then(|r| doc.node(table).and_then(|t| t.children.iter().position(|c| c == r)));
let child = child.map(|c| if i >= rows.len() { c + 1 } else { c });
make_row(doc, table, child, width, &vec![""; columns])?;
}
"removeRow" => {
if rows.len() <= 1 {
return Err(bad(cmd, "a table keeps at least one row"));
}
let i = at(rows.len() - 1, rows.len() - 1)?;
if let Some(r) = rows.get(i) {
doc.remove(*r)?;
}
}
"addColumn" => {
if columns >= MAX_COLUMNS {
return Err(bad(cmd, format!("a table has at most {MAX_COLUMNS} columns")));
}
let i = at(columns, columns)?;
let cw = width / columns as f64;
doc.get_mut(table)?.width = width + cw;
for r in &rows {
make_cell(doc, *r, Some(i), cw, "")?;
}
}
"removeColumn" => {
if columns <= 1 {
return Err(bad(cmd, "a table keeps at least one column"));
}
let i = at(columns - 1, columns - 1)?;
doc.get_mut(table)?.width = width - width / columns as f64;
for r in &rows {
if let Some(c) = cells(doc, *r).get(i) {
doc.remove(*c)?;
}
}
}
other => return Err(bad(cmd, format!("`action` is addRow, addColumn, removeRow or removeColumn, not `{other}`"))),
}
restyle(doc, table)?;
let out = answer(doc, table);
s.set_selection(vec![table]);
Ok(out)
}
/// A double-click on a table: type in the cell under the pointer (`leaf`). False when it hit no
/// cell (a column line, the table's edge).
pub(crate) fn edit_cell(s: &mut Session, doc: &Document, leaf: NodeId) -> Result<bool> {
let in_table = |c: &NodeId| {
let row = doc.node(*c).and_then(|n| n.parent);
row.and_then(|r| doc.node(r)).and_then(|r| r.parent).and_then(|t| doc.node(t)).is_some_and(|t| t.board == Some(BoardItem::Table))
};
let Some(cell) = std::iter::once(leaf).chain(doc.ancestors(leaf)).find(in_table) else { return Ok(false) };
let Some(text) = cells(doc, cell).into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) else {
return Ok(false);
};
textedit::begin(s, text, None, false)?;
textedit::key(s, "SelectAll", false, false, false)?;
Ok(true)
}
+257
View File
@@ -0,0 +1,257 @@
//! Boards: templates (each placed as board objects in one undo step) and tables (made from auto
//! layout frames, edited by rows and columns, typed into with the text tools, synced live).
use figcraft_doc::{BoardItem, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::board_tests::{board, id, node};
use crate::collab_live_tests::Room;
use crate::tools::{PointerEvent, PointerKind};
use crate::{EngineError, Session};
fn count(s: &Session, f: impl Fn(&BoardItem) -> bool) -> usize {
s.doc().unwrap().nodes().filter(|n| n.board.as_ref().is_some_and(&f)).count()
}
fn ids(v: &Value, key: &str) -> Vec<NodeId> {
v[key].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
/// Rows of cell texts' characters.
fn texts(s: &Session, table: &Value) -> Vec<Vec<String>> {
let chars = |v: &Value| match &node(s, NodeId(v.as_u64().unwrap())).kind {
NodeKind::Text(td) => td.characters.clone(),
_ => panic!("not text"),
};
table["cells"].as_array().unwrap().iter().map(|r| r.as_array().unwrap().iter().map(chars).collect()).collect()
}
/// A table's column lines (its laid-out-over children): their x and height.
fn lines(s: &Session, table: NodeId) -> Vec<(f64, f64)> {
let doc = s.doc().unwrap();
let t = doc.node(table).unwrap();
t.children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| n.child_layout.absolute).map(|n| (n.transform.translation().x, n.height)).collect()
}
#[test]
fn every_template_places_board_objects_in_one_undo_step() {
let mut s = board();
let list = s.execute("board.templates", &json!({})).unwrap();
let names: Vec<&str> = list.as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect();
assert_eq!(names, ["retrospective", "brainstorm", "weeklyPlanner", "flowChart", "kanban"]);
for (i, name) in names.iter().enumerate() {
let before = s.doc().unwrap().nodes().count();
let r = s.execute("board.template", &json!({"name": name, "x": i as f64 * 2000.0, "y": 0})).unwrap();
let made = ids(&r, "ids");
assert!(!made.is_empty(), "{name}");
assert_eq!(s.selection(), made, "{name}: what it placed is selected");
let doc = s.doc().unwrap();
// It sits where it was asked to, about its listed size.
let b = doc.bounds_of(&made).unwrap();
assert!((b.x0 - i as f64 * 2000.0).abs() < 1.0 && b.y0.abs() < 1.0, "{name} at {b:?}");
let size = &list[i];
assert!((b.width() - size["width"].as_f64().unwrap()).abs() < 2.0, "{name}: width {}", b.width());
assert!(b.height() <= size["height"].as_f64().unwrap() + 2.0, "{name}: height {}", b.height());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().nodes().count(), before, "{name}: one undo step");
s.execute("edit.redo", &json!({})).unwrap();
}
// By name too, centred in the view without a place.
let view = s.ds().unwrap().view.clone();
let r = s.execute("board.template", &json!({"name": "Kanban board"})).unwrap();
let b = s.doc().unwrap().bounds_of(&ids(&r, "ids")).unwrap();
assert!((b.center().x - view.cx).abs() < 2.0, "{b:?} centred on {}", view.cx);
assert!(s.execute("board.template", &json!({"name": "party"})).is_err());
assert!(s.execute("board.template", &json!({})).is_err());
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
assert!(matches!(d.execute("board.template", &json!({"name": "kanban"})), Err(EngineError::Disabled(..))), "boards only");
assert!(matches!(d.execute("board.table", &json!({})), Err(EngineError::Disabled(..))), "boards only");
}
#[test]
fn templates_are_made_of_ordinary_board_objects() {
let mut s = board();
s.execute("board.template", &json!({"name": "retrospective", "x": 0, "y": 0})).unwrap();
let doc = s.doc().unwrap();
let sections: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Section { .. })).collect();
assert_eq!(sections.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Went well", "To improve", "Next steps"]);
for sec in &sections {
let stickies = sec.children.iter().filter(|c| matches!(doc.node(**c).unwrap().board, Some(BoardItem::Sticky { .. }))).count();
assert_eq!(stickies, 2, "{}", sec.name);
}
// No author on a template's stickies.
assert!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).all(|n| n.board.as_ref().unwrap().author().is_empty()));
// The flow chart's connectors join its shapes, labelled at the decision.
s.execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 1000})).unwrap();
let doc = s.doc().unwrap();
let joined: Vec<_> = doc
.nodes()
.filter_map(|n| match &n.board {
Some(BoardItem::Connector(c)) => Some((c.from.node().unwrap(), c.to.node().unwrap())),
_ => None,
})
.collect();
assert_eq!(joined.len(), 6);
assert!(joined.iter().all(|(a, b)| matches!(doc.node(*a).unwrap().board, Some(BoardItem::Shape { .. }))
&& matches!(doc.node(*b).unwrap().board, Some(BoardItem::Shape { .. }))));
assert_eq!(count(&s, |b| matches!(b, BoardItem::Label { .. })), 2);
// The brainstorm's topic is joined to each of its four clusters.
s.execute("board.template", &json!({"name": "brainstorm", "x": 3000, "y": 0})).unwrap();
assert_eq!(count(&s, |b| matches!(b, BoardItem::Connector(_))), 10);
}
#[test]
fn a_table_is_auto_layout_frames_with_a_header_and_column_lines() {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["Name", "Role"], ["Ada", "Lead"]], "rows": 3})).unwrap();
let t = id(&r, "id");
assert_eq!((r["rows"].as_u64(), r["columns"].as_u64()), (Some(3), Some(2)));
assert_eq!(texts(&s, &r), [["Name", "Role"], ["Ada", "Lead"], ["", ""]]);
assert_eq!(node(&s, t).board, Some(BoardItem::Table));
assert_eq!(s.selection(), [t]);
let table = node(&s, t);
assert_eq!(table.width, 320.0);
let rows: Vec<_> = table.children.iter().map(|c| node(&s, *c)).filter(|n| !n.child_layout.absolute).collect();
assert_eq!(rows.len(), 3);
assert!(rows[0].fills.len() == 1 && rows[1].fills.is_empty(), "the header row is tinted");
assert!(rows[1].strokes.len() == 1 && rows[2].strokes.is_empty(), "a line under every row but the last");
let cell = node(&s, rows[1].children[1]);
assert_eq!((cell.width, cell.transform.translation().x), (160.0, 160.0), "cells share their row");
assert_eq!(table.height, rows.iter().map(|r| r.height).sum::<f64>(), "the table hugs its rows");
assert_eq!(lines(&s, t), [(159.5, table.height)], "the column line runs the table's height");
// Caps and refusals.
for bad in
[json!({"rows": 51}), json!({"columns": 21}), json!({"rows": 0}), json!({"rows": 2.5}), json!({"cells": [["a", "b", "c"]], "columns": 2})]
{
assert!(s.execute("board.table", &bad).is_err(), "{bad}");
}
assert!(s.execute("board.table", &json!({"rows": 50, "columns": 20})).is_ok(), "the largest table");
// It saves and reopens.
let doc = s.doc().unwrap();
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
assert_eq!(back.node(t).unwrap().board, Some(BoardItem::Table));
}
#[test]
fn table_edits_add_and_remove_rows_and_columns_keeping_text() {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["a", "b"], ["c", "d"]]})).unwrap();
let t = id(&r, "id");
let edit = |s: &mut Session, p: Value| s.execute("board.tableEdit", &p);
let r = edit(&mut s, json!({"id": t, "action": "addRow", "index": 1})).unwrap();
assert_eq!(texts(&s, &r), [["a", "b"], ["", ""], ["c", "d"]]);
let r = edit(&mut s, json!({"action": "addColumn", "index": 0})).unwrap();
assert_eq!(texts(&s, &r), [["", "a", "b"], ["", "", ""], ["", "c", "d"]]);
assert_eq!(node(&s, t).width, 480.0, "a new column as wide as the others");
assert_eq!(lines(&s, t).len(), 2);
let r = edit(&mut s, json!({"action": "removeRow", "index": 0})).unwrap();
assert_eq!(texts(&s, &r), [["", "", ""], ["", "c", "d"]]);
let first = node(&s, node(&s, t).children[0]);
assert_eq!(first.fills.len(), 1, "the new first row is the header");
let r = edit(&mut s, json!({"action": "removeColumn"})).unwrap();
assert_eq!(texts(&s, &r), [["", ""], ["", "c"]]);
assert_eq!(node(&s, t).width, 320.0);
// From a cell inside it too.
let cell_text = NodeId(r["cells"][1][1].as_u64().unwrap());
let r = edit(&mut s, json!({"id": cell_text, "action": "addRow"})).unwrap();
assert_eq!(r["rows"], 3);
// Undo, one step each.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, t).width, 480.0);
// Refusals: the wrong action or index, the last row or column, beyond the caps, not a table.
for p in [
json!({"id": t, "action": "explode"}),
json!({"id": t, "action": "addRow", "index": 9}),
json!({"id": t, "action": "removeColumn", "index": 3}),
json!({"id": t, "action": "addRow", "index": -1}),
json!({"id": t}),
] {
assert!(edit(&mut s, p.clone()).is_err(), "{p}");
}
let one = id(&s.execute("board.table", &json!({"rows": 1, "columns": 1})).unwrap(), "id");
assert!(edit(&mut s, json!({"id": one, "action": "removeRow"})).is_err());
assert!(edit(&mut s, json!({"id": one, "action": "removeColumn"})).is_err());
let full = id(&s.execute("board.table", &json!({"rows": 50, "columns": 20})).unwrap(), "id");
assert!(edit(&mut s, json!({"id": full, "action": "addRow"})).is_err());
assert!(edit(&mut s, json!({"id": full, "action": "addColumn"})).is_err());
let sticky = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id");
assert!(edit(&mut s, json!({"id": sticky, "action": "addRow"})).is_err());
}
#[test]
fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap();
let t = id(&r, "id");
let h0 = node(&s, t).height;
s.execute("select.none", &json!({})).unwrap();
// The middle cell (columns 160 wide, rows about 40 tall).
let row_h = h0 / 3.0;
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 240.0, row_h * 1.5, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the cell");
assert_eq!(te.id.0, r["cells"][1][1].as_u64().unwrap());
crate::textedit::insert(
&mut s,
"A longer note
over
several lines",
)
.unwrap();
crate::textedit::end(&mut s).unwrap();
let h1 = node(&s, t).height;
assert!(h1 > h0 + 10.0, "the row grew: {h0} → {h1}");
assert!(lines(&s, t).iter().all(|(_, h)| (*h - h1).abs() < 1e-6), "column lines follow: {:?} vs {h1}", lines(&s, t));
// A plain click picks the table whole.
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 240.0, 10.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 240.0, 10.0, 1.0)).unwrap();
assert_eq!(s.selection(), [t]);
}
#[test]
fn tables_and_templates_sync_in_live_sessions() {
let s = board();
let mut room = Room::new(s.doc().unwrap().clone(), 2, 11);
let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id");
room.s(1).execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 600})).unwrap();
room.settle();
room.check();
room.s(1).execute("board.tableEdit", &json!({"id": t, "action": "addColumn"})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
assert_eq!(doc.node(t).unwrap().width, 640.0);
assert_eq!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Connector(_)))).count(), 6);
}
#[test]
fn template_and_table_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!([1]),
json!({"name": 7, "x": "NaN", "y": 1e308, "rows": "x", "columns": -1, "cells": 5}),
json!({"name": "kanban", "x": -1e308, "y": 1e308, "rows": 1e308, "columns": 0.5, "cells": [[1, 2]]}),
json!({"name": "\u{0}", "cells": [["a"], "b"], "rows": 50, "columns": 20}),
json!({"id": "x", "action": 5, "index": 1e308}),
json!({"id": -1, "action": "addRow", "index": -1}),
json!({"id": 99_999, "action": "removeColumn", "index": "NaN"}),
json!({"id": 2, "action": "addColumn", "index": 0}),
];
for cmd in ["board.template", "board.templates", "board.table", "board.tableEdit"] {
for j in &junk {
let mut s = board();
let t = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.template", &json!({"name": "brainstorm"}));
let mut j = j.clone();
if j.get("action").is_some_and(|a| a == "addColumn") {
j["id"] = t;
}
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Board templates: ready-made layouts (a retrospective, a brainstorm, a weekly planner, a flow
//! chart, a kanban board) placed on a board as ordinary board objects in one undo step. They are
//! built in code by running the board commands, so everything in them edits like anything else on
//! the board; their wording is FigCraft's own.
use std::sync::Arc;
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::board::require;
use crate::cmd::CommandSpec;
use crate::params::{id_of, num, string};
use crate::{EngineError, Result, Session, bad, spec};
/// A board template.
pub struct BoardTemplate {
pub id: &'static str,
pub name: &'static str,
/// One line about what is in it.
pub description: &'static str,
/// Its width and height on the board.
pub size: (f64, f64),
make: fn(&mut Builder) -> Result<()>,
}
pub static TEMPLATES: &[BoardTemplate] = &[
BoardTemplate {
id: "retrospective",
name: "Retrospective",
description: "Three sections to look back on a stretch of work: what went well, what to improve, what to do next",
size: (1280.0, 592.0),
make: retrospective,
},
BoardTemplate {
id: "brainstorm",
name: "Brainstorm",
description: "A question in the middle and four clusters of ideas around it",
size: (1344.0, 760.0),
make: brainstorm,
},
BoardTemplate {
id: "weeklyPlanner",
name: "Weekly planner",
description: "A column for each working day with a note to start from",
size: (1380.0, 632.0),
make: weekly_planner,
},
BoardTemplate {
id: "flowChart",
name: "Flow chart",
description: "Steps, a decision and its two outcomes joined by connectors",
size: (1220.0, 360.0),
make: flow_chart,
},
BoardTemplate {
id: "kanban",
name: "Kanban board",
description: "To do, Doing and Done columns with cards to move along",
size: (900.0, 812.0),
make: kanban,
},
];
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.template",
"Board template",
"{name (a template id from board.templates), x?, y? (its top-left on the page; default centred in the view)} → {name, ids} its sections, stickies, shapes, connectors and text, selected",
true,
template_cmd
),
spec!("board.templates", "List board templates", "{} → [{id, name, description, width, height}]", false, |_, _| {
Ok(Value::Array(
TEMPLATES
.iter()
.map(|t| json!({"id": t.id, "name": t.name, "description": t.description, "width": t.size.0, "height": t.size.1}))
.collect(),
))
}),
]
}
/// The template called `name` (its id, or its name in any case).
pub fn find(name: &str) -> Option<&'static BoardTemplate> {
let name = name.trim();
TEMPLATES.iter().find(|t| t.id.eq_ignore_ascii_case(name) || t.name.eq_ignore_ascii_case(name))
}
fn template_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.template";
require(s, cmd)?;
let names = || TEMPLATES.iter().map(|t| t.id).collect::<Vec<_>>().join(", ");
let name = string(p, "name").ok_or_else(|| bad(cmd, format!("`name` is one of {}", names())))?;
let t = find(&name).ok_or_else(|| bad(cmd, format!("no template `{name}`: one of {}", names())))?;
let view = &s.ds()?.view;
let at = (num(p, "x").unwrap_or(view.cx - t.size.0 / 2.0), num(p, "y").unwrap_or(view.cy - t.size.1 / 2.0));
if !(at.0.is_finite() && at.1.is_finite()) {
return Err(bad(cmd, "`x` and `y` are finite numbers"));
}
let page = s.page()?;
let mut b = Builder { s, page, at, made: Vec::new() };
(t.make)(&mut b)?;
let made = b.made;
s.set_selection(made.clone());
Ok(json!({"name": t.id, "ids": made}))
}
/// A board with template `id` at the page's origin and nothing else (the picker's previews).
pub fn preview(id: &str) -> Result<Document> {
let mut s = Session::new();
s.execute("file.newBoard", &json!({"name": id}))?;
s.execute("board.template", &json!({"name": id, "x": 0, "y": 0}))?;
let ds = s.docs.pop().ok_or(EngineError::NoDocument)?;
Ok(Arc::try_unwrap(ds.doc).unwrap_or_else(|d| (*d).clone()))
}
/// Places a template's objects: positions are from its top-left (`at` on the page), or inside the
/// section they go in.
struct Builder<'a> {
s: &'a mut Session,
page: NodeId,
at: (f64, f64),
/// What it placed on the page.
made: Vec<NodeId>,
}
impl Builder<'_> {
/// Run board command `cmd` (in this command's undo step); the layer it made.
fn run(&mut self, cmd: &str, p: Value) -> Result<NodeId> {
let spec = crate::cmd::find_command(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?;
let v = (spec.run)(self.s, &p)?;
v.get("id").and_then(id_of).ok_or_else(|| EngineError::Other(format!("`{cmd}` made no layer")))
}
/// A layer on the page at (`x`, `y`) from the template's corner.
fn top(&mut self, cmd: &str, x: f64, y: f64, p: Value) -> Result<NodeId> {
let id = self.run(cmd, placed(p, self.page, (self.at.0 + x, self.at.1 + y)))?;
self.made.push(id);
Ok(id)
}
fn title(&mut self, text: &str) -> Result<NodeId> {
self.top("node.create", 0.0, 0.0, json!({"type": "text", "text": text, "props": {"fontSize": 32, "fills": ["#1E1E1E"]}}))
}
fn section(&mut self, name: &str, (x, y, w, h): (f64, f64, f64, f64), fill: &str) -> Result<NodeId> {
self.top("node.create", x, y, json!({"type": "section", "name": name, "width": w, "height": h, "props": {"fills": [fill]}}))
}
/// A sticky in `section` (or on the page) at (`x`, `y`) in it.
fn sticky(&mut self, section: Option<NodeId>, (x, y): (f64, f64), text: &str, color: &str) -> Result<NodeId> {
let p = json!({"text": text, "color": color, "author": false});
match section {
Some(sec) => self.run("board.sticky", placed(p, sec, (x, y))),
None => self.top("board.sticky", x, y, p),
}
}
fn shape(&mut self, shape: &str, (x, y, w, h): (f64, f64, f64, f64), text: &str, color: &str) -> Result<NodeId> {
self.top("board.shape", x, y, json!({"shape": shape, "width": w, "height": h, "text": text, "color": color}))
}
fn connector(&mut self, from: NodeId, to: NodeId, extra: Value) -> Result<NodeId> {
let id = self.run("board.connector", with(extra, [("from", json!(from)), ("to", json!(to))]))?;
self.made.push(id);
Ok(id)
}
}
/// Params `p` with `entries` added (`p` an object).
fn with<const N: usize>(mut p: Value, entries: [(&str, Value); N]) -> Value {
if let Some(o) = p.as_object_mut() {
o.extend(entries.map(|(k, v)| (k.to_string(), v)));
}
p
}
/// Params `p` placing a layer in `parent` at `at`.
fn placed(p: Value, parent: NodeId, at: (f64, f64)) -> Value {
with(p, [("parent", json!(parent)), ("x", json!(at.0)), ("y", json!(at.1))])
}
fn retrospective(b: &mut Builder) -> Result<()> {
b.title("Retrospective")?;
let parts = [
("Went well", "#EAF6E1", "green", ["Something that helped us", "A win worth repeating"]),
("To improve", "#FCE8EF", "pink", ["Something that slowed us down", "A question we still have"]),
("Next steps", "#E6F0FC", "blue", ["One change to try next", "Who looks after it?"]),
];
for (i, (name, fill, color, notes)) in parts.into_iter().enumerate() {
let sec = b.section(name, (i as f64 * 440.0, 72.0, 400.0, 520.0), fill)?;
for (j, note) in notes.into_iter().enumerate() {
b.sticky(Some(sec), (100.0, 60.0 + j as f64 * 230.0), note, color)?;
}
}
Ok(())
}
fn brainstorm(b: &mut Builder) -> Result<()> {
let topic = b.shape("ellipse", (532.0, 300.0, 280.0, 160.0), "What are we trying to solve?", "violet")?;
let clusters = [((0.0, 0.0), "yellow", 1), ((920.0, 0.0), "orange", 0), ((0.0, 560.0), "green", 1), ((920.0, 560.0), "blue", 0)];
for ((x, y), color, near) in clusters {
let notes = [b.sticky(None, (x, y), "An idea", color)?, b.sticky(None, (x + 224.0, y), "Build on it", color)?];
if let Some(n) = notes.get(near) {
b.connector(topic, *n, json!({"style": "straight", "endCap": "none"}))?;
}
}
Ok(())
}
fn weekly_planner(b: &mut Builder) -> Result<()> {
b.title("This week")?;
let days = [
("Monday", "Plan the week"),
("Tuesday", "Focus for the day"),
("Wednesday", "Focus for the day"),
("Thursday", "Focus for the day"),
("Friday", "Wrap up and share"),
];
for (i, (day, note)) in days.into_iter().enumerate() {
let sec = b.section(day, (i as f64 * 280.0, 72.0, 260.0, 560.0), "#FFFFFF")?;
b.sticky(Some(sec), (30.0, 60.0), note, if i % 2 == 0 { "yellow" } else { "orange" })?;
}
Ok(())
}
fn flow_chart(b: &mut Builder) -> Result<()> {
let start = b.shape("ellipse", (0.0, 30.0, 160.0, 100.0), "Start", "green")?;
let step = b.shape("rectangle", (240.0, 20.0, 180.0, 120.0), "Do the first step", "blue")?;
let check = b.shape("diamond", (500.0, 0.0, 200.0, 160.0), "Did it work?", "yellow")?;
let carry = b.shape("roundedRectangle", (800.0, 20.0, 180.0, 120.0), "Carry on", "blue")?;
let done = b.shape("ellipse", (1060.0, 30.0, 160.0, 100.0), "Done", "green")?;
let other = b.shape("roundedRectangle", (510.0, 240.0, 180.0, 120.0), "Try another way", "orange")?;
let elbow = json!({"style": "elbow"});
b.connector(start, step, elbow.clone())?;
b.connector(step, check, elbow.clone())?;
b.connector(check, carry, json!({"style": "elbow", "label": "Yes"}))?;
b.connector(carry, done, elbow)?;
b.connector(check, other, json!({"style": "elbow", "fromSide": "bottom", "toSide": "top", "label": "No"}))?;
// Back round to the step: out of the side, into the step from below.
b.connector(other, step, json!({"style": "elbow", "fromSide": "left", "toSide": "bottom"}))?;
Ok(())
}
fn kanban(b: &mut Builder) -> Result<()> {
b.title("Team board")?;
let columns: [(&str, &str, &[&str]); 3] = [
("To do", "yellow", &["Write the brief", "Sketch the first screens", "Plan a test session"]),
("Doing", "blue", &["Review the copy"]),
("Done", "green", &["Set up the board"]),
];
for (i, (name, color, cards)) in columns.into_iter().enumerate() {
let sec = b.section(name, (i as f64 * 310.0, 72.0, 280.0, 740.0), "#FFFFFF")?;
for (j, card) in cards.iter().enumerate() {
b.sticky(Some(sec), (40.0, 60.0 + j as f64 * 220.0), card, color)?;
}
}
Ok(())
}
+365
View File
@@ -0,0 +1,365 @@
//! Boards: the file kind, sticky notes, shapes with text, connectors that follow what they join
//! (locally, through undo and in live sessions), stamps, ink and the eraser, and refusals.
use figcraft_doc::{
BOARD_PAGE, BoardItem, BoardShape, Connector, End, FileKind, Node, NodeId, NodeKind, Routing, Side, Stamp, StrokeCap, board_color,
};
use figcraft_geom::Point;
use serde_json::{Value, json};
use crate::board::{route, sticky_font_size};
use crate::collab_live_tests::Room;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::{EngineError, Session};
pub(crate) fn board() -> Session {
let mut s = Session::new();
s.execute("file.newBoard", &json!({"name": "Ideas"})).unwrap();
s
}
pub(crate) fn id(v: &Value, key: &str) -> NodeId {
v[key].as_u64().map(NodeId).unwrap_or_else(|| panic!("no {key} in {v}"))
}
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
pub(crate) fn connector(s: &Session, id: NodeId) -> Connector {
match node(s, id).board {
Some(BoardItem::Connector(c)) => c,
other => panic!("not a connector: {other:?}"),
}
}
/// Connector `id`'s points on the page.
pub(crate) fn drawn(s: &Session, id: NodeId) -> Vec<Point> {
let doc = s.doc().unwrap();
let NodeKind::Vector { network } = &doc.node(id).unwrap().kind else { panic!("not a vector") };
let w = doc.world_transform(id);
network.vertices.iter().map(|v| w * v.point()).collect()
}
pub(crate) fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-6
}
#[test]
fn a_board_is_a_kind_of_file_that_saves_and_reopens() {
let s = board();
let doc = s.doc().unwrap();
assert_eq!(doc.kind, FileKind::Board);
assert!(matches!(doc.node(doc.pages[0]).unwrap().kind, NodeKind::Page { background, .. } if background == BOARD_PAGE));
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
assert_eq!(back.kind, FileKind::Board);
// Files from before boards (no kind) are design files, and design files don't write one.
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
let json = figcraft_format::to_json(d.doc().unwrap()).unwrap();
assert!(json.get("kind").is_none());
assert_eq!(figcraft_format::from_json(&json.to_string()).unwrap().0.kind, FileKind::Design);
assert_eq!(crate::inspect::document(&s, 0, false)["kind"], "board", "inspect shows the kind");
}
#[test]
fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() {
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0})), ("tool.sticky", json!({})), ("tool.set", json!({"tool": "marker"}))] {
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd} on a design file");
}
let mut s = board();
for cmd in ["view.devMode", "view.prototype", "proto.present", "proto.flow.add", "dev.focus"] {
let e = s.execute(cmd, &json!({})).unwrap_err();
assert!(e.to_string().contains("boards have no prototypes"), "{cmd}: {e}");
}
}
#[test]
fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() {
let mut s = board();
s.prefs.user_name = "Ada".into();
let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap();
let (st, note) = (id(&r, "id"), id(&r, "text"));
let n = node(&s, st);
assert!(n.is_board_atom() && matches!(&n.board, Some(BoardItem::Sticky { author }) if author == "Ada"));
assert_eq!((n.width, n.height), (200.0, 200.0));
assert_eq!(n.fills[0].solid_color(), board_color("blue"));
assert!(n.layout.is_some() && !n.effects.is_empty());
assert_eq!(n.children.len(), 2, "the note and its author");
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
// A click on the note picks the sticky whole.
let doc = s.doc().unwrap();
assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st);
// A long note takes a smaller size; a very long one grows the sticky past its square.
let long = "word ".repeat(120);
s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long));
assert!(sticky_font_size(&long) < 16.0);
assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0);
// Undo puts the short note and its size back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
}
#[test]
fn next_sticky_goes_beside_in_the_same_colour_and_emptied_notes_stay() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink"})).unwrap(), "id");
let r = s.execute("board.nextSticky", &json!({})).unwrap();
let b = id(&r, "id");
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(nb.transform.translation().x, na.width + 24.0);
assert_eq!(nb.fills, na.fills);
assert_eq!(s.selection(), vec![b]);
// Typing in it and emptying it again keeps its note; ending selects the sticky.
let note = id(&r, "text");
crate::board::edit_text(&mut s, b).unwrap();
crate::textedit::insert(&mut s, "x").unwrap();
crate::textedit::key(&mut s, "Backspace", false, false, false).unwrap();
crate::textedit::end(&mut s).unwrap();
assert!(s.doc().unwrap().contains(note));
assert_eq!(s.selection(), vec![b]);
}
#[test]
fn shapes_carry_text_and_change_outline() {
let mut s = board();
let r = s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "text": "Decide", "color": "green"})).unwrap();
let sh = id(&r, "id");
let n = node(&s, sh);
assert_eq!(n.board, Some(BoardItem::Shape { shape: BoardShape::Diamond }));
assert_eq!(n.children.len(), 2);
assert!(matches!(node(&s, n.children[0]).kind, NodeKind::Vector { .. }));
s.execute("board.setStyle", &json!({"ids": [sh], "shape": "speechBubble", "fontSize": 24})).unwrap();
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::SpeechBubble }));
assert_eq!(node(&s, id(&r, "text")).kind.text().unwrap().style.font_size, 24.0);
s.execute("board.setColor", &json!({"ids": [sh], "color": "#FF0000"})).unwrap();
assert_eq!(node(&s, n.children[0]).fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
assert!(s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).is_err());
}
#[test]
fn connectors_follow_what_they_join_and_undo_with_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id");
let pts = drawn(&s, c);
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}");
assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow);
// Its label sits at the middle.
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
let lb = s.doc().unwrap().world_bounds(label).unwrap();
assert!(near(lb.center(), Point::new(300.0, 100.0)), "{lb:?}");
// Move one end: the connector re-routes (elbow now, out of the facing sides).
s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap();
s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap();
let pts = drawn(&s, c);
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}");
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}");
// Undo takes the move back and the route with it.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
// A deleted end stays where it was, as a point.
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { .. }));
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
// A connector moved on its own takes its free end along; the attached one stays.
s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c));
assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0)));
// Deleting the connector takes its label.
s.execute("edit.delete", &json!({"ids": [c]})).unwrap();
assert!(!s.doc().unwrap().contains(label));
assert!(s.execute("board.connector", &json!({"from": a, "to": a})).is_err());
}
#[test]
fn routes_are_the_same_for_the_same_document() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id");
let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow };
let doc = s.doc().unwrap();
let r = route(doc, &c).unwrap();
assert_eq!(r, route(&doc.clone(), &c).unwrap());
// Down out of the bottom, across halfway, down into the point.
assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]);
}
#[test]
fn stamps_and_ink_and_the_eraser() {
let mut s = board();
s.prefs.user_name = "Grace".into();
let st = id(&s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "heart"})).unwrap(), "id");
let n = node(&s, st);
assert_eq!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into() }));
assert_eq!(s.doc().unwrap().world_bounds(st).unwrap().center(), Point::new(100.0, 100.0));
assert!(!n.children.is_empty());
// Marker and highlighter strokes through the tools; the eraser takes what it touches.
let draw = |s: &mut Session, tool: &str, y: f64| {
s.execute("tool.set", &json!({"tool": tool})).unwrap();
for (k, x) in [(PointerKind::Down, 0.0), (PointerKind::Move, 50.0), (PointerKind::Move, 100.0), (PointerKind::Up, 150.0)] {
s.pointer(&PointerEvent::at(k, x, y, 1.0)).unwrap();
}
};
draw(&mut s, "marker", 300.0);
draw(&mut s, "highlighter", 400.0);
let inks: Vec<Node> = s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).cloned().collect();
assert_eq!(inks.len(), 2);
assert!(inks.iter().any(|n| n.opacity < 1.0 && n.stroke.weight > 10.0), "a highlighter stroke");
let undo = s.undo_depth().0;
s.execute("tool.eraser", &json!({})).unwrap();
for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Move, 60.0), (PointerKind::Up, 80.0)] {
s.pointer(&PointerEvent::at(k, x, 300.0, 1.0)).unwrap();
}
assert_eq!(s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).count(), 1, "the marker stroke is gone");
assert_eq!(s.undo_depth().0, undo + 1, "one step");
assert_eq!(s.tool.tool, ToolId::Eraser);
}
#[test]
fn the_sticky_and_connector_tools() {
let mut s = board();
s.execute("tool.sticky", &json!({"color": "orange"})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the new sticky");
let a = crate::board::owner_of_text(s.doc().unwrap(), te.id).unwrap();
assert_eq!(node(&s, a).fills[0].solid_color(), board_color("orange"));
crate::textedit::insert(&mut s, "First").unwrap();
crate::textedit::end(&mut s).unwrap();
let b = id(&s.execute("board.sticky", &json!({"x": 500, "y": 0})).unwrap(), "id");
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 300.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 100.0, 1.0)).unwrap();
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
let k = connector(&s, c);
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
}
#[test]
fn boards_sync_in_live_sessions() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap(), "id");
let mut room = Room::new(s.doc().unwrap().clone(), 2, 7);
assert_eq!(room.s(1).doc().unwrap().kind, FileKind::Board, "joiners get a board");
let b = id(&room.s(1).execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap(), "id");
room.settle();
let c = id(&room.s(0).execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
room.s(1).execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "star"})).unwrap();
room.settle();
room.check();
// Person 1 moves the sticky person 0 connected: both see the connector follow it.
room.s(1).execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 250})).unwrap();
room.s(0).execute("board.setColor", &json!({"ids": [a], "color": "violet"})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") };
let end = doc.world_transform(c) * network.vertices.last().unwrap().point();
assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side");
assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet"));
}
#[test]
fn board_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!([1]),
json!({"x": "NaN", "y": 1e308, "text": 5, "color": [1], "author": {"x": 1}, "parent": "p"}),
json!({"x": -1e308, "y": 0, "color": "neon", "shape": 7, "width": -5, "height": 1e308, "stamp": "skull"}),
json!({"from": {"x": "a"}, "to": 99_999, "fromSide": "inside", "style": 3, "startCap": "spike", "endCap": 7, "label": [1]}),
json!({"from": {"x": 1e308, "y": -1e308}, "to": {"x": 0, "y": 0}, "style": "elbow", "label": "\u{0}"}),
json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}),
json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}),
json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}),
json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}),
json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}),
];
let ids: Vec<&str> = crate::cmd::all_commands()
.iter()
.map(|c| c.id)
.filter(|i| {
i.starts_with("board.")
|| ["tool.sticky", "tool.shape", "tool.connector", "tool.marker", "tool.highlighter", "tool.eraser", "tool.stamp", "file.newBoard"]
.contains(i)
})
.collect();
assert!(ids.len() >= 17, "{ids:?}");
for cmd in ids {
for j in &junk {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let b = s.execute("board.shape", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
// Every other command on a board with objects on it.
for spec in crate::cmd::all_commands() {
if spec.id.starts_with("file.") && spec.id != "file.newBoard" || spec.id == "text.rescanFonts" || spec.id == "image.place" {
continue;
}
for j in &junk[..3] {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let b = s.execute("board.stamp", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.connector", &json!({"from": a, "to": b}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
#[test]
fn a_press_on_a_stickys_background_picks_and_drags_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
s.execute("select.none", &json!({})).unwrap();
// Below the note, on the sticky's own fill (a top-level frame's background is the canvas).
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap();
assert_eq!(s.selection(), vec![a]);
assert_eq!(node(&s, a).transform.translation().y, 300.0);
assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
}
#[test]
fn a_copy_of_joined_objects_joins_the_copies() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})).unwrap(), "id");
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
s.execute("edit.duplicate", &json!({"ids": [a, b, c, label]})).unwrap();
let copies = s.selection();
assert_eq!(copies.len(), 4, "{copies:?}");
let k = copies.iter().find_map(|i| match node(&s, *i).board {
Some(BoardItem::Connector(k)) => Some(k),
_ => None,
});
let k = k.unwrap();
assert!(copies.contains(&k.from.node().unwrap()) && copies.contains(&k.to.node().unwrap()), "{k:?} {copies:?}");
// Copy and paste too.
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let pasted = s.selection();
let k = pasted.iter().find_map(|i| match node(&s, *i).board {
Some(BoardItem::Connector(k)) => Some(k),
_ => None,
});
let k = k.unwrap();
assert!(pasted.contains(&k.from.node().unwrap()) && pasted.contains(&k.to.node().unwrap()), "{k:?} {pasted:?}");
}
+277
View File
@@ -0,0 +1,277 @@
//! Boards, continued: copies and pastes (within a file and from another), routes with named sides
//! and free points, connectors inside moving sections, styles and labels with their refusals, and
//! the shape, stamp, highlighter and connector tools with double-clicks.
use figcraft_doc::{BoardItem, BoardShape, End, NodeId, Side, Stamp, StrokeCap, board_color};
use figcraft_geom::Point;
use serde_json::json;
use crate::Session;
use crate::board::route;
use crate::board_tests::{board, connector, drawn, id, near, node};
use crate::tools::{PointerEvent, PointerKind, ToolId};
/// The connector among `ids`, if any.
fn connector_in(s: &Session, ids: &[NodeId]) -> Option<figcraft_doc::Connector> {
let doc = s.doc().ok()?;
ids.iter().find_map(|i| match &doc.node(*i)?.board {
Some(BoardItem::Connector(k)) => Some(*k),
_ => None,
})
}
#[test]
fn pasting_into_another_board_detaches_what_was_left_behind() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let r = s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "x"})).unwrap();
let (c, label) = (id(&r, "id"), id(&r, "label"));
let first = s.active;
// A second board whose own ids are the same small numbers as the first's.
let second = s.execute("file.newBoard", &json!({})).unwrap()["index"].clone();
for x in [0, 300, 600, 900] {
s.execute("board.sticky", &json!({"x": x, "y": 500})).unwrap();
}
let others: Vec<NodeId> = s.doc().unwrap().nodes().map(|n| n.id).collect();
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [a, c, label]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("doc.switch", &json!({"index": second})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = s.doc().unwrap();
let pasted = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_))) && !others.contains(&n.id)).unwrap().clone();
let k = connector(&s, pasted.id);
let from = k.from.node().unwrap();
assert!(!others.contains(&from) && matches!(doc.node(from).unwrap().board, Some(BoardItem::Sticky { .. })), "joins the pasted sticky");
let to = drawn(&s, pasted.id).last().copied().unwrap();
assert!(matches!(k.to, End::Point { x, y } if near(Point::new(x, y), to)), "the end left behind stays where it is drawn: {k:?}");
let pasted_label = crate::board::label_of(doc, pasted.id).expect("the label came with its connector");
assert!(!others.contains(&pasted_label));
// A label copied without its connector doesn't come.
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [label]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("doc.switch", &json!({"index": second})).unwrap();
let labels = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Label { .. }))).count();
let before = labels(&s);
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(labels(&s), before);
// Within its own file, a connector pasted alone stays joined to the same layers.
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let k = connector_in(&s, &s.selection()).unwrap();
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
}
#[test]
fn duplicating_and_alt_dragging_keep_connections_inside_copied_sections() {
let mut s = board();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": -50, "y": -50, "width": 400, "height": 400})).unwrap(), "id");
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
s.execute("edit.duplicate", &json!({"ids": [sec, c]})).unwrap();
let copies = s.selection();
let from = connector_in(&s, &copies).unwrap().from.node().unwrap();
let doc = s.doc().unwrap();
assert!(from != a && copies.iter().any(|r| doc.is_ancestor(*r, from)), "the copy joins the sticky inside the copied section");
// Alt-drag copies the same way.
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
let press = |k, x| PointerEvent { alt: true, ..PointerEvent::at(k, x, 100.0, 1.0) };
for (k, x) in [(PointerKind::Down, 700.0), (PointerKind::Move, 760.0), (PointerKind::Move, 800.0), (PointerKind::Up, 800.0)] {
s.pointer(&press(k, x)).unwrap();
}
let copies = s.selection();
assert_eq!(copies.len(), 2);
let k = connector_in(&s, &copies).unwrap();
assert!(copies.contains(&k.to.node().unwrap()), "{k:?}");
}
#[test]
fn routes_take_named_sides_and_free_points() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id");
let doc = s.doc().unwrap();
let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow }).unwrap();
let side = |n, side| End::Node { node: n, side };
let p = |x, y| End::Point { x, y };
let pt = Point::new;
// Side to side: out, across halfway, in.
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]);
// A side and a top or bottom: one turn.
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]);
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]);
// Two free points: the longer way first; in a line, no turns.
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]);
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]);
// An automatic side faces the other end.
let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight });
assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]);
assert_eq!(Side::from_id("TOP"), Some(Side::Top));
assert_eq!(Side::from_id("inside"), None);
// The same place twice is refused, as are a side that doesn't exist and a point without numbers.
assert!(s.execute("board.connector", &json!({"from": {"x": 5, "y": 5}, "to": {"x": 5, "y": 5}})).is_err());
assert!(s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "inside"})).is_err());
assert!(s.execute("board.connector", &json!({"from": a, "to": {"x": "a"}})).is_err());
assert!(s.execute("board.connector", &json!({"from": a})).is_err());
}
#[test]
fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() {
let mut s = board();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id");
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0)));
// Only the section is edited; the sticky inside it moves with it, and so does the connector.
s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap();
assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c));
// An edit that touches neither end leaves the connector as it was.
let before = s.doc().unwrap().node_arc(c).cloned().unwrap();
s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap();
assert!(std::sync::Arc::ptr_eq(&before, s.doc().unwrap().node_arc(c).unwrap()));
}
#[test]
fn styles_labels_and_their_refusals() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "author": false})).unwrap(), "id");
assert_eq!(node(&s, a).children.len(), 1, "no author line");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0, "author": " Lin "})).unwrap(), "id");
assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { ref author }) if author == "Lin"));
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "startCap": "circleFilled", "endCap": "none"})).unwrap(), "id");
assert_eq!((node(&s, c).stroke.cap_start, node(&s, c).stroke.cap_end), (StrokeCap::CircleFilled, StrokeCap::None));
// Labels: set, change, size, remove; named by the label itself too.
let l = id(&s.execute("board.label", &json!({"id": c, "text": "first"})).unwrap(), "label");
assert_eq!(id(&s.execute("board.label", &json!({"id": l, "text": "second"})).unwrap(), "label"), l, "the same label, new text");
let text = crate::board::text_of(s.doc().unwrap(), l).unwrap();
assert_eq!(node(&s, text).kind.text().unwrap().characters, "second");
s.execute("board.setStyle", &json!({"ids": [l], "fontSize": 36})).unwrap();
assert_eq!(node(&s, text).kind.text().unwrap().style.font_size, 36.0);
assert!(s.execute("board.label", &json!({"id": c, "text": ""})).unwrap()["label"].is_null());
assert!(!s.doc().unwrap().contains(l));
// What doesn't take a style or colour says so.
assert!(s.execute("board.setStyle", &json!({"ids": [a], "fontSize": 30})).is_err(), "stickies size their own text");
assert!(s.execute("board.setStyle", &json!({"ids": [c], "fontSize": 0})).is_err());
let st = id(&s.execute("board.stamp", &json!({"x": 0, "y": 0, "stamp": "check"})).unwrap(), "id");
assert!(s.execute("board.setColor", &json!({"ids": [st], "color": "blue"})).is_err());
assert!(s.execute("board.setColor", &json!({"ids": [a]})).is_err());
assert!(s.execute("board.label", &json!({"id": a, "text": "x"})).is_err());
// A palette colour on a line draws in its deeper shade; a hex colour as given.
s.execute("board.setColor", &json!({"ids": [c], "color": "blue"})).unwrap();
assert!(node(&s, c).strokes[0].solid_color().is_some() && node(&s, c).strokes[0].solid_color() != board_color("blue"));
s.execute("board.setColor", &json!({"ids": [c], "color": "#123456"})).unwrap();
assert_eq!(node(&s, c).strokes[0].solid_color().map(|c| c.to_hex()), Some("123456".into()));
// Next stickies on each side, and refusals.
for (side, dx, dy) in [("below", 0.0, 224.0), ("left", -224.0, 0.0), ("above", 0.0, -224.0)] {
let n = id(&s.execute("board.nextSticky", &json!({"id": a, "side": side})).unwrap(), "id");
let t = node(&s, n).transform.translation();
assert_eq!((t.x, t.y), (dx, dy), "{side}");
}
assert!(s.execute("board.nextSticky", &json!({"id": a, "side": "diagonal"})).is_err());
assert!(s.execute("board.nextSticky", &json!({"id": c})).is_err());
assert!(s.execute("board.ink", &json!({})).is_err());
assert!(s.execute("board.sticky", &json!({"x": 0, "y": 0, "parent": c})).is_err(), "a connector holds no layers");
}
#[test]
fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
let mut s = board();
let click = |s: &mut Session, x, y| {
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
};
s.execute("tool.shape", &json!({"shape": "roundedRectangle"})).unwrap();
click(&mut s, 100.0, 100.0);
let sh = s.selection()[0];
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::RoundedRectangle }));
assert_eq!(s.tool.tool, ToolId::Move, "back to Move after placing");
s.execute("tool.stamp", &json!({"stamp": "question"})).unwrap();
click(&mut s, 400.0, 100.0);
click(&mut s, 450.0, 100.0);
let stamps = s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Question, .. }))).count();
assert_eq!(stamps, 2);
assert_eq!(s.tool.tool, ToolId::Stamp, "stamps go on one after another");
assert!(s.execute("tool.stamp", &json!({"stamp": "skull"})).is_err());
s.execute("tool.highlighter", &json!({"color": "pink"})).unwrap();
assert_eq!(Some(s.tool.board.highlighter), board_color("pink"));
s.execute("tool.marker", &json!({"color": "#336699"})).unwrap();
assert_eq!(s.tool.board.marker.to_hex(), "336699");
// A connector drag that is only a click, or from an object to itself, makes nothing.
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
s.pointer(&PointerEvent::at(PointerKind::Down, 80.0, 140.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 80.0, 600.0, 1.0)).unwrap();
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { .. }));
// Double-clicks: on the shape its text, on the connector a new label, both typed into.
s.execute("tool.move", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 140.0, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the shape");
assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(sh));
crate::textedit::end(&mut s).unwrap();
let line = drawn(&s, c);
let m = line[0].midpoint(line[1]);
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, m.x, m.y, 1.0)).unwrap();
let label = crate::board::label_of(s.doc().unwrap(), c).expect("a label was added");
let te = s.ds().unwrap().text_edit.clone().expect("typing in the label");
assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(label));
}
#[test]
fn an_ink_stroke_ends_where_the_button_comes_up() {
let mut s = board();
s.execute("tool.marker", &json!({})).unwrap();
// Down and up with no move between: the stroke still runs from the press to the release.
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 150.0, 0.0, 1.0)).unwrap();
let doc = s.doc().unwrap();
let ink = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Ink))).map(|n| n.id).expect("a stroke");
let b = doc.world_bounds(ink).unwrap();
assert!(b.x0.abs() < 6.0 && (b.x1 - 150.0).abs() < 6.0, "from 0 to 150 (stroke width aside): {b:?}");
}
#[test]
fn a_second_sticky_ends_the_first_ones_typing_and_undoes_alone() {
let mut s = board();
s.execute("tool.sticky", &json!({})).unwrap();
s.prefs.keep_tool = true;
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
let text = s.ds().unwrap().text_edit.as_ref().expect("typing in the new sticky").id;
crate::textedit::insert(&mut s, "first note").unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 500.0, 100.0, 1.0)).unwrap();
let stickies = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).count();
let typed = |s: &Session| s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters.clone();
assert_eq!(stickies(&s), 2);
crate::textedit::end(&mut s).unwrap();
s.undo().unwrap();
assert_eq!(stickies(&s), 1, "the second sticky is its own undo step");
assert_eq!(typed(&s), "first note", "the first sticky keeps what was typed in it");
}
#[test]
fn a_board_tool_left_on_presses_as_move_in_a_design_file() {
let mut s = board();
s.execute("tool.sticky", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 50.0, 50.0, 1.0)).unwrap();
assert_eq!(s.tool.tool, ToolId::Move);
assert_eq!(s.selection(), vec![r], "the press selected as Move does");
}
+33
View File
@@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() {
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
}
#[test]
fn binding_keeps_an_opacity_set_on_the_paint() {
// A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only.
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]}));
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"}));
let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]});
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap();
let r = NodeId(r["id"].as_u64().unwrap());
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"}));
let p = s.doc().unwrap().node(r).unwrap().fills[0].clone();
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) });
assert!(near(p.opacity, 0.15), "{}", p.opacity);
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15));
// An opacity set on a `#RRGGBBAA` fill is the user's too, and stays.
let (mut s, r) = setup("#FF0000", "#FF0000");
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25}));
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
}
#[test]
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
let (mut s, r) = setup("#FF000080", "#FF000080");
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
}
}
+1 -1
View File
@@ -458,7 +458,7 @@ fn entry_label(plan: &Plan, entry: &Entry, value: Option<&Value>) -> Option<(&'s
Entry::Variable(_) => "variable",
Entry::Collection(_) => "variable collection",
Entry::Comment(_) => "comment",
Entry::DevSnapshot(_) | Entry::Image(_) => return None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => return None,
};
let base = plan.base().value(&Target::Entry(entry.clone())).ok().flatten();
let named = value.or(base.as_ref().map(|j| &j.0)).and_then(|v| v.get("name").or_else(|| v.get("text"))).and_then(Value::as_str);
+4 -3
View File
@@ -21,8 +21,9 @@ pub const SEP: &str = "-";
pub static MENU: &[MenuItem] = &[
// File
m!("File", "New design file", "file.new"),
// FigCraft's own: starter templates (not counted by `parity`, like the other items of its own
// listed in `OWN`).
m!("File", "New board", "file.newBoard"),
// FigCraft's own: boards and starter templates (not counted by `parity`, like the other items
// of its own listed in `OWN`).
m!("File", "New from template…", "file.newFromTemplate"),
m!("File", "New window", "window.new"),
m!("File", "Open…", "file.open"),
@@ -289,7 +290,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+9 -2
View File
@@ -206,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
let Some(first) = items.first() else { return ok() };
let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max);
let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 };
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
for (i, b) in items.clone() {
@@ -494,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
t.paragraph_spacing *= k;
t.paragraph_indent *= k;
t.list_spacing *= k;
if let Some(tp) = &mut t.path {
tp.scale(k, k);
}
}
if let NodeKind::Vector { network } = &mut n.kind {
network.scale(k, k);
for v in &mut network.vertices {
v.radius *= k;
}
}
n.width *= k;
n.height *= k;
+49 -29
View File
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.resetOverride",
"Reset override",
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
true,
reset_override
),
@@ -217,14 +217,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.addProperty",
"Add component property",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
add_prop
),
spec!(
"component.editProperty",
"Edit component property",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
edit_prop
),
@@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layers": pushed}))
}
fn restore_prop_defs(doc: &Document, props: &BTreeMap<String, Value>) -> BTreeMap<String, PropDef> {
let mut defs = BTreeMap::new();
for (k, v) in props {
let def = match v {
Value::Bool(b) => PropDef::Boolean { default: *b },
Value::String(t) => PropDef::Text { default: t.clone() },
other => match id_of(other) {
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
_ => continue,
},
};
defs.insert(k.clone(), def);
}
defs
}
fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = BTreeSet::new();
map.insert(i, component);
used.insert(component);
for d in sub.iter().filter(|d| **d != i) {
let src = doc.node(*d).and_then(|n| n.source);
let nid = match src {
Some(s) if !doc.contains(s) && used.insert(s) => s,
_ => doc.alloc_id(),
};
map.insert(*d, nid);
}
map
}
fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.restoreMain";
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
@@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
// The main comes back with its old id (other instances point to it) and its layers with the
// ids their copies mirror, so every instance re-links by source.
let sub = doc.subtree(i);
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
let mut used: BTreeSet<NodeId> = BTreeSet::new();
map.insert(i, component);
used.insert(component);
for d in sub.iter().filter(|d| **d != i) {
let src = doc.node(*d).and_then(|n| n.source);
let nid = match src {
Some(s) if !doc.contains(s) && used.insert(s) => s,
_ => doc.alloc_id(),
};
map.insert(*d, nid);
}
let map = restore_id_map(doc, i, component, &sub);
let mut nodes = Vec::new();
let mut nested = Vec::new();
for d in &sub {
let Some(mut n) = doc.node(*d).cloned() else { continue };
if *d == i {
let mut defs = BTreeMap::new();
for (k, v) in &props {
let def = match v {
Value::Bool(b) => PropDef::Boolean { default: *b },
Value::String(t) => PropDef::Text { default: t.clone() },
other => match id_of(other) {
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
_ => continue,
},
};
defs.insert(k.clone(), def);
}
let defs = restore_prop_defs(doc, &props);
n.kind = NodeKind::Component { description: String::new(), props: defs };
n.source = None;
n.overrides.clear();
@@ -1141,7 +1151,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
}
let layer_node = layer.and_then(|l| doc.node(l)).cloned();
let def = match t.to_ascii_lowercase().as_str() {
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) },
// Without a default, a bound layer keeps showing as it does: its visibility is the default.
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) },
"text" => PropDef::Text {
default: string(p, "default")
.or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone()))
@@ -1245,7 +1256,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi
}
_ => {}
}
doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string());
let n = doc.get_mut(layer)?;
n.prop_refs.insert(field.to_string(), property.to_string());
// On the main, a boolean property's value is the visibility of the layers it controls.
if let PropDef::Boolean { default } = def {
n.visible = *default;
}
Ok(())
}
@@ -1414,6 +1430,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
rename_prop_refs(doc, *c, &name, Some(nn))?;
}
}
// The main's bound layers show the new default.
if let PropDef::Boolean { default } = new_def {
set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default);
}
ok()
}
+69 -20
View File
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"image.place",
"Place image",
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
true,
place_image
),
@@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) ->
return None;
}
let n = doc.node(id)?;
if !n.visible || n.locked {
// Board objects (stickies, shapes with text) never take layers in.
if !n.visible || n.locked || n.is_board_atom() {
return None;
}
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
@@ -166,6 +167,42 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
pub(crate) struct PathShape {
/// The network, with the path's own box moved to the origin and scaled to the size asked for.
pub network: VectorNetwork,
pub width: f64,
pub height: f64,
/// Where the path's own box starts in its coordinates (unscaled).
pub offset: (f64, f64),
}
/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves
/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse
/// or draws nothing is refused.
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
}
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(empty());
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
let network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(empty());
}
Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) })
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
@@ -228,27 +265,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
let shape = path_network(cmd, &d, asked.0, asked.1)?;
*network = shape.network;
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
(w, h, offset) = (shape.width, shape.height, shape.offset);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
if pts.len() < 2 {
@@ -372,9 +394,28 @@ fn read_path(path: &str) -> Result<Vec<u8>> {
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// Layers that can show an image inside their own outline.
pub fn accepts_image_fill(kind: &NodeKind) -> bool {
kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. })
}
/// Add an image fill on top of the layer's fills: the others keep their place and variable
/// bindings; a linked fill style no longer describes the list, so the link goes.
fn add_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) {
node.fills.push(Paint::image(image, mode));
node.styles.fill = None;
}
/// Place an image as a rectangle with an image fill (Figma's image layer).
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.place";
let target = opt_id(cmd, p, "target")?;
if let Some(id) = target {
let node = s.doc()?.node(id).ok_or(crate::EngineError::NoNode(id))?;
if !accepts_image_fill(&node.kind) {
return Err(bad(cmd, "`target` must be a layer that can have a fill"));
}
}
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
@@ -390,6 +431,14 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
"tile" => ImageScaleMode::Tile,
_ => ImageScaleMode::Fill,
};
if let Some(id) = target {
let doc = s.doc_mut()?;
let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
let node = doc.get_mut(id)?;
add_image_fill(node, hash.clone(), mode);
return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile}));
}
let page = s.page()?;
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
+198 -33
View File
@@ -4,11 +4,11 @@ use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -216,7 +216,13 @@ pub fn specs() -> Vec<CommandSpec> {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent),
spec!(
"node.reparent",
"Move layer into",
"{ids?, parent, index? (final position among parent's children, bottom first; default top)}",
true,
reparent
),
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
let i = req_id("node.reorder", p, "id")?;
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
@@ -229,6 +235,22 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
spec!(
"stroke.copy",
"Copy stroke",
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
false,
|s, p| paint_copy(s, p, false)
),
spec!(
"stroke.paste",
"Paste stroke",
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
true,
|s, p| paint_paste(s, p, false)
),
spec!(
"fill.pattern",
"Pattern fill",
@@ -276,6 +298,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
for id in req_targets("grid.add", s, p)? {
@@ -336,15 +360,16 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<(String, Value)> = match get(p, "commands") {
let cmds: Vec<BatchStep> = match get(p, "commands") {
Some(Value::Array(a)) => {
let mut out = Vec::new();
for (i, c) in a.iter().enumerate() {
let cmd =
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
let params = c.get("params").cloned().unwrap_or(json!({}));
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
}
out
}
@@ -494,6 +519,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
out.push(nid);
}
// Board connectors copied with what they join join the copies.
let map = crate::board::copy_map(doc, &ids, &out);
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
s.set_selection(out.clone());
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
Ok(json!({"ids": out}))
@@ -532,6 +560,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1);
Ok(())
}
@@ -591,6 +620,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
}
/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing,
/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers,
/// store them as a foreign-file clipboard source with a bumped layers generation, then
/// delegate to `paste` and append import warnings.
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.pasteDesignLayers";
let bytes = match (string(p, "html"), string(p, "data")) {
@@ -605,10 +638,28 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
}
let (nodes, images, bounds) = copied(&doc, &tops);
// A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it
// still pastes afterwards.
let props = s.clipboard.props.take();
let paint = s.clipboard.paint.take();
let stroke = s.clipboard.stroke.take();
let effect = s.clipboard.effect.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
s.clipboard = crate::Clipboard {
nodes,
images,
props,
paint,
stroke,
effect,
bounds,
source_doc: 0,
source: Some(doc),
source_path: None,
copied_components: BTreeSet::new(),
layers_generation,
};
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -675,6 +726,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?);
all.extend(map);
}
// Board connectors pasted with what they join join the pasted copies.
let originals: Vec<(NodeId, figcraft_doc::BoardItem)> =
clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect();
crate::board::rejoin(doc, &originals, &all, !copy_local);
if let Some(src) = foreign {
crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?;
}
@@ -691,7 +746,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let target = id(p, "parent").or_else(|| {
sel.first()
.copied()
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
.filter(|i| {
sel.len() == 1
&& doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom())
})
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
});
let parent = target.unwrap_or(page);
@@ -1159,6 +1217,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"matches": hits}))
}
/// Keys only some layer types take, the test for those layers, and why the others skip them.
type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str);
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("node.set", s, p)?;
let props = match get(p, "props") {
@@ -1189,34 +1250,44 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
None => Vec::new(),
};
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
// the first refuses them (`props::layout_mut`).
// Type-specific keys go to the layers that take them and are skipped, with a warning, on the
// others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets
// that take them, the first refuses them (`props::layout_mut`, `props::apply`).
let doc = &s.ds()?.doc;
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
let mut without_layout = props.clone();
let mut skipped = BTreeSet::new();
if !layout_keys.is_empty() {
let doc = &s.ds()?.doc;
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
if ids.iter().any(frame_like) {
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect();
let frame_like = |n: &Node| n.kind.is_frame_like();
let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. });
let typed: [TypedKeys; 2] = [
(&layout_keys, &frame_like, "only frames, components and instances have auto layout"),
(&path_keys, &vector, "only vector layers have a path"),
];
let mut skipped: BTreeMap<NodeId, Map<String, Value>> = BTreeMap::new();
for (keys, takes, why) in typed {
let takes = |i: &NodeId| doc.node(*i).is_some_and(takes);
if keys.is_empty() || !ids.iter().any(takes) {
continue;
}
if !skipped.is_empty() {
let names: Vec<String> =
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: only frames, components and instances have auto layout",
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
let others: Vec<NodeId> = ids.iter().copied().filter(|i| !takes(i)).collect();
if others.is_empty() {
continue;
}
for i in &others {
skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str()));
}
let names: Vec<String> =
others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: {why}",
keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
}
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
@@ -1302,9 +1373,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
let index = num(p, "index").map(|i| i.max(0.0) as usize);
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
// The layers land together under the first sibling at or above `index` that stays put (none: on
// top), wherever they came from.
let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied());
// `index` is where the first moved layer ends up among the parent's children (bottom first),
// counted after the move, the way `node.reorder` counts it. The layers land together, keeping
// their order, under the sibling that stays put and ends up just after them (none: on top).
let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied());
for i in &ids {
// Counted with the layer still in place, as `reparent` expects.
let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a));
@@ -1365,10 +1437,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
if c.a < 1.0 {
slot.opacity = c.a;
slot.color_alpha = c.a;
}
}
if let Some(o) = num(p, "opacity") {
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
slot.color_alpha = 1.0;
}
if let Some(v) = boolean(p, "visible") {
slot.visible = v;
@@ -1386,6 +1460,61 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
ok()
}
/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke
/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along.
fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.copy" } else { "stroke.copy" };
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let at = opt_index(cmd, p, "index")?;
let (paint, stroke) = {
let doc = s.doc()?;
let n = doc.get(id)?;
let list = if fills { &n.fills } else { &n.strokes };
let at = at.unwrap_or(list.len().saturating_sub(1));
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
(paint, (!fills).then(|| n.stroke.clone()))
};
s.clipboard.paint = Some((fills, paint.clone()));
s.clipboard.stroke = stroke;
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
}
/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A
/// stroke's copied settings go onto each target too.
fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
};
if copied_fills != fills {
let what = if fills { "stroke" } else { "fill" };
return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}")));
}
let ids = req_targets(cmd, s, p)?;
// A copied pattern pointing at a target or a layer inside it would make the layer pattern
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern.
if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind {
let doc = s.doc()?;
if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) {
return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it"));
}
}
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
let out = match opt_index(cmd, p, "index")? {
Some(at) => {
q["index"] = json!(at);
paint_set(s, &q, fills)?
}
None => paint_list(s, &q, fills, true)?,
};
if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) {
for id in ids {
s.doc_mut()?.get_mut(id)?.stroke = stroke.clone();
}
}
Ok(out)
}
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "fill.pattern";
let src = req_id(CMD, p, "source")?;
@@ -1422,3 +1551,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
}
ok()
}
/// Copy effect `index` (the top one by default) of the first target into the clipboard.
fn effect_copy(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.copy";
let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?;
let at = opt_index(CMD, p, "index")?;
let e = {
let doc = s.doc()?;
let list = &doc.get(id)?.effects;
let at = at.unwrap_or(list.len().saturating_sub(1));
list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))?
};
s.clipboard.effect = Some(e.clone());
serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string()))
}
/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one.
fn effect_paste(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.paste";
let Some(e) = s.clipboard.effect.clone() else {
return Err(EngineError::Disabled(CMD.into(), "no copied effect".into()));
};
let at = opt_index(CMD, p, "index")?;
for id in req_targets(CMD, s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
match at {
Some(i) => {
if let Some(slot) = n.effects.get_mut(i) {
*slot = e.clone();
}
}
None => n.effects.push(e.clone()),
}
}
ok()
}
+373
View File
@@ -0,0 +1,373 @@
//! Running a session on a board: a countdown everyone sees and votes on objects.
//!
//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live
//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are.
use std::sync::Arc;
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
use figcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::board::{require, text_of};
use crate::cmd::CommandSpec;
use crate::params::{id, num, req_string, req_targets, string};
use crate::tools::PointerEvent;
use crate::{EngineError, Result, Session, bad, spec};
/// Minutes a timer starts with, and adds, when none are given.
const START_MINUTES: f64 = 5.0;
const ADD_MINUTES: f64 = 1.0;
/// Votes each person gets when none are given.
const VOTES: u32 = 3;
/// A vote badge's radius on screen (logical pixels).
pub const BADGE_PX: f64 = 11.0;
/// The longest a voter name is kept.
const MAX_VOTER: usize = 64;
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.timer",
"Timer",
"{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session",
false,
timer_cmd
),
spec!(
"board.timerState",
"Timer state",
"{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}",
false,
|s, _| {
require(s, "board.timerState")?;
Ok(timer_json(s.doc()?.timer, now_ms()))
}
),
spec!(
"board.vote",
"Voting session",
"{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends",
false,
vote_cmd
),
spec!(
"board.voteResults",
"Voting results",
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}",
false,
|s, p| {
require(s, "board.voteResults")?;
let who = voter(s, p);
Ok(vote_json(s.doc()?, &who))
}
),
]
}
/// Milliseconds since the Unix epoch (0 if the clock is before it).
pub fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up).
pub fn clock(left: u64) -> String {
let s = left.div_ceil(1000);
let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it).
pub fn timer_state(t: Option<BoardTimer>, now: u64) -> &'static str {
match t {
None => "off",
Some(BoardTimer::Paused { .. }) => "paused",
Some(t) if t.left(now) == 0 => "done",
Some(_) => "running",
}
}
fn timer_json(t: Option<BoardTimer>, now: u64) -> Value {
let left = t.map_or(0, |t| t.left(now));
let ends = match t {
Some(BoardTimer::Running { ends }) => Some(ends),
_ => None,
};
json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends})
}
/// `minutes` (or `default`) as milliseconds: more than 0, at most a day.
fn duration(cmd: &str, p: &Value, default: f64) -> Result<u64> {
let m = match p.get("minutes").filter(|v| !v.is_null()) {
Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?,
None => default,
};
let ms = (m * 60_000.0).round();
if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) {
return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes"));
}
Ok(ms as u64)
}
/// The timer after `action` at `now` (`Ok(None)`: stopped).
pub fn timer_after(cmd: &str, t: Option<BoardTimer>, action: &str, p: &Value, now: u64) -> Result<Option<BoardTimer>> {
let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into());
Ok(match action {
"start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }),
"stop" => None,
"pause" => match t {
Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }),
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())),
},
"resume" => match t {
Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())),
},
"add" => {
let more = duration(cmd, p, ADD_MINUTES)?;
let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS);
match t {
Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }),
_ => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
}
}
other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))),
})
}
fn timer_cmd(s: &mut Session, p: &Value) -> Result<Value> {
require(s, "board.timer")?;
let action = req_string("board.timer", p, "action")?;
let now = now_ms();
let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?;
if s.doc()?.timer != t {
s.doc_mut()?.timer = t;
}
Ok(timer_json(t, now))
}
/// Who votes: `voter` from the params, else you as the live session knows you (your client id
/// there, so two people of the same name vote apart), else your name.
pub fn voter(s: &Session, p: &Value) -> String {
if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::<String>())
&& !v.is_empty()
{
return v;
}
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref());
match live {
Some(info) => format!("peer {}", info.me),
None => crate::cmd::comment::user_name(s),
}
}
fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> {
match &doc.vote {
Some(v) if !v.ended => Ok(v),
_ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())),
}
}
fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "board.vote";
require(s, CMD)?;
let action = req_string(CMD, p, "action")?;
let who = voter(s, p);
match action.as_str() {
"start" => start_vote(s, p, CMD)?,
"cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?,
"end" => {
running(CMD, s.doc()?)?;
if let Some(v) = s.doc_mut()?.vote.as_mut() {
v.ended = true;
}
}
"clear" => {
let doc = s.doc()?;
if doc.vote.is_some() || !doc.votes.is_empty() {
let doc = s.doc_mut()?;
doc.vote = None;
doc.votes.clear();
}
}
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
}
Ok(vote_json(s.doc()?, &who))
}
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) {
return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into()));
}
let per = match p.get("votes").filter(|v| !v.is_null()) {
Some(_) => num(p, "votes")
.filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n))
.ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?,
None => f64::from(VOTES),
} as u32;
let scope = match string(p, "on").as_deref().unwrap_or("all") {
"all" => VoteScope::All,
"stickies" => VoteScope::Stickies,
"selection" => {
let doc = s.doc()?;
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect();
if ids.is_empty() {
return Err(bad(cmd, "pick the objects to vote on (not pages)"));
}
VoteScope::Only { ids }
}
other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))),
};
let by = crate::board::author(s);
let doc = s.doc_mut()?;
doc.votes.clear();
doc.vote = Some(Vote { per_person: per, scope, ended: false, by });
Ok(())
}
/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back
/// from an object, within the running vote's scope and votes per person.
fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> {
let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?;
let doc = s.doc()?;
let vote = running(cmd, doc)?;
let mut ballot: Vec<NodeId> = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect();
if cast {
if !vote.scope.takes(doc, target) {
return Err(match doc.node(target) {
None => EngineError::NoNode(target),
Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()),
});
}
if ballot.len() >= vote.per_person as usize {
return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into()));
}
ballot.push(target);
} else {
let i =
ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?;
ballot.remove(i);
}
let votes = &mut s.doc_mut()?.votes;
if ballot.is_empty() {
votes.remove(who);
} else {
votes.insert(who.to_string(), ballot);
}
Ok(())
}
/// `who`'s votes per object, in the order first voted on.
pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> {
let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() };
let mut out: Vec<(NodeId, u32)> = Vec::new();
for id in vote.counted(doc, ballot) {
match out.iter_mut().find(|(i, _)| *i == id) {
Some((_, n)) => *n += 1,
None => out.push((id, 1)),
}
}
out
}
/// What an object is called in the results: its text (a sticky's note), else its name.
pub fn object_name(doc: &Document, id: NodeId) -> String {
let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string());
let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default();
let line = name.lines().next().unwrap_or_default();
let short: String = line.chars().take(60).collect();
if short.len() < name.len() { format!("{short}…") } else { short }
}
fn vote_json(doc: &Document, who: &str) -> Value {
let Some(vote) = &doc.vote else { return json!({"state": "off"}) };
let on = match &vote.scope {
VoteScope::All => json!("all"),
VoteScope::Stickies => json!("stickies"),
VoteScope::Only { ids } => json!({"selection": ids}),
};
let mine = mine(doc, who);
let used: u32 = mine.iter().map(|m| m.1).sum();
let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count();
let mut out = json!({
"state": if vote.ended { "ended" } else { "running" },
"votesPerPerson": vote.per_person,
"on": on,
"by": vote.by,
"left": vote.per_person.saturating_sub(used),
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
"voters": voters,
});
if vote.ended {
let results: Vec<Value> =
ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect();
out["results"] = json!(results);
}
out
}
/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4").
pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> {
let mut last = None;
let mut rank = 0;
vote.tally(doc, &doc.votes)
.into_iter()
.enumerate()
.map(|(i, (id, n))| {
if last != Some(n) {
rank = i + 1;
last = Some(n);
}
(rank, id, n)
})
.collect()
}
/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at
/// `zoom` screen pixels per unit.
pub fn badge(b: Rect, zoom: f64) -> (Point, f64) {
(Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6))
}
impl Session {
/// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it
/// takes that vote back; on an object the vote is on, it puts a vote there. False when the
/// press is for the tool (no vote running, or nothing to vote on there).
pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result<bool> {
let doc = self.doc()?;
let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) };
let who = voter(self, &Value::Null);
let p = ev.p();
let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| {
doc.world_bounds(*id).is_some_and(|b| {
let (c, r) = badge(b, ev.zoom);
(p - c).hypot() <= r
})
});
let hit = || {
let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?;
vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false))
};
let (action, target) = match on_badge {
Some(id) => ("retract", id),
None => match hit() {
Some(id) => (if ev.alt { "retract" } else { "cast" }, id),
None => return Ok(false),
},
};
self.execute("board.vote", &json!({"action": action, "id": target}))?;
Ok(true)
}
}
/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable:
/// keep them as they are now (`from`).
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
let d = Arc::make_mut(to);
d.timer = from.timer;
d.vote = from.vote.clone();
d.votes = from.votes.clone();
}
}
Loaded 100 of 490 files, more files were not shown because too many files have changed in this diff. Show more