Compare commits

..
Author SHA1 Message Date
bflatastic 59fe9b5ea8 Record #326, #317 and #306's rebased heads (same content as landed in #530 and #529) 2026-10-16 01:17:17 +07:00
bflatastic 862d73bf9f Merge #312: test(canvas): grow the slow zoom-preview scene until it crosses the threshold (@tylerjenningsw) 2026-10-16 01:17:16 +07:00
BFlat 9dcfe4ecbc Community batch 4: 11 reviewed PRs (#530)
Lands 11 reviewed community PRs with their own commits plus maintainer fixes on their branches: #305 #363 #400 #338 #272 #368 #352 #308 #322 #317 #306.
2026-10-16 01:11:44 +07:00
BFlat 2fe7a9449a Community batch 3: 20 reviewed PRs (#529)
Lands 20 reviewed community PRs with their own commits plus maintainer fixes on their branches: #257 #263 #312 #253 #303 #331 #336 #280 #313 #332 #279 #281 #300 #326 #361 #310 #474 #346 #469 #477. Also: the FreeBSD CI VM installs DejaVu, so its text tests measure with a real font (they failed on main's nightly too).
2026-10-16 01:11:29 +07:00
bflatastic 916ee3276c #306: say why the window can't open in the interface language
The message now comes from the UI crate (dialogs::window_failed) through
tr() and trf(), with entries in every catalog, and the app picks the
language from the preferences before the event loop starts, so the box
reads in it even though no frame was ever drawn. The commands to type stay
as written.
2026-10-15 20:24:05 +03:00
Barzabel 97f9c2fc5d M1: say why when the window can't open (#261)
Release builds on Windows have no console, so an eframe::run_native error
(no usable graphics adapter, a device the driver refuses) went to an unseen
standard error and the app quit silently. The error is now logged and shown
in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise
update the driver or try another graphics API (OpenGL on Windows), and
where to report it. With --control the box is skipped so agents aren't
blocked.
2026-10-15 20:24:04 +03:00
bflatastic 4a2d19f0db #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them 2026-10-15 20:23:07 +03:00
bflatastic a7bcc6d6ff #317: compute the backdrop once under a casting layer
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.
2026-10-15 20:23:07 +03:00
Barzabel f4d53a659b M1: memory budgets for decoded images and a file's images (#203, #189) 2026-10-15 20:23:07 +03:00
Barzabel 3074db1cbb M1: drop shadows under a background blur (#255)
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
2026-10-15 20:23:07 +03:00
tylerjenningsw d769320d45 test(canvas): grow the slow zoom-preview scene until it crosses the threshold (M8)
The browser-worker test assumed a fixed 2400x2400 glass scene renders
in at least 12 ms; a faster machine or renderer (the glass speedups)
would make it quick and the test fail. The scene now gets more and
more blurred rectangles until renders from scratch of the views the
test uses take three times the threshold.

Refs #295
2026-10-15 10:04:09 -04:00
bflatastic 4fbe8d2976 Merge batch 3's FreeBSD font fix 2026-10-15 20:22:41 +07:00
bflatastic 8c731ddedc ci: the FreeBSD VM installs DejaVu, so text tests measure with a real font as on Ubuntu 2026-10-15 20:22:37 +07:00
bflatastic 745e599118 Merge #272 and #322 updates 2026-10-15 20:03:07 +07:00
bflatastic ffc01cbac9 Merge #322's update (main merged in; same change) 2026-10-15 20:03:01 +07:00
bflatastic d2e0ac40aa Merge #272's update (main merged in; same change) 2026-10-15 20:02:50 +07:00
bflatastic 4ed5190d7f Merge the updated community batch 3 2026-10-15 20:00:20 +07:00
bflatastic 3ce80b25ab Merge #300 fix: colour emoji on Linux 2026-10-15 19:59:45 +07:00
bflatastic ff3981de73 #300: emoji take a colour face before a style face without colour glyphs (DejaVu Sans on Linux drew them in one colour); the scale test allows faces wider than the em 2026-10-15 19:59:24 +07:00
bflatastic f32e8c5d2d Merge #306: M1: say why when the window can't open (#261) (@Barzabel) 2026-10-15 19:55:06 +07:00
bflatastic 4812b3ebfa Merge #317: M1: drop shadows under a background blur (#255) (@Barzabel) 2026-10-15 19:55:05 +07:00
bflatastic 2c51dbe4e3 Merge #322: fix(vector): keep the canvas still when entering vector edit mode and picking a region (@rcoenen) 2026-10-15 19:55:04 +07:00
bflatastic 261225340f Merge #308: M1: leaving Design ends the pen path and goes back to Move (@marcoarnulfo) 2026-10-15 19:55:03 +07:00
bflatastic 0444dc39a0 Merge #352: fix(ui): step through the font picker with Up and Down (#345) (@vxMacDev) 2026-10-15 19:55:02 +07:00
bflatastic 01463fb78e Merge #368: Variables export as a download in the browser (@mateusvillain)
# Conflicts:
#	crates/ui-egui/src/variables_panel.rs
2026-10-15 19:55:00 +07:00
bflatastic 3acffcd362 Merge #272: fix(ui): scroll a long pages list in its own area so the layers stay in view (@marcoarnulfo) 2026-10-15 19:54:10 +07:00
bflatastic 2ea8c3f86f Merge #338: M9: remove someone from a live session (@HaSh3003) 2026-10-15 19:54:09 +07:00
bflatastic 9ba207e900 Merge #400: Let batch steps reference ids created by earlier steps (@edivanteixeira) 2026-10-15 19:54:08 +07:00
bflatastic 6551f870b6 Merge #363: fix(engine): drag with the Text tool to make a fixed-width text box (#360) (@vxMacDev) 2026-10-15 19:54:07 +07:00
bflatastic 7a9e9de38c Merge #305: M1: save files automatically (#296) (@AganzeFelicite)
# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	docs/mcp.md
2026-10-15 19:53:52 +07:00
Marco Arnulfo 1d5c9ae010 fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) 2026-10-15 14:44:31 +02:00
bflatastic 51549f9a60 #306: say why the window can't open in the interface language
The message now comes from the UI crate (dialogs::window_failed) through
tr() and trf(), with entries in every catalog, and the app picks the
language from the preferences before the event loop starts, so the box
reads in it even though no frame was ever drawn. The commands to type stay
as written.
2026-10-15 19:36:00 +07:00
bflatastic 3d7191f4d3 people.toml: one entry per person after the merges 2026-10-15 19:35:24 +07:00
bflatastic d25e302a57 README: list the Czech and Indonesian drafts 2026-10-15 19:35:02 +07:00
bflatastic 4004aecaea Merge #477: M9: Indonesian draft catalog (@RevDonz)
# Conflicts:
#	contributors/people.toml
#	crates/ui-egui/src/cjk.rs
#	crates/ui-egui/src/i18n_tests.rs
#	docs/translating.md
2026-10-15 19:34:49 +07:00
bflatastic 5a10623414 Merge #469: i18n: Czech (cs) UI catalog (draft) (@Losminek)
# Conflicts:
#	contributors/people.toml
#	crates/engine/src/cmd/view.rs
#	docs/mcp.md
#	docs/translating.md
2026-10-15 19:34:05 +07:00
bflatastic be875da985 Merge #346: Set the native window identity to match the desktop entry (@IanHollow) 2026-10-15 19:33:07 +07:00
bflatastic 2a3309902a Merge #474: M7: find Unix user fonts under XDG_DATA_HOME (@IanHollow) 2026-10-15 19:33:06 +07:00
bflatastic f3e6b00e1a Merge #310: engine: refuse page.move for node ids that are not pages (@midasmatterhorn) 2026-10-15 19:33:02 +07:00
bflatastic 58e33f8fbb Merge #361: Make edit.findReplace replace case-insensitive matches (@Silvertree2010) 2026-10-15 19:32:55 +07:00
bflatastic ef375c623e Merge #326: M1: memory budgets for decoded images and a file's images (#203, #189) (@Barzabel) 2026-10-15 19:32:43 +07:00
bflatastic e4d0b92e55 Merge #300: Text: colour emoji draw from their own colour tables (#268) (@fede9234) 2026-10-15 19:32:34 +07:00
bflatastic 650049b469 Merge #281: fix(format): import explicit variable modes referenced by asset key (@notdezzi) 2026-10-15 19:32:32 +07:00
bflatastic 731bb0247c Merge #279: M6: .fig import lays out the layers of resized instances by their constraints (@marcoarnulfo) 2026-10-15 19:32:30 +07:00
bflatastic b13fbb52b3 Merge #332: M0.9: every hidden window answers control requests, not only the main one (#269) (@patlux) 2026-10-15 19:32:28 +07:00
bflatastic 48f75c92da Merge #313: M0.9: answer control requests while the window is hidden (#269) (@AganzeFelicite) 2026-10-15 19:32:27 +07:00
bflatastic f39b9a11fd Merge #280: Fix text edits rolling back prior document changes (@justinjohn0306) 2026-10-15 19:32:25 +07:00
bflatastic 120aa8a7a0 Merge #336: M1: keep a recovery copy until this run has its own (#187) (@Barzabel) 2026-10-15 19:32:23 +07:00
bflatastic 09ad21489b Merge #331: fix(recovery): keep the version history and branches in recovery copies (#185) (@vxMacDev) 2026-10-15 19:32:22 +07:00
bflatastic 0a2ed2fb65 Merge #303: Recovery: wait for the write in flight before an emergency save and before removing copies (#202) (@MarekSpodBiedry) 2026-10-15 19:32:20 +07:00
bflatastic 4b29532261 Merge #253: Avoid macOS file dialog reentrancy crashes (@justinjohn0306)
# Conflicts:
#	contributors/people.toml
2026-10-15 19:32:18 +07:00
bflatastic 22d5f17828 Merge main and the community batch into #306
# Conflicts:
#	apps/figcraft/src/main.rs
2026-10-15 19:27:31 +07:00
bflatastic 4dc7c1b59c #317: compute the backdrop once under a casting layer
With drop shadows the backdrop was read twice: before the content and
again after the shadows. The first read is only what backdrop effects
inside the content see, so it now runs only when the layer has such
descendants, and the content is copied for the shadow caster only when it
gets a layer blur, noise or texture. The output is unchanged.
2026-10-15 19:25:05 +07:00
bflatastic a89031eab2 #300: emoji take a colour face before the symbol fonts, which drew them in one colour on Windows 2026-10-15 19:21:37 +07:00
bflatastic 7a819dd190 Merge main and the community batch into #317 2026-10-15 19:20:55 +07:00
bflatastic 6291e43605 #322: use widgets::note for the vector edit hints
The wrapped hint label duplicated widgets::note (a wrapped secondary note
inside the panel's side padding); the two hints use it, and the copy and
the extra spacing after them go.
2026-10-15 19:20:32 +07:00
bflatastic cc80bd2a5d Merge main and the community batch into #322
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-15 19:17:03 +07:00
bflatastic 9894ee0e65 #308: pressing Design again keeps the pen path going
Design is already the mode while the pen draws, so re-pressing it leaves
nothing: only the Pencil goes back to Move there, as before. Leaving for
Prototype or Inspect still ends the path.
2026-10-15 19:14:46 +07:00
bflatastic 62a10ea460 Merge main and the community batch into #308 2026-10-15 19:13:08 +07:00
bflatastic 8f58c39bd7 #352: keep the highlighted font in view in a shorter list
Where the window has less room the font list is shorter than its tallest,
and stepping down left the highlighted row cut off at the bottom. The
scroll now uses the list's height as last drawn.
2026-10-15 19:12:26 +07:00
bflatastic b2d5dfecc1 #477: Indonesian for the alignment hint added since (draft) 2026-10-15 19:08:37 +07:00
bflatastic 62ed3452d6 #352: name the font list's height once
The list's max height and the arrow keys' scroll offset now share one
LIST_HEIGHT const.
2026-10-15 19:07:26 +07:00
bflatastic d96d03f452 Merge main and the community batch into #477
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
2026-10-15 19:06:54 +07:00
bflatastic cf06f2ca30 Merge main and the community batch into #352 2026-10-15 19:06:22 +07:00
bflatastic 42c8e762a0 #469: Czech for the alignment hint added since (draft) 2026-10-15 19:06:11 +07:00
bflatastic dd84097b6d #368: revoke a download's object URL after a delay
Revoking it right after the click can cancel the download in some
browsers; it now goes 40 s later on a timer.
2026-10-15 19:04:16 +07:00
bflatastic 8de536960e Merge main and the community batch into #469
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	docs/localization-parity.md
#	docs/roadmap.md
2026-10-15 19:03:31 +07:00
bflatastic 466bf6a813 Merge main and the community batch into #368
# Conflicts:
#	apps/figcraft-web/src/web.rs
#	crates/ui-egui/src/lib.rs
2026-10-15 19:02:58 +07:00
bflatastic e100c165fb #346: FreeBSD's desktop entry matches the window class there (the binary's name) 2026-10-15 19:02:40 +07:00
bflatastic 370d9f7adc Merge main and the community batch into #346 2026-10-15 19:00:48 +07:00
bflatastic a51b8d2271 Merge main and the community batch into #474
# Conflicts:
#	ROADMAP.md
2026-10-15 18:57:06 +07:00
bflatastic 68e130aae6 Merge main and the community batch into #310
# Conflicts:
#	crates/engine/src/panels_behaviour_tests.rs
2026-10-15 18:56:41 +07:00
bflatastic 50f1237617 #272: refuse page drops released outside the visible pages list
A page drag released over the Layers below could still reorder against a
row scrolled out of view, and a layer dropped there could move to that
page. The drop now counts only inside the list's visible area. The guard
is ported from #366 by Skyfuller1303 (@Skyfuller1303).
2026-10-15 18:56:27 +07:00
bflatastic a95f95ae37 #361: a replacement that only changes the case replaces every occurrence; the text's chars are collected once 2026-10-15 18:54:50 +07:00
bflatastic 6215cae877 Merge main and the community batch into #361
# Conflicts:
#	crates/engine/src/cmd/edit.rs
2026-10-15 18:52:02 +07:00
bflatastic c230a97cca #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them 2026-10-15 18:50:55 +07:00
bflatastic f2eb75da0b Merge main and the community batch into #272
# Conflicts:
#	crates/ui-egui/src/layers.rs
#	crates/ui-egui/src/layers_behaviour_tests.rs
2026-10-15 18:50:28 +07:00
bflatastic 18381ff701 Merge main and the community batch into #326 2026-10-15 18:48:04 +07:00
bflatastic 3d112c4b8c #300: a colour PNG strike's size is checked before its pixels are allocated 2026-10-15 18:47:52 +07:00
bflatastic 18d4715cd9 Merge main and the community batch into #300
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-15 18:45:15 +07:00
bflatastic 25beadea65 #281: say the asset reference lookup is original code, read from the schema the file embeds 2026-10-15 18:44:29 +07:00
bflatastic de1d2f77df Merge main, the community batch and #279 into #281
# Conflicts:
#	crates/format/src/fig/tests.rs
2026-10-15 18:44:08 +07:00
bflatastic aeeb1a7cde #338: forget a file's barred addresses when its session closes
The hub kept the addresses removed from every file it ever had open. The
entry now goes when the file is closed (everyone left) or removed from the
server, so a long-running public server doesn't keep collecting them.
2026-10-15 18:41:10 +07:00
bflatastic fa3d0873ed Merge main and the community batch into #279 2026-10-15 18:40:50 +07:00
bflatastic 4aa6ac6fcf Merge main, the community batch and #313 into #332
# Conflicts:
#	crates/ui-egui/src/control.rs
2026-10-15 18:40:35 +07:00
bflatastic b53967b423 #313: hidden-window control requests run inside the frame guard 2026-10-15 18:38:34 +07:00
bflatastic 8bcb0f78dd #338: drop the start/end feat marker comments 2026-10-15 18:37:52 +07:00
bflatastic e50bafbb75 Merge main and the community batch into #313
# Conflicts:
#	ROADMAP.md
#	apps/figcraft/src/main.rs
#	docs/roadmap.md
2026-10-15 18:37:11 +07:00
bflatastic 54f9212a2f Merge main and the community batch into #338 2026-10-15 18:35:05 +07:00
bflatastic 477f019737 #280: a drag released off the window ends where the pointer was last seen, not mid-canvas; the test follows #278 2026-10-15 18:34:23 +07:00
bflatastic 41d2ef688e Merge #312: perf(canvas): refresh full-viewport previews during zoom (M8) (@tylerjenningsw) 2026-10-15 18:33:29 +07:00
bflatastic 4378308c30 Merge main into #312
# Conflicts:
#	ROADMAP.md
2026-10-15 18:30:37 +07:00
bflatastic e11a182afd #400: a nested batch keeps its own step references
The outer batch passes a command.batch step on as written, so the inner
steps' `$step` references point at the inner batch's steps. The docs say
that `$step` is a reserved key in step params.
2026-10-15 18:30:05 +07:00
bflatastic 4aadfa0439 README: list the Turkish catalog 2026-10-15 18:28:43 +07:00
bflatastic 394b609ec4 Merge #263: M9: Turkish catalog (reviewed) (@yyamandevrim)
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	docs/localization-parity.md
#	docs/translating.md
2026-10-15 18:28:32 +07:00
bflatastic 29954e6067 Merge main and the community batch into #280 2026-10-15 18:28:18 +07:00
bflatastic 272973e6f9 Merge #257: M9: Russian interface translation (@VanTan666) 2026-10-15 18:27:48 +07:00
bflatastic e15242f506 Merge main, the community batch, #303 and #331 into #336
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:27:43 +07:00
bflatastic 6d9024e0d0 Merge main and the community batch into #400 2026-10-15 18:27:37 +07:00
Rob Coenen 121ba72957 Merge remote-tracking branch 'origin/main' into vector-edit-canvas-stays-put
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-15 07:26:11 -04:00
bflatastic adf96ca2e7 #363: a dragged text box keeps the drag's width and height
A drag with the Text tool now makes a fixed-size box (the drag's size, at
least one line tall) instead of a fixed width with an auto height.
2026-10-15 18:25:06 +07:00
bflatastic 0757800b3d Merge main, the community batch and #303 into #331
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:24:31 +07:00
BFlat 102404e8c3 Community batch 2: 20 reviewed fixes (#510)
Lands 20 reviewed community PRs with their own commits: #314 #337 #330 #297 #301 #282 #284 #254 #475 #384 #358 #472 #471 #339 #328 #288 #343 #344 #353 #290, plus a test fix on #297's branch so its retry tests run on Windows.
2026-10-15 18:23:50 +07:00
bflatastic 9b1c79277a Merge main and the community batch into #363 2026-10-15 18:22:43 +07:00
bflatastic 379e4b5c55 Merge main and the community batch into #303
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:21:15 +07:00
bflatastic b14a332405 #257: keep only Spanish in the must-be-complete list, so new UI strings don't require Russian too 2026-10-15 18:14:19 +07:00
bflatastic 65c9352c5c Merge main and the community batch into #257
# Conflicts:
#	ROADMAP.md
#	contributors/people.toml
#	docs/localization-parity.md
#	docs/roadmap.md
2026-10-15 18:13:34 +07:00
bflatastic 69de1ee36f #253: a dialog's result goes to the file and window it was opened for; dialogs are parented to the main window 2026-10-15 18:12:35 +07:00
bflatastic b75e5bea3b #305: auto-save after a pause, off the UI thread
Nothing is collected while auto-save is off. The wait restarts whenever an
unsaved file changes, so continuous editing doesn't save every few seconds,
and the file is encoded on a thread of its own, then written.
2026-10-15 18:11:54 +07:00
bflatastic 8616f3d1c5 #305: save a file from a snapshot encoded elsewhere
FileSnapshot takes what saving writes, encodes it (thumbnail and all) on any
thread, and writes it back as of the snapshot: edits since stay unsaved, and
bytes from before another save never overwrite the newer file.
2026-10-15 18:11:52 +07:00
BFlat 3ddb19edee Community batch 1: 22 reviewed fixes (#507)
Lands 22 reviewed community PRs with their own commits: #271 #293 #372 #386 #327 #311 #265 #276 #444 #275 #388 #390 #318 #355 #324 #375 #283 #277 #262 #340 #302 #294.
2026-10-15 18:03:38 +07:00
bflatastic 8b1613c7ee Merge main and the community batch into #253 2026-10-15 18:00:57 +07:00
bflatastic 4f12bf1e00 Merge #297 fix: recovery retry tests run on Windows 2026-10-15 18:00:10 +07:00
bflatastic 471694dc48 #297: recovery retry tests fail the copy with a folder in its place, so they also run on Windows 2026-10-15 18:00:02 +07:00
bflatastic fc7556ae7f Merge main and the community batch into #253
# Conflicts:
#	contributors/people.toml
2026-10-15 17:58:35 +07:00
bflatastic bebe509a25 Merge main and the community batch into #305
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-15 17:58:14 +07:00
bflatastic 55eaeb8709 Merge #290: M1: open .figcraft files from Finder, Open With and the Dock on macOS (@marcoarnulfo) 2026-10-15 17:54:25 +07:00
bflatastic 3c52dad774 Merge #353: engine: long digit strings no longer overflow object.opacityKeys (@midasmatterhorn) 2026-10-15 17:54:23 +07:00
bflatastic 5a57388b8d Merge #344: doc: name counters stop at u64::MAX instead of overflowing (@midasmatterhorn) 2026-10-15 17:54:21 +07:00
bflatastic 7a3d2870c5 Merge #343: collab: refuse sequence numbers a host cannot advance (@midasmatterhorn) 2026-10-15 17:54:20 +07:00
bflatastic cc0c3fb10a Merge #288: Add Japanese labels for templates, recent files, and editor controls (@haranishi)
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/locales/ja.toml
#	docs/localization-parity.md
2026-10-15 17:54:17 +07:00
bflatastic b71084f366 Merge #328: packaging: RPM declares the glibc 2.35 floor, so RHEL 9 refuses it (@tydude001) 2026-10-15 17:53:14 +07:00
bflatastic 5dc213820b Merge #339: M1: cursor and double-click tests scan the system fonts themselves (#333) (@Barzabel) 2026-10-15 17:53:12 +07:00
bflatastic f518d87b71 Merge #471: M4: hanging punctuation test holds for fonts that kern with x offsets (@rcoenen) 2026-10-15 17:53:10 +07:00
bflatastic 1dae6a439d Merge #472: M1: Cmd+R rename test sends Ctrl+R only where Ctrl is the command key (@rcoenen) 2026-10-15 17:53:08 +07:00
bflatastic 022821211f Merge #358: Replace every occurrence in edit.findReplace when replacement contains find (@Silvertree2010) 2026-10-15 17:53:06 +07:00
bflatastic 03716da731 Merge #384: Reject negative or fractional index in effect.set, export.update and export.remove (@Silvertree2010) 2026-10-15 17:53:04 +07:00
bflatastic 2420791015 Merge #475: Refuse negative or fractional indexes in page.select and doc.switch (@Silvertree2010) 2026-10-15 17:53:02 +07:00
bflatastic 8ae567c06b Merge #254: fix(engine): read the documented gradient shorthand on every route (#252) (@pcastro93) 2026-10-15 17:53:00 +07:00
bflatastic a25952dc60 Merge #284: fix(format): exclusive unique staging files for atomic save/export (@notdezzi) 2026-10-15 17:52:58 +07:00
bflatastic 4d93274eb1 Merge #282: fix(engine): roll back failed commands during interactions too (@notdezzi) 2026-10-15 17:52:56 +07:00
bflatastic a663e97a5d Merge #301: Keep the redo steps when a failed command batch rolls back (#196) (@MarekSpodBiedry) 2026-10-15 17:52:54 +07:00
bflatastic 097110b359 Merge #297: Count a recovery revision as copied only after its write succeeds (@Silvertree2010)
# Conflicts:
#	contributors/people.toml
2026-10-15 17:52:15 +07:00
bflatastic 1778b2317c Merge #330: M1: deeply nested layers don't run the layout out of stack (#210) (@Barzabel) 2026-10-15 17:51:46 +07:00
bflatastic e5405f4eb6 Merge #337: M3: renaming a style folder no longer closes the app (@Barzabel) 2026-10-15 17:51:44 +07:00
bflatastic 23bb8040ad Merge #314: M1: creating a variable collection no longer closes the app (#224) (@Barzabel) 2026-10-15 17:50:49 +07:00
bflatastic d337b34380 Merge #340: M9: a compaction cut short closes the file instead of confirming lost edits (#183) (@Barzabel) 2026-10-15 15:10:38 +07:00
bflatastic 731b7e356a Merge #294: fix(desktop): Windows' first preferred language picks the UI language (#291) (@fede9234)
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-15 15:10:19 +07:00
bflatastic a8604adbca Merge #302: fix(server): keep each team library version in its own file (#206) (@MarekSpodBiedry) 2026-10-15 15:10:14 +07:00
bflatastic 8959030255 Merge #262: M7: follow system theme from the first frame (@VanTan666)
# Conflicts:
#	contributors/people.toml
#	docs/ui-parity.md
2026-10-15 15:10:11 +07:00
bflatastic 1d71fb4044 Merge #277: fix(pen): show the bezier handles while drawing (#256) (@Nael-Nathanael) 2026-10-15 15:09:44 +07:00
bflatastic 8c8c00fcba Merge #283: fix(engine): Alt-drag copies snap to their original; Cmd+D repeats the step (#256) (@Nael-Nathanael) 2026-10-15 15:09:41 +07:00
bflatastic 990cb4993f Merge #375: Copy and paste through the system clipboard in the browser (@mateusvillain) 2026-10-15 15:09:38 +07:00
bflatastic b321ff48d0 Merge #324: fix(home): keep a long path from drawing over the file name in Open files (@patlux) 2026-10-15 15:09:05 +07:00
bflatastic ec83040535 Merge #355: M7: the Small caps sample in Type settings draws without a box (@StrajnarFilip) 2026-10-15 15:09:01 +07:00
bflatastic 55a476053a Merge #318: fix(home): recent files notice files deleted while the home screen is open (@rcoenen) 2026-10-15 15:08:58 +07:00
bflatastic eededda4fd Merge #390: Keep text selection highlight inside the box at the end of the text (@Silvertree2010) 2026-10-15 15:08:55 +07:00
bflatastic 92d00ca04a Merge #388: Keep corner resize cursors diagonal on wide and tall layers (@Silvertree2010) 2026-10-15 15:08:52 +07:00
bflatastic b93ace5cbf Merge #275: fix(ui): gradient type icons that show their gradient (#256) (@Nael-Nathanael) 2026-10-15 15:08:48 +07:00
bflatastic f4a7b39731 Merge #444: Fix Send to back leaving layers above siblings across frames (@Silvertree2010)
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-15 15:08:45 +07:00
bflatastic f9b7dcf9d1 Merge #276: fix(ui): disable alignment when the selection can't be aligned (#256) (@Nael-Nathanael) 2026-10-15 15:08:11 +07:00
bflatastic 663c203914 Merge #265: M7: a slice's export preview shows the page content under it (@ahmed-226) 2026-10-15 15:08:05 +07:00
bflatastic 36c6cf6bd2 Merge #311: fix(pages): inherit the current page background when adding pages (M1) (@tylerjenningsw)
# Conflicts:
#	ROADMAP.md
2026-10-15 15:08:00 +07:00
bflatastic 23b82e1f00 Merge #327: M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (@marcoarnulfo) 2026-10-15 15:07:22 +07:00
bflatastic 4ac1d80fe8 Merge #386: Outline stroke on every selected layer, not just the first (@Silvertree2010) 2026-10-15 15:07:19 +07:00
bflatastic 2f0369a7ad Merge #372: fix(engine): new text is readable on the frame it is drawn in (#362) (@vxMacDev) 2026-10-15 15:07:17 +07:00
bflatastic 457c688872 Merge #293: M1: a press on a corner handle resizes instead of picking the corner-radius handle (@marcoarnulfo) 2026-10-15 15:07:14 +07:00
bflatastic 0b364ae1fe Merge #271: M7: double-clicking a page in the Pages list no longer closes the app (@marcoarnulfo) 2026-10-15 15:07:11 +07:00
Reva Doni AprilioandClaude Opus 5.5 627933ae06 M9: Indonesian draft catalog
Adds crates/ui-egui/locales/id.toml, a draft Indonesian catalog covering
every UI string (1,577 keys), command label (557) and menu wording (31),
machine-drafted and marked "draft — needs native review".

Indonesian keeps a noun unchanged after a count, so plural entries give
only `other` and plural_one needs no change. Preferences ▸ Language lists
Bahasa Indonesia; System maps id / id_ID to it (new assertion in
i18n_tests.rs) and `id` joins the cjk can-show list. Docs updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:39:08 +07:00
Andrey 8c74c72b50 Merge branch 'main' into fix/261-startup-failure-dialog 2026-10-15 10:15:53 +03:00
Andrey 17a3e2b6d6 Merge branch 'main' into fix/224-variables-collection-crash 2026-10-15 10:15:49 +03:00
Andrey e4126949bf Merge branch 'main' into fix/255-shadow-behind-blur 2026-10-15 10:15:46 +03:00
Andrey 15ca8d4881 Merge branch 'main' into fix/203-189-image-memory-budgets 2026-10-15 10:15:42 +03:00
Andrey 704831830e Merge branch 'main' into fix/210-deep-nesting-stack 2026-10-15 10:15:39 +03:00
Andrey de30e34538 Merge branch 'main' into fix/187-keep-recovery-until-recopied 2026-10-15 10:15:36 +03:00
Andrey 4e5ff2bd46 Merge branch 'main' into fix/style-folder-rename-focus 2026-10-15 10:15:32 +03:00
Andrey 6fbf3ca9aa Merge branch 'main' into fix/333-engine-tests-scan-fonts 2026-10-15 10:15:29 +03:00
Andrey e41e12324d Merge branch 'main' into fix/183-compaction-keeps-edits 2026-10-15 10:15:26 +03:00
NetterSilverandClaude Haiku 5.5 7f2f8ee09a Refuse negative or fractional indexes in page.select and doc.switch
Found while testing crates/engine/src/cmd/page.rs:50

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:02:06 +02:00
IanHollow 611fb324b9 M7: find Unix user fonts under XDG_DATA_HOME
Assisted-by: GPT-6
Signed-off-by: IanHollow <[email protected]>
2026-10-14 22:59:17 -07:00
Rob Coenen 1b67ac1e6d Merge remote-tracking branch 'origin/main' into claude/wonderful-neumann-adc42b 2026-10-15 01:15:53 -04:00
LosminekandClaude Opus 5.5 49a2c643c6 i18n: add Czech (cs) UI catalog as a draft
Adds crates/ui-egui/locales/cs.toml with all 2165 entries (ui, commands,
menu), marked "draft — needs native review" and kept out of COMPLETE.
Plurals use one/other only; the other form is worded count-neutral so it
reads correctly for 2-4 as well. Lists cs in the pref.language help text,
adds a cs_CZ.UTF-8 detection test and updates the localization docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:27 +02:00
Rob CoenenandClaude Opus 5.5 996852d15c M4: hanging punctuation test holds for fonts that kern with x offsets
On macOS without craft-fonts the default face is Helvetica Neue, whose
kerning comes out of the shaper partly as an x offset (e.g. on the "e" of
"here."), so a glyph's x + advance is not its pen end. The layout was
right (the full stop's pen sits exactly on the edge); the test assumed no
offsets. Each hanging line is now compared with the same line set flush:
every glyph must move by exactly the hanging mark's advance, and the flush
right line must end on the edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:55:43 -04:00
NetterSilverandClaude Haiku 5.5 1199f690ad Fix Send to back leaving layers above siblings across frames
Found while testing crates/engine/src/cmd/arrange.rs:327-330

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:12:21 +02:00
Rob Coenen 099465212e Merge remote-tracking branch 'origin/main' into vector-edit-canvas-stays-put
# Conflicts:
#	crates/ui-egui/src/home_tests.rs
2026-10-14 23:57:57 -04:00
Andrey 8482c3e85b M7: home tests wait for the recent files reader instead of a fixed frame count (#351) (#354) 2026-10-15 05:53:18 +02:00
skylar taylor-barrickandskylartaylor 3a1aeb7a4b ci: add comfortable runtime limits to unbounded jobs (#397)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:15:58 +02:00
Edivan TeixeiraandClaude Opus 5.5 13691c1cc0 Let batch steps reference ids created by earlier steps
Inside command.batch, `{"$step": n}` anywhere in a step's params is
replaced by the `id` in step n's result before the step runs
(`{"$step": n, "field": "..."}` picks another field). A nested tree,
such as a frame with children, now takes one batch and one undo step,
not one round-trip per level with ids copied between calls by hand.

Malformed references (forward or self, negative or fractional index,
missing or non-string field, extra keys) fail the batch with BadParams
naming the step, and the existing group machinery rolls it back. The
params walk is depth-capped. Steps are journalled with resolved params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:43:31 -03:00
NetterSilverandClaude Haiku 5.5 d1430e3054 Keep text selection highlight inside the box at the end of the text
Fixes #365

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:56:27 +02:00
NetterSilverandClaude Haiku 5.5 fc6f52e852 Keep corner resize cursors diagonal on wide and tall layers
Fixes #370

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:50:00 +02:00
NetterSilverandClaude Haiku 5.5 3c00ea6440 Outline stroke on every selected layer, not just the first
Fixes #378

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:41:17 +02:00
NetterSilverandClaude Haiku 5.5 87b9a3a9ce Reject negative or fractional index in effect.set, export.update and export.remove
Found while testing crates/engine/src/cmd/edit.rs:256 (effect.set), :305 (export.update), :317 (export.remove)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:08:36 +00:00
vxMacDev 12d4dbfbc9 fix(engine): new text is readable on the frame it is drawn in (#362)
The colour of new text in the app was chosen from the page alone, so
on a dark page text drawn in a white frame (a new frame's default) was
white and invisible. readable_text_fills now looks at what the text is
drawn on: the nearest layer from its parent out whose top visible fill
is a mostly opaque solid colour, else the page. The Text tool and
text.create / node.create pass the text's parent.

The two tests that asserted the page-only rule in a frame now expect
black text in a white frame; new cases cover dark, unfilled, nested,
faint and stacked fills, and the Text tool.
2026-10-14 18:35:21 -05:00
Mateus Villain 3edb15c417 M1: copy and paste through the system clipboard in the browser
The web build's Services never wrote to the clipboard, so Cmd+V after
Cmd+C got the paste event with whatever text the clipboard held before
and made a text layer of it instead of pasting the copied layers.
WebServices now writes copies (text and PNG) through egui's output,
which eframe hands to the browser clipboard during the copy event.
2026-10-14 20:26:18 -03:00
Mateus Villain 01a0f4f332 M3: variables export as a download in the browser
The web build has no save dialogs, so Export… ▸ Variables JSON / CSS
custom properties returned silently. Services gains downloads() and
download(name, bytes); the browser hands the file over through a Blob
link, and the variables panel uses it where the platform saves by
downloads.
2026-10-14 20:01:53 -03:00
vxMacDev b87d2e476f fix(engine): drag with the Text tool to make a fixed-width text box (#360)
The Text tool made its text on the press and ignored the drag, so a
drag gave auto-width text like a click. The new text now stays in a
TextBox gesture while pressed: past the drag slop it becomes a box as
wide as the drag, its top-left at the drag's, with a fixed width and a
height that follows its text. A click, or a drag within the slop, still
makes auto-width text.
2026-10-14 17:29:09 -05:00
NetterSilverandClaude Haiku 5.5 2aa3ca3c45 Make edit.findReplace replace case-insensitive matches
Found while testing crates/engine/src/cmd/edit.rs:962-977

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 22:26:37 +00:00
NetterSilverandClaude Haiku 5.5 c02e864873 Replace every occurrence in edit.findReplace when replacement contains find
Found while testing crates/engine/src/cmd/edit.rs:975-981

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:22:29 +00:00
Rob CoenenandClaude Opus 5.5 3c03f370f9 M1: Cmd+R rename test sends Ctrl+R only where Ctrl is the command key
On macOS egui never sends `ctrl` together with `command`; Ctrl comes alone there
and Ctrl+R isn't a shortcut (rename is Cmd+R or F2), so case 1 failed on every
macOS run. The keymap is right; the test now presses Ctrl+R only off macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:07:05 -04:00
vxMacDev 8efb66ba19 fix(ui): step through the font picker with Up and Down (#345)
Up and Down now move a highlight through the family list, starting
from the current family and scrolling to keep it in view. The text
panel previews the highlighted family on the canvas, as it does for a
hovered font style, and Enter applies it. Escape leaves the text as it
was.
2026-10-14 15:24:40 -05:00
midasmatterhorn 65e52aa7b9 engine: long digit strings no longer overflow object.opacityKeys
opacity_from_digits multiplied and added on every character of its input
string before the one-or-two-digit check, so "9999999999" overflowed u32 on
the way (panic in debug builds, wrap in release). The scan now steps n with
checked_mul/checked_add and stops (None) at the first character the number
cannot take, so a long digit string is refused like any other bad input.

Test: opacity_digits (engine keymap) covers the ten-digit string and the
command's BadParams refusal.
2026-10-14 23:22:41 +03:00
IanHollow 212438a6e3 Set the native window identity to match the desktop entry
Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-14 13:12:38 -07:00
HaSh3003 f387d3f464 M9: translate Remove from session
The menu item and the collab.removePerson label in es, de, fr, ja, ko, pt-BR and zh-Hans (es must be complete).
2026-10-14 23:00:42 +03:00
Barzabel 90a4606c65 M9: a compaction cut short closes the file instead of confirming lost edits (#183) 2026-10-14 22:53:02 +03:00
midasmatterhorn 245837ff27 doc: name counters stop at u64::MAX instead of overflowing
Document::next_name summed max + 1 over numbers parsed from existing layer
names, and bump_name did the same for the duplicate-rename path: a document
(or peer, or node.set) holding a layer named "Frame 18446744073709551615"
overflowed both (panic in debug, wrap to a wrong name in release). next_name
now collects the numbers it sees and, when the counter is spent, picks the
lowest free number (mirrors new_style_id); bump_name only bumps when the
number can step, else falls to its existing " 2" branch.

Tests: next_name_never_overflows (doc) and
duplicating_a_layer_named_past_the_counter_does_not_overflow (engine panel
behaviour).
2026-10-14 22:52:15 +03:00
midasmatterhorn ca2f5dbcee collab: refuse sequence numbers a host cannot advance
The welcome's seq and processed.last_batch are the host's word and were used
unchecked: accepted computed self.seq + 1 and queue bumped next_batch, so a
host sending u64::MAX overflowed the client (panic in debug, wrap in release).
Both counters now step with checked_add/saturating_add, and a sequence that
cannot advance is refused as a protocol error (the established rejoin path).

Test: hostile_sequence_numbers_do_not_overflow_the_client.
2026-10-14 22:47:51 +03:00
Barzabel 5e14632207 M1: cursor and double-click tests scan the system fonts themselves (#333) 2026-10-14 22:39:17 +03:00
HaSh3003 fa0365470e M9: remove someone from a live session
Right-click a person's avatar ▸ "Remove <name> from session", or
`collab.removePerson {client}`, when you host the session or own the file
on a server. The hub closes their connection as the end of the session for
them (close 1001: they keep their copy and don't reconnect) and bars their
address from that file while it runs, so the code or link they hold no
longer gets them in; everyone else stays connected.

Protocol addition within version 2: `Admin::RemovePeer {file, client}` on
an open hosting connection with the file's owner token (or the server
key). Older servers say they can't.

Tests: loopback (removed, can't rejoin, others stay), engine (host can,
guest can't, bad ids refused), codec round trip; the collab junk sweep
covers the new command.
2026-10-14 22:32:56 +03:00
Barzabel b8043cc2ad M3: renaming a style folder no longer closes the app 2026-10-14 22:23:51 +03:00
Patrick Wozniak 30478894fd M0.9: a window on screen draws requests for it while the main window is hidden (#269)
Requests reach the UI through the main window's logic. A request for
another window is answered in that window's frame, and the main window's
frame (`drain`) asks that window to draw. While the main window is
hidden, only its logic runs and nothing asked: an idle window on screen
draws no frames, so the request waited until something else repainted
the window, or timed out after 60 s.

`drain_hidden` now asks a window on screen that a request waits for to
draw. A hidden window isn't asked: it can't draw, and the main window's
logic already answers what it can.
2026-10-14 21:17:42 +02:00
Filip Strajnar 6bd87bccc6 M7: the Small caps sample in Type settings draws without a box
The Small caps button in Type settings labelled itself "Aɢ", but the
small-capital G (U+0262) is missing from egui's default fonts, which the
UI uses whenever Inter isn't built in, so the button showed "A" and a box.
The sample is now a capital followed by smaller capitals laid out from
ordinary letters every UI font has. A test checks the UI fonts draw every
letter of the sample.
2026-10-14 21:12:39 +02:00
Barzabel ac8a0a112c M1: keep a recovery copy until this run has its own (#187) 2026-10-14 22:10:49 +03:00
Patrick Wozniak 64eae8c8c5 M0.9: every hidden window answers control requests, not only the main one (#269)
`drain_hidden` answered only requests for the main window, and only
while that window was hidden. A request addressed to another window
(`window.new`, `window` param) that is minimised or covered still waited
for that window's frame and timed out after 60 s, whether the main
window was on screen or not. An agent that works in its own window lost
it as soon as the window went behind others or to another Space.

`drain_hidden` now looks at the target window of each request: if that
window is hidden (`ViewportInfo::visible() == Some(false)` for its
viewport) and the method needs no frame, it is answered from the main
window's logic with the target window's state swapped in, as while it
draws, so it acts on that window's file. `draw` and the new
`windows::as_window` share the swap (`enter`, `adopt`, `leave`). The
main window's logic runs on every request (the control server asks for
a repaint), so this works while the main window is on screen too. What
needs a frame keeps waiting, as before; a request for a window that
doesn't exist is an error at once instead of waiting for a frame.
2026-10-14 20:45:19 +02:00
vxMacDev b821b6f8f4 fix(recovery): keep the version history and branches in recovery copies (#185)
Recovery copies were written with plain `save_bytes`, so a file
recovered after a crash came back without its version history and
branches, even though `file.recover` reads both. Copies now go through
`save_bytes_with_history` like a normal save, with each open branch
tab's latest edits (`branches::current`), since `sync` only puts those
in before the next command.

A copy was also only redone when the main file's revision changed, and
a file with edits only in a branch tab didn't count as changed. Both
checks now include the file's branch tabs.
2026-10-14 13:41:17 -05:00
Barzabel a3048770b6 M1: deeply nested layers don't run the layout out of stack (#210) 2026-10-14 21:37:55 +03:00
tydude001andClaude Opus 5.5 1c1b699598 packaging: RPM declares the glibc 2.35 floor, so RHEL 9 refuses it
FigCraft's RPM declares no glibc floor, though the deb has `libc6 (>= 2.35)`. On Rocky Linux 9
(glibc 2.34) the RPM installs, and then `figcraft-cli` fails with ``/lib64/libm.so.6: version
`GLIBC_2.35' not found``.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 13:35:50 -05:00
Rob CoenenandClaude Opus 5.5 3e777d2485 M7: home screen test waits for the recent-file reader
The first-run test settled the home screen with a fixed 12 frames, but
the recent files are looked at on a thread of their own and a card shows
no name until that thread has answered. On a busy CI runner it hadn't
yet, so "ridgeline" was still a grey placeholder and the test failed
(seen on this PR and on codex/issue-250-async-dialog). settle() now waits
until every recent file has been looked at, as home_recents_tests does.
Checked with a reader slowed to 200 ms: the old test fails at the same
line as CI, the new one passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:30:10 -04:00
Marco Arnulfo ebf6045184 M1: SVG export keeps a nested frame's fill and stroke inside its parent's clip (fixes #325) 2026-10-14 19:40:08 +02:00
Barzabel 15ae2e9115 M1: memory budgets for decoded images and a file's images (#203, #189) 2026-10-14 20:36:47 +03:00
Patrick Wozniak 639c4d7db0 Home: test the file row layout on Open files
Recent files are cards since #249, so the row test now opens two
documents with synthetic paths and checks the Open files list: the row
that fits shows name and path in full; the long one shows the name cut at
its end, then the path cut at its start, at least 12 px apart, and both in
full on hover. Without the row fix the name shows as "Quar…" under the
path.
2026-10-14 19:05:12 +02:00
Patrick Wozniak f1e0a758d1 Home: file rows no longer draw the path over the name
The Open files and Recent files rows painted the detail (the file's path)
right-aligned at its full width and only then elided the name into
whatever was left, with a floor of 40 px. A path wider than the row ran
straight over the name, and the name itself shrank to "Mae…".

The row is now laid out name first: the name keeps its full width when
both fit, otherwise at least 60% of the row; the detail gets the room
left after a 16 px gap and is cut at its start ("…/02-technik/KNX/
file.figcraft"), which keeps the file and its nearest folders. A detail
with less than 48 px of room is left out. Rows of Recents' own lists show
name and path in full on hover when either was cut.

The new widgets::elide_start_font mirrors elide_font, keeping the end.
2026-10-14 18:59:47 +02:00
Rob CoenenandClaude Opus 5.5 9404226235 M4: vector edit mode keeps the canvas still under the pointer
With no points picked, the Design panel's hint ("Select points to edit
their position, radius and mirroring") didn't wrap and made the canvas
49 points wider, so entering vector edit mode moved the view by about 25
points and picking a region moved it back. The jump came in the middle
of the click that picked the region: with the smallest pointer movement
the release landed elsewhere on the page and the region was dragged by
that distance (about 47 units at 70%). Both edit mode hints now wrap.
Regression test: the canvas rectangle stays the same entering edit mode,
picking a region and switching to the width tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:35:06 -04:00
Rob CoenenandClaude Opus 5.5 d5fee489f5 M7: recent files notice files deleted while the home screen is open
The recent files' dates were only read again when the home screen came
back into view, so a file deleted or moved in the meantime kept its card
until then. They are now also read again every 2 seconds while in view,
and at once when the window gets the focus back; a file that is gone
shows "File not found" without leaving the home screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:07:53 -04:00
tylerjenningsw 82d84d86b1 docs: zoom preview latency boundary and measurements (M8)
Refs #295
2026-10-14 11:59:24 -04:00
tylerjenningsw 72390d7b51 fix(canvas): preview in the browser and judge previews by coverage (M8)
Review of the zoom previews found three problems:

- The browser worker never previewed: wasm builds timed every render
  as 0 ms, below the 12 ms threshold. Renders are now timed with
  web-time's monotonic clock on every target (already in the tree via
  the engine), and the inline worker is built everywhere so a test runs
  it with the production threshold: preview one frame, refine the next,
  a newer request skips the old refinement.
- A preview was refused whenever the texture on screen was refined at
  the same zoom, even when that texture showed another part of the page
  (back at 100% about another point after 800%: 1/64 of the view). It
  now has to show the whole view asked for, not just its zoom.
- The latencies read the frame's start time before the inline render
  and before the texture was set. They now run on the same clock from
  the request to the image handed to egui as the canvas texture.

Refs #295
2026-10-14 11:57:58 -04:00
Barzabel 698e249fbf M1: drop shadows under a background blur (#255)
A layer with a background blur (or glass) drew its blurred backdrop into
its own pixels before casting its drop shadows from them, so a translucent
fill cast a shadow as dark as an opaque one, and with "show behind
transparent areas" the shadow was cast from the backdrop's pixels. The
backdrop was also read before the shadows were drawn, so a shadow showing
behind was never seen through the blur.

When a layer has both, it now casts its shadows from what it paints (with
the layer blur, noise and texture it will get), draws them, and only then
reads the backdrop, which goes under its content; the content still sees
the first backdrop under it while it's drawn. Layers without both draw as
before.
2026-10-14 18:45:15 +03:00
Marco Arnulfo 1aeb5abb74 M1: a click on the toolbar no longer reaches the canvas under it (#307) 2026-10-14 17:43:32 +02:00
AganzeFelicite f9b7aa03ca M0.9: answer control requests while the window is hidden
A minimised or covered main window draws no frames, and control requests
were only answered inside a frame, so every control-channel and attached
MCP call (save_file among them) timed out after 60 s while the app was out
of sight. eframe still calls the app's logic then whenever a repaint is
requested, which the control server already does for each request.

The desktop app's logic now answers, while the window is hidden, the
requests that need no frame: engine.execute, engine.commands,
document.inspect, node.get, window.list and ui.render. Screenshots, input
and the interface's own state still wait for the window, and their
timeout now says that the window has to be on screen.

Closes #269
2026-10-14 17:27:01 +02:00
tylerjenningsw 3405a07d33 Contributors: add tylerjenningsw's names 2026-10-14 11:24:31 -04:00
Barzabel 4f321fb68d M1: creating a variable collection no longer closes the app (#224)
Creating a collection (and adding a mode or a variable, double-clicking
a row, or Rename in its menu) asked egui to focus the new name's field in
the same frame, but that field is only drawn from the next frame on. For
one frame the AccessKit update named a focused node that wasn't in the
tree, and the platform adapter aborts on that, outside the app's frame
guard; Windows keeps AccessKit on through UI Automation far more often,
hence the silent close there. A rename now leaves a flag and its field
takes the focus in the frame it's drawn, through widgets::rename_field,
as the layer rows do.
2026-10-14 18:21:08 +03:00
tylerjenningsw d72e5e0516 docs: zoom previews in the roadmap, gaps and control protocol (M8)
Refs #295
2026-10-14 11:15:11 -04:00
Marco Arnulfo ed0e7a9b2d M1: leaving Design ends the pen path and goes back to Move (fixes #307) 2026-10-14 17:11:20 +02:00
tylerjenningsw 4c23cb71c0 perf(canvas): render coarse zoom previews before refinement (M8)
A new zoom can't reuse the retained canvas image, and the previous one,
re-projected, covers little of the view after a zoom out (1/64 of it
from 800% to 100%), so the page background showed until the full render
finished.

When renders from scratch have been slow (a frame or more), the worker
first renders the whole requested view with a quarter of the pixels
each way, in a Retained of its own so pans and edits still build on the
full image, then refines it unless a newer request is already waiting
(that one gets its own preview). The web build renders the preview and
the refined image in consecutive frames. Pixel preview gets none.

Finished images carry the file, page and whether they are coarse: the
canvas never shows an image of another file or page, and a late preview
never replaces a refined image already at the zoom asked for. ui.inspect
reports the canvas quality and the latency from request to the frame
showing the preview and the refined image.

Refs #295
2026-10-14 11:08:55 -04:00
midasmatterhorn f1f7da1f40 engine: refuse page.move for node ids that are not pages 2026-10-14 18:01:41 +03:00
tylerjenningsw a3a273cfd3 fix(pages): inherit the current page background when adding pages (M1)
page.add gave every added page the interface theme's colour. Figma's
documentation says added pages inherit the current page's background;
the theme only decides a new file's page. Read the active page's colour
before creating the new page, in the command, so the app, CLI, control
channel and MCP agree.

Refs #295
2026-10-14 10:58:17 -04:00
fede9234 3917c26802 Text: colour-glyph comments say only what the fonts are documented to carry
"Placeholder rectangles" was a claim about Apple Color Emoji's glyf
outlines that no source I can show supports: the font's sbix chapter
documents that most emoji are bitmaps while the flags are drawn from
outlines, and it is Noto Color Emoji that has no outlines at all. The
comments, the ROADMAP row and the parity row now say that instead.

No behaviour change: comments and docs only.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 16:55:07 +02:00
AganzeFelicite 00ac44f6ca M1: save files automatically
Preferences > Save files automatically (pref.autoSave, off by default;
hidden in the browser, which has no files to save to). With it on, each
open file that has a path is saved 3 s after it changes, never during a
gesture, a live text preview or while a version is shown. A failed save
says why in a toast and tries again a minute later.

Version history checkpoints (version.autosave, every 30 minutes) only ran
for files with unsaved changes, so a file that saves itself would stop
getting them; with auto-save on, edits made since the file was opened
count.

Closes #296
2026-10-14 16:48:56 +02:00
fede9234 de5ba60bc4 Text: colour-glyph comments cite the right source and size
The placement formula is Skia's own fontations port
(src/ports/fontations/src/bitmap.rs) reading the sbix spec's
lower-left-corner rule; the earlier comment named CoreText, which that
source does not mention. Apple Color Emoji measures ~180 MB on current
macOS (the 190 MB figure is a hosted copy of the file), so the comments
say ~180 MB.

No behaviour change: comments only.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 16:44:20 +02:00
Barzabel 8b8825ce68 M1: say why when the window can't open (#261)
Release builds on Windows have no console, so an eframe::run_native error
(no usable graphics adapter, a device the driver refuses) went to an unseen
standard error and the app quit silently. The error is now logged and shown
in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise
update the driver or try another graphics API (OpenGL on Windows), and
where to report it. With --control the box is skipped so agents aren't
blocked.
2026-10-14 17:37:59 +03:00
fede9234 54ba84eddc Text: colour emoji draw from their own colour tables (#268)
Colour emoji came out as the placeholder rectangles their outlines carry
(Apple Color Emoji) or as .notdef when the face has no outline at all:
nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for
colour ink before its outline: an embedded bitmap strike decoded to
premultiplied RGBA and placed by the strike's bearings the way Skia (and
CoreText) places it, scaled to the size the run asks for, or a COLR v0
glyph's layers filled from the font's palette.

Faces that carry only colour tables are indexed again: Apple Color Emoji
is no longer skipped for being 190 MB (the scan still reads headers and
name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join
"Apple Color Emoji" as colour fallbacks, read only for a text that
nothing else covers. The png crate decodes the strikes; skrifa keeps
shaping, metrics and outlines.

Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and
PDF exports (text is outlines there).

Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding,
scaling, COLR layers and bitmaps of the machine's own colour font) and a
render case gated on that font; the font-free ones run everywhere.
Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji
(CBDT) read from a downloaded file; the sbix path runs through the same
code and needs a macOS run to be seen.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 16:19:46 +02:00
MarekSpodBiedry a8a689d682 fix(server): keep each team library version in its own file (#206) 2026-10-14 16:19:01 +02:00
MarekSpodBiedry 6eeac4fa04 Keep the redo steps when a failed command batch rolls back (#196) 2026-10-14 16:14:35 +02:00
MarekSpodBiedry 8b961b980a Recovery: wait for the write in flight before an emergency save and before removing copies (#202) 2026-10-14 16:12:40 +02:00
NetterSilverandClaude Haiku 5.5 4193a82dfb Count a recovery revision as copied only after its write succeeds
Fixes #186

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:08:47 +02:00
Brandon ThomasandClaude Opus 5.5 833f0d2c27 Release 0.6.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:05:59 -04:00
fede9234 3709e7320c fix(desktop): Windows' first preferred language picks the UI language (#291)
Windows' preferred-language list is also the input-language list: a language
added for a keyboard sits in it, and taking the first entry with a catalog
turned the interface German on a Polish system (a German keyboard) while the
system and the other apps stayed Polish. Only the first preferred language now
decides; when it has no catalog the interface reads English. macOS'
AppleLanguages and gettext's `LANGUAGE` still take the first language with a
catalog. Fixes #291.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <[email protected]>
2026-10-14 14:54:01 +02:00
Marco Arnulfo e92792e3e8 M1: a press on a corner handle resizes instead of picking the corner-radius handle (fixes #292)
The corner-radius handle sits 10 px in from each corner and answers within 6 px, so a press on the inner half of the 8 px corner square picked it: the drag rounded the corners or, dragged outward, did nothing. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the constrain-proportions link); further in the radius handle still answers.
2026-10-14 14:36:39 +02:00
Marco Arnulfo 310cb40af9 M1: open .figcraft files from Finder, Open With and the Dock on macOS (fixes #289) 2026-10-14 13:58:37 +02:00
haranishi d1ac66a803 M9: 日本語のテンプレートと編集操作ラベルを補完 2026-10-14 20:43:30 +09:00
notdezzi 661144021e fix(format): exclusive unique staging files for atomic save/export
Saving and exporting wrote a truncating std::fs::write to a deterministic
staging name (<path>.tmp-figcraft, <file>.figcraft.tmp) before renaming, so an
unrelated file or a planted symlink at that name was clobbered or written
through, and separate writers of one destination shared a staging file they
could corrupt. Both paths now go through figcraft_format::write_file_atomic: a
uniquely named sibling staging file is created exclusively (create_new) and
written through its own handle, renamed over the destination, and removed again
on any failure, so only the staging file a write created is ever touched. Tests
cover pre-existing staging files and symlinks, concurrent writers, and
injected rename and permission failures.
2026-10-14 13:11:28 +02:00
RealtaNuaandClaude Opus 5.5 73b2845ba4 fix(engine): Alt-drag copies snap to their original; Duplicate repeats the step
- A copy Alt-dragged off a layer snaps to that layer too: its edges,
  centres and equal spacing (Targets::add_box with the originals' box).
  Before, the originals were left out of the snap targets, so a copy
  couldn't line up with or space evenly from the layer it came from.
- Duplicate (Cmd+D) repeats the last duplicate's page offset while its
  copies are what it duplicates: Alt-drag a copy, or Duplicate and move
  the copy, then each Cmd+D steps out the next one as far again. The
  step is measured when Duplicate runs. It lasts as long as the
  selection (undo reuses ids), copies placed by an auto layout set no
  step, and explicit dx/dy win.
- measure_tests: an Alt-dragged copy between its original and another
  layer now snaps to equal spacing (45/45 instead of 50/40).

Tests: duplicate_spacing_tests (snap to the original, equal spacing,
repeat after moving a copy and after an Alt-drag, only for the last
copies, undo then a new layer, rotated frame, auto layout).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:03:48 +07:00
notdezzi 10dc38495f fix(engine): roll back failed commands during interactions too
A command that fails part-way (node.set applies geometry keys before
values like visible, which can fail validation) mutated the document
even when an interaction was under way, because the rollback in
Session::execute was gated on interaction.is_none(); drop the gate so
the command-entry snapshot is restored during interactions as well,
which is the interaction's last valid preview and leaves the gesture
itself intact.
2026-10-14 12:54:02 +02:00
notdezzi efdd5fc645 fix(format): import explicit variable modes on frames and instances
A collection a saved file references by its asset key (the form library
collections keep) instead of a guid resolved to nothing, so every
explicitVariableModes entry was dropped silently and bound layers rendered
in the collection's default mode (#267). The .fig import now resolves such
references against the file's own nodes (the collection node carries the
same key) for mode entries and the variables' collection link, and reports
modes of collections it can't resolve.
2026-10-14 12:40:17 +02:00
Justin John 61def74383 M0: finish canvas drags released outside 2026-10-14 16:04:34 +05:30
Marco Arnulfo ce366ea325 M6: .fig import: a resized nested instance keeps the non-geometric derived data; resize check with a tolerance (#266) 2026-10-14 12:14:45 +02:00
Marco Arnulfo d8556e8f17 M6: .fig import: size overrides keep the constraint placement, nested instances resized again drop their old derived layout (#266) 2026-10-14 12:12:46 +02:00
Marco Arnulfo 01eb35df25 M6: .fig import lays out the layers of resized instances by their constraints (fixes #266) 2026-10-14 12:00:02 +02:00
RealtaNuaandClaude Opus 5.5 b8e5b8e133 fix(pen): show the bezier handles while drawing
The Pen showed only its anchors while a path was drawn; handles appeared
only in vector edit mode afterwards. Now:

- The point being dragged shows its in and out handles, and the point
  before it its out handle (the handles shaping the segments at the pen),
  drawn like vector edit mode's (one shared canvas helper).
- Moving on to the next point, the rubber band is the segment a click
  would make: it leaves the last anchor along its out handle instead of
  going straight.
- A press no longer leaves the previous straight rubber band on screen
  over the segment being dragged.

Overlay gains pen_handles and rubber_handle (shown in ui.inspect).

Tests: pen_overlay_tests (handles during and after a drag, none and a
straight band for corner points, cleared on finish); canvas_fixes_tests
(handle lines and dots painted mid-drag, curved band after release).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:58:51 +07:00
RealtaNuaandClaude Opus 5.5 9c0b441119 fix(ui): disable alignment when the selection can't be aligned
One layer directly on the page has nothing to align to (one layer aligns
to its parent frame), so the six Alignment buttons looked active and did
nothing. They are now disabled there, with a hover explaining why.

- cmd/arrange.rs: align_target (unchanged rule) and pub can_align.
- right.rs: the align rows in add_enabled_ui + on_disabled_hover_text.
- The hover text in every catalog.

Tests: a_layer_alone_on_the_page_has_nothing_to_align_to (engine),
the_align_buttons_are_off_for_a_layer_alone_on_the_page (AccessKit).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:53:33 +07:00
RealtaNuaandClaude Opus 5.5 3f57a4b76a fix(ui): gradient type icons that show their gradient
The colour picker's paint type row borrowed unrelated icons for the
gradient kinds (auto layout's bars, a circle, the rotate arrow, the
instance diamond). Each kind now has its own icon: the Solid swatch as
an outline with that kind's fade inside (linear ramp, radial, a conic
sweep from the top, diamond), drawn with a new per-vertex-alpha mesh
helper.

Test: gradient_icons_fade (each paints a mesh from opaque to clear).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 16:52:00 +07:00
Marco Arnulfo bd482ea6dd fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) 2026-10-14 11:15:32 +02:00
Marco Arnulfo 64a54ca408 M7: double-clicking a page in the Pages list no longer closes the app (fixes #264) 2026-10-14 11:11:00 +02:00
Yaman DevrimandClaude Opus 5.5 5f3e1e1e6c M9: Turkish catalog reviewed by a native speaker
Drops the draft status from tr.toml (the Preferences window no longer
calls it a draft) and marks Turkish reviewed in ROADMAP.md,
docs/localization-parity.md and docs/translating.md. Turkish stays out of
COMPLETE, so new strings don't have to be translated into it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:47:57 +03:00
ahmed-226 dde193cbf4 M7: a slice's export preview shows the page content under it
A slice paints nothing itself, so thumbnail() rendered an empty pixmap of the slice's own box: expanding Preview for a slice showed only the transparency checkerboard while exporting the same slice wrote the artwork correctly.

Give the preview the export's slice rule (drawn_layers + export_area in cmd/file.rs): draw every top-level layer of the slice's page over its world bounds. Regression tests cover a slice over artwork, a slice with nothing under it, and a plain layer.

Signed-off-by: Ahmed Mahmoud <[email protected]>
2026-10-14 10:37:23 +03:00
VanTan 4964910cc9 M7: follow system theme from the first frame 2026-10-14 10:34:14 +03:00
Yaman DevrimandClaude Opus 5.5 06850b8900 M9: Turkish draft catalog
crates/ui-egui/locales/tr.toml translates every UI string (1,577 keys),
command label (557) and menu wording (31), machine-drafted and marked
"draft — needs native review". Buttons and menu items use bare
imperatives (Kaydet, Sil), instructions the polite -in/-ın (seçin,
tıklayın); the percent sign goes before the number (%50). Turkish keeps
a noun singular after a count, so plural entries give only `other`.

Preferences ▸ Language lists Türkçe; System maps tr and tr-TR to it.
The catalog passes the i18n check even when listed as complete.
docs/translating.md, docs/localization-parity.md and ROADMAP.md list
the new draft. Adds my own contributors/people.toml entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:34:10 +03:00
VanTan 7a0bd980c6 M9: add Russian interface translation 2026-10-14 09:33:58 +03:00
Piettro CastroandClaude Opus 5.5 f3cd0509cb fix(engine): read the documented gradient shorthand on every route (#252)
`{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP
tools, so `figcraft-cli run` and the control channel's `engine.execute`
refused it ("`fills` must be paints or colours"). The expansion now lives in
the engine (`params::normalize_paint`, used by `paint_of`), so node.set,
node.create props, fill.add/set, strokes and paint styles all take it; MCP
calls the engine's copy instead of its own.

Tests: engine (every paint command, MCP-equal defaults, junk still refused),
control channel `engine.execute`, and `figcraft-cli run` end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:02:45 -05:00
Justin John 94ce2a3611 M13: make native file dialogs asynchronous 2026-10-14 11:04:30 +05:30
bcedfeba10 M7: recent files as thumbnail cards on the home screen (#249)
The Recents section shows recent files as cards: the picture each file
carries (thumbnail.png in .figcraft and .fig, an image file itself) fitted
on its first page's colour, a divider, and a footer with the file's icon,
name and "Edited ... ago" (or "File not found"); the full path on hover.
Pictures are read off the frame's thread, only for cards in view, cached
by path and modification time, capped in bytes and pixels; missing,
damaged or oversized ones give a placeholder. They fade in, and cards
show a skeleton until their file has been looked at.

A sort menu (Alphabetical, Date created, Last modified; either order) and
a grid/list toggle sit above them. The list is a Name, Last modified and
Created table whose headers sort. View (per section) and sort are kept in
the preferences and set with ui.set {homeRecentsView, homeRecentsSort,
homeRecentsOrder}. A click selects, a double-click or Enter opens, arrow
keys move the selection; a right-click offers Open, Show in folder and
Remove from recents (file.openRecent {remove}).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bflatastic <[email protected]>
2026-10-14 12:00:20 +07:00
Brandon ThomasandClaude Opus 5.5 b34c5de2ab CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR (#121)
They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock,
which is nearly every PR. See craftrules release/playbook.md §2.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 11:53:56 +07:00
BFlat 52d013e1db Docs: roadmap entries for the community fixes in #221, #226, #228, #229, #231 to #238 and #240 to #242 (#246) 2026-10-14 11:35:04 +07:00
179 changed files with 19117 additions and 831 deletions

No files matched your search

+2
View File
@@ -29,6 +29,7 @@ concurrency:
jobs:
test:
timeout-minutes: 35
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
@@ -69,6 +70,7 @@ jobs:
run: cargo run --locked -q -p xtask -- assets
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
+7 -4
View File
@@ -5,12 +5,14 @@
# 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 push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
@@ -41,8 +43,9 @@ jobs:
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
# dejavu: the text tests need one real font, as the Ubuntu runner has.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
+1
View File
@@ -19,6 +19,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
+9
View File
@@ -41,6 +41,7 @@ env:
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
@@ -69,6 +70,7 @@ jobs:
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
@@ -127,6 +129,7 @@ jobs:
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
@@ -187,6 +190,7 @@ jobs:
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
@@ -252,6 +256,7 @@ jobs:
if-no-files-found: error
server:
timeout-minutes: 90
name: Server Linux ${{ matrix.arch }}
needs: version
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
@@ -295,6 +300,7 @@ jobs:
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
@@ -388,6 +394,7 @@ jobs:
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
@@ -433,6 +440,7 @@ jobs:
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
@@ -502,6 +510,7 @@ jobs:
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
runs-on: ubuntu-latest
+6 -4
View File
@@ -4,15 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "41 4 * * *"
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * *"
workflow_dispatch:
concurrency:
Generated
+37 -21
View File
@@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"arboard",
"eframe",
@@ -1418,6 +1418,7 @@ dependencies = [
"figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"fmv-macos-events",
"image",
"log",
"rfd",
@@ -1430,7 +1431,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1444,7 +1445,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1459,7 +1460,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1482,7 +1483,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"kurbo",
"serde",
@@ -1491,7 +1492,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1503,7 +1504,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1530,7 +1531,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1545,7 +1546,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"kurbo",
"serde",
@@ -1553,7 +1554,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1561,7 +1562,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1577,7 +1578,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1586,7 +1587,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1603,7 +1604,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1616,7 +1617,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1634,7 +1635,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1658,7 +1659,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1674,12 +1675,13 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
"log",
"png",
"skrifa 0.47.0",
"unicode-bidi",
"unicode-segmentation",
@@ -1687,7 +1689,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"base64 0.22.1",
"egui",
@@ -1695,6 +1697,7 @@ dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
"figcraft-geom",
"figcraft-layout",
"figcraft-pathops",
@@ -1706,17 +1709,19 @@ dependencies = [
"serde_json",
"toml",
"wat",
"web-time",
]
[[package]]
name = "figcraft-web"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"eframe",
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-ui-egui",
"js-sys",
"log",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -1749,6 +1754,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -5806,7 +5822,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.5.1"
version = "0.6.0"
dependencies = [
"figcraft-engine",
"serde_json",
+1 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.5.1"
version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -1
View File
@@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English and Spanish (a draft waiting for a native speaker's review), picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **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 two 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).
+5 -1
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-14 · **Change:** minor (community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **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.
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
@@ -311,6 +313,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
+21
View File
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
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".
let d = tmp("gradient");
let script = d.join("s.json");
std::fs::write(
&script,
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
)
.unwrap();
let file = d.join("g.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
let json = d.join("g.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let doc = std::fs::read_to_string(&json).unwrap();
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@@ -800,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() {
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
server.stop();
}
#[test]
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
let dir = TempDir::new("libfail");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
// Version 1, then a changed library as version 2.
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();
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
let files = dir.path().join(crate::store::FILES).join(&file.id);
// The description can't be written: a directory sits where its temporary file goes.
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
std::fs::create_dir(&tmp).unwrap();
assert!(store.publish_library(&file.id, &v2).is_err());
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
let served = |reply: LibraryReply| match reply {
LibraryReply::Library { version, archive } => (version, archive),
other => panic!("{other:?}"),
};
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
// A crash right after the archive was written leaves it on disk, the description unchanged.
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
drop(store);
std::fs::remove_dir(&tmp).unwrap();
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
// Publishing again works, and serves the new version.
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
+69 -16
View File
@@ -10,7 +10,8 @@
//! and a join code's hash and wrapped token (see `codes`)
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
//! <data>/files/<id>/log.bin what changed since (see `disk`)
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
//! <data>/files/<id>/library-<version>.bin its team library, when one was published (a `.figcraft`
//! archive; `library.bin` from servers before versioned names)
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
//! <data>/plugins/ the plugin directory (see `directory`)
//! <data>/blocked.json addresses the operator blocked
@@ -179,7 +180,7 @@ impl Meta {
}
}
/// What a file's team library is (`library.bin` holds it).
/// What a file's team library is ([`library_file`] holds it).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct LibraryMeta {
id: String,
@@ -265,6 +266,9 @@ pub struct Store {
next_sweep: u64,
/// Compactions so far (tests count them).
compactions: u64,
/// Open files closed by a compaction cut short (their snapshot replaced, their log not):
/// reported at the next flush, so nothing appended after it is confirmed.
cut_short: Vec<String>,
/// The free space last seen on the data directory's disk, and when.
free: Option<(Instant, Option<u64>)>,
_lock: File,
@@ -360,6 +364,7 @@ impl Store {
gone_codes: VecDeque::new(),
next_sweep: 0,
compactions: 0,
cut_short: Vec::new(),
free: None,
_lock: lock,
};
@@ -919,11 +924,12 @@ impl Store {
/// A file's team library for `ip`, unless its version is `have`; within what one address and
/// everyone may download (the size is known before anything is read).
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
if have == Some(lib.version) {
return Ok(LibraryReply::Unchanged { version: lib.version });
}
let path = library_file(&dir, lib.version);
let size = fs::metadata(&path).map_or(0, |m| m.len());
let now = Instant::now();
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
@@ -965,14 +971,24 @@ impl Store {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
}
// The archive first: a description never names a version whose library isn't there (a
// failed description write leaves the older number, which the next publish follows).
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
// 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
// the new bytes under the old number.
let path = dir.join(library_name(m.version));
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
// Never served (the description doesn't name it); the next publish of it replaces it.
if let Err(r) = fs::remove_file(&path) {
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
}
entry.meta.library = before;
return Err(format!("couldn't keep the library: {e}"));
}
// The previous version isn't served any more.
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
}
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
Ok(m.version)
}
@@ -1124,7 +1140,7 @@ 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)?,
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
};
self.entry(file)?.log = Some(log);
Ok(server)
@@ -1137,21 +1153,24 @@ impl Store {
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
let state = server.state().map_err(|e| kept(e.to_string()))?;
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
// and log still hold everything.
if bytes.len() as u64 > self.snapshot_cap() {
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
}
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
// From here the old log no longer goes with the snapshot on disk.
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
if let Ok(e) = self.entry(file) {
e.bytes = (bytes.len() + LOG_HEADER) as u64;
e.meta.seq = server.seq();
@@ -1204,8 +1223,19 @@ impl Store {
}
log::debug!("compacted file {file}");
}
// The new snapshot holds everything, but a restart would drop whatever is appended to
// the old log now (its generation no longer matches): the file closes, the hub hears
// it at the next flush, and it opens again from the new snapshot.
Err(RewriteError { why, snapshot_replaced: true }) => {
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
log::error!("file {file}: compaction was cut short: {why}");
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
self.cut_short.push(file.to_string());
}
// The old snapshot and log still hold everything: carry on appending.
Err(e) => log::error!("file {file}: compaction failed: {e}"),
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
}
}
@@ -1213,7 +1243,7 @@ impl Store {
/// Those are closed here (their pending records dropped: nobody was told of them) and open
/// again from what is on disk.
fn flush_all(&mut self) -> Vec<String> {
let mut failed = Vec::new();
let mut failed = std::mem::take(&mut self.cut_short);
for (id, e) in &mut self.files {
if let Some(log) = e.log.as_mut() {
match flush_log(id, log) {
@@ -1229,6 +1259,13 @@ impl Store {
}
}
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
/// before it failed (then the old log no longer goes with it).
struct RewriteError {
why: String,
snapshot_replaced: bool,
}
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
/// can't be trusted to hold them).
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
@@ -1249,6 +1286,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
}
}
/// The name of the file holding version `version` of a file's team library.
pub fn library_name(version: u64) -> String {
format!("library-{version}.bin")
}
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
/// version published before each version had one.
fn library_file(dir: &Path, version: u64) -> PathBuf {
let path = dir.join(library_name(version));
if path.exists() { path } else { dir.join(LIBRARY) }
}
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
disk::write_atomic(path, &text).map_err(|e| e.to_string())
@@ -1316,6 +1365,8 @@ impl Backend for Store {
if dirty {
self.compact(file, server);
}
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
}
@@ -1438,6 +1489,8 @@ impl Backend for Store {
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
Admin::Owned { files } => self.owned(&files).into(),
// The hub removes people itself (it knows who is connected); it never asks the store.
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
+44
View File
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
let _ = disk::read_log(&bytes);
}
}
/// The first page's name in `server`'s document.
fn page_name(server: &Server) -> String {
let d = server.replica().document();
d.node(d.first_page().unwrap()).unwrap().name.clone()
}
#[test]
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let dir = TempDir::new("cutshort");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let folder = dir.path().join(FILES);
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);
// The next compaction writes its snapshot, then can't start its log.
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
fs::create_dir(&blocker).unwrap();
// What the hub would confirm: every change whose flush didn't report the file.
let mut confirmed = String::new();
let mut failed = false;
let names = ["P0", "P1", "P2", "After 1", "After 2"];
for name in names {
rename(&mut server, &mut a, name);
store.changed(&id, &mut server);
if store.flush().contains(&id) {
failed = true;
break;
}
confirmed = name.to_string();
}
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
// A crash, then the disk recovers.
drop(store);
fs::remove_dir(&blocker).unwrap();
let mut store = Store::open(dir.path(), c).unwrap();
let restored = store.open(&id).unwrap();
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
let at = |n: &str| names.iter().position(|x| *x == n);
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
+2 -1
View File
@@ -24,5 +24,6 @@ log = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+53 -3
View File
@@ -40,7 +40,7 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
Ok(Box::new(WebShell { fig }))
}),
)
@@ -69,14 +69,35 @@ impl eframe::App for WebShell {
}
}
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
/// goes through egui's own web clipboard handling.
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
ctx: egui::Context,
}
impl Services for WebServices {
fn downloads(&self) -> bool {
true
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
log::error!("couldn't download {name}: {e:?}");
false
}
}
}
fn clipboard_set_text(&mut self, s: &str) {
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
}
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
}
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") {
@@ -108,3 +129,32 @@ impl Services for WebServices {
held
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
let o = web_sys::BlobPropertyBag::new();
o.set_type("application/octet-stream");
o
})?;
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
a.set_href(&url);
a.set_download(name);
body.append_child(&a)?;
a.click();
a.remove();
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
// Without the timer the URL just stays until the page closes.
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
Ok(())
}
/// How long a download's object URL is kept (milliseconds).
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
+7
View File
@@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# 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
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
[dev-dependencies]
wat = { workspace = true }
+57
View File
@@ -0,0 +1,57 @@
//! macOS open-documents and quit Apple events (#289).
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
use std::path::PathBuf;
use figcraft_ui_egui::FigApp;
use fmv_macos_events::{Event, Inbox, Registration};
use serde_json::json;
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
self.inbox.clone()
}
}
/// Open the documents and act on the quit requests that arrived since the last frame.
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => open_documents(fig, paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
}
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
for p in paths {
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
//! a drop on the Dock icon) open as File › Open opens them (#289).
use std::path::PathBuf;
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, NoServices};
use serde_json::json;
use crate::apple_events::open_documents;
#[test]
fn documents_from_finder_open_and_leave_the_home_screen() {
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("BLA-001.figcraft");
let mut s = Session::new();
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
app.ui.home = true;
// A file that can't be read is reported and doesn't keep the others from opening.
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
assert_eq!(paths, [Some(file)]);
assert!(!app.ui.home, "the opened document shows");
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -107,7 +107,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})
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?)"})
}
}
}
+46 -3
View File
@@ -9,13 +9,18 @@
#![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")]
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
mod control_server;
mod gamepad;
mod graphics;
mod recovery;
mod services;
mod startup;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
#[cfg(test)]
mod args_tests;
#[cfg(test)]
@@ -26,6 +31,8 @@ mod recovery_online_tests;
mod recovery_tests;
#[cfg(test)]
mod services_tests;
#[cfg(test)]
mod startup_tests;
use std::sync::{Arc, Mutex};
@@ -43,6 +50,9 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
}
impl eframe::App for App {
@@ -55,6 +65,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);
#[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
// windows are open).
figcraft_ui_egui::windows::on_close_request(fig, ctx);
@@ -81,9 +94,9 @@ impl eframe::App for App {
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))));
}
fn on_exit(&mut self) {
@@ -157,6 +170,9 @@ fn main() -> eframe::Result {
if session.docs.is_empty() && !first_run {
let _ = session.execute("file.new", &json!({}));
}
// The interface language from the first frame on, and for the message when the window can't
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
fig.note_recovered(recovered.copies);
@@ -168,8 +184,11 @@ fn main() -> eframe::Result {
control_server::start(p, queue.clone(), ctx_slot.clone());
}
let icon = services::window_icon();
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
// and StartupWMClass (FreeBSD's package matches the binary's name instead).
let mut viewport = egui::ViewportBuilder::default()
.with_title("FigCraft")
.with_app_id("ai.storyteller.figcraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
@@ -188,5 +207,29 @@ fn main() -> eframe::Result {
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
// Registered before the event loop starts, so it catches the Finder event that launched the app
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
}))
}),
);
// Say why instead of quitting silently: release builds on Windows have no console.
if let Err(e) = &result {
startup::failed(e, port.is_some());
}
result
}
+177 -66
View File
@@ -4,7 +4,10 @@
//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
//! whose lock is free belong to runs that ended without closing: their copies open again as
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of
//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run
//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder
//! with its last copy. Closing the app normally removes its own.
//!
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
//! after the copy it describes): the server, the file's id, and who the app was in the file with
@@ -22,7 +25,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::Session;
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
/// Seconds between copies of a document that keeps changing.
@@ -41,20 +45,61 @@ pub struct Recovery {
dir: PathBuf,
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
_lock: File,
/// The revision each document was last copied at, and the write in flight.
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
/// What was copied of each document, and the write in flight.
written: HashMap<u64, Copy>,
/// When (UI clock) the next copies are due.
next: f64,
/// Copies of online files from earlier runs, kept until their outcome is known.
waiting: Vec<Waiting>,
}
/// A copy of one of your online files whose file is being joined again as session `session`.
/// The state of the copy of one document: only a write that succeeded counts as a copy.
#[derive(Default)]
struct Copy {
/// The [`stamp`] whose copy was written.
done: Option<Vec<u64>>,
/// The write in flight: the stamp it copies, and whether it succeeded once it ends.
flight: Option<(Vec<u64>, JoinHandle<bool>)>,
}
impl Copy {
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
fn reap(&mut self) {
if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) {
self.join();
}
}
/// Wait for the write in flight and take in its result.
fn join(&mut self) {
if let Some((stamp, h)) = self.flight.take()
&& h.join().unwrap_or(false)
{
self.done = Some(stamp);
}
}
/// Whether `stamp` has neither been copied nor is being copied.
fn pending(&self, stamp: &[u64]) -> bool {
self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp)
}
}
/// A copy from an earlier run, kept until what it became is safe.
pub struct Waiting {
session: u64,
until: Until,
copy: PathBuf,
/// The lock of the folder it is in: no other start takes the copy meanwhile.
_lock: Arc<File>,
/// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy
/// left directly in the root by older versions).
_lock: Option<Arc<File>>,
}
/// What a kept copy waits for.
enum Until {
/// One of your online files, joined again as this session: its outcome.
Session(u64),
/// Opened as document `uid`: this run's own copy of it (or its save or close).
Copied(u64),
}
/// What [`recover_all`] brought back.
@@ -62,21 +107,26 @@ pub struct Waiting {
pub struct Recovered {
/// Documents opened from their copies.
pub copies: usize,
/// Copies of online files waiting for their file ([`Recovery::adopt`]).
/// The copies kept until what they became is safe ([`Recovery::adopt`]).
pub waiting: Vec<Waiting>,
}
impl Recovery {
/// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
/// A crashed run with the same process id may still hold its folder (its copies kept): then
/// another name is taken.
pub fn start(root: &Path) -> Option<Self> {
let dir = root.join(std::process::id().to_string());
std::fs::create_dir_all(&dir).ok()?;
let lock = File::create(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
let pid = std::process::id();
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
/// Keep earlier runs' copies of online files until their outcome is known.
/// Keep earlier runs' copies until what they became is safe.
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
self.waiting.extend(waiting);
}
@@ -85,38 +135,47 @@ impl Recovery {
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
/// an idle window can sleep).
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
self.written.values_mut().for_each(Copy::reap);
self.settle(s);
// A join on its way settles within its timeouts: look again soon.
let settling = (!self.waiting.is_empty()).then_some(0.5);
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
// A join on its way settles within its timeouts: look again soon. A copy waiting for this
// run's own is looked at again with the next copies.
let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_)));
let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) };
if now < self.next {
return pending.then_some(self.next - now).or(settling);
return self.pending(s).then_some(self.next - now).or(settling);
}
self.write(s, false);
self.next = now + INTERVAL;
settling
// A copy that failed is tried again at the next turn, without another edit.
settling.or(self.pending(s).then_some(INTERVAL))
}
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
fn settle(&mut self, s: &Session) {
if self.waiting.iter().all(|w| s.live.recovering(w.session)) {
return;
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
fn pending(&self, s: &Session) -> bool {
s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d))))
}
/// Remove earlier runs' copies once what they became is safe: copied here (the write
/// succeeded), saved or closed. An online file's copy goes once its outcome is known and every
/// document is safe, since which of them it became isn't known.
pub fn settle(&mut self, s: &Session) {
let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id));
if self.waiting.iter().any(joined) {
self.write(s, true);
}
self.write(s, true);
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) =
std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session));
let all_safe = s.docs.iter().all(|d| self.safe(s, d));
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until {
Until::Session(_) => joined(w) && all_safe,
Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)),
});
self.waiting = waiting;
for w in done {
let _ = std::fs::remove_file(&w.copy);
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
// The folder goes with its last copy (while another waits there its lock is held).
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1);
drop(w);
if let Some(dir) = dir {
let _ = std::fs::remove_file(dir.join(LOCK));
let _ = std::fs::remove_dir(dir);
}
}
done.into_iter().for_each(remove_kept);
}
/// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write
/// that succeeded) with no write in flight.
fn safe(&self, s: &Session, d: &DocState) -> bool {
!needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none())
}
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
@@ -124,47 +183,60 @@ impl Recovery {
for d in &s.docs {
let path = self.dir.join(format!("{}.figcraft", d.uid));
let note = path.with_extension(ONLINE);
if !needs_copy(d) {
if self.written.remove(&d.uid).is_some() {
if !needs_copy(s, d) {
if let Some(mut w) = self.written.remove(&d.uid) {
// A write still running would bring the copy back after it was removed.
w.join();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&note);
}
continue;
}
let entry = self.written.entry(d.uid).or_insert((0, None));
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
if entry.0 == d.revision || busy {
let entry = self.written.entry(d.uid).or_default();
// An emergency save must copy the newest revision: let the write in flight finish first.
if wait {
entry.join();
} else {
entry.reap();
}
// A write still in flight (for any revision) is waited for; a failed one is tried again.
let stamp = stamp(s, d);
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
continue;
}
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
// 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 job = move || {
if !write_atomic(&path, &doc) {
return;
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
}
let _ = match online {
Some(text) => std::fs::write(&note, text),
None => std::fs::remove_file(&note),
};
true
};
// The stamp counts as copied only once its write succeeded.
if wait {
job();
entry.1 = None;
if job() {
entry.done = Some(stamp);
}
} else {
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h));
}
entry.0 = d.revision;
}
// Closed documents.
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
let dir = &self.dir;
self.written.retain(|uid, _| {
self.written.retain(|uid, w| {
let keep = open.contains(uid);
if !keep {
w.join();
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
}
@@ -172,12 +244,20 @@ impl Recovery {
});
}
/// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`.
#[cfg(test)]
pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<bool>) {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
let Self { dir, _lock, written, .. } = self;
let Self { dir, _lock, written, waiting, .. } = self;
// The documents earlier runs' copies became were saved or discarded too.
waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept);
// Windows removes a folder only once nothing in it is open: copies being written first.
for h in written.into_values().filter_map(|w| w.1) {
for (_, h) in written.into_values().filter_map(|w| w.flight) {
let _ = h.join();
}
drop(_lock);
@@ -185,6 +265,20 @@ impl Recovery {
}
}
/// Remove a kept copy and its note, and its folder with its last copy (while another is kept
/// there, its lock is held).
fn remove_kept(w: Waiting) {
let _ = std::fs::remove_file(&w.copy);
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1);
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last);
drop(w);
if let Some(dir) = dir {
let _ = std::fs::remove_file(dir.join(LOCK));
let _ = std::fs::remove_dir(dir);
}
}
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
@@ -201,15 +295,27 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
/// A document whose changes would be lost (a branch tab's edits are in its main file).
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
d.is_dirty() && d.branch_of.is_none()
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
}
/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy
/// before it is still there).
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool {
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false };
/// The tabs showing branches of the file in tab `uid`.
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
/// only its own revision). A new copy is due when they change.
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// Write `doc` with its version history and branches to `path` through a temporary file; false
/// when it couldn't be written (the copy before it is still there).
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()
}
@@ -222,9 +328,9 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
}
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
/// A copy that can't be read stays where it is. A copy of one of your online files is kept
/// instead while its file is joined again ([`Recovered::waiting`]).
/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept
/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your
/// online files, while its file is joined again. A copy that can't be read stays where it is.
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
let mut out = Recovered::default();
let Ok(entries) = std::fs::read_dir(root) else { return out };
@@ -234,10 +340,15 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
}
// One of your online files comes back from its server, its copy kept until then.
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() });
} else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) });
return;
}
let before: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
out.copies += 1;
let _ = std::fs::remove_file(file);
if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) {
out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() });
}
}
};
for e in entries.flatten() {
@@ -265,7 +376,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
let _ = std::fs::remove_file(f);
}
// Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held).
// Empty now unless a copy couldn't be read, or is kept (its lock held).
if Arc::strong_count(&lock) == 1 {
drop(lock);
let _ = std::fs::remove_file(path.join(LOCK));
+25 -1
View File
@@ -223,6 +223,30 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_online_copy_stays_while_what_it_became_cant_be_copied() {
let root = root("ahead-blocked");
let (mut s, script, mut server, _) = shared();
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
script.lock().unwrap().sent.clear();
let left = crash(&s, &root);
let (mut next, script, mut r, _) = restart(&s, &root);
arrive(&mut next, &script, &mut server);
assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet");
// Something is in the way of this run's copy: writing it fails.
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid));
std::fs::create_dir_all(&mine).unwrap();
r.tick(&next, 1.0);
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk");
// Once it can be written, the old copy goes.
std::fs::remove_dir(&mine).unwrap();
next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap();
r.tick(&next, 2.0);
assert!(!left.exists());
drop(r);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_the_server_already_has_is_discarded() {
let root = root("equal");
@@ -332,7 +356,7 @@ fn without_its_record_the_copy_opens_as_any_other() {
assert_eq!(recover(&root, &mut next), 1);
assert!(script.lock().unwrap().joined.is_empty());
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
let _ = std::fs::remove_dir_all(&root);
}
+288 -2
View File
@@ -4,7 +4,7 @@
use figcraft_engine::Session;
use serde_json::json;
use crate::recovery::{Recovery, recover};
use crate::recovery::{Recovery, recover, recover_all};
fn root(name: &str) -> std::path::PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
@@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() {
let ds = next.docs.first().unwrap();
assert!(ds.is_dirty() && ds.path.is_none());
assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
let _ = std::fs::remove_dir_all(&root);
}
@@ -62,3 +62,289 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
assert!(!dir.exists(), "a normal quit removes the copies");
let _ = std::fs::remove_dir_all(&root);
}
/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as
/// another process's).
fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf {
let mut r = Recovery::start(root).unwrap();
r.write(s, true);
drop(r);
let left = root.join("crashed");
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
left
}
fn copies_in(d: &std::path::Path) -> usize {
std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count())
}
/// The next start: the copies come back, and this run's recovery keeps them until it covers them.
fn restart(root: &std::path::Path) -> (Session, Recovery) {
let mut next = Session::empty();
let recovered = recover_all(root, &mut next);
assert_eq!(recovered.copies, 1);
let mut r = Recovery::start(root).unwrap();
r.adopt(recovered.waiting);
(next, r)
}
#[test]
fn a_recovered_copy_stays_until_this_run_has_its_own_copy() {
let root = root("handoff");
let left = crashed(&edited(), &root);
let (next, mut r) = restart(&root);
assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one");
r.write(&next, true);
r.tick(&next, 100.0);
assert!(!left.exists(), "covered by this run's copy, the old one goes");
assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1);
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recovered_copy_stays_while_its_replacement_cant_be_written() {
let root = root("blocked");
let left = crashed(&edited(), &root);
let (next, mut r) = restart(&root);
// Something is in the way of this run's copy: writing it fails.
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid));
std::fs::create_dir_all(&mine).unwrap();
r.write(&next, true);
r.tick(&next, 100.0);
assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk");
// Once it can be written, the old copy goes.
std::fs::remove_dir(&mine).unwrap();
let mut next = next;
next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
r.write(&next, true);
r.tick(&next, 200.0);
assert!(!left.exists());
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() {
let root = root("nocopies");
crashed(&edited(), &root);
let mut next = Session::empty();
let recovered = recover_all(&root, &mut next);
assert_eq!(recovered.copies, 1);
// This run couldn't take a folder of its own: it ends (or crashes) with nothing copied.
drop(recovered);
let mut after = Session::empty();
assert_eq!(recover(&root, &mut after), 1, "the work comes back again");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() {
for close in [false, true] {
let root = root(if close { "closed" } else { "saved" });
let left = crashed(&edited(), &root);
let (mut next, mut r) = restart(&root);
if close {
next.docs.clear();
} else {
next.ds_mut().unwrap().mark_saved();
}
r.tick(&next, 0.0);
assert!(!left.exists(), "close {close}");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let root = root("quitafter");
let left = crashed(&edited(), &root);
let (_, r) = restart(&root);
// Quitting asked to save or discard every document.
r.finish();
assert!(!left.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
// A crashed run with this process's id whose folder the start still holds (its copy waits).
let held = root.join(std::process::id().to_string());
std::fs::create_dir_all(&held).unwrap();
let lock = std::fs::File::create(held.join("lock")).unwrap();
lock.try_lock().unwrap();
let mut r = Recovery::start(&root).expect("a folder of its own");
let s = edited();
r.write(&s, true);
assert_eq!(copies_in(&held), 0);
let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count();
assert_eq!(dirs, 1);
r.finish();
drop(lock);
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
}
/// The documents a crashed run's copies under `root` open as.
fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session {
drop(r);
let mut next = Session::empty();
assert_eq!(recover(root, &mut next), 1);
next
}
#[test]
fn copies_keep_the_version_history_and_branches() {
// #185: a copy has what a save would write, the latest edits on a branch tab included.
let root = root("history");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let next = crash_and_recover(r, &root);
let ds = next.docs.first().unwrap();
assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::<Vec<_>>(), [("Explore", 2)]);
let _ = std::fs::remove_dir_all(&root);
}
/// The one copy under `root` (the run's folder), read back.
fn copy(root: &std::path::Path) -> figcraft_format::Loaded {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap();
figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap()
}
#[test]
fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() {
let root = root("history-only");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
for d in &mut s.docs {
d.mark_saved();
}
let mut r = Recovery::start(&root).unwrap();
// An edit on the branch tab only: the main file has no changes of its own yet.
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [2]);
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [3]);
// A version renamed on the main file leaves its document as it was.
s.execute("doc.switch", &json!({"index": 0})).unwrap();
r.write(&s, true);
let id = s.docs[0].versions[0].id.clone();
s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap();
r.write(&s, true);
assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1 (renamed)"]);
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<bool> {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
std::fs::write(path, b"stale").is_ok()
})
}
#[test]
fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() {
let root = root("slow-cleanup");
let mut s = edited();
let mut r = Recovery::start(&root).unwrap();
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 1, slow_write(copy.clone()));
s.ds_mut().unwrap().mark_saved();
r.write(&s, false);
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!copy.exists(), "a saved document's copy stays removed");
// Closed: the same.
let s = edited();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 1, slow_write(copy.clone()));
r.write(&Session::empty(), false);
std::thread::sleep(std::time::Duration::from_millis(400));
assert!(!copy.exists(), "a closed document's copy stays removed");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() {
let root = root("slow-emergency");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
let uid = s.docs[0].uid;
let copy = dir.join(format!("{uid}.figcraft"));
r.hold(uid, 0, slow_write(copy.clone()));
r.write(&s, true);
assert!(std::fs::read(&copy).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write");
let _ = std::fs::remove_dir_all(&root);
}
/// The files named `*.figcraft` in the run folder under `root`.
fn copy_count(root: &std::path::Path) -> usize {
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
}
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
std::fs::create_dir(&blocker).unwrap();
blocker
}
#[test]
fn a_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
// away fails on Windows, where the run's lock file keeps it open).
let blocker = blocked_copy(&root, &s);
r.write(&s, true);
std::fs::remove_dir(&blocker).unwrap();
// Nothing was edited since: the same revision still has to be copied.
r.write(&s, true);
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
let root = root("retry-bg");
let s = edited();
let mut r = Recovery::start(&root).unwrap();
let blocker = blocked_copy(&root, &s);
r.write(&s, false);
// Let the failing write end before the way is clear again.
std::thread::sleep(std::time::Duration::from_millis(300));
std::fs::remove_dir(&blocker).unwrap();
for _ in 0..50 {
r.write(&s, false);
if copy_count(&root) == 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
+73 -9
View File
@@ -1,11 +1,12 @@
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
use serde_json::{Value, json};
pub struct Desktop {
@@ -59,12 +60,16 @@ impl Desktop {
/// The system's preferred interface languages, most preferred first, comma-separated
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
/// Windows keeps only its first preferred language (and English when it has no catalog): that
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
/// system) must not decide the interface language.
fn system_language() -> Option<String> {
#[cfg(target_os = "windows")]
{
windows::System::UserProfile::GlobalizationPreferences::Languages()
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
.and_then(|l| l.GetAt(0))
.map(|s| s.to_string())
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
.ok()
}
@@ -131,6 +136,34 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// 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")]
let mut command = {
let mut c = std::process::Command::new("open");
c.arg("-R").arg(path);
c
};
#[cfg(windows)]
let mut command = {
let mut arg = std::ffi::OsString::from("/select,");
arg.push(path);
let mut c = std::process::Command::new("explorer");
c.arg(arg);
c
};
#[cfg(not(any(target_os = "macos", windows)))]
let mut command = {
let mut c = std::process::Command::new("xdg-open");
c.arg(path.parent().unwrap_or(path));
c
};
// The file manager keeps running; reap it off this thread so it leaves no zombie.
let mut child = command.spawn()?;
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
Ok(())
}
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
@@ -166,15 +199,39 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
f.write_all(bytes)
}
thread_local! {
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
}
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
let r = f();
PARENT.with(|p| p.replace(None));
r
}
/// A dialog with `title`, parented to the main window while it draws.
fn dialog(title: &str) -> rfd::AsyncFileDialog {
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
}
/// The dialog opens now; its future gives the path picked.
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file()
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
}
fn pick_folder(&mut self, title: &str) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).pick_folder()
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
@@ -201,6 +258,11 @@ impl Services for Desktop {
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
fn reveal_path(&mut self, path: &Path) {
if let Err(e) = reveal(path) {
log::warn!("show in folder: {}: {e}", path.display());
}
}
fn save_prefs(&mut self, prefs: &Prefs) {
if let Some(dir) = &self.dir {
write_prefs(dir, prefs);
@@ -209,6 +271,8 @@ impl Services for Desktop {
fn emergency_save(&mut self, session: &Session) {
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
r.write(session, true);
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
r.settle(session);
}
}
fn reduce_motion(&self) -> bool {
+27
View File
@@ -0,0 +1,27 @@
//! When the window can't open.
//!
//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable
//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
/// keep the caller waiting, and it reads standard error), show it in a message box.
pub fn failed(error: &eframe::Error, quiet: bool) {
// In the interface language: the text and its catalog entries are the UI crate's.
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
// Standard error even when RUST_LOG turned errors off.
if log::log_enabled!(log::Level::Error) {
log::error!("{text}");
} else {
eprintln!("figcraft: {text}");
}
if quiet {
return;
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("FigCraft")
.set_description(text)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
+35
View File
@@ -0,0 +1,35 @@
//! The message shown when the window can't open: it carries the error, a next step and where to
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
let text = message(ERROR, false);
assert!(text.contains(ERROR), "the error itself: {text}");
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
}
#[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("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
}
#[cfg(windows)]
#[test]
fn on_windows_it_shows_how_to_start_with_opengl() {
let text = message(ERROR, false);
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
}
#[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}");
}
+23
View File
@@ -16,3 +16,26 @@
#
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.VanTan666]
display_name = "VanTan"
[people.tylerjenningsw]
real_name = "Tyler Jennings"
display_name = "tylerjenningsw"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.yyamandevrim]
real_name = "Yaman Devrim"
sync_github_name = true
[people.justinjohn0306]
real_name = "Justin John"
[people.Losminek]
[people.RevDonz]
sync_github_name = true
+53
View File
@@ -0,0 +1,53 @@
//! Addresses removed from a file are kept only while its session lasts, so a long-running server
//! doesn't collect them for every file it ever had open.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr};
use figcraft_collab::{Limits, Server};
use figcraft_doc::Document;
use super::{Admission, Backend, Closing, Hub, InFlight, Room};
/// A backend that closes a file as soon as everyone left it.
struct Closes;
impl Backend for Closes {
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
Err("no".into())
}
fn open(&mut self, _file: &str) -> Result<Server, String> {
Err("no".into())
}
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
true
}
fn closing(&self) -> Closing {
Closing::Restarting
}
}
#[test]
fn a_closed_file_forgets_whom_it_barred() {
let mut doc = Document::new("File");
doc.repair();
let mut hub = Hub {
in_flight: InFlight(Default::default()),
backend: Closes,
rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
barred: HashMap::new(),
};
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7));
hub.barred.entry("f".into()).or_default().insert(ip);
hub.barred.entry("other".into()).or_default().insert(ip);
hub.close_if_empty("f");
assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed");
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
assert!(hub.barred.contains_key("other"), "another file's stay");
}
+7
View File
@@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
use figcraft_doc::Document;
use figcraft_engine::live::{Event, PEOPLE};
pub use crate::hub::REMOVED;
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
use crate::inbox::Inbox;
pub use crate::join::ENDED;
@@ -129,6 +130,12 @@ impl Host {
self.hosting.send(msg);
}
/// Remove client `id` from the session: they are disconnected and can't join again from
/// their address while it runs. The host can't remove themselves.
pub fn remove(&self, id: u32) {
self.hosting.remove(id);
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
+88 -2
View File
@@ -21,7 +21,7 @@
//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is
//! answered `200 ok` for uptime checks.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{self, Read, Write};
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -413,6 +413,8 @@ enum HubIn {
},
Local(Presence),
LocalMsg(ClientMsg),
/// The person hosting removes client `id` from their file (app sessions).
Remove(u32),
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
@@ -461,7 +463,15 @@ impl Hosting {
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
let addr = listener.local_addr()?;
let in_flight = InFlight(Arc::default());
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() };
let mut hub = Hub {
backend,
rooms: HashMap::new(),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
in_flight: in_flight.clone(),
barred: HashMap::new(),
};
if let Some(l) = local {
let mut room = Room::new(l.server);
room.local = Some((l.id, l.inbox));
@@ -551,6 +561,12 @@ impl Hosting {
let _ = self.hub.send(HubIn::LocalMsg(msg));
}
/// The hosting person removes client `id` from their file (app sessions; see
/// [`HubIn::Remove`]).
pub(crate) fn remove(&self, id: u32) {
let _ = self.hub.send(HubIn::Remove(id));
}
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
@@ -1108,6 +1124,16 @@ fn unsupported(protocol: u32) -> String {
/// sharing): the session's end for them.
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
/// What someone removed from a file is told: the session's end for them (they keep their copy;
/// nothing retries).
pub const REMOVED: &str = "You were removed from this session. Your copy stays open";
/// Why a removed person's address can't join the file again.
const BARRED: &str = "you were removed from this session";
/// The most addresses one file keeps barred.
const MAX_BARRED: usize = 256;
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
@@ -1121,6 +1147,9 @@ struct Hub<B> {
local: Option<String>,
/// What the servers said, sent once the backend made what changed durable.
outbox: Vec<(String, Outgoing)>,
/// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its
/// session closes (the file is closed or removed; the entry goes with it).
barred: HashMap<String, HashSet<IpAddr>>,
}
impl<B: Backend> Hub<B> {
@@ -1208,6 +1237,13 @@ impl<B: Backend> Hub<B> {
self.client_message(&file, id, msg, None);
}
}
HubIn::Remove(id) => {
if let Some(file) = self.local.clone()
&& let Err(why) = self.remove_peer(&file, id)
{
log::info!("live session: couldn't remove client {id}: {why}");
}
}
#[cfg(test)]
HubIn::Cut => {
for c in self.conns.values() {
@@ -1232,6 +1268,14 @@ impl<B: Backend> Hub<B> {
self.client_message(&file, id, msg, Some(bytes));
}
Some(Kind::Hosting { owns }) => match msg {
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = if owns.as_deref() == Some(file.as_str()) {
self.removed_reply(&file, client)
} else {
AdminReply::Failed { reason: "an owner token manages only its own file".into() }
};
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
ClientMsg::Admin(req) => {
let owns = owns.clone();
let rooms = &self.rooms;
@@ -1243,6 +1287,10 @@ impl<B: Backend> Hub<B> {
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
},
Some(Kind::Managing) => match msg {
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = self.removed_reply(&file, client);
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
ClientMsg::Admin(req) => self.admin(conn, req),
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
@@ -1305,6 +1353,10 @@ impl<B: Backend> Hub<B> {
Ok(a) => a,
Err(why) => return refuse(&c.out, &why),
};
if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) {
log::info!("event=refused ip={} reason=removed", c.ip);
return refuse(&c.out, BARRED);
}
let hosting = reads_hosting(&hello.client_version);
// The backend checked the secret: the sessions don't keep it.
hello.token.clear();
@@ -1362,6 +1414,34 @@ impl<B: Backend> Hub<B> {
}
}
/// Remove client `id` from `file`: they are told the session is over for them and
/// disconnected, and their address can't join the file again while its session lasts. The
/// person hosting can't be removed.
fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> {
let room = self.rooms.get(file).ok_or("nobody is in this file")?;
if room.local.as_ref().is_some_and(|(local, _)| *local == id) {
return Err("the person hosting can't be removed".into());
}
let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
let barred = self.barred.entry(file.to_string()).or_default();
if barred.len() < MAX_BARRED {
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()));
self.gone(conn);
Ok(())
}
/// [`remove_peer`](Self::remove_peer) as a managing connection's answer.
fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply {
match self.remove_peer(file, id) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
}
}
/// Take in connection `c` as `kind` and send it `first`.
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
@@ -1479,6 +1559,7 @@ impl<B: Backend> Hub<B> {
.collect();
// Closed without saving: the file is gone.
self.rooms.remove(&file);
self.barred.remove(&file);
self.kick(&users, "this file was removed from the server");
}
if let Some(ip) = handled.blocked {
@@ -1556,6 +1637,7 @@ impl<B: Backend> Hub<B> {
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
if self.backend.idle(file, &mut room.server) {
self.rooms.remove(file);
self.barred.remove(file);
}
}
@@ -1661,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
#[cfg(test)]
#[path = "queue_tests.rs"]
mod queue_tests;
#[cfg(test)]
#[path = "barred_tests.rs"]
mod barred_tests;
+12
View File
@@ -243,6 +243,18 @@ impl LiveLink for NetLink {
}
}
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
match self.sessions.get(session) {
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
Some(Active::Host(h)) => {
h.remove(client);
Ok(())
}
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
None => Err("this session is over".into()),
}
}
fn leave(&mut self, session: SessionId) {
if let Some(active) = self.sessions.remove(session) {
active.end();
+32 -1
View File
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
use crate::guest::{Guest, GuestConfig};
use crate::host::{ENDED, Host, HostConfig};
use crate::host::{ENDED, Host, HostConfig, REMOVED};
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::net::{Bucket, NetLimits};
@@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
}
}
}
#[test]
fn the_host_removes_someone_and_they_cant_come_back() {
let (mut host, mut hp, invite) = start();
let (_a, mut ap) = join(&invite, "Ada");
let (b, mut bp) = join(&invite, "Bo");
ap.wait("welcome", welcomed);
bp.wait("welcome", welcomed);
let (ada, bo) = (ap.me(), bp.me());
connections_settle(&host, 2);
// The host can't remove themselves, nor someone who isn't there: nothing happens.
host.remove(host.me().client_id);
host.remove(9999);
host.remove(ada);
// Ada is told the session is over for her (no reconnecting); the others see her leave.
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
connections_settle(&host, 1);
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
assert!(!hp.left().contains(&bo), "only Ada was removed");
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
let (_again, mut againp) = join(&invite, "Ada");
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
assert!(!welcomed(&againp.events));
// Bo, already in, stays and can still talk to the host.
b.presence(presence(3.0, 4.0));
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
host.stop();
assert_eq!(host.connections(), 0);
}
+7 -2
View File
@@ -307,7 +307,8 @@ impl Client {
/// Queue `changes` as a new batch (already shown in the view).
fn queue(&mut self, changes: Vec<Change>, kind: BatchKind, step: Option<u64>) {
let batch_id = self.next_batch;
self.next_batch += 1;
// `next_batch` starts from the welcome's `last_batch`, the host's word: saturate.
self.next_batch = self.next_batch.saturating_add(1);
self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false });
}
@@ -484,7 +485,11 @@ impl Client {
/// A batch the server accepted (in order). Follow-ups it causes are queued.
fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> {
if acc.seq != self.seq + 1 {
// The host's sequence number is untrusted: at `u64::MAX` it can't step past it.
let Some(next) = self.seq.checked_add(1) else {
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
};
if acc.seq != next {
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
}
if acc.author != self.id {
+43
View File
@@ -0,0 +1,43 @@
//! A client's handling of the host's messages: sequence numbers and batch ids come from the host,
//! so they are treated as hostile input (they must never overflow).
use figcraft_doc::NodeKind;
use crate::message::{Accepted, ClientMsg, Hello, ServerMsg};
use crate::sim_tests::base;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
fn hello() -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
h
}
/// A welcome whose sequence numbers are what a lying host would send.
fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg {
let mut s = Server::new(base(), "tok", Limits::default());
let (_, out) = s.join(hello()).unwrap();
let mut welcome = out
.into_iter()
.find_map(|o| match o {
Outgoing::To(_, m) => Some(m),
_ => None,
})
.unwrap();
let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") };
*seq0 = seq;
processed.last_batch = last_batch;
welcome
}
#[test]
fn hostile_sequence_numbers_do_not_overflow_the_client() {
let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap();
// This client's own first batch must not overflow its batch counter.
let mut d = c.document().clone();
let page = d.first_page().unwrap();
d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
assert!(c.edit(&d).unwrap().is_some(), "the edit is batched");
// A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow.
let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() };
assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err());
}
+1
View File
@@ -75,6 +75,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
ClientMsg::Admin(Admin::Blocked),
ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }),
ClientMsg::Report { reason: "Spam ☕".into() },
];
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
+2
View File
@@ -123,6 +123,8 @@ impl Recompute for NoRecompute {
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
}
#[cfg(test)]
mod client_tests;
#[cfg(test)]
mod codec_tests;
#[cfg(test)]
+4
View File
@@ -274,6 +274,10 @@ pub enum Admin {
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
Owned { files: Vec<(String, String)> },
/// Remove client `client` from `file`'s session: they are disconnected and their address
/// can't join the file again while its session lasts. With the server key, or on an open
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
RemovePeer { file: String, client: u32 },
}
/// The most files one [`Admin::Owned`] asks about.
+8 -1
View File
@@ -903,15 +903,22 @@ impl Document {
/// A unique name like "Frame 3" for a new node of a kind.
pub fn next_name(&self, base: &str) -> String {
let mut max = 0u64;
let mut taken = std::collections::BTreeSet::new();
for n in self.nodes() {
if let Some(rest) = n.name.strip_prefix(base)
&& let Some(num) = rest.strip_prefix(' ')
&& let Ok(k) = num.parse::<u64>()
{
max = max.max(k);
taken.insert(k);
}
}
format!("{base} {}", max + 1)
let next = match max.checked_add(1) {
Some(next) => next,
// The counter is spent: the lowest free number instead of wrapping onto a name.
None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1),
};
format!("{base} {next}")
}
pub fn style(&self, id: &str) -> Option<&Style> {
+10
View File
@@ -335,6 +335,16 @@ fn next_name_increments() {
assert_eq!(d.next_name("Ellipse"), "Ellipse 1");
}
#[test]
fn next_name_never_overflows() {
let mut d = Document::new("x");
let p = page(&d);
d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap();
assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX");
d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap();
assert_eq!(d.next_name("Frame"), "Frame 2");
}
#[test]
fn custom_easings() {
use crate::proto::Easing;
+49
View File
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn files_that_save_themselves_still_get_checkpoints() {
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
rect(&mut s, 0.0);
s.execute("file.save", &json!({"path": path})).unwrap();
assert!(autosave(&mut s), "saved, but changed since the last version");
assert!(!autosave(&mut s), "no change since the checkpoint");
// Just opened: nothing to checkpoint.
let mut t = Session::empty();
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
t.execute("file.open", &json!({"path": path})).unwrap();
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
use crate::cmd::file::FileSnapshot;
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("file.save", &json!({"path": path})).unwrap();
rect(&mut s, 0.0);
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
rect(&mut s, 50.0);
assert!(file.save(&mut s, &bytes).unwrap());
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
// Saved meanwhile: the older bytes never overwrite the newer file.
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
rect(&mut s, 100.0);
s.execute("file.save", &json!({})).unwrap();
assert!(!file.save(&mut s, &bytes).unwrap());
assert!(!s.ds().unwrap().is_dirty());
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
let _ = std::fs::remove_dir_all(&dir);
}
+22
View File
@@ -61,3 +61,25 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state()
assert_eq!(s.journal, before_journal);
assert_eq!(s.message, before_message);
}
#[test]
fn failed_group_keeps_the_redo_steps() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(s.undo().unwrap());
assert_eq!(s.undo_depth().1, 1);
let result = s.execute(
"command.batch",
&json!({
"commands": [
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch");
assert!(s.redo().unwrap());
}
+112
View File
@@ -0,0 +1,112 @@
use serde_json::json;
use crate::{EngineError, Session};
fn id(value: &serde_json::Value) -> crate::NodeId {
crate::params::id_of(&value["id"]).unwrap()
}
#[test]
fn later_steps_target_layers_created_by_earlier_steps() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({
"label": "Card",
"commands": [
{"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}},
{"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}}
]
}),
)
.unwrap();
let frame = id(&results[0]);
let rect = id(&results[1]);
let doc = s.doc().unwrap();
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
}
#[test]
fn field_picks_another_part_of_the_result() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}},
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}}
]}),
)
.unwrap();
assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0])));
}
#[test]
fn bad_references_fail_and_roll_back() {
let mut s = Session::new();
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}});
for (bad, needle) in [
(json!({"$step": 1}), "earlier step"),
(json!({"$step": 5}), "earlier step"),
(json!({"$step": -1}), "step index"),
(json!({"$step": "0"}), "step index"),
(json!({"$step": 0, "field": "nope"}), "no `nope`"),
(json!({"$step": 0, "field": 3}), "`field` must be a string"),
(json!({"$step": 0, "extra": true}), "not `extra`"),
] {
let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]}));
match r {
Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"),
other => panic!("expected BadParams for {bad}, got {other:?}"),
}
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
}
}
#[test]
fn deep_params_are_refused_not_overflowed() {
let mut s = Session::new();
let mut deep = json!(1);
for _ in 0..200 {
deep = json!([deep]);
}
let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]}));
assert!(matches!(r, Err(EngineError::BadParams { .. })));
}
#[test]
fn text_that_looks_like_a_reference_is_left_alone() {
let mut s = Session::new();
let results = s
.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]}))
.unwrap();
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}"));
}
#[test]
fn a_nested_batch_refers_to_its_own_steps() {
let mut s = Session::new();
let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
let results = s
.execute(
"command.batch",
&json!({"commands": [
frame("Outer"),
{"command": "command.batch", "params": {"commands": [
frame("Inner"),
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}}
]}}
]}),
)
.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 first frame, not the outer one");
}
+16
View File
@@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) {
}
}
/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a
/// branch tab's latest edits in before the next command.
pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() };
let mut branches = ds.branches.clone();
for d in &s.docs {
let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue };
let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue };
b.head = d.document_itself().clone();
if d.versions_changed {
b.versions = d.versions.clone();
}
}
branches
}
/// The tab a save writes: the main file's for a branch tab, else the active tab.
pub fn file_tab(s: &Session) -> usize {
s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active)
+24 -13
View File
@@ -145,21 +145,28 @@ pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> R
Ok(())
}
/// Whether aligning `ids` would move anything: a layer alone on the page has nothing to align to.
pub fn can_align(doc: &Document, ids: &[NodeId]) -> bool {
align_target(doc, &roots(doc, ids)).is_some()
}
/// What `roots` align to: one layer to its parent frame, several to their combined bounds.
fn align_target(doc: &Document, roots: &[NodeId]) -> Option<Rect> {
match roots {
[] => None,
[id] => {
let parent = doc.node(*id)?.parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page))?;
Some(doc.world_bounds(parent).unwrap_or_default())
}
_ => Some(doc.bounds_of(roots).unwrap_or_default()),
}
}
fn align(s: &mut Session, p: &Value, a: Al) -> Result<Value> {
let ids = req_targets("align", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
// One layer aligns within its parent frame; several align to their combined bounds.
let target: Rect = if ids.len() == 1 {
let Some(id) = ids.first() else { return ok() };
let parent = doc.get(*id)?.parent;
match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) {
Some(pp) => doc.world_bounds(pp).unwrap_or_default(),
None => return ok(),
}
} else {
doc.bounds_of(&ids).unwrap_or_default()
};
let Some(target) = align_target(doc, &ids) else { return ok() };
for i in ids {
let Some(b) = doc.world_bounds(i) else { continue };
let (dx, dy) = match a {
@@ -325,8 +332,12 @@ fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
}
}
if matches!(z, Z::Back) {
for (k, i) in ids.iter().enumerate() {
doc.reorder(*i, k)?;
let mut next: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
for i in &ids {
let Some(pp) = doc.get(*i)?.parent else { continue };
let k = next.entry(pp).or_insert(0);
doc.reorder(*i, *k)?;
*k += 1;
}
}
ok()
+1 -1
View File
@@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let doc = ds.doc_mut();
let nid = doc.alloc_id();
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) {
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) {
node.fills = fills;
}
node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
+92 -25
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
true,
duplicate
),
@@ -253,7 +253,7 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
let i = req_num("effect.set", p, "index")? as usize;
let i = req_index("effect.set", p, "index")?;
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
for id in req_targets("effect.set", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
@@ -302,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
true,
|s, p| {
let cmd = "export.update";
let i = req_num(cmd, p, "index")? as usize;
let i = req_index(cmd, p, "index")?;
super::file::export_setting(cmd, ExportSetting::default(), p)?;
for id in req_targets(cmd, s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
@@ -314,7 +314,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| {
let i = req_num("export.remove", p, "index")? as usize;
let i = req_index("export.remove", p, "index")?;
for id in req_targets("export.remove", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if i < n.exports.len() {
@@ -412,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"deleted": ids}))
}
/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the
/// copies are the selection, Duplicate places the next copies as far from them as they are from
/// their sources, measured then: so moving the copies first sets the step.
#[derive(Clone, Debug)]
pub struct Duplicated {
pub sources: Vec<NodeId>,
pub copies: Vec<NodeId>,
}
impl Duplicated {
pub fn is_selection(&self, sel: &[NodeId]) -> bool {
let set = |v: &[NodeId]| v.iter().copied().collect::<BTreeSet<_>>();
!self.copies.is_empty() && set(&self.copies) == set(sel)
}
/// The page offset from the sources to the copies, if both are still there and the copies move
/// freely (an auto layout places its own).
fn offset(&self, doc: &Document) -> Option<(f64, f64)> {
let free = self
.copies
.iter()
.all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none())));
if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) {
return None;
}
let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?);
let d = (b.x0 - a.x0, b.y0 - a.y0);
(d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d)
}
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.duplicate", s, p)?;
let rename = s.prefs.rename_duplicates;
let explicit = num(p, "dx").is_some() || num(p, "dy").is_some();
let ds = s.ds()?;
let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids));
let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc));
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let ids = roots_now;
let mut out = Vec::new();
for i in ids {
for i in ids.iter().copied() {
let n = doc.get(i)?;
let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?;
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
@@ -432,22 +467,30 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
_ if top_frame => (w + 100.0, 0.0),
_ => (0.0, 0.0),
};
let nn = doc.get_mut(nid)?;
nn.transform = Affine::translate((dx, dy)) * nn.transform;
match repeat {
Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?,
None => {
let nn = doc.get_mut(nid)?;
nn.transform = Affine::translate((dx, dy)) * nn.transform;
}
}
if rename {
let nn = doc.get_mut(nid)?;
nn.name = bump_name(&nn.name);
}
out.push(nid);
}
s.set_selection(out.clone());
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
Ok(json!({"ids": out}))
}
fn bump_name(n: &str) -> String {
if let Some((base, num)) = n.rsplit_once(' ')
&& let Ok(k) = num.parse::<u64>()
&& let Some(next) = k.checked_add(1)
{
return format!("{base} {}", k + 1);
return format!("{base} {next}");
}
format!("{n} 2")
}
@@ -803,7 +846,8 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
let mut n = 0u32;
let mut count = 0;
for c in digits.chars() {
n = n * 10 + c.to_digit(10)?;
// Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps.
n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?;
count += 1;
}
match count {
@@ -944,6 +988,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::<Vec<_>>()}))
}
/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`.
/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets.
fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option<usize> {
if needle.is_empty() {
return None;
}
(from..hay.len())
.find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase()))))
}
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
let find = req_string("edit.findReplace", p, "find")?;
if find.is_empty() {
@@ -954,11 +1008,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
let page = s.page()?;
let doc = s.doc_mut()?;
let mut hits = Vec::new();
let lower = find.to_lowercase();
let needle: Vec<char> = find.chars().collect();
let r_chars: Vec<char> = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default();
for i in doc.subtree(page) {
let Some(n) = doc.node(i) else { continue };
let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower));
let name_hit = names && n.name.to_lowercase().contains(&lower);
let text: Option<Vec<char>> = match &n.kind {
NodeKind::Text(t) => Some(t.characters.chars().collect()),
_ => None,
};
let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some());
let name_hit = names && find_ci(&n.name.chars().collect::<Vec<_>>(), &needle, 0).is_some();
if !(text_hit || name_hit) || n.is_page() {
continue;
}
@@ -966,19 +1025,27 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = &replace {
let n = doc.get_mut(i)?;
if names && name_hit {
n.name = n.name.replace(find.as_str(), r);
let chars: Vec<char> = n.name.chars().collect();
let mut out = String::new();
let mut at = 0;
while let Some(pos) = find_ci(&chars, &needle, at) {
out.extend(chars.get(at..pos).unwrap_or_default());
out.push_str(r);
at = pos + needle.len();
}
out.extend(chars.get(at..).unwrap_or_default());
n.name = out;
}
if let NodeKind::Text(t) = &mut n.kind {
// Replace occurrence by occurrence so styles shift correctly.
let mut guard = 0;
while let Some(pos) = t.characters.find(find.as_str()) {
let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0);
let len = find.chars().count();
t.replace_range(start, start + len, r);
guard += 1;
if guard > 10_000 || r.contains(find.as_str()) {
break;
}
if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) {
// Replace occurrence by occurrence so styles shift correctly. The search resumes
// after each inserted replacement, so a replacement that matches `find` (in any
// case) neither loops forever nor stops the other occurrences.
let mut cursor = 0;
for _ in 0..10_000 {
let Some(start) = find_ci(&chars, &needle, cursor) else { break };
t.replace_range(start, start + needle.len(), r);
chars.splice(start..start + needle.len(), r_chars.iter().copied());
cursor = start + r_chars.len();
}
}
}
+203 -18
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.openRecent",
"Open recent",
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
"{path? | index? (into the list), clear? (forget them all), remove? (a path: forget that one)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
false,
open_recent
),
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"version.autosave",
"Autosave version",
"{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}",
"{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}",
false,
autosave
),
@@ -219,7 +219,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
let i = req_num("doc.switch", p, "index")? as usize;
let i = req_index("doc.switch", p, "index")?;
if i >= s.docs.len() {
return Err(bad("doc.switch", "no such document"));
}
@@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20;
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
let auto_save = s.prefs.auto_save;
let ds = s.ds_mut()?;
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
// A file that saves itself is rarely unsaved: edits since it was opened count then.
let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty());
if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() {
return Ok(json!({"saved": false}));
}
Ok(match add_version(ds, AUTOSAVE, "", true) {
@@ -437,15 +440,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
read(path)
}
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never
/// truncates `path`, and no file or symlink at a staging name is ever written through.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
let tmp = format!("{path}.tmp-figcraft");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| {
// Leave nothing behind when the file can't take its place.
let _ = std::fs::remove_file(&tmp);
EngineError::Other(format!("{path}: {e}"))
})
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
@@ -625,6 +625,9 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
}
if let Some(gone) = string(p, "remove") {
s.prefs.recent_files.retain(|r| *r != gone);
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
@@ -693,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
/// The `.figcraft` file for a document: content, thumbnail and version history.
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
let mut doc = (*ds.doc).clone();
doc.current_page = Some(ds.page);
let thumb = thumbnail(&doc);
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
FileSnapshot::of(ds).encode()
}
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
/// [`save`](Self::save) the bytes back on the session's thread.
pub struct FileSnapshot {
uid: u64,
path: Option<String>,
revision: u64,
doc: Arc<Document>,
/// The document as last saved when the snapshot was taken: another save since makes it stale.
saved: Arc<Document>,
page: NodeId,
versions: Vec<Version>,
branches: Vec<figcraft_format::Branch>,
}
impl FileSnapshot {
pub fn of(ds: &DocState) -> Self {
Self {
uid: ds.uid,
path: ds.path.clone(),
revision: ds.revision,
doc: ds.doc.clone(),
saved: ds.saved_doc.clone(),
page: ds.page,
versions: ds.versions.clone(),
branches: ds.branches.clone(),
}
}
/// The file's bytes: content, thumbnail and version history.
pub fn encode(&self) -> Result<Vec<u8>> {
let mut doc = (*self.doc).clone();
doc.current_page = Some(self.page);
let thumb = thumbnail(&doc);
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
.map_err(|e| EngineError::Other(e.to_string()))
}
/// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved
/// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document
/// was closed, saved, reverted or given another path since: the bytes are out of date.
pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result<bool> {
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
return Ok(false);
};
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
// The history or branches may have changed since: then they still count as unsaved.
if ds.revision == self.revision {
ds.versions_changed = false;
}
ds.saved_doc = self.doc;
ds.revision += 1;
branch_tabs_saved(s, self.uid);
Ok(true)
}
}
/// The branch tabs of the file in tab `uid` were saved with it.
fn branch_tabs_saved(s: &mut Session, uid: u64) {
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
tab.mark_saved();
}
}
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
@@ -721,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
tab.mark_saved();
}
branch_tabs_saved(s, uid);
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
}
@@ -1324,3 +1386,126 @@ mod rest_import_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod staging_write_tests {
use super::*;
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges).
#[cfg(unix)]
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
std::os::unix::fs::symlink(to, from).is_ok()
}
#[cfg(windows)]
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
std::os::windows::fs::symlink_file(to, from).is_ok()
}
#[test]
fn an_existing_staging_file_is_never_written_through() {
let d = dir("existing");
let path = d.join("shot.svg");
// The old deterministic staging name, held by an unrelated file: the write must pick its
// own staging name instead of truncating this one.
let old = d.join("shot.svg.tmp-figcraft");
std::fs::write(&old, b"not yours").unwrap();
write(&path.to_string_lossy(), b"<svg/>").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched");
// Just the file and the untouched staging file: no new staging leftovers.
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_staging_symlink_is_never_followed() {
let d = dir("symlink");
let path = d.join("shot.svg");
let victim = d.join("victim.txt");
std::fs::write(&victim, b"innocent").unwrap();
let old = d.join("shot.svg.tmp-figcraft");
if !symlink(&old, &victim) {
return; // Making a symlink needs privileges on some Windows setups.
}
write(&path.to_string_lossy(), b"<svg/>").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through");
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn concurrent_writers_of_one_file_never_interleave() {
let d = dir("concurrent");
let path = d.join("shot.svg");
for round in 0..5u8 {
let mut handles = Vec::new();
for w in 0u8..4 {
let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]);
handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes)));
}
let mut wins = 0;
for h in handles {
// On Windows a rename over a file another writer just replaced can lose the race.
if h.join().unwrap().is_ok() {
wins += 1;
}
}
assert!(wins > 0, "round {round}: every writer lost");
// Whatever landed is one writer's complete payload, never a mix.
let got = std::fs::read(&path).unwrap();
assert_eq!(got.len(), 8192, "round {round}: a complete payload landed");
assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix");
}
// No staging file was left stuck (a failed writer removes its own).
let left = std::fs::read_dir(&d).unwrap().count();
assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
let d = dir("failure");
// The destination is a directory: the rename fails, our staging file is removed and the
// directory is untouched.
let taken = d.join("over-a-directory");
std::fs::create_dir_all(&taken).unwrap();
assert!(write(&taken.to_string_lossy(), b"payload").is_err());
assert!(taken.is_dir());
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind");
// A missing parent directory fails without creating anything anywhere.
assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err());
assert!(!d.join("no").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// A read-only directory refuses the staging file, and the old destination stays.
let ro = d.join("read-only");
std::fs::create_dir_all(&ro).unwrap();
let dest = ro.join("keep.svg");
std::fs::write(&dest, b"keep").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&ro, perms).unwrap();
// Running as root would defeat the read-only directory: skip instead of failing.
let probe = ro.join(".probe");
if std::fs::write(&probe, b"").is_ok() {
let _ = std::fs::remove_file(&probe);
} else {
assert!(write(&dest.to_string_lossy(), b"payload").is_err());
assert_eq!(std::fs::read(&dest).unwrap(), b"keep");
assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind");
}
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&ro, perms).unwrap();
}
let _ = std::fs::remove_dir_all(&d);
}
}
+10 -2
View File
@@ -26,7 +26,12 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
let background = s.new_page_background();
// An added page takes the current page's colour; the theme only decides new files.
let current = s.page().ok().and_then(|p| match s.doc().ok()?.node(p)?.kind {
NodeKind::Page { background, .. } => Some(background),
_ => None,
});
let background = current.unwrap_or_else(|| s.new_page_background());
let doc = s.doc_mut()?;
let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1));
let id = doc.add_page(&name);
@@ -47,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
let id = match id(p, "id") {
Some(i) => i,
None => {
let k = req_num("page.select", p, "index")? as usize;
let k = req_index("page.select", p, "index")?;
*doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))?
}
};
@@ -102,6 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
let id = id(p, "id").unwrap_or(s.page()?);
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
let doc = s.doc_mut()?;
if !doc.pages.contains(&id) {
return Err(bad("page.move", format!("{id} is not a page")));
}
doc.pages.retain(|x| *x != id);
let k = k.min(doc.pages.len());
doc.pages.insert(k, id);
+1 -1
View File
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
+2 -2
View File
@@ -362,9 +362,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.outlineStroke", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for i in ids {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
if !n.has_visible_stroke() {
continue;
@@ -409,7 +409,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
return Ok(json!({"ids": out}));
continue;
}
// Standing in for the layer, the outline keeps how it is drawn and placed.
(v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect);
+18 -11
View File
@@ -334,6 +334,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
@@ -369,18 +370,24 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER}))
}
),
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
let lang = req_string("pref.language", p, "language")?;
let lang = lang.trim();
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
let tag = lang.len() <= 35
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
if !tag {
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
spec!(
"pref.language",
"Interface language",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
false,
|s, p| {
let lang = req_string("pref.language", p, "language")?;
let lang = lang.trim();
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
let tag = lang.len() <= 35
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
if !tag {
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
}
s.prefs.language = lang.to_string();
Ok(json!({"language": s.prefs.language}))
}
s.prefs.language = lang.to_string();
Ok(json!({"language": s.prefs.language}))
}),
),
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
+3
View File
@@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool {
/// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected.
fn scene() -> (Session, NodeId, NodeId) {
// The text is measured with a font: without craft-fonts that is a system one, scanned here
// rather than by whichever test happens to run first (#333).
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})));
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}})));
+47 -8
View File
@@ -1,6 +1,6 @@
//! Text made by commands in the app (the interface reported its theme) is readable on its page
//! like text drawn with the Text tool: white on a dark page unless a fill is named. A session
//! without an interface (CLI, MCP, exports) keeps the model's black everywhere.
//! Text made by commands in the app (the interface reported its theme) is readable where it is
//! drawn, like text drawn with the Text tool: white on a dark page or a dark frame unless a fill is
//! named. A session without an interface (CLI, MCP, exports) keeps the model's black everywhere.
use figcraft_color::{Color, Paint};
use figcraft_doc::NodeId;
@@ -62,12 +62,51 @@ fn text_made_by_commands_is_readable_on_its_page() {
}
#[test]
fn text_in_a_frame_follows_the_page_it_is_on() {
// As with the Text tool: the page decides, also inside a frame; made in one undo step.
fn text_in_a_frame_is_readable_on_the_frame() {
// #362: what the text is drawn on decides: the nearest layer around it with a solid fill (a
// new frame's white too), else its page; made in one undo step.
let (white, black) = (vec![Paint::solid(Color::WHITE)], vec![Paint::solid(Color::BLACK)]);
let mut s = session(Some(true));
let frame: NodeId = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap();
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": frame}));
assert_eq!(fills(&s, &t), vec![Paint::solid(Color::WHITE)]);
let frame = |s: &mut Session, x: i64, parent: Option<NodeId>, fills: Option<Value>| -> NodeId {
let mut p = json!({"x": x, "y": 0, "width": 200, "height": 100});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
let f = id_of(&ex(s, "frame.create", p)["id"]).unwrap();
if let Some(fills) = fills {
ex(s, "node.set", json!({"id": f, "props": {"fills": fills}}));
}
f
};
let light = frame(&mut s, 0, None, None);
let dark = frame(&mut s, 300, None, Some(json!([{"type": "solid", "color": "#202020"}])));
let bare = frame(&mut s, 600, None, Some(json!([])));
let inner = frame(&mut s, 0, Some(light), Some(json!([])));
let faint = frame(&mut s, 900, None, Some(json!([{"type": "solid", "color": "#FFFFFF", "opacity": 0.2}])));
let stacked = frame(&mut s, 1200, None, Some(json!([{"type": "solid", "color": "#202020"}, {"type": "solid", "color": "#FFFFFF"}])));
let cases = [
(light, &black, "a white frame on a dark page: black text"),
(dark, &white, "a dark frame: white"),
(bare, &white, "a frame without a fill: the page decides"),
(inner, &black, "a frame without a fill in a white one: the white one"),
(faint, &white, "a faint fill lets the page show through: the page decides"),
(stacked, &black, "the fill on top counts"),
];
for (parent, want, why) in cases {
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": parent}));
assert_eq!(&fills(&s, &t), want, "{why}");
}
// The Text tool reads it the same way.
use crate::tools::{PointerEvent, PointerKind, ToolId};
let at = s.doc().unwrap().world_bounds(light).unwrap().center();
s.set_tool(ToolId::Text);
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::at(kind, at.x, at.y, 1.0)).unwrap();
}
let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id;
assert_eq!(s.doc().unwrap().node(typed).unwrap().fills, black, "the Text tool in a white frame");
crate::textedit::end(&mut s).unwrap();
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": light}));
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(id_of(&t["id"]).unwrap()).is_none());
}
+23
View File
@@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
/// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text).
fn scene(s: &mut Session) -> (NodeId, NodeId) {
// The text is measured with a font: without craft-fonts that is a system one, scanned here
// rather than by whichever test happens to run first (#333).
figcraft_text::scan_system_fonts();
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap();
let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap();
let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
@@ -74,6 +77,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() {
}
}
#[test]
fn editing_text_keeps_other_document_changes_since_open() {
let mut s = Session::new();
let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}}));
ex(&mut s, "edit.delete", json!({"ids": [deleted]}));
let bounds = s.doc().unwrap().world_bounds(text).unwrap();
double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0);
assert_eq!(editing(&s), Some(text));
crate::textedit::insert(&mut s, "B").unwrap();
crate::textedit::end(&mut s).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0);
assert!(!s.doc().unwrap().contains(deleted));
assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B");
}
#[test]
fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] {
@@ -0,0 +1,159 @@
//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges,
//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies
//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap()
}
/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards).
fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..PointerEvent::at(kind, x, y, 1.0) };
s.pointer(&ev(PointerKind::Down, from)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();
}
s.pointer(&ev(PointerKind::Up, to)).unwrap();
let sel = s.selection();
assert_eq!(sel.len(), 1, "one copy selected");
sel[0]
}
/// Duplicate the selection: the copy.
fn cmd_d(s: &mut Session) -> NodeId {
let out = ex(s, "edit.duplicate", json!({}));
id_of(&out["ids"][0]).unwrap()
}
fn at(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
#[test]
fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
// 2 px below the original's top edge: within the snap distance.
let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0));
assert_ne!(copy, a);
assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's");
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
}
#[test]
fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() {
let mut s = Session::new();
rect(&mut s, 0.0, 0.0);
rect(&mut s, 200.0, 0.0);
// B's copy dropped 2 px short of the 100 gap that A and B have.
let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0));
assert_eq!(at(&s, copy), (400.0, 0.0));
}
#[test]
fn duplicate_repeats_the_step_set_by_moving_the_copy() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}}));
let c2 = cmd_d(&mut s);
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
let c3 = cmd_d(&mut s);
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
}
#[test]
fn duplicate_after_an_alt_drag_repeats_its_step() {
let mut s = Session::new();
rect(&mut s, 0.0, 0.0);
let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0));
assert_eq!(at(&s, copy), (130.0, 0.0));
let next = cmd_d(&mut s);
assert_eq!(at(&s, next), (260.0, 0.0));
}
#[test]
fn duplicate_only_repeats_for_the_last_copies() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
// Another selection forgets it: the original duplicates in place.
ex(&mut s, "select.set", json!({"ids": [a]}));
ex(&mut s, "select.set", json!({"ids": [c1]}));
let c2 = cmd_d(&mut s);
assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed");
// An offset given explicitly wins over a remembered one.
ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}}));
let out = ex(&mut s, "edit.duplicate", json!({"dx": 10}));
let c3 = id_of(&out["ids"][0]).unwrap();
assert_eq!(at(&s, c3), (310.0, 0.0));
}
#[test]
fn undo_doesnt_let_a_new_layer_take_a_remembered_step() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert!(!s.doc().unwrap().contains(c1));
// Undo gives ids back: the new rectangle may well get the undone copy's.
let r = rect(&mut s, 500.0, 500.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
let c = cmd_d(&mut s);
assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place");
}
#[test]
fn the_step_is_measured_in_page_space_inside_a_rotated_frame() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}}));
let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap();
ex(&mut s, "select.set", json!({"ids": [a]}));
let c1 = cmd_d(&mut s);
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
let (p0, p1) = (at(&s, a), at(&s, c1));
let c2 = cmd_d(&mut s);
let p2 = at(&s, c2);
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}");
}
#[test]
fn an_alt_drag_into_auto_layout_sets_no_step() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
for i in 0..2 {
ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f}));
}
ex(&mut s, "layout.add", json!({"ids": [f]}));
ex(&mut s, "select.none", json!({}));
let kids = s.doc().unwrap().node(f).unwrap().children.clone();
let b = s.doc().unwrap().world_bounds(kids[0]).unwrap();
let from = (b.center().x, b.center().y);
alt_drag(&mut s, from, (from.0 + 70.0, from.1));
assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat");
let n = s.doc().unwrap().node(f).unwrap().children.len();
cmd_d(&mut s);
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
}
+21
View File
@@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() {
let css = code_for(doc, r, CodeLang::Css);
assert!(css.contains("backdrop-filter: blur(2px);"), "{css}");
}
#[test]
fn negative_or_fractional_index_is_refused_not_item_zero() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
let before = s.doc().unwrap().node(r).unwrap().clone();
assert_eq!(before.exports.len(), 2);
for index in [json!(-1), json!(-0.5), json!(1.5)] {
let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}});
assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}");
let upd = json!({"ids": [r], "index": index, "format": "svg"});
assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}");
assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}");
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.effects, before.effects, "index {index}");
assert_eq!(n.exports, before.exports, "index {index}");
}
}
@@ -0,0 +1,107 @@
//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works
//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control
//! channel send params straight to `Session::execute`.
use figcraft_color::Paint;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::{id_of, paint_of};
/// The red→blue left-to-right gradient as written in the docs.
fn short() -> Value {
json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]})
}
/// The same gradient in the engine's own form.
fn long() -> Value {
json!({"type": "gradient", "kind": "linear",
"stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}},
{"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}],
"from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]})
}
fn rect(s: &mut Session) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap()
}
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
s.doc().unwrap().node(id).unwrap().fills.clone()
}
#[test]
fn the_shorthand_reads_as_the_long_form() {
assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap());
}
#[test]
fn node_set_takes_the_shorthand() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap();
s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap();
let want = paint_of(&long()).unwrap();
assert_eq!(fills(&s, r), vec![want.clone()]);
assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]);
}
#[test]
fn node_create_fill_add_and_fill_set_take_the_shorthand() {
let want = paint_of(&long()).unwrap();
let mut s = Session::new();
let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap();
let r = id_of(&v["id"]).unwrap();
assert_eq!(fills(&s, r), vec![want.clone()]);
s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap();
assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]);
let r2 = rect(&mut s);
s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap();
assert_eq!(fills(&s, r2), vec![want]);
}
#[test]
fn shorthand_defaults_match_the_mcp_server() {
// No handles: top to bottom, with a square width handle.
let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap();
let v = serde_json::to_value(&p).unwrap();
assert_eq!(
(v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()),
(json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0]))
);
// Plain colour stops are spread evenly.
let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap();
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["kind"], "radial");
let pos: Vec<f64> = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect();
assert_eq!(pos, vec![0.0, 0.5, 1.0]);
for kind in ["angular", "diamond"] {
let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap();
assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind);
}
// A CSS colour inside a solid paint or a positioned stop is read too.
assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00")));
let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap();
assert_eq!(p, paint_of(&long()).unwrap());
}
#[test]
fn junk_is_still_refused() {
let mut s = Session::new();
let r = rect(&mut s);
for junk in [
json!({"type": "linearGradient", "stops": "red"}),
json!({"type": "linearGradient", "stops": [42]}),
json!({"type": "fooGradient", "stops": ["#ff0000"]}),
json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}),
] {
let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string();
assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}");
}
// Deep nesting doesn't overflow the stack.
let mut deep = json!("#ff0000");
for _ in 0..200 {
deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]});
}
assert!(paint_of(&deep).is_none());
}
@@ -0,0 +1,67 @@
//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing
//! behind — also during an interaction, whose last valid preview and own snapshot survive it.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
fn rect(s: &mut Session) -> crate::NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap()
}
/// `width` applies (geometry keys go first), then `visible` fails validation.
fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result<serde_json::Value> {
s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"}))
}
#[test]
fn a_failed_command_during_an_interaction_keeps_the_prior_preview() {
let mut s = Session::new();
let r = rect(&mut s);
s.begin_interaction("Move").unwrap();
let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]);
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
let (previewed, selection, page) = {
let ds = s.ds().unwrap();
(ds.doc.clone(), ds.selection.clone(), ds.page)
};
let before_journal = s.journal.clone();
let before_history = s.ds().unwrap().history.undo.len();
assert!(partially_applicable(&mut s, r).is_err());
// The rejected edit is gone: the command-entry state (the valid preview) is back.
let ds = s.ds().unwrap();
assert_eq!(*ds.doc, *previewed, "the document is what the preview showed");
assert_eq!(ds.doc.node(r).unwrap().width, 80.0);
assert_eq!(ds.selection, selection);
assert_eq!(ds.page, page);
assert_eq!(ds.history.undo.len(), before_history);
assert_eq!(s.journal, before_journal);
// The interaction is alive and commits the preview, not the rejected mutation.
s.commit_interaction().unwrap();
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!((n.width, n.visible), (80.0, true));
assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed");
assert_eq!(s.undo_depth(), (before_history + 1, 0));
assert_eq!(s.undo_labels(1), vec!["Move".to_string()]);
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0);
}
#[test]
fn a_failed_command_without_an_interaction_still_rolls_back() {
let mut s = Session::new();
let r = rect(&mut s);
let before_journal = s.journal.clone();
let before_history = s.ds().unwrap().history.undo.len();
assert!(partially_applicable(&mut s, r).is_err());
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!((n.width, n.visible), (80.0, true));
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
assert_eq!(s.journal, before_journal);
}
+6 -1
View File
@@ -4,11 +4,11 @@
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::edit::opacity_from_digits;
use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary};
use crate::params::id_of;
use crate::tools::ToolId;
use crate::{EngineError, Session};
use figcraft_doc::NodeId;
const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other];
@@ -186,6 +186,7 @@ fn opacity_digits() {
assert_eq!(opacity_from_digits("00"), Some(0.0));
assert_eq!(opacity_from_digits(""), None);
assert_eq!(opacity_from_digits("123"), None);
assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32");
assert_eq!(opacity_from_digits("4x"), None);
assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only");
@@ -201,6 +202,10 @@ fn opacity_digits() {
ex(&mut s, "edit.undo", json!({}));
assert_eq!(op(&s, a), 0.3, "one undo step");
assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err());
assert!(
matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })),
"an overflowing digit string is a parameter error, not a panic"
);
assert!(s.execute("object.opacityKeys", &json!({})).is_err());
ex(&mut s, "select.none", json!({}));
assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected");
+99 -9
View File
@@ -107,6 +107,47 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
}
/// Deepest params nesting `resolve_step_refs` walks before giving up.
const STEP_REF_MAX_DEPTH: usize = 64;
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
/// `id`) of `results[n]`, the result of an earlier batch step.
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
if depth > STEP_REF_MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") => {
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
}
let n = o
.get("$step")
.and_then(Value::as_u64)
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
let field = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
};
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
};
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
}
Value::Object(o) => {
let mut out = serde_json::Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// One undo step.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
@@ -328,6 +369,9 @@ pub struct DocState {
pub guide: Option<(NodeId, usize)>,
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@@ -383,6 +427,7 @@ impl DocState {
focus: None,
guide: None,
rotation_origin: None,
last_duplicate: None,
};
st.zoom_to_fit();
st
@@ -538,6 +583,10 @@ impl DocState {
if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) {
self.rotation_origin = None;
}
// So does the last duplicate (undo reuses ids: a new layer could take a copy's).
if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) {
self.last_duplicate = None;
}
}
}
@@ -552,6 +601,7 @@ pub struct Prefs {
pub highlight_on_hover: bool,
pub number_keys_opacity: bool,
pub show_dimensions: bool,
/// "system" by default; an explicit "light" or "dark" choice stays saved.
pub theme: String,
/// The widths of the main window's left and right panels (the interface keeps them within its
/// limits; other windows have their own while they are open).
@@ -564,6 +614,9 @@ pub struct Prefs {
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
/// Files that have a path are saved by themselves a few seconds after they change (the app's
/// timer; off: only File ▸ Save writes them).
pub auto_save: bool,
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
/// builds, off for development builds.
pub invite_links: Option<bool>,
@@ -591,6 +644,14 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub home_views: BTreeMap<String, String>,
/// What the home screen's recent files are ordered by: `"modified"`, `"name"` or `"created"`.
pub home_sort: String,
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
pub home_order: String,
}
impl Default for Prefs {
@@ -603,11 +664,12 @@ impl Default for Prefs {
highlight_on_hover: true,
number_keys_opacity: true,
show_dimensions: true,
theme: "light".into(),
theme: "system".into(),
panel_widths: (240.0, 240.0),
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
auto_save: false,
invite_links: None,
plugins: Vec::new(),
plugin_settings: BTreeMap::new(),
@@ -616,6 +678,9 @@ impl Default for Prefs {
recent_files: Vec::new(),
online_files: Vec::new(),
getting_started_dismissed: false,
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
}
}
}
@@ -652,8 +717,8 @@ pub struct Session {
pub prefs: Prefs,
pub tool: tools::ToolState,
pub ui_requests: Vec<UiRequest>,
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages
/// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the
/// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no
/// theme, so what scripts make is the same everywhere.
pub ui_dark: Option<bool>,
/// Command log (id + params) for actions/automation.
@@ -710,7 +775,7 @@ impl Session {
}
}
/// The background new pages get: the dark page colour while the interface is dark.
/// The background a new file's page gets: the dark page colour while the interface is dark.
pub fn new_page_background(&self) -> figcraft_color::Color {
if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE }
}
@@ -826,10 +891,11 @@ impl Session {
}
}
Err(_) => {
// Roll back any partial edit.
// Roll back any partial edit — also during an interaction, where `before` is the
// interaction's last valid preview: restore it and leave the gesture itself (its
// own snapshot) alone.
if let Some((doc, sel, page, uid)) = before
&& let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid)
&& ds.interaction.is_none()
{
ds.doc = doc;
ds.selection = sel;
@@ -1060,9 +1126,20 @@ impl Session {
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
}
/// Run several commands as one undo step.
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
/// so a step can target a layer that an earlier step of the same batch created. Any object
/// with a `$step` key in a step's params is such a reference, except inside a nested
/// `command.batch` step: its references are to its own steps.
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect())
self.grouped(label, |s| {
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
for (step, (id, p)) in cmds.iter().enumerate() {
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
results.push(s.execute(id, &p)?);
}
Ok(results)
})
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
@@ -1071,6 +1148,8 @@ impl Session {
self.ds()?;
let before: Vec<_> =
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
// An edit inside clears the redo steps; a failed group has to bring them back.
let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect();
let active = self.active;
let journal_len = self.journal.len();
@@ -1088,8 +1167,9 @@ impl Session {
if result.is_err() {
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
for (uid, doc, selection, page, undo_len, limit) in &before {
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
ds.history.redo = redo;
ds.doc = doc.clone();
ds.selection = selection.clone();
ds.page = *page;
@@ -1382,6 +1462,8 @@ mod autosave_tests;
#[cfg(test)]
mod batch_rollback_tests;
#[cfg(test)]
mod batch_step_ref_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
@@ -1430,6 +1512,8 @@ mod double_click_tests;
#[cfg(test)]
mod draw_tools_tests;
#[cfg(test)]
mod duplicate_spacing_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
mod export_behaviour_tests;
@@ -1456,6 +1540,8 @@ mod fx_behaviour_tests;
#[cfg(test)]
mod gesture_preview_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod hide_publishing_tests;
#[cfg(test)]
mod history_behaviour_tests;
@@ -1466,6 +1552,8 @@ mod import_export_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod interaction_rollback_tests;
#[cfg(test)]
mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
@@ -1506,6 +1594,8 @@ mod panels_behaviour_tests;
#[cfg(test)]
mod pdf_print_tests;
#[cfg(test)]
mod pen_overlay_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod playground_tests;
+65
View File
@@ -517,6 +517,11 @@ pub trait LiveLink: Send {
fn background(&mut self, _job: u64, _req: NetRequest) -> bool {
false
}
/// Remove client `client` from session `session`, which this link hosts: they are
/// disconnected and can't join again from their address while it runs. Returns at once.
fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> {
Err("removing people needs the desktop app or the MCP server".into())
}
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
/// that is over already.
fn leave(&mut self, session: SessionId);
@@ -1889,6 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"follow": s.live().follow}))
}
/// Remove someone from the open file's session: in a session this app hosts, the hub drops them;
/// in a file of yours on a server, the server does (with the file's owner token). They are told
/// the session is over for them, keep their copy, and can't join again from their address while
/// the session (or the server) runs.
fn remove_person(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.removePerson";
let client =
get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?;
let live = s.live();
if live.status != Status::Live {
return Err(bad(CMD, "the open file isn't in a live session"));
}
let name =
live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?;
let (id, role, server_file) = (live.id, live.role(), live.server_file());
match (role, server_file) {
(Some(Role::Host), _) => {
let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?;
link.remove(id, client).map_err(EngineError::Other)?;
}
(Some(Role::Guest), Some((server, file))) => {
let owner =
s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty());
let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?;
let req = Some(Admin::RemovePeer { file: file.clone(), client });
match hosting_request(s, CMD, &server, &file, &owner, req) {
Ok((_, Some(AdminReply::Done))) => {}
Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))),
// A server older than this request can't read it.
Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))),
}
}
_ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")),
}
s.message = Some(format!("{name} was removed from the session"));
Ok(json!({"removed": client, "name": name}))
}
/// You may remove others from the open file's session: you host it, or it is your file on a
/// server.
pub fn can_remove_people(s: &Session) -> bool {
let live = s.live();
live.status == Status::Live
&& match live.role() {
Some(Role::Host) => true,
Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| {
live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty())
|| s.online_owner(&server, &file).is_some_and(|o| !o.is_empty())
}),
None => false,
}
}
fn cursor(s: &mut Session, p: &Value) -> Result<Value> {
let at = match (num(p, "x"), num(p, "y")) {
(Some(x), Some(y)) => Some((x, y)),
@@ -1997,6 +2055,13 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| Ok(s.live_summary())
),
spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow),
spec!(
"collab.removePerson",
"Remove from session",
"{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it",
false,
remove_person
),
spec!(
"collab.cursor",
"Show cursor at",
+43
View File
@@ -28,6 +28,8 @@ pub(crate) struct Script {
/// The session each presence in `sent` and each message in `msgs` was for.
pub(crate) sent_to: Vec<u64>,
pub(crate) msgs_to: Vec<u64>,
/// The people removed: `(session, client)`.
pub(crate) removed: Vec<(u64, u32)>,
}
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
@@ -62,6 +64,10 @@ impl LiveLink for Mock {
s.published.push(opts);
Ok(())
}
fn remove(&mut self, session: u64, client: u32) -> Result<(), String> {
self.0.lock().unwrap().removed.push((session, client));
Ok(())
}
fn leave(&mut self, session: u64) {
let mut s = self.0.lock().unwrap();
s.left += 1;
@@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() {
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
}
#[test]
fn the_host_removes_people_and_guests_cant() {
let (mut s, script) = session();
let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client }));
// Not in a session: nobody to remove.
assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. })));
assert!(!crate::live::can_remove_people(&s));
s.execute("collab.host", &json!({})).unwrap();
let id = s.live().id;
push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]);
s.live_poll();
assert!(crate::live::can_remove_people(&s));
// Only someone who is here, by a client id.
for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] {
assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}");
}
let r = remove(&mut s, json!(2)).unwrap();
assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada")));
assert_eq!(script.lock().unwrap().removed, [(id, 2)]);
assert_eq!(s.message.as_deref(), Some("Ada was removed from the session"));
// The host's own session tells them she left.
push(&script, vec![Event::PeerLeft(2)]);
s.live_poll();
assert_eq!(s.live().peers.keys().copied().collect::<Vec<_>>(), [3]);
s.execute("collab.stop", &json!({})).unwrap();
// A guest in someone else's session can't remove anyone.
let (mut s, script) = session();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]);
s.live_poll();
assert!(!crate::live::can_remove_people(&s));
let e = remove(&mut s, json!(3)).unwrap_err().to_string();
assert!(e.contains("only the person hosting"), "{e}");
assert!(script.lock().unwrap().removed.is_empty());
}
#[test]
fn collab_commands_survive_junk() {
let junk = [
+6 -5
View File
@@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() {
e
};
s.pointer(&ev(PointerKind::Down, 30.0)).unwrap();
// Alt duplicates: the copy moves to 110..160, between the original and the other layer.
// Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original
// and the other layer (105..155).
s.pointer(&ev(PointerKind::Move, 130.0)).unwrap();
let g = s.tool.overlay.guides.clone();
assert_eq!(
labelled(&g),
vec![
("30".into(), [135, 0, 135, 30]),
("30".into(), [135, 70, 135, 100]),
("40".into(), [160, 50, 200, 50]),
("50".into(), [60, 50, 110, 50])
("30".into(), [130, 0, 130, 30]),
("30".into(), [130, 70, 130, 100]),
("45".into(), [60, 50, 105, 50]),
("45".into(), [155, 50, 200, 50])
]
);
s.pointer(&ev(PointerKind::Up, 130.0)).unwrap();
+13
View File
@@ -33,6 +33,19 @@ fn open_recent_lists_opens_prunes_and_clears() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn one_recent_file_is_forgotten_and_nothing_opens() {
let mut s = Session::empty();
s.prefs.recent_files = vec!["/tmp/a.figcraft".into(), "/tmp/b.fig".into(), "/tmp/c.svg".into()];
let list = s.execute("file.openRecent", &json!({"remove": "/tmp/b.fig"})).unwrap();
assert_eq!(s.prefs.recent_files, vec!["/tmp/a.figcraft".to_string(), "/tmp/c.svg".to_string()]);
assert_eq!(list["files"].as_array().map(Vec::len), Some(2), "the list as it is now");
assert!(s.docs.is_empty(), "nothing opens");
// A path that isn't in the list changes nothing.
s.execute("file.openRecent", &json!({"remove": "/tmp/zzz.fig"})).unwrap();
assert_eq!(s.prefs.recent_files.len(), 2);
}
#[test]
fn recent_files_are_capped() {
let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id()));
@@ -337,6 +337,34 @@ fn pages_by_the_documented_rules() {
assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider");
}
#[test]
fn page_move_refuses_what_is_not_a_page() {
let mut s = Session::new();
let r = rect(&mut s, "r", 30.0, 40.0, None);
let before = pages(&mut s);
assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page");
assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page");
assert_eq!(pages(&mut s), before, "the page list is untouched");
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
}
#[test]
fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
let mut s = Session::new();
let p2 = id(&ex(&mut s, "page.add", json!({})));
for bad_index in [json!(-1), json!(0.5)] {
assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail");
assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone");
}
ex(&mut s, "file.new", json!({}));
let active = s.active;
assert_eq!(active, 1);
for bad_index in [json!(-1), json!(0.5)] {
assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail");
assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone");
}
}
#[test]
fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
let mut s = Session::new();
@@ -386,3 +414,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped");
assert_eq!(s.doc().unwrap().page_of(into), Some(page));
}
#[test]
fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
let mut s = Session::new();
ex(&mut s, "pref.renameDuplicates", json!({"on": true}));
let r = rect(&mut s, "r", 30.0, 40.0, None);
ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}}));
let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap();
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
}
+71 -3
View File
@@ -3,7 +3,7 @@
use figcraft_color::{BlendMode, Color, Paint};
use figcraft_doc::NodeId;
use serde_json::Value;
use serde_json::{Value, json};
use crate::{EngineError, Result, Session, bad};
@@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option<Color> {
get(p, key).and_then(color_of)
}
/// A paint from JSON: a colour string/object (solid) or a full paint object.
/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own
/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a},
/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default
/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours
/// are spread evenly. Anything else is left for serde to refuse.
pub fn normalize_paint(v: &mut Value) {
normalize_paintish(v, 0);
}
fn normalize_paintish(v: &mut Value, depth: usize) {
if depth > 16 {
return;
}
match v {
Value::Array(a) => {
for x in a {
normalize_paintish(x, depth + 1);
}
}
Value::Object(o) => {
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) {
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
}
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
if is_gradient {
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
&& t != "gradient"
{
let kind = t.trim_end_matches("gradient").to_string();
o.insert("type".into(), json!("gradient"));
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
}
o.entry("kind").or_insert(json!("linear"));
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
o.entry("from").or_insert(json!(from));
o.entry("to").or_insert(json!(to));
// A linear gradient's width handle goes square to its axis (else its bands skew,
// or vanish when the handle lies on the axis).
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
if linear
&& !o.contains_key("width")
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
{
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
}
o.entry("width").or_insert(json!(width));
if let Some(Value::Array(stops)) = o.get_mut("stops") {
let n = stops.len();
for (i, s) in stops.iter_mut().enumerate() {
if let Some(c) = color_of(s).filter(|_| s.is_string()) {
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
*s = json!({"position": pos, "color": c});
} else {
normalize_paintish(s, depth + 1);
}
}
}
}
}
_ => {}
}
}
/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand
/// ([`normalize_paint`]).
pub fn paint_of(v: &Value) -> Option<Paint> {
if let Some(c) = color_of(v) {
return Some(Paint::solid(c));
}
let mut p: Paint = serde_json::from_value(v.clone()).ok()?;
let mut v = v.clone();
normalize_paint(&mut v);
let mut p: Paint = serde_json::from_value(v).ok()?;
p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 };
Some(p)
}
+65
View File
@@ -0,0 +1,65 @@
//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments
//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle.
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
}
/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle
/// (to - p).
fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) {
at(s, PointerKind::Down, x, y);
at(s, PointerKind::Move, to_x, to_y);
at(s, PointerKind::Up, to_x, to_y);
}
#[test]
fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point");
// The second point, mid-drag.
at(&mut s, PointerKind::Down, 200.0, 200.0);
assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band");
at(&mut s, PointerKind::Move, 200.0, 240.0);
assert_eq!(
s.tool.overlay.pen_handles,
vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]],
"the one before's out handle, then the dragged point's in and out"
);
at(&mut s, PointerKind::Up, 200.0, 240.0);
// Moving on: the band leaves along the last out handle.
at(&mut s, PointerKind::Hover, 300.0, 200.0);
assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0]));
assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0]));
}
#[test]
fn corner_points_have_no_handles_and_a_straight_band() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
for (x, y) in [(100.0, 100.0), (200.0, 100.0)] {
at(&mut s, PointerKind::Down, x, y);
at(&mut s, PointerKind::Up, x, y);
}
at(&mut s, PointerKind::Hover, 250.0, 200.0);
assert!(s.tool.overlay.pen_handles.is_empty());
assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0]));
assert_eq!(s.tool.overlay.rubber_handle, None, "straight");
}
#[test]
fn finishing_the_path_clears_its_handles_and_band() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
drag_point(&mut s, 200.0, 200.0, 200.0, 240.0);
at(&mut s, PointerKind::Hover, 300.0, 200.0);
s.pen_finish().unwrap();
let ov = &s.tool.overlay;
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
}
@@ -431,3 +431,31 @@ fn flatten_and_outline_stroke() {
let (path, _) = figcraft_render::node_outline(s.doc().unwrap(), &n).unwrap();
assert!((path.bounding_box().height() - 10.0).abs() < 1e-6, "the outline is as thick as the stroke was");
}
#[test]
fn outline_stroke_outlines_every_selected_layer() {
// Fixed (#378): with several filled, stroked shapes selected, only the first was outlined.
for by_selection in [false, true] {
let mut s = Session::new();
let a = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
let b = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}));
let line = create(&mut s, "shape.line", json!({"x": 0, "y": 100, "width": 100, "props": {"strokeWeight": 4}}));
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 4}}));
let out = if by_selection {
s.set_selection(vec![a, b, line]);
ex(&mut s, "vector.outlineStroke", json!({}))
} else {
ex(&mut s, "vector.outlineStroke", json!({"ids": [a, b, line]}))
};
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
assert_eq!(ids.len(), 3, "one result per layer: {out}");
assert!(matches!(node(&s, ids[0]).kind, NodeKind::Group), "filled shape a becomes a group");
assert!(matches!(node(&s, ids[1]).kind, NodeKind::Group), "filled shape b becomes a group");
assert!(matches!(node(&s, ids[2]).kind, NodeKind::Vector { .. }), "the line becomes a vector");
for id in [a, b] {
assert!(node(&s, id).strokes.is_empty(), "{id:?} still has its stroke");
}
assert!(s.doc().unwrap().node(line).is_none(), "the line was replaced by its outline");
assert_eq!(s.selection(), ids, "the results are selected");
}
}
+12 -10
View File
@@ -74,15 +74,6 @@ impl Targets {
// A layer's box in snap space.
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() };
let push = |r: Rect, t: &mut Targets| {
for x in [r.x0, r.center().x, r.x1] {
t.xs.push((x, r.y0, r.y1));
}
for y in [r.y0, r.center().y, r.y1] {
t.ys.push((y, r.x0, r.x1));
}
t.boxes.push(r);
};
let parent = parent_of(doc, page, moving);
// The parent frame's own box (and its layout grids).
if parent != page
@@ -109,7 +100,7 @@ impl Targets {
continue;
}
if let Some(b) = bbox(*c) {
push(b, &mut t);
t.add_box(b);
}
}
}
@@ -136,6 +127,17 @@ impl Targets {
t
}
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
pub fn add_box(&mut self, r: Rect) {
for x in [r.x0, r.center().x, r.x1] {
self.xs.push((x, r.y0, r.y1));
}
for y in [r.y0, r.center().y, r.y1] {
self.ys.push((y, r.x0, r.x1));
}
self.boxes.push(r);
}
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
/// and a marker when a moving point lands on it.
pub fn add_point(&mut self, p: Point) {
+89
View File
@@ -161,6 +161,28 @@ fn text_create_autosize_and_edit() {
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
}
#[test]
fn find_replace_replaces_every_occurrence_when_replacement_contains_find() {
for (text, find, replace, want) in [
("cat and cat", "cat", "cats", "cats and cats"),
("cat and cat", "cat", "category", "category and category"),
("banana", "a", "aa", "baanaanaa"),
] {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap());
s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap();
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}");
}
}
#[test]
fn find_replace_rejects_empty_find() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap());
assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err());
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat");
}
#[test]
fn components_and_instances_sync() {
let mut s = sess();
@@ -253,6 +275,49 @@ fn align_distribute_zorder() {
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
}
#[test]
fn a_layer_alone_on_the_page_has_nothing_to_align_to() {
use crate::cmd::arrange::can_align;
let mut s = sess();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
let inner = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 10, "height": 10, "parent": f})).unwrap());
let loose = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 5, "width": 10, "height": 10})).unwrap());
let doc = s.doc().unwrap();
assert!(!can_align(doc, &[]));
assert!(!can_align(doc, &[loose]), "alone on the page");
assert!(!can_align(doc, &[f, inner]), "a frame with its own child is the frame alone");
assert!(can_align(doc, &[inner]), "one in a frame aligns to it");
assert!(can_align(doc, &[f, loose]), "two align to each other");
// Where the buttons are off, the command still does nothing (and no harm).
let before = doc.world_bounds(loose);
s.execute("align.right", &json!({"ids": [loose]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(loose), before);
s.execute("align.right", &json!({"ids": [inner]})).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap().x1, 200.0);
}
#[test]
fn send_to_back_across_frames_counts_per_parent() {
let mut s = sess();
let make = |s: &mut Session, p: Value| id(&s.execute("node.create", &p).unwrap());
let fa = make(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200}));
let fb = make(&mut s, json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200}));
let rect = |parent: NodeId| json!({"type": "rectangle", "parent": parent, "x": 10, "y": 10, "width": 20, "height": 20});
let a: Vec<NodeId> = (0..2).map(|_| make(&mut s, rect(fa))).collect();
let b: Vec<NodeId> = (0..3).map(|_| make(&mut s, rect(fb))).collect();
let idx = |s: &Session, n: NodeId| s.doc().unwrap().index_in_parent(n);
assert_eq!(idx(&s, a[1]), Some(1));
assert_eq!(idx(&s, b[2]), Some(2));
s.execute("arrange.back", &json!({"ids": [a[1], b[2]]})).unwrap();
assert_eq!(idx(&s, a[1]), Some(0), "top of frame A at the back");
assert_eq!(idx(&s, b[2]), Some(0), "top of frame B at the back");
// Two from B plus one from A: each parent's selection ends at 0..n in its original order.
s.execute("arrange.back", &json!({"ids": [b[1], a[0], b[0]]})).unwrap();
let kids = |s: &Session, f: NodeId| s.doc().unwrap().node(f).unwrap().children.clone();
assert_eq!(kids(&s, fa), vec![a[0], a[1]]);
assert_eq!(kids(&s, fb), vec![b[0], b[1], b[2]]);
}
#[test]
fn copy_paste_and_duplicate() {
let mut s = sess();
@@ -532,3 +597,27 @@ fn guides_add_move_remove() {
s.execute("guide.clear", &json!({})).unwrap();
assert!(guides(&s).is_empty());
}
#[test]
fn find_replace_matches_and_replaces_case_insensitively() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap());
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap());
let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone();
let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap();
assert_eq!(r["matches"], json!([t, u, f]));
assert_eq!(chars(&s, t), "bye");
assert_eq!(chars(&s, u), "İ bye bye");
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box");
}
#[test]
fn find_replace_changing_only_the_case_replaces_every_occurrence() {
let mut s = sess();
let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap());
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap());
s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap();
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft");
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft");
}
+37
View File
@@ -192,3 +192,40 @@ fn paragraph_indent_only_moves_left_aligned_text() {
let (x, w2) = first_x(&s);
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
}
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
/// `chars`: its box and resize mode.
fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) {
use crate::tools::{PointerEvent, PointerKind, ToolId};
let mut s = Session::new();
s.set_tool(ToolId::Text);
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
crate::textedit::insert(&mut s, chars).unwrap();
let t = s.ds().unwrap().text_edit.as_ref().unwrap().id;
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") };
(doc.world_bounds(t).unwrap(), td.auto_resize)
}
#[test]
fn a_drag_with_the_text_tool_makes_a_fixed_size_box() {
// #360: the drag's size, its top-left where the drag started, and its text wraps in it; a
// click, or a drag within a few pixels, still makes auto-width text.
use figcraft_doc::TextAutoResize;
figcraft_text::scan_system_fonts();
let long = "A sentence much longer than eighty pixels";
let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long);
assert_eq!(auto, TextAutoResize::WidthAndHeight);
assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click");
let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long);
assert_eq!(auto, TextAutoResize::None);
assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size");
// Dragged up and to the left, the box is the same.
let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long);
assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0));
// A flat drag still makes a box one line tall.
let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A");
assert_eq!((b3.width(), b3.height()), (80.0, line.height()));
}
+63 -9
View File
@@ -1,7 +1,7 @@
//! The page colour of new files and pages follows the interface's theme when they are made (dark:
//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface
//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is
//! white on a dark page and black on a light one.
//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E,
//! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page
//! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the
//! light default. Text drawn with the Text tool is white on a dark page and black on a light one.
use figcraft_color::{Color, Paint};
use figcraft_doc::{NodeId, NodeKind};
@@ -38,8 +38,8 @@ fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId {
}
#[test]
fn new_files_and_pages_take_the_theme_they_are_made_in() {
// (theme the interface reported, colour of a new file's page and of an added page)
fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() {
// (theme the interface reported, colour of a new file's page and of a page added to it)
for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] {
let mut s = Session::empty();
if let Some(d) = dark {
@@ -62,8 +62,10 @@ fn a_change_of_theme_leaves_existing_pages_alone() {
ex(&mut s, "file.new", json!({}));
s.set_ui_dark(false);
ex(&mut s, "page.add", json!({}));
assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme");
ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"}));
s.set_ui_dark(true);
assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with");
assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour");
// A copy of a page is that page's colour, whatever the theme.
let first = s.doc().unwrap().pages[0];
s.set_ui_dark(false);
@@ -71,6 +73,57 @@ fn a_change_of_theme_leaves_existing_pages_alone() {
assert_eq!(background(&s, copy), Color::DARK_PAGE);
}
#[test]
fn an_added_page_takes_the_current_pages_colour() {
// Whatever the theme, and headless too: the page it was added from decides.
for dark in [None, Some(false), Some(true)] {
let mut s = Session::empty();
if let Some(d) = dark {
s.set_ui_dark(d);
}
ex(&mut s, "file.new", json!({}));
ex(&mut s, "page.setBackground", json!({"color": "#336699"}));
ex(&mut s, "page.add", json!({}));
assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}");
// From the second page, now current, after giving it its own colour.
ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"}));
ex(&mut s, "page.add", json!({"name": "Third"}));
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}");
// One undo step, the colour with it.
ex(&mut s, "edit.undo", json!({}));
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo");
ex(&mut s, "edit.redo", json!({}));
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo");
}
// From a page other than the first: go there, then add.
let mut s = Session::new();
s.set_ui_dark(true);
ex(&mut s, "page.add", json!({}));
ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"}));
ex(&mut s, "page.select", json!({"index": 0}));
ex(&mut s, "page.add", json!({}));
assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last");
}
#[test]
fn added_page_colours_survive_save_and_reopen() {
let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pages.figcraft");
let mut s = Session::new();
ex(&mut s, "page.setBackground", json!({"color": "#336699"}));
ex(&mut s, "page.add", json!({}));
ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()}));
let mut s = Session::empty();
s.set_ui_dark(true);
ex(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
assert_eq!(backgrounds(&s), ["#336699", "#336699"]);
// And a page added to the reopened file in the dark theme follows the file, not the theme.
ex(&mut s, "page.add", json!({}));
assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() {
// Made before the interface knew its theme, nothing done to it yet.
@@ -118,13 +171,14 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
let t = draw_text(&mut s, 40.0, 40.0);
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}");
}
// In a frame on a dark page too; so is text a command makes in the app (`dark_page_text_tests`).
// In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes
// in the app (`dark_page_text_tests`).
let mut s = Session::empty();
s.set_ui_dark(true);
ex(&mut s, "file.new", json!({}));
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
let t = draw_text(&mut s, 50.0, 50.0);
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::BLACK)]);
let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap();
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
}
+112 -12
View File
@@ -266,6 +266,12 @@ pub struct Overlay {
/// Pen tool: the in-progress path's anchor points and the rubber band to the pointer.
pub pen: Vec<[f64; 2]>,
pub rubber: Option<[f64; 4]>,
/// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last
/// anchor's in and out handles and the one before's out handle.
pub pen_handles: Vec<[f64; 4]>,
/// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the
/// curve a click would make, leaving along it.
pub rubber_handle: Option<[f64; 2]>,
/// Frame/section the pointer is drawing or dropping into (highlighted).
pub drop_target: Option<NodeId>,
/// Auto layout insertion indicator (x0, y0, x1, y1).
@@ -370,6 +376,13 @@ pub(crate) enum Gesture {
TextSelect {
id: NodeId,
},
/// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag
/// (at least `line`, the height of its first line, tall).
TextBox {
id: NodeId,
start: Point,
line: f64,
},
PenDrag {
anchor: Point,
},
@@ -397,6 +410,14 @@ pub(crate) enum Gesture {
},
}
/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in
/// and out handles and the one before's out handle, those that aren't zero.
fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> {
let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]);
let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o));
before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect()
}
/// The pen tool's in-progress path (page coordinates).
#[derive(Clone, Debug, Default)]
pub struct PenPath {
@@ -530,12 +551,16 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str {
ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl")
}
/// The fill new text takes to be readable where it is drawn: white on a dark `page` (None: the
/// model's default, black). For text made in the app: by the Text tool, or by a command that
/// names no fill while the interface is up.
pub(crate) fn readable_text_fills(doc: &Document, page: NodeId) -> Option<Vec<figcraft_color::Paint>> {
matches!(doc.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark())
.then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark
/// background (None: the model's default, black). The background is the nearest layer from `parent`
/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made
/// in the app: by the Text tool, or by a command that names no fill while the interface is up.
pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option<Vec<figcraft_color::Paint>> {
let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind {
NodeKind::Page { background, .. } => Some(background.is_dark()),
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()),
});
dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
}
const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"];
@@ -617,6 +642,18 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
handle_at_corners(&corners, p, zoom, is_line)
}
/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels,
/// upright whatever the box's rotation).
const CORNER_SQUARE_PX: f64 = 4.0;
/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner
/// resizes, although a corner-radius handle 10 px in reaches that far too.
fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool {
let reach = CORNER_SQUARE_PX / zoom.max(1e-6);
matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_)))
&& selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach))
}
/// A box whose sides are both shorter than this on screen (pixels) would be covered by its
/// handles: the canvas draws just its outline (the handles still answer just outside it).
pub const SMALL_BOX_PX: f64 = 16.0;
@@ -685,7 +722,18 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor {
Handle::Corner(i) => {
let a = c.get(i as usize).copied().unwrap_or_default();
let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default();
Cursor::Resize(angle(a, b))
// Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a
// diagonal cursor. A box with no width or height falls back to the diagonal.
let unit = |v: figcraft_geom::kurbo::Vec2| {
let len = v.hypot();
(len.is_finite() && len > 1e-9).then(|| v / len)
};
let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default();
let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default();
match (unit(next - a), unit(prev - a)) {
(Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()),
_ => Cursor::Resize(angle(a, b)),
}
}
Handle::Edge(i) => {
let a = c.get(i as usize).copied().unwrap_or_default();
@@ -982,7 +1030,11 @@ impl Session {
let mut measure_box = None;
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
self.tool.overlay.arc_hover = arc_hover;
let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) {
crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h)
} else {
None
};
self.tool.overlay.shape_hover = shape_hover;
if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited {
cursor = Cursor::Text;
@@ -1026,8 +1078,9 @@ impl Session {
let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? };
guides = g;
self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom));
if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]);
}
}
self.tool.overlay.guides = guides;
@@ -1090,6 +1143,7 @@ impl Session {
return Ok(());
}
if self.arc_handles_shown()
&& !on_corner_square(&doc, &sel, ev.p(), ev.zoom)
&& let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom)
{
self.begin_interaction("Corner radius")?;
@@ -1164,7 +1218,13 @@ impl Session {
if ids.is_empty() {
return Ok(());
}
let targets = Targets::gather(&doc, page, &ids);
let mut targets = Targets::gather(&doc, page, &ids);
// A copy dragged out with Alt snaps to the layers it came from too (they stay put).
if ev.alt
&& let Some(b) = targets.moving
{
targets.add_box(b);
}
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
@@ -1347,6 +1407,11 @@ impl Session {
self.tool.gesture = g;
return self.draw_drag(ev, start, id, parent, &targets);
}
Gesture::TextBox { id, start, line } => {
let (id, start, line) = (*id, *start, *line);
self.tool.gesture = g;
return self.text_box_drag(ev, id, start, line);
}
Gesture::Arc { id, handle, arc } => {
let (id, handle, arc) = (*id, *handle, *arc);
self.tool.gesture = g;
@@ -1675,6 +1740,7 @@ impl Session {
// dropped; layers in an auto layout frame already sit in their slot.
let page = self.page()?;
let sel = self.selection();
let sources = if duplicate { ids.clone() } else { Vec::new() };
let moving = if duplicate { sel } else { ids };
let doc = self.doc()?.clone();
let in_al =
@@ -1703,6 +1769,10 @@ impl Session {
self.cancel_interaction()?;
} else {
self.commit_interaction()?;
// Duplicate repeats a free Alt-drag's step (an auto layout places its own).
if duplicate && !in_al && moving.len() == sources.len() {
self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving });
}
}
} else {
self.cancel_interaction()?;
@@ -1760,7 +1830,7 @@ impl Session {
self.tool.overlay.pen.clear();
self.pencil_finish(&points, &pressure)?;
}
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {}
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {}
Gesture::Vector(_) => self.commit_interaction()?,
}
@@ -1878,22 +1948,46 @@ impl Session {
let at = Point::new(ev.x.round(), (ev.y - 7.0).round());
n.transform = pw.inverse() * Affine::translate(at.to_vec2());
n.name = String::new();
if let Some(fills) = readable_text_fills(d, page) {
if let Some(fills) = readable_text_fills(d, parent) {
n.fills = fills;
}
if let Some((w, h)) = figcraft_text::auto_size(&n) {
n.width = w.max(1.0);
n.height = h;
}
let line = n.height;
d.insert(n, parent, None)?;
ds.selection = vec![id];
textedit::begin(self, id, Some(0), true)?;
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
if !self.prefs.keep_tool {
self.tool.tool = ToolId::Move;
}
Ok(())
}
/// Drag out a new text box: past the drag slop it has the drag's size (at least one line
/// tall) and its top-left, and that size is fixed.
fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> {
if within_slop(ev, start) {
return Ok(());
}
let r = Rect::from_points(start, ev.p());
let d = self.ds_mut()?.doc_mut();
let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default();
let n = d.get_mut(id)?;
n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round()));
n.width = r.width().round().max(1.0);
n.height = r.height().round().max(line);
n.child_layout.sizing_h = Sizing::Fixed;
n.child_layout.sizing_v = Sizing::Fixed;
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = TextAutoResize::None;
}
self.ds_mut()?.revision += 1;
Ok(())
}
fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let doc = ds.doc.clone();
@@ -1931,6 +2025,9 @@ impl Session {
fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> {
self.tool.overlay.guides.clear();
// The segment being placed shows as itself while the press lasts.
self.tool.overlay.rubber = None;
self.tool.overlay.rubber_handle = None;
let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 };
let (doc, page) = {
let ds = self.ds()?;
@@ -1985,6 +2082,7 @@ impl Session {
fn pen_update(&mut self) -> Result<()> {
let Some(pen) = self.tool.pen.clone() else { return Ok(()) };
self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect();
self.tool.overlay.pen_handles = pen_handles(&pen.points);
if pen.points.len() < 2 {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
@@ -2067,7 +2165,9 @@ impl Session {
pub fn pen_finish(&mut self) -> Result<()> {
let pen = self.tool.pen.take();
self.tool.overlay.pen.clear();
self.tool.overlay.pen_handles.clear();
self.tool.overlay.rubber = None;
self.tool.overlay.rubber_handle = None;
self.tool.gesture = Gesture::None;
if pen.as_ref().is_some_and(|p| p.id.is_some()) {
self.commit_interaction()?;
@@ -277,6 +277,35 @@ fn constrain_proportions_keeps_the_other_side_in_ratio() {
assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale");
}
#[test]
fn a_press_anywhere_on_a_corner_handle_resizes() {
// The canvas draws each corner handle as an 8 px square centred on the corner, and the
// corner-radius handle sits 10 px in along the diagonal: a press on the square's inner half
// resizes (both sides, in ratio with the link on), it doesn't pick the radius handle.
let radii = |s: &Session, r: NodeId| s.doc().unwrap().node(r).unwrap().corners.radii;
for lock in [false, true] {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None);
set(&mut s, &[r], json!({"constrainProportions": lock}));
ex(&mut s, "select.set", json!({"ids": [r]}));
let on_square = Point::new(997.0, 597.0);
s.pointer(&ev(PointerKind::Hover, on_square, NONE)).unwrap();
assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)), "lock {lock}: {:?}", s.tool.overlay.cursor);
drag(&mut s, on_square, Point::new(1197.0, 637.0), NONE);
let (w, h) = size(&s, r);
let want_h = if lock { 718.2 } else { 637.0 };
assert!(near(w, 1197.0) && near(h, want_h), "lock {lock}: both sides follow the corner, got {w}×{h}");
assert_eq!(radii(&s, r), [0.0; 4]);
}
// Further in, past the square, the radius handle still answers.
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None);
ex(&mut s, "select.set", json!({"ids": [r]}));
drag(&mut s, Point::new(993.0, 593.0), Point::new(980.0, 580.0), NONE);
assert_eq!(size(&s, r), (1000.0, 600.0));
assert!(radii(&s, r)[0] > 0.0, "the radius handle rounds the corners");
}
#[test]
fn frames_resize_with_constraints_and_cmd_ignores_them() {
let mut s = session();
@@ -378,6 +407,30 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
}
#[test]
fn corner_cursors_stay_diagonal_whatever_the_proportions() {
let mut s = session();
let mod180 = |a: f64, b: f64| near((a - b).rem_euclid(180.0).min((b - a).rem_euclid(180.0)), 0.0);
for (w, h) in [(65.0, 15.0), (100.0, 100.0), (60.0, 200.0), (200.0, 50.0)] {
let r = rect(&mut s, 0.0, 0.0, w, h, None);
let doc = s.doc().unwrap().clone();
let q = selection_box(&doc, &[r]).unwrap();
for (i, want) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] {
let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() };
assert!(mod180(a, want), "{w}x{h} corner {i}: {a}, want {want}");
}
// Turned, the corner cursors turn with the layer's own axes.
set(&mut s, &[r], json!({"rotation": 30}));
let doc = s.doc().unwrap().clone();
let q = selection_box(&doc, &[r]).unwrap();
let axis = (q[1] - q[0]).atan2().to_degrees();
for (i, off) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] {
let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() };
assert!(mod180(a, axis + off), "{w}x{h} turned corner {i}: {a}, want {}", axis + off);
}
}
}
#[test]
fn the_transform_box_and_its_handles() {
let mut s = session();
+203 -26
View File
@@ -25,6 +25,8 @@ const MAX_DEPTH: usize = 256;
const MAX_COMMANDS: usize = 200_000;
/// Id of "no style".
const NO_STYLE: &str = "4294967295:4294967295";
/// Longest asset `key` or `version` kept for resolving references given as assets.
const MAX_ASSET_PART: usize = 256;
/// The REST API file JSON for message `msg`, with the warnings about what was skipped.
pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
@@ -86,6 +88,10 @@ fn vec2(v: &Kv) -> Option<Value> {
Some(json!({"x": num(v, "x")?, "y": num(v, "y")?}))
}
fn size(v: &Kv) -> Option<(f64, f64)> {
Some((num(v, "x")?, num(v, "y")?))
}
fn matrix(m: &Kv) -> Option<Affine> {
let c = |k: &str| num(m, k);
let a = Affine::new([c("m00")?, c("m10")?, c("m01")?, c("m11")?, c("m02")?, c("m12")?]);
@@ -113,11 +119,21 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
#[derive(Clone)]
struct View<'a> {
layers: Vec<&'a Kv<'a>>,
/// Size and transform worked out from the constraints, over the layers' own.
fitted: Option<((f64, f64), Affine)>,
}
impl<'a> View<'a> {
fn of(n: &'a Kv<'a>) -> Self {
Self { layers: vec![n] }
Self { layers: vec![n], fitted: None }
}
fn size(&self) -> Option<(f64, f64)> {
self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size))
}
fn transform(&self) -> Option<Affine> {
self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix))
}
fn with(&self, top: impl IntoIterator<Item = &'a Kv<'a>>) -> Self {
@@ -147,13 +163,83 @@ impl<'a> View<'a> {
}
}
/// How the layers of a resized container follow it when the file gives no derived layout for
/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.
#[derive(Clone, Copy)]
enum Fit {
Frame { from: (f64, f64), to: (f64, f64) },
Scale(f64, f64),
}
impl Fit {
fn frame(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option<Self> {
let (from, to) = (from?, to?);
((from.0 - to.0).abs() > 1e-9 || (from.1 - to.1).abs() > 1e-9).then_some(Fit::Frame { from, to })
}
fn scale(from: Option<(f64, f64)>, to: Option<(f64, f64)>) -> Option<Self> {
let (from, to) = (from?, to?);
let k = |a: f64, b: f64| if a > 1e-9 { b / a } else { 1.0 };
let (kx, ky) = (k(from.0, to.0), k(from.1, to.1));
((kx - 1.0).abs() > 1e-9 || (ky - 1.0).abs() > 1e-9).then_some(Fit::Scale(kx, ky))
}
/// The size and transform (in the parent's coordinates) of a `w` × `h` layer at `t` with
/// constraints `h_con`, `v_con`. Like the editor's constraint solver, a layer keeps its
/// rotation and flip and stretches along its own axes.
fn apply(self, w: f64, h: f64, t: Affine, h_con: Option<&str>, v_con: Option<&str>) -> ((f64, f64), Affine) {
let [a, b, c, d, _, _] = t.as_coeffs();
let lin = Affine::new([a, b, c, d, 0.0, 0.0]);
let bb = figcraft_doc::geom::transform_rect_bbox(t, figcraft_doc::geom::Rect::new(0.0, 0.0, w, h));
// Where the new box goes: its top-left (constraints) or its centre (a group's scale).
let (kx, ky, x0, y0, centred) = match self {
Fit::Frame { from, to } => {
let axis = |con: Option<&str>, lo: f64, hi: f64, old: f64, new: f64| match con {
Some("MAX" | "FIXED_MAX") => (lo + new - old, hi + new - old),
Some("STRETCH") => (lo, hi + new - old),
Some("CENTER") => (lo + (new - old) / 2.0, hi + (new - old) / 2.0),
Some("SCALE") if old > 1e-9 => (lo * new / old, hi * new / old),
_ => (lo, hi),
};
let (x0, x1) = axis(h_con, bb.x0, bb.x1, from.0, to.0);
let (y0, y1) = axis(v_con, bb.y0, bb.y1, from.1, to.1);
let k = |new: f64, old: f64| if old > 1e-9 { (new / old).max(0.0) } else { 1.0 };
(k(x1 - x0, bb.width()), k(y1 - y0, bb.height()), x0, y0, false)
}
Fit::Scale(kx, ky) => (kx, ky, bb.center().x * kx, bb.center().y * ky, true),
};
let along = |x: f64, y: f64, fallback: f64| {
let len = x.hypot(y);
let k = (kx * x).hypot(ky * y) / len;
if len > 1e-12 && k.is_finite() { k } else { fallback }
};
let size = (w * along(a, b, kx), h * along(c, d, ky));
let nb = figcraft_doc::geom::transform_rect_bbox(lin, figcraft_doc::geom::Rect::new(0.0, 0.0, size.0, size.1));
let at = if centred { nb.center() } else { figcraft_doc::geom::Point::new(nb.x0, nb.y0) };
(size, Affine::translate((x0 - at.x, y0 - at.y)) * lin)
}
}
/// Overrides and derived layout by id path.
type Overrides<'a> = BTreeMap<Vec<String>, Vec<(Entry, &'a Kv<'a>)>>;
/// Where an override entry comes from.
#[derive(Clone, Copy, PartialEq)]
enum Entry {
Override,
Derived,
/// Derived layout of a nested instance for a size it no longer has: its size and place no
/// longer hold, the rest (resolved colours, text) still does.
Stale,
}
/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
/// instance (by id path), the path of the instance being expanded, and the property values.
#[derive(Clone)]
struct Inst<'a> {
root: String,
path: Vec<String>,
overrides: Rc<BTreeMap<Vec<String>, Vec<&'a Kv<'a>>>>,
overrides: Rc<Overrides<'a>>,
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
/// Mains being expanded (an instance inside its own main stops there).
symbols: Vec<String>,
@@ -165,6 +251,10 @@ struct Mapper<'a> {
blobs: Vec<&'a [u8]>,
/// Component property definitions: id → (REST name "Name#id", type).
prop_defs: BTreeMap<String, (String, &'a str)>,
/// File nodes by asset `key` and `version`: references can name a collection, variable or
/// style as the asset instead of the guid, and the file carries the resources to resolve
/// them (the collection node itself says the same `key`).
assets: BTreeMap<String, BTreeMap<String, String>>,
styles: Obj,
/// Descriptions of components and component sets (REST `components` / `componentSets`).
components: Obj,
@@ -203,7 +293,37 @@ impl<'a> Mapper<'a> {
}
let blobs =
msg.get("blobs").map(Kv::list).unwrap_or_default().iter().map(|b| b.get("bytes").and_then(Kv::bytes).unwrap_or_default()).collect();
Self { nodes, kids, blobs, prop_defs, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
let mut assets: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for n in msg.get("nodeChanges").map(Kv::list).unwrap_or_default() {
let (Some(key), Some(id)) = (n.get("key").and_then(Kv::str), n.get("guid").and_then(guid)) else { continue };
if key.is_empty() || key.len() > MAX_ASSET_PART {
continue;
}
let version = n.get("version").and_then(Kv::str).filter(|v| !v.is_empty() && v.len() <= MAX_ASSET_PART).unwrap_or("");
assets.entry(key.to_string()).or_default().entry(version.to_string()).or_insert(id);
}
Self { nodes, kids, blobs, prop_defs, assets, styles: Obj::new(), components: Obj::new(), sets: Obj::new(), warnings: Vec::new(), count: 0 }
}
/// The id of the resource a `VariableSetID` (or `VariableID`, `StyleId`) names: its `guid`,
/// else the file node its asset `key` and `version` names (a collection from a library is
/// referenced as the asset, and the file's own node for it says the same `key`). `None` when
/// the reference has neither or the asset names nothing (or ambiguously) in the file.
/// Original code for this importer: the field names are those of the schema each file embeds.
fn resource_id(&self, v: &Kv) -> Option<String> {
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) {
return Some(g);
}
let asset = v.get("assetRef")?;
let key = asset.get("key").and_then(Kv::str)?;
let versions = self.assets.get(key)?;
let version = asset.get("version").and_then(Kv::str);
match version {
Some(v) => versions.get(v),
// Without a version: an unversioned record, else the file's only one.
None => versions.get("").or_else(|| (versions.len() == 1).then(|| versions.values().next()).flatten()),
}
.cloned()
}
fn warn(&mut self, w: impl Into<String>) {
@@ -263,7 +383,7 @@ impl<'a> Mapper<'a> {
let name = sp.get("name").and_then(Kv::str).filter(|s| !s.is_empty()).unwrap_or("Flow");
flows.push((sp.get("position").and_then(Kv::str).unwrap_or(""), json!({"nodeId": c, "name": name})));
}
kids.extend(self.layer(&c, None, Affine::IDENTITY, None, 0));
kids.extend(self.layer(&c, None, Affine::IDENTITY, None, None, 0));
}
flows.sort_by(|a, b| a.0.cmp(b.0));
if !flows.is_empty() {
@@ -276,7 +396,15 @@ impl<'a> Mapper<'a> {
/// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
/// parent's coordinates to the enclosing frame's (groups and booleans place their children in
/// the frame's space, like the REST JSON). `parent`: the parent's view (grid tracks).
fn layer(&mut self, id: &str, inst: Option<&Inst<'a>>, space: Affine, parent: Option<&View<'a>>, depth: usize) -> Option<Value> {
fn layer(
&mut self,
id: &str,
inst: Option<&Inst<'a>>,
space: Affine,
parent: Option<&View<'a>>,
fit: Option<Fit>,
depth: usize,
) -> Option<Value> {
if depth > MAX_DEPTH {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return None;
@@ -287,10 +415,28 @@ impl<'a> Mapper<'a> {
}
let base = *self.nodes.get(id)?;
let path = inst.map(|i| [i.path.as_slice(), &[id.to_string()]].concat());
let view = match (inst, &path) {
(Some(i), Some(p)) => View::of(base).with(i.overrides.get(p).into_iter().flatten().copied()),
_ => View::of(base),
let entries = match (inst, &path) {
(Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(),
_ => &[],
};
let mut view = View::of(base).with(entries.iter().map(|e| e.1));
// A layer of a resized instance the file gives no derived layout for follows its
// constraints from the main's geometry, then takes the size and place overridden on it
// (auto layout places the others, as in the editor).
let derived = entries.iter().any(|(d, l)| *d == Entry::Derived && (l.get("size").is_some() || l.get("transform").is_some()));
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
&& view.str("stackPositioning") != Some("ABSOLUTE");
let main = View::of(base);
let set = |k: &str| entries.iter().rev().filter(|e| e.0 != Entry::Stale).find_map(|(_, l)| l.get(k));
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
let t = main.transform().unwrap_or_default();
let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"));
view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft)));
} else if entries.iter().any(|(d, l)| *d == Entry::Stale && (l.get("size").is_some() || l.get("transform").is_some()))
&& let Some(s) = set("size").and_then(size).or_else(|| main.size())
{
view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default()));
}
let rest_id = match (inst, &path) {
(Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")),
_ => id.to_string(),
@@ -338,21 +484,26 @@ impl<'a> Mapper<'a> {
self.component(&mut o, &view, &rest_id);
}
self.prop_refs(&mut o, &view, inst);
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") {
space * view.get("transform").and_then(matrix).unwrap_or_default()
} else {
Affine::IDENTITY
};
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
let mut kids = Vec::new();
if ty == "INSTANCE" {
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
let main = self.nodes.get(&sym).map(|m| View::of(m));
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
for c in self.children(&sym) {
kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), depth + 1));
kids.extend(self.layer(&c, Some(&sub), child_space, Some(&view), fit, depth + 1));
}
}
} else {
// Inside an instance a layer's children are its main's: they follow it from the main's
// size to its own.
let fit = match (inst, ty) {
(None, _) => None,
(Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()),
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
};
for c in self.children(id) {
kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1));
kids.extend(self.layer(&c, inst, child_space, Some(&view), fit, depth + 1));
}
}
if stand.is_some_and(|s| s.0 == "FRAME") {
@@ -487,10 +638,10 @@ impl<'a> Mapper<'a> {
if let Some(b) = v.str("blendMode").filter(|b| *b != "UNSET") {
o.insert("blendMode".into(), json!(b));
}
if let Some(s) = v.get("size").and_then(vec2) {
o.insert("size".into(), s);
if let Some((x, y)) = v.size() {
o.insert("size".into(), json!({"x": x, "y": y}));
}
let t = v.get("transform").and_then(matrix).unwrap_or_default();
let t = v.transform().unwrap_or_default();
o.insert("relativeTransform".into(), rows(space * t));
// Paints, from the shared style when one is linked (the layer's own copy can be stale).
let mut styles = Obj::new();
@@ -739,7 +890,8 @@ impl<'a> Mapper<'a> {
vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect();
let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0);
let scale = |k: &str| {
let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k)));
let size = v.size().map(|s| if k == "x" { s.0 } else { s.1 });
let norm = vd.get("normalizedSize").and_then(|s| num(s, k));
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
};
let (sx, sy) = (scale("x"), scale("y"));
@@ -908,7 +1060,15 @@ impl<'a> Mapper<'a> {
}
// Overrides by id path below this instance (the instance itself is addressed by its main's id).
let own_path: Vec<String> = outer.map(|i| [i.path.as_slice(), &[id.to_string()]].concat()).unwrap_or_default();
let mut overrides: BTreeMap<Vec<String>, Vec<&'a Kv<'a>>> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
let mut overrides: Overrides<'a> = outer.map(|i| (*i.overrides).clone()).unwrap_or_default();
// A nested instance's derived layout is for the size it has in its main: resized here, the
// sizes and places in it no longer hold and its layers follow their constraints instead.
let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0);
let resized = outer.is_some()
&& match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) {
(Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1),
(a, b) => a.is_some() != b.is_some(),
};
let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
for (layer, list) in lists.into_iter().enumerate() {
for ov in list {
@@ -920,9 +1080,16 @@ impl<'a> Mapper<'a> {
let e = overrides.entry([own_path.as_slice(), &rel].concat()).or_default();
// The outer instance's overrides win over this one's own; derived layout comes last.
if layer == 0 && outer.is_some() {
e.insert(0, ov);
e.insert(0, (Entry::Override, ov));
} else {
e.push(ov);
e.push((
match (layer, resized) {
(0, _) => Entry::Override,
(_, false) => Entry::Derived,
(_, true) => Entry::Stale,
},
ov,
));
}
}
}
@@ -1105,11 +1272,21 @@ impl<'a> Mapper<'a> {
o.insert("style".into(), json!({"boundVariables": text_bound}));
}
let mut modes = Obj::new();
let mut lost = false;
for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
if let (Some(set), Some(mode)) = (e.get("variableSetID").and_then(ref_guid), e.get("variableModeID").and_then(guid)) {
modes.insert(set, json!(mode));
let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue };
match self.resource_id(set) {
Some(id) => {
modes.insert(id, json!(mode));
}
// A mode of a collection neither named by guid nor resolvable in the file.
None if set.get("guid").is_some() || set.get("assetRef").is_some() => lost = true,
None => {}
}
}
if lost {
self.warn("variable modes of collections that aren't in the file were skipped");
}
if !modes.is_empty() {
o.insert("explicitVariableModes".into(), Value::Object(modes));
}
@@ -1128,7 +1305,7 @@ impl<'a> Mapper<'a> {
found.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
let mut out = Vec::new();
for (_, id, n) in found {
let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, 0) else { break };
let Some(node) = self.layer(&id, None, Affine::IDENTITY, None, None, 0) else { break };
let name = n.get("name").and_then(Kv::str).unwrap_or("Style");
let desc = n.get("styleDescription").or_else(|| n.get("description")).and_then(Kv::str).unwrap_or("");
let ty = n.get("styleType").and_then(Kv::str).unwrap_or("");
@@ -1174,7 +1351,7 @@ impl<'a> Mapper<'a> {
let mut variable_order = Vec::new();
let mut variables = Obj::new();
for (id, n) in of_type("VARIABLE", "sortPosition") {
let Some(set) = n.get("variableSetID").and_then(ref_guid).filter(|s| collections.contains_key(s)) else { continue };
let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue };
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
continue;
+208 -3
View File
@@ -32,18 +32,20 @@ enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQU
enum NavigationType NAVIGATE OVERLAY
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
enum EasingType IN_CUBIC OUT_CUBIC
enum ConstraintType MIN CENTER MAX STRETCH SCALE
struct GUID uint sessionID, uint localID
struct Color float r, float g, float b, float a
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
struct ColorStop Color color, float position
message Image byte[] hash, string name
message VariableID GUID guid
message AssetRef string key, string version
message VariableID GUID guid, AssetRef assetRef
message PropRefValue GUID defId
message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue
message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments
message VariableTarget VariableID id
message VariableSetID GUID guid
message VariableSetID GUID guid, AssetRef assetRef
message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
@@ -70,7 +72,7 @@ message Event InteractionType interactionType
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
message Interaction Event event, Action[] actions
message StartingPoint string name, string position
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -665,6 +667,209 @@ fn instances_inside_their_own_main_stop() {
assert!(doc.nodes().any(|n| n.name == "Self"));
}
/// A collection from a library is named by its asset key, not a guid — also in the explicit mode
/// map of the frames, components and instances set it on, and in the variables' collection link.
#[test]
fn modes_of_collections_named_by_asset_import() {
let modes = arr(vec![
o(vec![("id", g(90)), ("name", s("Dark")), ("sortPosition", s("!"))]),
o(vec![("id", g(91)), ("name", s("Light")), ("sortPosition", s("\""))]),
]);
let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
let values = o(vec![(
"entries",
arr(vec![
o(vec![("modeID", g(90)), ("variableData", color_value(rgb(0.0, 0.0, 0.0)))]),
o(vec![("modeID", g(91)), ("variableData", color_value(rgb(1.0, 1.0, 1.0)))]),
]),
)]);
let by_asset = |key: &'static str, version: Option<&'static str>| {
let mut r = vec![("key", s(key))];
r.extend(version.map(|v| ("version", s(v))));
o(vec![("assetRef", o(r))])
};
let mode_map = |set: Kv<'static>| o(vec![("entries", arr(vec![o(vec![("variableSetID", set), ("variableModeID", g(91))])]))]);
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page A", vec![]),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
// The collection itself says the asset key its references name.
node(4, Some((2, "!")), "VARIABLE_SET", "Schema", vec![("variableSetModes", modes), ("key", s("schema-collection")), ("version", s("7"))]),
node(
5,
Some((2, "\"")),
"VARIABLE",
"surface",
vec![("variableSetID", by_asset("schema-collection", Some("7"))), ("variableResolvedType", e("COLOR")), ("variableDataValues", values)],
),
node(
10,
Some((1, "!")),
"FRAME",
"Light section",
vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7"))))],
),
node(
20,
Some((1, "\"")),
"SYMBOL",
"Button",
vec![("size", xy(100.0, 40.0)), ("variableModeBySetMap", mode_map(by_asset("schema-collection", None)))],
),
node(
21,
Some((1, "#")),
"INSTANCE",
"Button copy",
vec![
("size", xy(100.0, 40.0)),
("symbolData", o(vec![("symbolID", g(20))])),
("variableModeBySetMap", mode_map(by_asset("schema-collection", Some("7")))),
],
),
// One names a collection that isn't in the file: its mode is skipped, with a warning.
node(11, Some((1, "$")), "FRAME", "Gone", vec![("size", xy(400.0, 300.0)), ("variableModeBySetMap", mode_map(by_asset("gone", None)))]),
];
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert_eq!(doc.variables.collections.len(), 1);
assert_eq!(doc.variables.variables.len(), 1);
assert_eq!(doc.variables.variables[0].collection, "1:4");
for name in ["Light section", "Button", "Button copy"] {
let n = named(&doc, name);
assert_eq!(n.var_modes.get("1:4").map(String::as_str), Some("1:91"), "{name}");
}
assert!(named(&doc, "Gone").var_modes.is_empty());
assert!(warnings.iter().any(|w| w.contains("variable modes of collections that aren't in the file")), "{warnings:?}");
}
#[test]
fn resized_instances_lay_their_layers_out_by_their_constraints() {
let pin = |h: &'static str, v: &'static str| vec![("horizontalConstraint", e(h)), ("verticalConstraint", e(v))];
let layer = |id, pos, ty, name, w, h, x, y, c: Vec<(&'static str, Kv<'static>)>| {
node(id, Some((5, pos)), ty, name, [vec![("size", xy(w, h)), ("transform", at(x, y))], c].concat())
};
let nodes = vec![
node(0, None, "DOCUMENT", "D", vec![]),
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
node(5, Some((1, "!")), "SYMBOL", "Logo", vec![("size", xy(132.0, 22.0)), ("transform", at(0.0, 0.0))]),
layer(6, "!", "ROUNDED_RECTANGLE", "Mark", 132.0, 22.0, 0.0, 0.0, pin("SCALE", "SCALE")),
layer(7, "\"", "ROUNDED_RECTANGLE", "Badge", 32.0, 10.0, 100.0, 12.0, pin("MAX", "MAX")),
layer(8, "#", "ROUNDED_RECTANGLE", "Bar", 112.0, 2.0, 10.0, 0.0, pin("STRETCH", "MIN")),
// A group scales its layers with it.
layer(11, "$", "FRAME", "Wordmark", 66.0, 22.0, 66.0, 0.0, [pin("SCALE", "SCALE"), vec![("resizeToFit", Kv::Bool(true))]].concat()),
node(12, Some((11, "!")), "ROUNDED_RECTANGLE", "Letter", vec![("size", xy(66.0, 22.0)), ("transform", at(0.0, 0.0))]),
node(
9,
Some((1, "\"")),
"INSTANCE",
"Small logo",
vec![("size", xy(90.0, 15.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(5))]))],
),
// Derived layout the file gives wins over the constraints.
node(
10,
Some((1, "#")),
"INSTANCE",
"Derived logo",
vec![
("size", xy(90.0, 15.0)),
("transform", at(0.0, 200.0)),
("symbolData", o(vec![("symbolID", g(5))])),
("derivedSymbolData", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(50.0, 5.0))])])),
],
),
// A size the instance overrides: the place still follows the constraints.
node(
13,
Some((1, "$")),
"INSTANCE",
"Resized badge",
vec![
("size", xy(90.0, 15.0)),
("transform", at(0.0, 300.0)),
(
"symbolData",
o(vec![
("symbolID", g(5)),
("symbolOverrides", arr(vec![o(vec![("guidPath", o(vec![("guids", arr(vec![g(7)]))])), ("size", xy(20.0, 10.0))])])),
]),
),
],
),
];
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
let box_of = |inst: &str, name: &str| {
let i = named(&doc, inst);
let n = i.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == name).unwrap();
let t = n.transform.translation();
[t.x, t.y, n.width, n.height].map(|x| (x * 1e3).round() / 1e3)
};
assert_eq!(box_of("Small logo", "Mark"), [0.0, 0.0, 90.0, 15.0]);
assert_eq!(box_of("Small logo", "Badge"), [58.0, 5.0, 32.0, 10.0]);
assert_eq!(box_of("Small logo", "Bar"), [10.0, 0.0, 70.0, 2.0]);
// The main's own, then the instances'.
let mut letters: Vec<[f64; 2]> =
doc.nodes().filter(|n| n.name == "Letter").map(|n| [n.width, n.height].map(|x| (x * 1e3).round() / 1e3)).collect();
letters.sort_by(|a, b| a[0].total_cmp(&b[0]));
assert_eq!(letters, [[45.0, 15.0], [45.0, 15.0], [45.0, 15.0], [66.0, 22.0]]);
assert_eq!(box_of("Derived logo", "Mark")[2..], [50.0, 5.0]);
assert_eq!(box_of("Resized badge", "Badge"), [58.0, 5.0, 20.0, 10.0]);
}
#[test]
fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() {
let stretch = || vec![("horizontalConstraint", e("STRETCH")), ("verticalConstraint", e("MIN"))];
let nodes = vec![
node(0, None, "DOCUMENT", "D", vec![]),
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
node(20, Some((1, "!")), "SYMBOL", "Track", vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))]),
node(21, Some((20, "!")), "ROUNDED_RECTANGLE", "Fill", [vec![("size", xy(100.0, 10.0)), ("transform", at(0.0, 0.0))], stretch()].concat()),
node(30, Some((1, "\"")), "SYMBOL", "Slider", vec![("size", xy(200.0, 50.0)), ("transform", at(0.0, 100.0))]),
// The nested instance is 150 wide in its main, with the layout derived for that.
node(
31,
Some((30, "!")),
"INSTANCE",
"Inner",
[
vec![
("size", xy(150.0, 10.0)),
("transform", at(0.0, 0.0)),
("symbolData", o(vec![("symbolID", g(20))])),
(
"derivedSymbolData",
arr(vec![o(vec![
("guidPath", o(vec![("guids", arr(vec![g(21)]))])),
("size", xy(150.0, 10.0)),
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
])]),
),
],
stretch(),
]
.concat(),
),
node(
40,
Some((1, "#")),
"INSTANCE",
"Wide slider",
vec![("size", xy(300.0, 50.0)), ("transform", at(0.0, 200.0)), ("symbolData", o(vec![("symbolID", g(30))]))],
),
];
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
let width = |outer: &str| {
let inner = doc.node(named(&doc, outer).children[0]).unwrap();
(inner.width, doc.node(inner.children[0]).unwrap().width)
};
// In its main the derived layout holds; stretched to 250 it no longer does.
assert_eq!(width("Slider"), (150.0, 150.0));
assert_eq!(width("Wide slider"), (250.0, 250.0));
// What else the derived data gives (the colour a variable resolves to, say) still holds.
let fill = doc.node(doc.node(named(&doc, "Wide slider").children[0]).unwrap().children[0]).unwrap();
assert!(matches!(fill.fills.first().map(|p| &p.kind), Some(figcraft_doc::color::PaintKind::Solid { color }) if color.r == 1.0 && color.g == 0.0));
}
#[path = "tail_tests.rs"]
mod tail;
+93
View File
@@ -0,0 +1,93 @@
//! The images of a native file within a budget (#189): however many members each within the
//! per-member limit a file holds, the images read from it together stay within a byte and a count
//! budget. The document's own images are read first, and the ones past the budget are reported.
use std::io::Write;
use figcraft_doc::Document;
use super::*;
fn png(k: u8) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A saved document with `n` images, and their hashes and total size.
fn file_with_images(n: u8) -> (Vec<u8>, Vec<String>, u64) {
let mut d = Document::new("File");
let mut hashes = Vec::new();
let mut total = 0;
for k in 0..n {
let bytes = png(k);
total += bytes.len() as u64;
hashes.push(add_image(&mut d, bytes).unwrap().0);
}
(save_bytes(&d, None).unwrap(), hashes, total)
}
/// `bytes` with one more member, `name` holding `data`.
fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec<u8> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names: Vec<String> = z.file_names().map(str::to_string).collect();
let mut buf = std::io::Cursor::new(Vec::new());
let mut w = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for n in names {
let member = read_member(&mut z, &n, u64::MAX).unwrap();
w.start_file(n, stored).unwrap();
w.write_all(&member).unwrap();
}
w.start_file(name, stored).unwrap();
w.write_all(data).unwrap();
w.finish().unwrap();
buf.into_inner()
}
#[test]
fn images_exactly_within_the_budget_all_load() {
let (bytes, hashes, total) = file_with_images(3);
let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap();
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)));
}
#[test]
fn images_past_the_byte_budget_are_left_out_and_reported() {
let (bytes, hashes, total) = file_with_images(3);
let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap();
let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum();
assert!(held < total, "{held} bytes held");
assert_eq!(loaded.doc.images.len(), 2);
let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect();
assert_eq!(lost.len(), 1);
assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues);
}
#[test]
fn images_past_the_count_budget_are_left_out_and_reported() {
let (bytes, _, _) = file_with_images(4);
let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap();
assert_eq!(loaded.doc.images.len(), 3);
assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues);
}
#[test]
fn the_documents_own_images_are_read_before_others() {
let (bytes, hashes, total) = file_with_images(2);
// An image the document doesn't list, named to come first in the archive's order.
let extra = with_member(&bytes, "images/0000", &png(99));
let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap();
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues);
assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues);
}
#[test]
fn a_file_within_the_shipped_budget_loads_as_before() {
let (bytes, hashes, _) = file_with_images(3);
let loaded = load_bytes_with_versions(&bytes).unwrap();
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)));
}
+101 -17
View File
@@ -20,6 +20,7 @@
pub mod fig;
pub mod library;
pub mod rest;
pub mod thumbnail;
pub use library::{is_valid_library_id, read_library, read_library_within};
@@ -46,6 +47,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000;
/// Largest version index and total size of the version snapshots read from one file.
const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
/// Most images, and image bytes together, read from one file (members past it are left out).
const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 };
const BRANCH_INDEX: &str = "branches/index.json";
/// Most branches a file keeps: making more is refused, extra entries in a file are skipped.
pub const MAX_BRANCHES: usize = 100;
@@ -452,8 +455,20 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
Ok(data)
}
/// How many images, and how many bytes of them together, one file may give.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ImageBudget {
pub images: usize,
pub bytes: u64,
}
/// Read a `.figcraft` archive (or bare JSON) with its version history.
pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
load_within(bytes, IMAGE_BUDGET)
}
/// [`load_bytes_with_versions`] reading at most `budget` of images.
pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, FormatError> {
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
if trimmed == Some(&b'{') {
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
@@ -463,18 +478,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let (mut doc, mut issues, own_images) = parse_json(&text)?;
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
let mut images = BTreeMap::new();
let mut unread = Vec::new();
for name in names {
let hash = name.trim_start_matches("images/").to_string();
match read_member(&mut z, &name, MAX_MEMBER) {
Ok(data) => {
images.insert(hash, image_data(data));
}
Err(e) => unread.push((hash, e)),
}
}
let (images, unread) = read_images(&mut z, own_images.as_ref(), budget);
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
match library::read(&mut z, &images, u64::MAX) {
Ok(lib) => doc.published = lib.map(Arc::new),
@@ -496,6 +500,41 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
Ok(Loaded { doc, issues, versions, branches })
}
/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read
/// with why. The document's own images (`own`) are read first, so a file past the budget loses
/// the images only its history uses before its own.
fn read_images<R: Read + Seek>(
z: &mut zip::ZipArchive<R>,
own: Option<&BTreeSet<String>>,
budget: ImageBudget,
) -> (BTreeMap<String, ImageData>, Vec<(String, FormatError)>) {
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect();
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
let mut images = BTreeMap::new();
let mut unread = Vec::new();
let mut left = budget.bytes;
for (i, hash) in hashes.into_iter().enumerate() {
let name = format!("images/{hash}");
if i >= budget.images {
unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images))));
continue;
}
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
let mb = budget.bytes / (1024 * 1024);
unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB"))));
continue;
}
match read_member(z, &name, left.min(MAX_MEMBER)) {
Ok(data) => {
left = left.saturating_sub(data.len() as u64);
images.insert(hash, image_data(data));
}
Err(e) => unread.push((hash, e)),
}
}
(images, unread)
}
/// Most lost images reported one by one (the rest as a count).
const MAX_IMAGE_ISSUES: usize = 20;
@@ -653,14 +692,53 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
out
}
/// Save atomically: write a temp file next to `path`, then rename over it.
/// Save atomically: write a staging file next to `path`, then rename over it.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> Result<(), FormatError> {
let bytes = save_bytes(doc, thumbnail_png)?;
let tmp = path.with_extension("figcraft.tmp");
std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?;
Ok(())
write_file_atomic(path, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))
}
/// Write `bytes` to `path` through a staging file and one atomic rename, so a reader of `path`
/// sees either the previous file or the complete new one, never a partial write. The staging file
/// is a uniquely named sibling (`<path>.tmp-figcraft-<process id>-<counter>`) created exclusively
/// (`create_new`) and written through its own handle: a file or symlink that already exists at a
/// staging name is never truncated or followed, and separate writers (also of separate processes)
/// never share a staging file. On failure only the staging file this call created is removed, and
/// `path` is replaced only on success. The rename replaces an existing destination on every
/// platform (`std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows).
/// (No `sync_all` yet: nothing around here promises durability across a power cut.)
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
static STAGING: AtomicU64 = AtomicU64::new(0);
// Three tries at a fresh name: the counter is unique within this process, so only a leftover
// of a dead process that now shares our process id can collide, and the next counter won't.
for _ in 0..3 {
let mut name = path.as_os_str().to_os_string();
name.push(format!(".tmp-figcraft-{}-{}", std::process::id(), STAGING.fetch_add(1, Relaxed)));
let tmp = std::path::PathBuf::from(name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(mut file) => {
if let Err(e) = file.write_all(bytes) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
return match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
// Leave nothing behind when the file can't take its place.
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
};
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free staging name next to the file"))
}
#[cfg(not(target_arch = "wasm32"))]
@@ -672,10 +750,16 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod image_budget_tests;
#[cfg(test)]
mod image_tests;
#[cfg(test)]
mod large_tests;
#[cfg(test)]
mod staging_tests;
#[cfg(test)]
mod thumbnail_tests;
#[cfg(test)]
mod version_tests;
#[cfg(test)]
+125
View File
@@ -0,0 +1,125 @@
//! The staging write behind `save_file`: a pre-existing file or symlink at a staging name is never
//! truncated or followed, concurrent savers of one file never corrupt it, and a failed write
//! cleans up after itself and leaves the destination alone.
use figcraft_doc::Document;
use crate::{save_file, write_file_atomic};
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("figcraft-staging-tests-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges).
#[cfg(unix)]
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
std::os::unix::fs::symlink(to, from).is_ok()
}
#[cfg(windows)]
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
std::os::windows::fs::symlink_file(to, from).is_ok()
}
#[test]
fn an_existing_staging_file_is_never_written_through() {
let d = dir("existing");
let path = d.join("a.figcraft");
// The old deterministic staging name, held by an unrelated file: saving must pick its own
// staging name instead of truncating this one.
let old = d.join("a.figcraft.tmp");
std::fs::write(&old, b"not yours").unwrap();
save_file(&Document::new("Mine"), &path, None).unwrap();
assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched");
assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine");
// Just the file and the untouched staging file: no new staging leftovers.
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_staging_symlink_is_never_followed() {
let d = dir("symlink");
let path = d.join("a.figcraft");
let victim = d.join("victim.txt");
std::fs::write(&victim, b"innocent").unwrap();
let old = d.join("a.figcraft.tmp");
if !symlink(&old, &victim) {
return; // Making a symlink needs privileges on some Windows setups.
}
save_file(&Document::new("Mine"), &path, None).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through");
assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn concurrent_savers_of_one_file_never_interleave() {
let d = dir("concurrent");
let path = d.join("a.figcraft");
let names = ["W0", "W1", "W2", "W3"];
for round in 0..5 {
let mut handles = Vec::new();
for name in names {
let (path, doc) = (path.clone(), Document::new(name));
handles.push(std::thread::spawn(move || save_file(&doc, &path, None)));
}
for h in handles {
// On Windows a rename over a file another writer just replaced can lose the race.
if cfg!(windows) {
let _ = h.join().unwrap();
} else {
h.join().unwrap().unwrap();
}
}
// Whatever landed is one writer's complete file, never a mix: a torn archive wouldn't load.
let (doc, _) = crate::load_file(&path).unwrap();
assert!(names.contains(&doc.name.as_str()), "round {round}: {:?}", doc.name);
}
// No staging file was left stuck.
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
let d = dir("failure");
// The destination is a directory: the rename fails, our staging file is removed, the
// directory is untouched.
let taken = d.join("folder.figcraft");
std::fs::create_dir_all(&taken).unwrap();
assert!(save_file(&Document::new("Mine"), &taken, None).is_err());
assert!(write_file_atomic(&taken, b"payload").is_err());
assert!(taken.is_dir());
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind");
// A missing parent directory fails without creating anything anywhere.
assert!(write_file_atomic(&d.join("no").join("such.figcraft"), b"payload").is_err());
assert!(!d.join("no").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// A read-only directory refuses the staging file, and the old destination stays.
let ro = d.join("read-only");
std::fs::create_dir_all(&ro).unwrap();
let dest = ro.join("keep.figcraft");
std::fs::write(&dest, b"keep").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&ro, perms).unwrap();
// Running as root would defeat the read-only directory: skip instead of failing.
let probe = ro.join(".probe");
if std::fs::write(&probe, b"").is_ok() {
let _ = std::fs::remove_file(&probe);
} else {
assert!(write_file_atomic(&dest, b"payload").is_err());
assert_eq!(std::fs::read(&dest).unwrap(), b"keep");
assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind");
}
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&ro, perms).unwrap();
}
let _ = std::fs::remove_dir_all(&d);
}
+170
View File
@@ -0,0 +1,170 @@
//! The picture a saved file carries of itself: `thumbnail.png` inside a `.figcraft` or `.fig` zip
//! (the home screen's recent files show it), and the colour of the page it sits on. Image files are
//! their own picture. Everything is read within caps: a missing, damaged or oversized picture gives
//! `None`, never an error or a panic.
use std::io::{Read, Seek};
use std::path::Path;
/// Largest `thumbnail.png` read from an archive.
pub const MAX_THUMBNAIL_BYTES: u64 = 8 * 1024 * 1024;
/// Largest image file read as its own picture.
pub const MAX_IMAGE_FILE_BYTES: u64 = 32 * 1024 * 1024;
/// Widest or tallest picture decoded (pixels); a larger one gives none.
pub const MAX_THUMBNAIL_SIDE: u32 = 8192;
/// Largest `document.json` read for its page colour, and `.fig` `meta.json`.
const MAX_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024;
const MAX_META_BYTES: u64 = 1024 * 1024;
/// Most memory one decode may take.
const MAX_DECODE_ALLOC: u64 = 256 * 1024 * 1024;
/// A decoded picture: straight (unpremultiplied) RGBA, `width * height * 4` bytes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Thumbnail {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The bytes of `thumbnail.png` in a zip archive (`.figcraft`, `.fig`); `None` when it isn't a zip,
/// has no thumbnail, or the thumbnail is larger than [`MAX_THUMBNAIL_BYTES`].
pub fn thumbnail_png<R: Read + Seek>(archive: R) -> Option<Vec<u8>> {
let mut z = zip::ZipArchive::new(archive).ok()?;
crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok()
}
/// Decode a picture (PNG, JPEG, WebP, GIF, BMP or TIFF) no larger than [`MAX_THUMBNAIL_SIDE`] on
/// either side, scaled down to at most `fit` pixels on its longer side (never up).
pub fn decode_thumbnail(bytes: &[u8], fit: u32) -> Option<Thumbnail> {
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_THUMBNAIL_SIDE);
limits.max_image_height = Some(MAX_THUMBNAIL_SIDE);
limits.max_alloc = Some(MAX_DECODE_ALLOC);
reader.limits(limits);
let mut picture = reader.decode().ok()?;
let fit = fit.clamp(1, MAX_THUMBNAIL_SIDE);
if picture.width() > fit || picture.height() > fit {
picture = picture.thumbnail(fit, fit);
}
let rgba = picture.to_rgba8();
let (width, height) = rgba.dimensions();
(width > 0 && height > 0).then(|| Thumbnail { width, height, rgba: rgba.into_raw() })
}
/// What kind of file `path` is, for its picture and its icon.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
/// `.figcraft`
FigCraft,
/// `.fig`
Fig,
/// `.svg`
Svg,
/// A raster image (`.png`, `.jpg`, `.webp`, …).
Image,
Other,
}
impl FileKind {
pub fn of(path: &Path) -> FileKind {
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"figcraft" => FileKind::FigCraft,
"fig" => FileKind::Fig,
"svg" => FileKind::Svg,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image,
_ => FileKind::Other,
}
}
}
/// The picture of the file at `path`, at most `fit` pixels on its longer side: the thumbnail a
/// `.figcraft` or `.fig` file carries, or an image file itself (up to [`MAX_IMAGE_FILE_BYTES`]).
/// `None` for anything else, and for a file that is missing, unreadable or damaged.
pub fn file_thumbnail(path: &Path, fit: u32) -> Option<Thumbnail> {
match FileKind::of(path) {
FileKind::FigCraft | FileKind::Fig => {
let file = std::fs::File::open(path).ok()?;
let png = thumbnail_png(std::io::BufReader::new(file))?;
decode_thumbnail(&png, fit)
}
FileKind::Image => {
let file = std::fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_IMAGE_FILE_BYTES.saturating_add(1)).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_IMAGE_FILE_BYTES {
return None;
}
decode_thumbnail(&bytes, fit)
}
FileKind::Svg | FileKind::Other => None,
}
}
/// How a file looks on the home screen: the colour of its first page (straight RGBA, 0 to 1) and
/// the picture it carries. Either may be missing: a file with nothing on it has a page colour and no
/// picture; an image file has a picture and no page.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Preview {
pub page: Option<[f32; 4]>,
pub picture: Option<Thumbnail>,
}
/// The [`Preview`] of the file at `path`, its picture at most `fit` pixels on its longer side.
/// Empty for a file that is missing, unreadable, damaged or of another kind.
pub fn file_preview(path: &Path, fit: u32) -> Preview {
match FileKind::of(path) {
FileKind::FigCraft | FileKind::Fig => {
let Ok(file) = std::fs::File::open(path) else { return Preview::default() };
archive_preview(std::io::BufReader::new(file), fit)
}
FileKind::Image => Preview { page: None, picture: file_thumbnail(path, fit) },
FileKind::Svg | FileKind::Other => Preview::default(),
}
}
/// The [`Preview`] of a `.figcraft` or `.fig` archive: `thumbnail.png`, and the first page's colour
/// from `document.json` (`.figcraft`) or `meta.json` (`.fig`).
pub fn archive_preview<R: Read + Seek>(archive: R, fit: u32) -> Preview {
let Ok(mut z) = zip::ZipArchive::new(archive) else { return Preview::default() };
let picture = crate::read_member(&mut z, "thumbnail.png", MAX_THUMBNAIL_BYTES).ok().and_then(|png| decode_thumbnail(&png, fit));
let page = if z.file_names().any(|n| n == "document.json") {
crate::read_member(&mut z, "document.json", MAX_DOCUMENT_BYTES).ok().and_then(|json| figcraft_page(&json))
} else {
crate::read_member(&mut z, "meta.json", MAX_META_BYTES).ok().and_then(|json| fig_page(&json))
};
Preview { page, picture }
}
/// The first page's background in a `.figcraft` `document.json`.
fn figcraft_page(json: &[u8]) -> Option<[f32; 4]> {
#[derive(serde::Deserialize)]
struct Head {
#[serde(default)]
pages: Vec<u64>,
#[serde(default)]
nodes: std::collections::BTreeMap<String, Node>,
}
#[derive(serde::Deserialize)]
struct Node {
#[serde(default)]
background: Option<serde_json::Value>,
}
let head: Head = serde_json::from_slice(json).ok()?;
let first = head.pages.first()?;
rgba(head.nodes.get(&first.to_string())?.background.as_ref()?)
}
/// The canvas colour a `.fig` file's `meta.json` gives (`client_meta.background_color`).
fn fig_page(json: &[u8]) -> Option<[f32; 4]> {
let meta: serde_json::Value = serde_json::from_slice(json).ok()?;
rgba(meta.get("client_meta")?.get("background_color")?)
}
/// `{r, g, b, a?}` (0 to 1) as a colour; `None` unless every channel is a finite number.
fn rgba(v: &serde_json::Value) -> Option<[f32; 4]> {
let channel = |k: &str| v.get(k).and_then(serde_json::Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32);
let a = if v.get("a").is_some() { channel("a")? } else { 1.0 };
Some([channel("r")?, channel("g")?, channel("b")?, a])
}
+178
View File
@@ -0,0 +1,178 @@
//! Reading the picture a saved file carries: from `.figcraft` and `.fig` archives and image files,
//! and `None` (never a panic) for missing, damaged, oversized and non-zip input.
use std::io::{Cursor, Write};
use figcraft_doc::Document;
use crate::thumbnail::{FileKind, MAX_THUMBNAIL_BYTES, MAX_THUMBNAIL_SIDE, decode_thumbnail, file_thumbnail, thumbnail_png};
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 255]));
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A zip holding `members` (stored).
fn zip(members: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut buf);
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, bytes) in members {
z.start_file(*name, stored).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap();
buf.into_inner()
}
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("figcraft-thumbnail-tests-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_figcraft_file_gives_its_thumbnail() {
let thumb = png(40, 20);
let bytes = crate::save_bytes(&Document::new("File"), Some(&thumb)).unwrap();
assert_eq!(thumbnail_png(Cursor::new(&bytes)).as_deref(), Some(thumb.as_slice()));
let t = decode_thumbnail(&thumb, 640).unwrap();
assert_eq!((t.width, t.height, t.rgba.len()), (40, 20, 40 * 20 * 4));
assert_eq!(t.rgba.get(..4), Some([10, 20, 30, 255].as_slice()));
// And from disk.
let path = dir("figcraft").join("a.figcraft");
std::fs::write(&path, &bytes).unwrap();
assert_eq!(file_thumbnail(&path, 640), Some(t));
}
#[test]
fn a_fig_file_gives_its_thumbnail() {
let thumb = png(30, 60);
let (bytes, _) = crate::fig::export(&Document::new("File"), Some(&thumb)).unwrap();
assert_eq!(thumbnail_png(Cursor::new(&bytes)).as_deref(), Some(thumb.as_slice()));
let path = dir("fig").join("b.fig");
std::fs::write(&path, &bytes).unwrap();
let t = file_thumbnail(&path, 640).unwrap();
assert_eq!((t.width, t.height), (30, 60));
}
#[test]
fn large_pictures_are_scaled_down_to_fit_never_up() {
let t = decode_thumbnail(&png(800, 400), 200).unwrap();
assert_eq!((t.width, t.height), (200, 100));
let t = decode_thumbnail(&png(300, 900), 300).unwrap();
assert_eq!((t.width, t.height), (100, 300));
let t = decode_thumbnail(&png(16, 8), 640).unwrap();
assert_eq!((t.width, t.height), (16, 8));
}
#[test]
fn missing_damaged_oversized_and_non_zip_input_give_none() {
// No thumbnail in the archive.
let bytes = crate::save_bytes(&Document::new("File"), None).unwrap();
assert_eq!(thumbnail_png(Cursor::new(&bytes)), None);
// Not a zip: bare JSON, an old-style `.fig`, nothing at all.
assert_eq!(thumbnail_png(Cursor::new(b"{\"format\":\"figcraft\"}".as_slice())), None);
assert_eq!(thumbnail_png(Cursor::new(b"fig-kiwi\x0f\0\0\0".as_slice())), None);
assert_eq!(thumbnail_png(Cursor::new(Vec::new())), None);
// A thumbnail that isn't a picture, or is cut short.
let damaged = zip(&[("thumbnail.png", b"\x89PNG\r\n\x1a\nnot really")]);
let png_bytes = thumbnail_png(Cursor::new(&damaged)).unwrap();
assert_eq!(decode_thumbnail(&png_bytes, 640), None);
let whole = png(20, 20);
assert_eq!(decode_thumbnail(&whole[..whole.len() / 2], 640), None);
assert_eq!(decode_thumbnail(b"", 640), None);
// Too many bytes for a thumbnail.
let big = vec![0u8; MAX_THUMBNAIL_BYTES as usize + 1];
assert_eq!(thumbnail_png(Cursor::new(zip(&[("thumbnail.png", &big)]))), None);
// Too many pixels on a side, however small the file.
assert_eq!(decode_thumbnail(&png(MAX_THUMBNAIL_SIDE + 1, 1), 640), None);
// A zip with a damaged central directory.
let mut cut = crate::save_bytes(&Document::new("File"), Some(&png(4, 4))).unwrap();
cut.truncate(cut.len() - 10);
assert_eq!(thumbnail_png(Cursor::new(cut)), None);
}
#[test]
fn files_on_disk_by_kind() {
let d = dir("kinds");
// An image is its own picture; an svg and a missing file have none.
let image = d.join("photo.PNG");
std::fs::write(&image, png(12, 6)).unwrap();
assert_eq!(file_thumbnail(&image, 640).map(|t| (t.width, t.height)), Some((12, 6)));
let svg = d.join("icon.svg");
std::fs::write(&svg, "<svg xmlns='http://www.w3.org/2000/svg'/>").unwrap();
assert_eq!(file_thumbnail(&svg, 640), None);
assert_eq!(file_thumbnail(&d.join("gone.figcraft"), 640), None);
// A `.figcraft` that is plain JSON, and one that is junk.
let json = d.join("plain.figcraft");
std::fs::write(&json, "{}").unwrap();
assert_eq!(file_thumbnail(&json, 640), None);
let junk = d.join("junk.fig");
std::fs::write(&junk, [0xFFu8; 64]).unwrap();
assert_eq!(file_thumbnail(&junk, 640), None);
// A folder named like a file.
let folder = d.join("folder.figcraft");
std::fs::create_dir_all(&folder).unwrap();
assert_eq!(file_thumbnail(&folder, 640), None);
let kinds: Vec<FileKind> =
["a.figcraft", "a.FIG", "a.svg", "a.jpeg", "a.webp", "a.txt", "noext"].iter().map(|p| FileKind::of(std::path::Path::new(p))).collect();
assert_eq!(kinds, [FileKind::FigCraft, FileKind::Fig, FileKind::Svg, FileKind::Image, FileKind::Image, FileKind::Other, FileKind::Other]);
}
/// A document whose first page is `rgb`.
fn doc_on(rgb: [f32; 3]) -> Document {
let mut d = Document::new("File");
let p = d.first_page().unwrap();
if let Some(figcraft_doc::NodeKind::Page { background, .. }) = d.node_mut(p).map(|n| &mut n.kind) {
*background = figcraft_doc::color::Color { r: rgb[0], g: rgb[1], b: rgb[2], a: 1.0 };
}
d
}
#[test]
fn previews_carry_the_first_pages_colour() {
use crate::thumbnail::{Preview, archive_preview, file_preview};
let thumb = png(8, 4);
// `.figcraft`: the first page's background from document.json.
let bytes = crate::save_bytes(&doc_on([0.1, 0.2, 0.3]), Some(&thumb)).unwrap();
let p = archive_preview(Cursor::new(&bytes), 640);
assert_eq!(p.page, Some([0.1, 0.2, 0.3, 1.0]));
assert_eq!(p.picture.map(|t| (t.width, t.height)), Some((8, 4)));
// `.fig`: the canvas colour from meta.json.
let (bytes, _) = crate::fig::export(&doc_on([0.5, 0.25, 0.75]), Some(&thumb)).unwrap();
let p = archive_preview(Cursor::new(&bytes), 640);
assert_eq!(p.page, Some([0.5, 0.25, 0.75, 1.0]));
assert!(p.picture.is_some());
// A file with nothing on it: its colour, no picture.
let bytes = crate::save_bytes(&doc_on([0.9, 0.9, 0.9]), None).unwrap();
assert_eq!(archive_preview(Cursor::new(&bytes), 640), Preview { page: Some([0.9, 0.9, 0.9, 1.0]), picture: None });
// Damaged or odd metadata: no colour, never a panic.
for meta in [&b"{"[..], b"{\"client_meta\":{\"background_color\":{\"r\":\"x\",\"g\":0,\"b\":0}}}", b"[]", b"{\"client_meta\":7}"] {
assert_eq!(archive_preview(Cursor::new(zip(&[("meta.json", meta)])), 640).page, None, "{}", String::from_utf8_lossy(meta));
}
for doc in [
&b"{\"pages\":[5],\"nodes\":{}}"[..],
b"{\"pages\":[],\"nodes\":{}}",
b"{\"pages\":\"x\"}",
b"{\"pages\":[1],\"nodes\":{\"1\":{\"background\":[1,2]}}}",
] {
assert_eq!(archive_preview(Cursor::new(zip(&[("document.json", doc)])), 640).page, None, "{}", String::from_utf8_lossy(doc));
}
// Out-of-range channels are clamped; alpha defaults to opaque.
let doc = b"{\"pages\":[1],\"nodes\":{\"1\":{\"background\":{\"r\":2,\"g\":-1,\"b\":0.5}}}}";
assert_eq!(archive_preview(Cursor::new(zip(&[("document.json", doc)])), 640).page, Some([1.0, 0.0, 0.5, 1.0]));
// Not an archive, or no file at all.
assert_eq!(archive_preview(Cursor::new(b"junk".to_vec()), 640), Preview::default());
assert_eq!(file_preview(std::path::Path::new("/nonexistent/x.figcraft"), 640), Preview::default());
let d = dir("preview");
let svg = d.join("a.svg");
std::fs::write(&svg, "<svg/>").unwrap();
assert_eq!(file_preview(&svg, 640), Preview::default());
let image = d.join("a.png");
std::fs::write(&image, png(5, 5)).unwrap();
let p = file_preview(&image, 640);
assert!(p.page.is_none() && p.picture.is_some());
}
+95
View File
@@ -0,0 +1,95 @@
//! Deeply nested layers and a resize (#210): constraints, group scaling and auto layout fill
//! follow a resize down to [`MAX_NESTING`] levels and stop there, instead of running out of stack.
//! Each case runs on a 1 MB stack, the main thread's on Windows; thousands of levels overflowed it.
use super::*;
use figcraft_doc::{AutoLayout, Constraints, Document, HConstraint, NodeKind, VConstraint};
/// Far deeper than the nesting the layout follows.
const DEEP: usize = 5000;
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap();
}
fn width(d: &Document, id: NodeId) -> f64 {
d.node(id).unwrap().width
}
/// A 100 × 100 frame holding `depth` frames, each inside the last and stretching with it; the
/// root and the chain from the top.
fn stretching_frames(depth: usize) -> (Document, NodeId, Vec<NodeId>) {
let mut d = Document::new("deep");
let page = d.first_page().unwrap();
let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let mut chain = Vec::new();
let mut parent = root;
for _ in 0..depth {
let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(f).unwrap().constraints = Constraints { horizontal: HConstraint::LeftRight, vertical: VConstraint::TopBottom };
chain.push(f);
parent = f;
}
(d, root, chain)
}
#[test]
fn deeply_nested_frames_follow_their_constraints_without_running_out_of_stack() {
on_small_stack(|| {
let (mut d, root, chain) = stretching_frames(DEEP);
set_size(&mut d, root, 200.0, 200.0);
apply_constraints(&mut d, root, 100.0, 100.0);
assert_eq!(width(&d, chain[0]), 200.0);
assert_eq!(width(&d, chain[MAX_NESTING - 2]), 200.0);
assert_eq!(width(&d, chain[DEEP - 1]), 100.0, "past the nesting the layout follows, layers stay");
});
}
#[test]
fn a_shallow_chain_follows_all_the_way_down() {
let (mut d, root, chain) = stretching_frames(20);
set_size(&mut d, root, 200.0, 200.0);
apply_constraints(&mut d, root, 100.0, 100.0);
assert!(chain.iter().all(|f| width(&d, *f) == 200.0));
}
#[test]
fn deeply_nested_groups_scale_without_running_out_of_stack() {
on_small_stack(|| {
let mut d = Document::new("deep");
let mut parent = d.first_page().unwrap();
let root = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
parent = root;
let mut chain = Vec::new();
for _ in 0..DEEP {
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
chain.push(parent);
}
scale_group_children(&mut d, root, 2.0, 2.0);
assert_eq!(width(&d, chain[0]), 200.0);
});
}
#[test]
fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() {
on_small_stack(|| {
let mut d = Document::new("deep");
let page = d.first_page().unwrap();
let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(root).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() });
let mut parent = root;
let mut chain = Vec::new();
for _ in 0..DEEP {
let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap();
let n = d.get_mut(f).unwrap();
n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
chain.push(f);
parent = f;
}
d.get_mut(root).unwrap().width = 300.0;
layout_frame(&mut d, root, &NoMeasure);
assert_eq!(width(&d, chain[0]), 300.0);
assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay");
});
}
+29
View File
@@ -20,6 +20,30 @@ use figcraft_doc::{
};
use figcraft_geom::{Affine, Point, Rect};
/// Most layout calls nested in one another (about one per level of layers): deeper layers don't
/// follow a resize, so a deeply nested document can't run the stack out (#210).
pub const MAX_NESTING: usize = 256;
thread_local! {
static NESTING: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// One level of nested layout calls, released when dropped.
struct Nested;
impl Nested {
/// Enter one more level; `None` when [`MAX_NESTING`] are in progress.
fn enter() -> Option<Self> {
NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self))
}
}
impl Drop for Nested {
fn drop(&mut self) {
NESTING.with(|n| n.set(n.get().saturating_sub(1)));
}
}
/// Measures text for a given width (auto-height text inside a Fill container).
pub trait Measure {
/// The height a text node needs at `width`.
@@ -311,6 +335,7 @@ struct Item {
/// Lay out one auto layout frame's children and size the frame if it hugs.
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let Some(_nested) = Nested::enter() else { return };
let Some(f) = doc.node(frame) else { return };
let Some(al) = f.layout.clone() else { return };
let horizontal = matches!(al.mode, LayoutMode::Horizontal);
@@ -893,6 +918,7 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu
/// (recursively for children frames that resize). In an auto layout frame only the children that
/// ignore auto layout (absolute position) follow constraints; the layout places the others.
pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
let Some(_nested) = Nested::enter() else { return };
let Some(f) = doc.node(id) else { return };
let absolute_only = f.layout.is_some();
let (nw, nh) = (f.width, f.height);
@@ -977,6 +1003,7 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
/// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its
/// centre following the group's scale.
pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) {
let Some(_nested) = Nested::enter() else { return };
let Some(n) = doc.node(id) else { return };
for c in n.children.clone() {
let Some(cn) = doc.node(c) else { continue };
@@ -1101,6 +1128,8 @@ mod behaviour_tests;
#[cfg(test)]
mod content_align_tests;
#[cfg(test)]
mod depth_tests;
#[cfg(test)]
mod nested_tests;
#[cfg(test)]
mod tests;
+7 -63
View File
@@ -3,6 +3,7 @@
//! behave the same.
use base64::Engine as _;
use figcraft_engine::params::normalize_paint;
use serde_json::{Map, Value, json};
use crate::backend::{Backend, decode_data, page_children};
@@ -127,7 +128,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"batch",
"Run commands as one step",
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. Note: later commands cannot reference ids created by earlier ones in the same batch (ids are only known afterwards), so create containers first, then batch the edits.",
"Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.",
obj(
json!({
"commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])},
@@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> {
acc
}
/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS
/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get
/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given
/// as plain colours are spread evenly.
/// Make paint/effect JSON friendlier than the engine's strict serde (see
/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects
/// still need it here.
pub fn normalize_props(props: &mut Map<String, Value>) {
for k in ["fills", "strokes", "effects"] {
if let Some(v) = props.get_mut(k) {
normalize_paintish(v, 0);
normalize_paint(v);
}
}
}
fn normalize_paintish(v: &mut Value, depth: usize) {
if depth > 16 {
return;
}
match v {
Value::Array(a) => {
for x in a {
normalize_paintish(x, depth + 1);
}
}
Value::Object(o) => {
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) {
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
}
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
if is_gradient {
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
&& t != "gradient"
{
let kind = t.trim_end_matches("gradient").to_string();
o.insert("type".into(), json!("gradient"));
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
}
o.entry("kind").or_insert(json!("linear"));
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
o.entry("from").or_insert(json!(from));
o.entry("to").or_insert(json!(to));
// A linear gradient's width handle goes square to its axis (else its bands skew,
// or vanish when the handle lies on the axis).
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
if linear
&& !o.contains_key("width")
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
{
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
}
o.entry("width").or_insert(json!(width));
if let Some(Value::Array(stops)) = o.get_mut("stops") {
let n = stops.len();
for (i, s) in stops.iter_mut().enumerate() {
if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) {
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
*s = json!({"position": pos, "color": c});
} else {
normalize_paintish(s, depth + 1);
}
}
}
}
}
_ => {}
}
}
/// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`.
fn friendly_params(p: &mut Value) {
let Some(o) = p.as_object_mut() else { return };
@@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) {
normalize_props(o);
for k in ["paint", "effect"] {
if let Some(v) = o.get_mut(k) {
normalize_paintish(v, 0);
normalize_paint(v);
}
}
}
+37
View File
@@ -307,3 +307,40 @@ fn task_vector_edit_keys() {
assert_eq!(c.page_children().len(), 1);
assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode");
}
/// A whole card in one `batch` call: later steps reach the frames earlier steps created through
/// `{"$step": n}`, so the tree is built in one round-trip and one undo step.
#[test]
fn task_card_in_one_batch() {
let mut c = Client::new();
let out = c.ok(
"batch",
json!({"label": "Card", "commands": [
{"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 320, "height": 50,
"props": {"layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Ada Lovelace"}},
{"command": "node.create", "params": {"type": "frame", "parent": {"$step": 0}, "name": "Button", "height": 44,
"props": {"layout": "horizontal", "sizingH": "fill"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 2}, "text": "Follow"}},
{"command": "node.set", "params": {"ids": [{"$step": 2}], "props": {"fill": "#3B5BDB"}}},
]}),
);
let results = ids(&out);
assert_eq!(results.len(), 5);
let (card, button) = (results[0]["id"].clone(), results[2]["id"].clone());
let n = c.node(&card);
assert_eq!(ids(&n["children"]).len(), 2, "title and button are inside the card");
let b = c.node(&button);
assert_eq!(ids(&b["children"]).len(), 1, "the label is inside the button");
assert_eq!(b["fills"][0]["color"].as_str().unwrap().to_ascii_uppercase(), "#3B5BDB");
assert_eq!(c.doc()["history"]["undo"][0], "Card", "one undo step");
// A forward reference fails the whole batch and names the step.
let e = c.err(
"batch",
json!({"commands": [
{"command": "node.create", "params": {"type": "frame", "parent": {"$step": 1}}},
{"command": "node.create", "params": {"type": "frame"}},
]}),
);
assert!(e.contains("step 0") && e.contains("earlier step"), "{e}");
}
@@ -0,0 +1,79 @@
//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer
//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its
//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow
//! that shows behind transparent areas is seen, blurred, through the layer.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow};
use super::*;
/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80
/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half)
/// and, when `blur`, a background blur.
fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) {
let mut d = Document::new("shadow behind blur");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap();
d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() {
let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())];
}
let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))];
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() };
n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }];
if blur {
n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true });
}
(d, f)
}
fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] {
let i = ((y * p.width() + x) * 4) as usize;
[p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]]
}
/// The largest channel difference between `a` and `b` over the shadow below the pane
/// (60..180 × 122..160).
fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 {
let mut worst = 0;
for y in 122..160 {
for x in 60..180 {
worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap());
}
}
worst
}
#[test]
fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() {
for show_behind in [false, true] {
let (with_blur, f) = scene(show_behind, true);
let (without, _) = scene(show_behind, false);
let got = render_node(&with_blur, f, 1.0).unwrap();
let want = render_node(&without, f, 1.0).unwrap();
let diff = max_diff_below_pane(&got, &want);
assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}");
// A 20% fill casts a faint shadow: the white between the stripes stays light.
let light = px(&got, 120, 140);
assert!(light[0] > 180, "show behind {show_behind}: {light:?}");
}
}
#[test]
fn a_shadow_showing_behind_is_seen_through_the_background_blur() {
let (d, f) = scene(true, true);
let got = render_node(&d, f, 1.0).unwrap();
// Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow
// under it, from y = 80).
let (top, low) = (px(&got, 120, 50), px(&got, 120, 110));
assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}");
// Hidden behind the layer, it doesn't.
let (d, f) = scene(false, true);
let got = render_node(&d, f, 1.0).unwrap();
let (top, low) = (px(&got, 120, 50), px(&got, 120, 110));
assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}");
}
+117 -30
View File
@@ -423,16 +423,18 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
let shape = node_outline(ctx.doc, n);
// Background blur and glass: the whole backdrop under the layer (every surface below this
// one, composited), through the shape, under the content.
if backdrop::has_effects(n)
&& let Some(sh) = &backdrop::outline(ctx.doc, n)
{
let pad = backdrop::pad(n, ctx.scale);
let p = i32::try_from(pad).unwrap_or(0);
if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)
&& let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
{
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None };
let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf));
// With drop shadows too, the layer casts them from what it paints, not from the backdrop, and
// the backdrop is read again once they lie under it (a shadow showing behind is seen through
// it). Until then, the layer's content sees the backdrop read first under it (#255).
let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_)));
// Kept apart, the first read is only what backdrop effects inside the content see: without
// any, the backdrop (blur or glass, the costly part) is computed once, after the shadows.
let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects));
let first_back = if !casts || nested() { read_backdrop(pix) } else { None };
if !casts && let Some(b) = &first_back {
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
}
let op = n.opacity.clamp(0.0, 1.0);
{
@@ -448,9 +450,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
};
let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_)));
// Backdrops further down see this layer composited onto `pix`.
// Backdrops further down see this layer composited onto `pix` (over its backdrop, when
// that is kept apart for now).
let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) };
draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under)));
let content_under = over_back.as_ref().unwrap_or(&under);
draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
}
// A group is no outline of its own: its inner shadows are cast from around what its layers
// draw together, over them.
@@ -465,24 +470,60 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
}
// Layer blur, noise and texture, in list order, over everything else.
if !casts {
layer_effects(ctx, &mut layer, n, id, lxf);
}
// Drop shadows go under the layer, in effect-list order.
{
// Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content
// with the layer effects it will get (a copy only when it gets some).
let layer_fx =
n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_)));
let content = (casts && layer_fx).then(|| {
let mut c = layer.clone();
layer_effects(ctx, &mut c, n, id, lxf);
c
});
let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale);
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::DropShadow(s) = &e.kind
&& let Some(sh) = shadow::drop_shadow(&caster, s)
{
composite(pix, (lx, ly), &sh, op, s.blend);
}
}
}
if casts {
// The backdrop with the shadows under it, the content over it, then the layer effects.
if let Some(mut b) = read_backdrop(pix) {
b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
layer = b;
}
layer_effects(ctx, &mut layer, n, id, lxf);
}
composite(pix, (lx, ly), &layer, op, n.blend);
}
/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on
/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it.
fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option<Pixmap> {
let ((lx, ly), (lw, lh)) = (at, size);
let pad = backdrop::pad(n, ctx.scale);
let p = i32::try_from(pad).unwrap_or(0);
let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?;
backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
}
/// Node `n`'s layer blur, noise and texture on its `layer`, in list order.
fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) {
for e in n.effects.iter().filter(|e| e.visible) {
match &e.kind {
EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale),
EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32),
EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32),
EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale),
EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32),
EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32),
_ => {}
}
}
// Drop shadows go under the layer, in effect-list order.
let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale);
for e in n.effects.iter().filter(|e| e.visible) {
if let EffectKind::DropShadow(s) = &e.kind
&& let Some(sh) = shadow::drop_shadow(&caster, s)
{
composite(pix, (lx, ly), &sh, op, s.blend);
}
}
composite(pix, (lx, ly), &layer, op, n.blend);
}
fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
@@ -531,21 +572,50 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
NodeKind::Text(t) => {
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
let gxf = xf * Affine::translate((0.0, dy));
// Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a
// bitmap the font ships, or COLR layers with a colour each.
let gs = gxf.determinant().abs().sqrt();
let mut ink: Vec<(usize, Ink)> = Vec::new();
// Group glyphs by style so runs with their own fills paint with them.
let mut by_style: HashMap<usize, BezPath> = HashMap::new();
for g in &layout.glyphs {
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
match g.color(gs) {
Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))),
Some(figcraft_text::ColorInk::Layers(layers)) => {
for layer in layers.iter() {
ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba)));
}
}
None => by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()),
}
}
for d in &layout.decorations {
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
}
let mut keys: Vec<usize> = by_style.keys().copied().collect();
// A run can have ink and no outline at all (bitmap-only fonts).
let mut keys: Vec<usize> = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect();
keys.sort_unstable();
// Every run's fills, then what paints over the fills, then the strokes on top.
keys.dedup();
// Every run's fills (its ink included), then what paints over the fills, then the
// strokes on top.
for k in &keys {
let Some(tp) = by_style.get(k).and_then(ts_path) else { continue };
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
if let Some(tp) = by_style.get(k).and_then(ts_path) {
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
}
for (_, item) in ink.iter().filter(|(style, _)| style == k) {
match item {
Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)),
Ink::Layer(path, rgba) => {
if let Some(tp) = ts_path(path) {
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
paint.anti_alias = true;
pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), None);
}
}
}
}
}
if let Some(f) = over_fills {
f(pix);
@@ -687,6 +757,23 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0))));
}
/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a
/// bitmap-strike glyph has no outline to stroke.
enum Ink {
/// An embedded bitmap and the transform from its pixels into layout coordinates.
Bitmap(Arc<figcraft_text::ColorBitmap>, Affine),
/// One COLR layer: its outline in layout coordinates and the colour to fill it with.
Layer(BezPath, [u8; 4]),
}
/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the
/// transform scales it.
fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) {
let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return };
let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() };
pix.draw_pixmap(0, 0, image, &paint, transform, None);
}
fn is_axis_aligned(xf: Affine) -> bool {
let c = xf.as_coeffs();
c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5])
+125
View File
@@ -0,0 +1,125 @@
//! Decoded images within a memory budget (#203): an image too large is refused from its header,
//! before its pixels are decoded, and the decoded-image caches hold a bounded number of bytes
//! however many images, filters and colour profiles a document uses.
use std::sync::Arc;
use figcraft_color::ColorProfile;
use tiny_skia::Pixmap;
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A PNG whose header claims `w` × `h` but which holds no pixel data at all.
fn png_header_only(w: u32, h: u32) -> Vec<u8> {
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&w.to_be_bytes());
ihdr.extend_from_slice(&h.to_be_bytes());
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
out.extend_from_slice(&13u32.to_be_bytes());
let chunk: Vec<u8> = b"IHDR".iter().copied().chain(ihdr).collect();
out.extend_from_slice(&chunk);
out.extend_from_slice(&crc32(&chunk).to_be_bytes());
out
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for b in data {
crc ^= u32::from(*b);
for _ in 0..8 {
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
#[test]
fn an_image_within_the_limits_decodes() {
let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 30, 30 * 20 * 4).unwrap();
assert_eq!((p.width(), p.height()), (30, 20));
}
#[test]
fn an_image_wider_or_taller_than_the_limit_is_refused() {
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none());
assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none());
}
#[test]
fn an_image_whose_pixels_pass_the_byte_budget_is_refused() {
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 1000, 30 * 20 * 4 - 1).is_none());
}
#[test]
fn a_huge_image_is_refused_from_its_header_alone() {
// 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and
// the file has none to decode: only the header is read.
assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none());
// Past the side limit, the shipped decode refuses it the same way.
assert!(images::decode(&png_header_only(20_000, 10)).is_none());
}
fn key(name: &str) -> images::Key {
(name.to_string(), [0; 7], ColorProfile::Srgb)
}
fn pixmap(side: u32) -> Arc<Pixmap> {
Arc::new(Pixmap::new(side, side).unwrap())
}
#[test]
fn the_image_cache_keeps_within_its_byte_budget() {
// 10 × 10 pixmaps are 400 bytes; 1000 bytes hold two.
let mut c = images::PixmapCache::default();
for name in ["a", "b", "c", "d", "e"] {
c.insert(key(name), pixmap(10), 1000);
assert!(c.bytes() <= 1000, "{} bytes held", c.bytes());
assert!(c.get(&key(name)).is_some(), "{name} was just added");
}
}
#[test]
fn a_full_image_cache_drops_the_least_recently_used_image_only() {
// 1000 bytes hold two 400-byte pixmaps: the third drops the one used longest ago.
let mut c = images::PixmapCache::default();
c.insert(key("a"), pixmap(10), 1000);
c.insert(key("b"), pixmap(10), 1000);
assert!(c.get(&key("a")).is_some());
c.insert(key("c"), pixmap(10), 1000);
assert!(c.get(&key("a")).is_some(), "used since b: kept");
assert!(c.get(&key("b")).is_none(), "used longest ago: dropped");
assert!(c.get(&key("c")).is_some());
assert_eq!(c.bytes(), 800);
}
#[test]
fn an_image_larger_than_the_budget_is_held_alone() {
let mut c = images::PixmapCache::default();
c.insert(key("small"), pixmap(10), 1000);
c.insert(key("big"), pixmap(20), 1000);
assert!(c.get(&key("big")).is_some());
assert!(c.get(&key("small")).is_none());
assert_eq!(c.bytes(), 20 * 20 * 4);
// The next image takes its place.
c.insert(key("next"), pixmap(10), 1000);
assert_eq!(c.bytes(), 400);
}
#[test]
fn filter_and_profile_variants_count_against_one_budget() {
let mut c = images::PixmapCache::default();
for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() {
for f in 0..4 {
c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000);
assert!(c.bytes() <= 1000, "{} bytes held", c.bytes());
}
}
}
+88 -22
View File
@@ -13,11 +13,69 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind};
use figcraft_doc::{Document, ImageData, NodeId};
use tiny_skia::Pixmap;
type Key = (String, [i32; 7], ColorProfile);
pub(crate) type Key = (String, [i32; 7], ColorProfile);
fn cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
/// The largest image side decoded.
const MAX_SIDE: u32 = 16_384;
/// The most bytes one image's decoding may allocate: the image crate's own default limit (a
/// larger image is refused from its header).
const MAX_DECODED_BYTES: u64 = 512 << 20;
/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held
/// alone).
const MAX_CACHED_BYTES: u64 = 512 << 20;
/// The most entries each decoded-image cache holds.
const MAX_CACHED_IMAGES: usize = 256;
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
/// the images on screen stay while they fit.
#[derive(Default)]
pub(crate) struct PixmapCache {
/// Each pixmap and when it was last used.
map: HashMap<Key, (Arc<Pixmap>, u64)>,
bytes: u64,
tick: u64,
}
impl PixmapCache {
pub(crate) fn get(&mut self, key: &Key) -> Option<Arc<Pixmap>> {
self.tick += 1;
let tick = self.tick;
self.map.get_mut(key).map(|(p, used)| {
*used = tick;
p.clone()
})
}
/// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone).
pub(crate) fn insert(&mut self, key: Key, p: Arc<Pixmap>, max_bytes: u64) {
let size = p.data().len() as u64;
self.remove(&key);
while self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES {
let Some(oldest) = self.map.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { break };
self.remove(&oldest);
}
self.tick += 1;
self.map.insert(key, (p, self.tick));
self.bytes = self.bytes.saturating_add(size);
}
fn remove(&mut self, key: &Key) {
if let Some((p, _)) = self.map.remove(key) {
self.bytes = self.bytes.saturating_sub(p.data().len() as u64);
}
}
/// The bytes of pixels held.
#[cfg(test)]
pub(crate) fn bytes(&self) -> u64 {
self.bytes
}
}
fn cache() -> &'static Mutex<PixmapCache> {
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PixmapCache::default()))
}
fn fkey(f: &ImageFilters) -> [i32; 7] {
@@ -34,7 +92,23 @@ pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
/// `target` conversion is still to do).
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES)
}
/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most
/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(max_side);
limits.max_image_height = Some(max_side);
limits.max_alloc = Some(max_bytes);
reader.limits(limits);
let mut dec = reader.into_decoder().ok()?;
if dec.total_bytes() > max_bytes {
return None;
}
let source = crate::image_profile::Source::read(bytes, &mut dec);
let img = image::DynamicImage::from_decoder(dec).ok()?;
match source.convert(&img, target) {
@@ -66,7 +140,7 @@ pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option<String> {
/// Straight RGBA → a premultiplied pixmap (max 16k per side).
fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 || w > 16_384 || h > 16_384 {
if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
let mut p = Pixmap::new(w, h)?;
@@ -87,17 +161,13 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option<Pixmap> {
pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
return Some(p);
}
let (mut p, srgb) = decode_in(&data?.bytes, profile)?;
// Images with their own profile are in `profile` already (sRGB → sRGB is the identity).
finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb });
let p = Arc::new(p);
let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 256 {
c.clear();
}
c.insert(key, p.clone());
cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone(), MAX_CACHED_BYTES);
Some(p)
}
@@ -166,7 +236,7 @@ pub fn decode_frames(bytes: &[u8]) -> Option<Vec<(Pixmap, f64)>> {
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?;
let (w, h) = dec.dimensions();
let frame_bytes = u64::from(w) * u64::from(h) * 4;
if frame_bytes == 0 || w > 16_384 || h > 16_384 {
if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE {
return None;
}
let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize;
@@ -304,22 +374,18 @@ pub fn pad(pm: &Pixmap) -> Option<Pixmap> {
Some(out)
}
fn padded_cache() -> &'static Mutex<HashMap<Key, Arc<Pixmap>>> {
static C: OnceLock<Mutex<HashMap<Key, Arc<Pixmap>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
fn padded_cache() -> &'static Mutex<PixmapCache> {
static C: OnceLock<Mutex<PixmapCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PixmapCache::default()))
}
/// `pm` with a transparent 1-pixel border (cached).
pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option<Arc<Pixmap>> {
let key = (hash.to_string(), fkey(filters), profile);
if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return Some(p.clone());
return Some(p);
}
let out = Arc::new(pad(pm)?);
let mut c = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 256 {
c.clear();
}
c.insert(key, out.clone());
padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, out.clone(), MAX_CACHED_BYTES);
Some(out)
}
+4
View File
@@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec<u8>, kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
out
}
#[cfg(test)]
mod backdrop_shadow_tests;
#[cfg(test)]
mod backdrop_tests;
#[cfg(test)]
@@ -442,6 +444,8 @@ mod grain_tests;
#[cfg(test)]
mod group_effects_tests;
#[cfg(test)]
mod image_budget_tests;
#[cfg(test)]
mod image_profile_tests;
#[cfg(test)]
mod lod_tests;
+31
View File
@@ -217,6 +217,37 @@ fn text_renders_something() {
}
}
/// The machine's colour emoji face, if it has one (this test skips without: no font ships here).
fn emoji_face() -> Option<(String, String)> {
figcraft_text::scan_system_fonts();
let mut db = figcraft_text::db().write().unwrap();
db.emoji_fallbacks().into_iter().find(|f| f.has_color()).map(|f| (f.family.clone(), f.style.clone()))
}
#[test]
fn colour_emoji_draw_their_pixels() {
let Some((family, style)) = emoji_face() else { return };
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let mut td = TextData::new("\u{1F600}");
(td.style.font_family, td.style.font_style) = (family.clone(), style.clone());
td.style.font_size = 48.0;
d.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 200.0, 64.0).unwrap();
let pix = render_view(&d, p, &view(100, 80), &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
// The emoji is the only thing in this document that paints saturated colour.
let coloured = pix
.data()
.as_chunks::<4>()
.0
.iter()
.filter(|c| {
let (r, g, b) = (i16::from(c[0]), i16::from(c[1]), i16::from(c[2]));
c[3] == 255 && (r.max(g).max(b) - r.min(g).min(b)) > 60
})
.count();
assert!(coloured > 200, "{family} drew {coloured} coloured pixels");
}
#[test]
fn export_png_roundtrip() {
let (d, _, r) = doc_with_rect(Color::from_hex("00FF00").unwrap());
+11 -3
View File
@@ -86,6 +86,12 @@ fn clip_attr(clip: &Option<String>) -> String {
clip.as_ref().map(|c| format!(" clip-path=\"url(#{c})\"")).unwrap_or_default()
}
/// `el` in a group with `clip` (a ` clip-path` attribute, or nothing). A clip path is in page
/// coordinates, and on an element with a `transform` it would be read after that transform.
fn clipped(clip: &str, el: String) -> String {
if clip.is_empty() { el } else { format!("<g{clip}>{el}</g>") }
}
fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
@@ -433,7 +439,8 @@ impl W<'_> {
let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.fills {
if let Some(a) = self.paint_attr(p, w, h) {
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(path));
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(path));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
@@ -505,7 +512,7 @@ impl W<'_> {
if n.has_visible_stroke()
&& let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten()
{
self.body.push_str(&simple);
self.body.push_str(&clipped(&paint_clip, simple));
} else if n.has_visible_stroke() {
let geom = match &n.kind {
NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
@@ -517,7 +524,8 @@ impl W<'_> {
let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
for p in &n.strokes {
if let Some(a) = self.paint_attr(p, w, h) {
let _ = write!(self.body, "<path transform=\"{}\" d=\"{}\" {a}{fr}{paint_clip}/>", matrix(xf), path_d(&area));
let el = format!("<path transform=\"{}\" d=\"{}\" {a}{fr}/>", matrix(xf), path_d(&area));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
+39
View File
@@ -43,3 +43,42 @@ fn import_gradient_and_groups() {
assert!(kinds.contains(&"GROUP"));
assert!(kinds.contains(&"RECTANGLE") && kinds.contains(&"ELLIPSE"), "{kinds:?}");
}
/// #325: a frame with children inside a clipping frame keeps its own paint inside the clip. The
/// clip path is in page coordinates, so it must sit on a group around the transformed path, never
/// on the path itself (where it would be read after the path's transform, offset by it).
#[test]
fn nested_frame_paint_is_clipped_in_page_space() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let page = d.create(p, NodeKind::Frame, "Page", 0.0, 0.0, 400.0, 300.0).unwrap();
d.get_mut(page).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
let card = d.create(page, NodeKind::Frame, "Card", 40.0, 60.0, 300.0, 120.0).unwrap();
let c = d.get_mut(card).unwrap();
c.fills = vec![Paint::solid(Color::from_hex("DDDDDD").unwrap())];
c.corners.radii = [12.0; 4];
c.clip = true;
let badge = d.create(card, NodeKind::Frame, "Badge", 200.0, 40.0, 80.0, 40.0).unwrap();
let b = d.get_mut(badge).unwrap();
b.fills = vec![Paint::solid(Color::from_hex("1E6F8E").unwrap())];
b.strokes = vec![Paint::solid(Color::from_hex("AA0000").unwrap())];
b.stroke.weight = 2.0;
let dot = d.create(badge, NodeKind::Rectangle, "Dot", 10.0, 10.0, 20.0, 20.0).unwrap();
d.get_mut(dot).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())];
for simplify_stroke in [true, false] {
let svg = export_svg(&d, &[page], &SvgOptions { simplify_stroke, ..SvgOptions::default() }).unwrap();
for tag in svg.split('<').filter(|t| t.contains(" clip-path=")) {
assert!(!tag.contains(" transform="), "clip-path on a transformed element: <{tag}\n{svg}");
}
// The card's clip: its rounded outline starts after the top-left radius.
let def = svg.find("<path d=\"M52 60").unwrap();
let open = svg[..def].rfind("<clipPath id=\"").unwrap() + "<clipPath id=\"".len();
let card_clip = &svg[open..open + svg[open..].find('"').unwrap()];
let wrapped = format!("<g clip-path=\"url(#{card_clip})\">");
for color in ["#1E6F8E", "#AA0000"] {
let at = svg.find(color).unwrap_or_else(|| panic!("{color} missing\n{svg}"));
let path = svg[..at].rfind("<path").unwrap();
assert!(svg[..path].ends_with(&wrapped), "{color} not in the card's clip\n{svg}");
}
}
}
+2
View File
@@ -13,6 +13,8 @@ figcraft-geom = { workspace = true }
figcraft-doc = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
# Colour emoji: sbix/CBDT strikes are PNG images the text layer decodes for the renderer.
png = { workspace = true }
unicode-bidi = { workspace = true }
unicode-segmentation = { workspace = true }
log = { workspace = true }
+313
View File
@@ -0,0 +1,313 @@
//! Colour glyphs: embedded bitmap strikes (sbix, CBDT/EBDT) and COLR v0 layers.
//!
//! Colour emoji fonts don't draw like other glyphs: the picture itself lives in a bitmap strike
//! (sbix, CBDT/EBDT) or in a stack of coloured layers, not in the glyph's outline — in Apple Color
//! Emoji most emoji are `sbix` bitmaps while the flags are drawn from outlines, and Noto Color
//! Emoji has no outlines at all. This module gives the renderer either a
//! premultiplied RGBA raster at the size it asked for, or the layer glyphs and the palette colours
//! to fill with its own outlines. Anything else (COLR v1's paint graphs, alpha-only strikes) is
//! left to the outline pipeline, which draws whatever outlines the face has, as before.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::PoisonError;
use figcraft_geom::Affine;
use skrifa::bitmap::{BitmapData, BitmapGlyph, BitmapStrikes, Origin};
use skrifa::color::ColorPalettes;
use skrifa::instance::Size;
use skrifa::raw::TableProvider;
use skrifa::{FontRef, GlyphId};
use crate::fontdb::{Coords, Face};
/// Colour rasters kept per face before the cache starts over, in bytes: they are pixels, and a
/// canvas zoomed far in makes each one large.
const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024;
/// Rasters kept per face (misses count too: a face whose strikes cover a few glyphs builds one
/// per glyph and size, and the zoom changes the size).
const MAX_RASTER_ENTRIES: usize = 8192;
/// COLR layer lists kept per face (24 bytes a layer, so a count is enough).
const MAX_LAYERS: usize = 4096;
/// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels).
const MAX_PNG_SIDE: u32 = 4096;
/// Largest colour raster per em (device pixels). Past this the renderer scales one up instead.
pub const MAX_PX_PER_EM: f64 = 512.0;
/// A colour bitmap glyph: premultiplied RGBA at `size` px per em, and the offset of its top-left
/// corner from the glyph's pen, in px at `size` (y down).
#[derive(Debug)]
pub struct ColorBitmap {
pub width: u32,
pub height: u32,
pub left: f64,
pub top: f64,
pub rgba: Arc<[u8]>,
}
/// One layer of a COLR v0 glyph: the glyph that draws the layer and the palette colour it fills
/// with (straight RGBA: the renderer paints it).
#[derive(Debug)]
pub struct ColorLayer {
pub gid: u32,
pub rgba: [u8; 4],
}
/// A glyph's colour ink: what draws instead of its outline, placed in coordinates the caller
/// supplies (a bitmap pixel maps through `place`; COLR layers are drawn like the glyph's outline).
pub enum ColorInk {
Bitmap(Arc<ColorBitmap>, Affine),
Layers(Arc<Vec<ColorLayer>>),
}
/// Colour rasters cached per face, keyed by glyph, variations and size.
#[derive(Default)]
pub(crate) struct Rasters {
entries: HashMap<(u32, Coords, u64), Option<Arc<ColorBitmap>>>,
bytes: usize,
}
impl Rasters {
fn get(&self, key: &(u32, Coords, u64)) -> Option<Arc<ColorBitmap>> {
self.entries.get(key).and_then(Option::clone)
}
fn known(&self, key: &(u32, Coords, u64)) -> bool {
self.entries.contains_key(key)
}
fn insert(&mut self, key: (u32, Coords, u64), bitmap: Option<Arc<ColorBitmap>>) {
if let Some(b) = &bitmap {
self.bytes += b.rgba.len();
}
if self.bytes > MAX_RASTER_BYTES || self.entries.len() > MAX_RASTER_ENTRIES {
self.entries.clear();
self.bytes = bitmap.as_ref().map(|b| b.rgba.len()).unwrap_or(0);
}
self.entries.insert(key, bitmap);
}
}
impl Face {
/// Whether the face has colour glyphs at all: bitmaps (sbix, CBDT/EBDT) or COLR layers.
pub fn has_color(&self) -> bool {
self.has_bitmaps() || self.has_colr()
}
/// The face has bitmap strikes (`sbix`/`CBDT`/`EBDT`)
pub fn has_bitmaps(&self) -> bool {
self.bitmap_strikes
}
/// The face has a `COLR` table.
pub fn has_colr(&self) -> bool {
self.colr
}
/// The glyph's embedded bitmap, scaled to `size` px per em (premultiplied RGBA), with the offset
/// of its top-left corner from the pen in px at `size` (y down). `None` when the face has no
/// bitmap for the glyph.
pub fn color_bitmap(&self, gid: u32, coords: &Coords, size: f64) -> Option<Arc<ColorBitmap>> {
if !self.bitmap_strikes || !size.is_finite() || size <= 0.0 {
return None;
}
let size = size.clamp(1.0, MAX_PX_PER_EM);
let key = (gid, coords.clone(), size.to_bits());
let mut cache = self.rasters.lock().unwrap_or_else(PoisonError::into_inner);
if cache.known(&key) {
return cache.get(&key);
}
let made = self.read_bitmap(gid, size).map(Arc::new);
cache.insert(key, made.clone());
made
}
/// The glyph's COLR v0 layers (empty when the glyph has none: COLR v1 glyphs, or no COLR data).
pub fn color_layers(&self, gid: u32) -> Option<Arc<Vec<ColorLayer>>> {
if !self.colr {
return None;
}
let mut cache = self.layers.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(hit) = cache.get(&gid) {
return hit.clone();
}
let made = self.read_layers(gid).map(Arc::new);
if cache.len() >= MAX_LAYERS {
cache.clear();
}
cache.insert(gid, made.clone());
made
}
/// Read a strike bitmap and place it the way Skia's fontations port does
/// (`src/ports/fontations/src/bitmap.rs`): the outer bearings are in font units and scale with
/// the em, the inner ones are pixels at the strike's ppem, and an sbix image is measured from
/// its lower-left corner (the spec places the graphic's origin at the glyph box's xMin, yMin).
fn read_bitmap(&self, gid: u32, size: f64) -> Option<ColorBitmap> {
let gid = GlyphId::new(gid);
let font = FontRef::from_index(self.bytes.data(), self.index).ok()?;
let strike = BitmapStrikes::new(&font).glyph_for_size(Size::new(size as f32), gid)?;
let (rgba, width, height) = decode(&strike)?;
if width == 0 || height == 0 || rgba.len() != width as usize * height as usize * 4 {
return None;
}
let (left, top) = placement(&strike, size, self.upem);
// Scale the strike's own pixels to the requested size (the caller blits them 1:1).
let scale = size / f64::from(strike.ppem_y.max(1.0));
let w = ((f64::from(width) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32;
let h = ((f64::from(height) * scale).round() as i64).clamp(1, MAX_PX_PER_EM as i64) as u32;
let rgba = if (w, h) == (width, height) { rgba } else { scaled(&rgba, width, height, w, h)? };
Some(ColorBitmap { width: w, height: h, left, top, rgba: rgba.into() })
}
/// Read the COLR v0 layers of a glyph (`None` for a glyph the table doesn't cover, which is
/// every glyph of a COLR v1-only font).
fn read_layers(&self, gid: u32) -> Option<Vec<ColorLayer>> {
let font = FontRef::from_index(self.bytes.data(), self.index).ok()?;
let colr = font.colr().ok()?;
let range = colr.v0_base_glyph(GlyphId::new(gid))?;
let palettes = ColorPalettes::new(&font);
let palette = palettes.get(0);
let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]);
let mut out = Vec::new();
for index in range {
let (layer, color_index) = colr.v0_layer(index)?;
let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]);
out.push(ColorLayer { gid: layer.to_u32(), rgba });
}
(!out.is_empty()).then_some(out)
}
}
/// Where a strike's image sits from the pen: the image's top-left corner in px at `size` (y down).
fn placement(glyph: &BitmapGlyph, size: f64, upem: f64) -> (f64, f64) {
let units = size / upem.max(1.0);
let pixels = size / f64::from(glyph.ppem_y.max(1.0));
let left = f64::from(glyph.inner_bearing_x) * pixels + f64::from(glyph.bearing_x) * units;
let above = match glyph.placement_origin {
// sbix: the origin is the image's lower-left corner, so the whole image hangs above it.
Origin::BottomLeft => f64::from(glyph.inner_bearing_y) + f64::from(glyph.height),
Origin::TopLeft => f64::from(glyph.inner_bearing_y),
};
(left, -above * pixels - f64::from(glyph.bearing_y) * units)
}
/// A strike's pixels as premultiplied RGBA (what the renderer's pixmaps hold), with their size.
fn decode(glyph: &BitmapGlyph<'_>) -> Option<(Vec<u8>, u32, u32)> {
match &glyph.data {
// The OpenType spec's PNG strikes are plain PNGs (straight alpha), so they are
// premultiplied here for the renderer.
BitmapData::Png(png) => {
let (rgba, width, height) = decode_png(png)?;
Some((premultiplied(rgba), width, height))
}
// Bit depth 32 without PNG is BGRA with premultiplied channels (the spec says so).
BitmapData::Bgra(bgra) => {
let (width, height) = (glyph.width, glyph.height);
let mut rgba = bgra.get(..width as usize * height as usize * 4)?.to_vec();
for px in rgba.chunks_exact_mut(4) {
px.swap(0, 2);
}
Some((rgba, width, height))
}
// Alpha-only strikes would be masks, not colour: the outline pipeline draws those glyphs.
BitmapData::Mask(_) => None,
}
}
#[cfg(test)]
#[path = "color_tests.rs"]
mod tests;
/// A PNG strike as straight 8-bit RGBA.
fn decode_png(png: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
// Expand palettes, `tRNS` and sub-byte samples to 8-bit RGBA.
decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA);
let mut reader = decoder.read_info().ok()?;
// The header's size is checked before anything is allocated for it: a font can claim any size.
let (w, h) = reader.info().size();
if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE {
return None;
}
let mut buf = vec![0u8; reader.output_buffer_size()?];
let info = reader.next_frame(&mut buf).ok()?;
let (width, height) = (info.width, info.height);
let data = buf.get(..info.buffer_size())?;
let rgba = match info.color_type {
png::ColorType::Rgba => data.to_vec(),
png::ColorType::Rgb => data.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
png::ColorType::Grayscale => data.iter().flat_map(|g| [*g, *g, *g, 255]).collect(),
png::ColorType::GrayscaleAlpha => data.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(),
png::ColorType::Indexed => return None,
};
Some((rgba, width, height))
}
/// Straight RGBA → premultiplied RGBA (in place).
fn premultiplied(mut rgba: Vec<u8>) -> Vec<u8> {
for px in rgba.chunks_exact_mut(4) {
let a = u32::from(px[3]);
for c in &mut px[..3] {
*c = ((u32::from(*c) * a + 127) / 255) as u8;
}
}
rgba
}
/// Premultiplied RGBA scaled to `dw`×`dh`: a box average when shrinking, bilinear when growing.
fn scaled(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Option<Vec<u8>> {
if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
return None;
}
let sample = |x: u32, y: u32| -> [u8; 4] {
let i = ((y.min(sh - 1) * sw + x.min(sw - 1)) * 4) as usize;
match src.get(i..i + 4) {
Some(px) => [px[0], px[1], px[2], px[3]],
None => [0, 0, 0, 0],
}
};
let mut out = vec![0u8; dw as usize * dh as usize * 4];
if dw >= sw && dh >= sh {
let at = |v: f64, n: u32| ((v + 0.5) * f64::from(n) - 0.5).clamp(0.0, f64::from(n - 1));
for y in 0..dh {
let sy = at(f64::from(y) / f64::from(dh), sh);
let (y0, fy) = (sy.floor() as u32, sy.fract());
for x in 0..dw {
let sx = at(f64::from(x) / f64::from(dw), sw);
let (x0, fx) = (sx.floor() as u32, sx.fract());
let (p00, p10, p01, p11) = (sample(x0, y0), sample(x0 + 1, y0), sample(x0, y0 + 1), sample(x0 + 1, y0 + 1));
let o = ((y * dw + x) * 4) as usize;
for c in 0..4 {
let top = f64::from(p00[c]) * (1.0 - fx) + f64::from(p10[c]) * fx;
let bottom = f64::from(p01[c]) * (1.0 - fx) + f64::from(p11[c]) * fx;
out[o + c] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8;
}
}
}
return Some(out);
}
let (fx, fy) = (f64::from(sw) / f64::from(dw), f64::from(sh) / f64::from(dh));
for y in 0..dh {
let (y0, y1) = ((f64::from(y) * fy).floor() as u32, ((f64::from(y) + 1.0) * fy).ceil() as u32);
for x in 0..dw {
let (x0, x1) = ((f64::from(x) * fx).floor() as u32, ((f64::from(x) + 1.0) * fx).ceil() as u32);
let mut sum = [0.0f64; 4];
let mut n = 0.0f64;
for sy in y0..y1.clamp(y0 + 1, sh) {
for sx in x0..x1.clamp(x0 + 1, sw) {
let px = sample(sx, sy);
for c in 0..4 {
sum[c] += f64::from(px[c]);
}
n += 1.0;
}
}
let o = ((y * dw + x) * 4) as usize;
for c in 0..4 {
out[o + c] = (sum[c] / n.max(1.0)).round().clamp(0.0, 255.0) as u8;
}
}
}
Some(out)
}
+231
View File
@@ -0,0 +1,231 @@
//! Colour glyph behaviour (colour emoji): bitmap strikes, COLR layers, and where they sit.
//!
//! The placement cases are pure numbers, so they run everywhere. The rest reads the machine's
//! colour emoji face and skips (like the other suites) on a machine without one; which format it
//! uses depends on the platform (Apple Color Emoji is sbix, Noto Color Emoji is CBDT, Segoe UI
//! Emoji is COLR), so a machine exercises the paths its own font has.
use super::*;
use crate::fontdb::{db, no_coords, scan_system_fonts};
use crate::layout::layout;
use figcraft_doc::TextData;
use skrifa::bitmap::{BitmapData, BitmapGlyph, MaskData, Origin};
/// A strike glyph described by hand, for the placement cases.
fn glyph(width: u32, height: u32, origin: Origin, bearing: (f32, f32), inner: (f32, f32), ppem: f32) -> BitmapGlyph<'static> {
BitmapGlyph {
data: BitmapData::Png(&[]),
bearing_x: bearing.0,
bearing_y: bearing.1,
inner_bearing_x: inner.0,
inner_bearing_y: inner.1,
ppem_x: ppem,
ppem_y: ppem,
advance: None,
width,
height,
placement_origin: origin,
}
}
/// A strike glyph with data, for the decoding cases (the fields they don't read stay neutral).
fn strike(data: BitmapData<'_>, width: u32, height: u32) -> BitmapGlyph<'_> {
BitmapGlyph {
data,
bearing_x: 0.0,
bearing_y: 0.0,
inner_bearing_x: 0.0,
inner_bearing_y: 0.0,
ppem_x: 10.0,
ppem_y: 10.0,
advance: None,
width,
height,
placement_origin: Origin::TopLeft,
}
}
#[test]
fn bitmap_placement_follows_the_strike() {
// CBDT: bearings are pixels from the origin to the bitmap's left and top edges, read at the
// strike's ppem and scaled to the size asked for: 2 px at ppem 10 in a 20 px run → 4 px.
let cbdt = glyph(100, 50, Origin::TopLeft, (0.0, 0.0), (2.0, 8.0), 10.0);
let (left, top) = placement(&cbdt, 20.0, 1000.0);
assert_eq!((left, top), (4.0, -16.0), "the left edge sits right of the pen, the top above it");
// sbix: bearings and origin offsets are font units, and the image is placed by its lower-left
// corner, so its top is the whole image above the origin.
let sbix = glyph(10, 10, Origin::BottomLeft, (-1.0, -3.0), (1.0, 2.0), 10.0);
let (left, top) = placement(&sbix, 20.0, 1000.0);
assert!((left - (2.0 - 0.02)).abs() < 1e-9, "{left}");
assert!((top - (-24.0 + 0.06)).abs() < 1e-9, "{top}");
// Doubling the size doubles the offset with it.
let scaled = glyph(10, 10, Origin::TopLeft, (0.0, 0.0), (1.0, 1.0), 10.0);
assert_eq!(placement(&scaled, 40.0, 1000.0).1, -4.0);
assert_eq!(placement(&scaled, 20.0, 1000.0).1, -2.0);
}
/// A 2×2 PNG with straight alpha, the format strikes carry: row 0 is (255,0,0,128) then
/// (0,255,0,255), row 1 is (0,0,255,0) then (10,20,30,51).
const PNG_STRAIGHT: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02,
0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x17, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xd0,
0xc0, 0xf0, 0x1f, 0x08, 0x19, 0x80, 0x98, 0x4b, 0x44, 0xce, 0x18, 0x00, 0x34, 0xb8, 0x04, 0xec, 0xa7, 0x3b, 0x38, 0x04, 0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn png_strikes_decode_to_premultiplied_rgba() {
let (rgba, width, height) = decode(&strike(BitmapData::Png(PNG_STRAIGHT), 2, 2)).unwrap();
assert_eq!((width, height), (2, 2));
assert_eq!(
rgba,
vec![128, 0, 0, 128, 0, 255, 0, 255, 0, 0, 0, 0, 2, 4, 6, 51],
"straight alpha times its own alpha, rounded: the renderer's surfaces are premultiplied"
);
}
#[test]
fn a_png_strike_claiming_a_huge_size_is_refused_before_anything_is_allocated() {
// PNG_STRAIGHT with its header saying 1 048 576 × 1 048 576 pixels (4 TiB as RGBA).
let mut png = PNG_STRAIGHT.to_vec();
assert_eq!(crc32(&png[12..29]).to_be_bytes(), png[29..33], "the patched header passes its checksum");
png[16..24].copy_from_slice(&[0, 0x10, 0, 0, 0, 0x10, 0, 0]);
let crc = crc32(&png[12..29]);
png[29..33].copy_from_slice(&crc.to_be_bytes());
assert!(decode_png(&png).is_none());
}
/// The CRC-32 a PNG chunk ends with.
fn crc32(bytes: &[u8]) -> u32 {
!bytes.iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }))
}
#[test]
fn raw_bgra_strikes_are_already_premultiplied() {
// BitmapData::Bgra is BGRA with premultiplied channels (the spec says so), so decoding only
// moves the red and blue channels.
let (rgba, width, height) = decode(&strike(BitmapData::Bgra(&[17, 34, 51, 255, 4, 8, 12, 16]), 2, 1)).unwrap();
assert_eq!((width, height), (2, 1));
assert_eq!(rgba, vec![51, 34, 17, 255, 12, 8, 4, 16]);
}
#[test]
fn strikes_that_are_not_colour_decode_to_nothing() {
let mask = MaskData { bpp: 8, is_packed: false, data: &[0; 16] };
assert!(decode(&strike(BitmapData::Mask(mask), 2, 2)).is_none(), "alpha-only strikes are masks, not colour");
assert!(decode(&strike(BitmapData::Png(b"not a png"), 2, 2)).is_none(), "a broken PNG is skipped, not drawn");
assert!(decode(&strike(BitmapData::Bgra(&[1, 2, 3]), 2, 2)).is_none(), "a buffer shorter than the glyph is skipped");
}
#[test]
fn scaling_keeps_the_pixels_premultiplied() {
// A 4×4 face: one straight colour over a falling alpha, premultiplied by hand.
let straight = [220u32, 180, 60];
let ramp = [255u32, 190, 128, 64, 0];
let mut src = Vec::new();
for y in 0..4 {
for x in 0..4 {
let a = ramp[(x + y) % ramp.len()];
src.extend(straight.iter().map(|c| ((c * a + 127) / 255) as u8));
src.push(a as u8);
}
}
for (dw, dh) in [(2, 2), (3, 4), (9, 7), (16, 16)] {
let out = scaled(&src, 4, 4, dw, dh).unwrap();
assert_eq!(out.len(), (dw * dh * 4) as usize);
for px in out.chunks_exact(4) {
assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "{dw}×{dh}: {px:?}");
}
}
assert!(scaled(&src, 4, 4, 0, 4).is_none() && scaled(&src, 0, 0, 4, 4).is_none(), "an empty side scales to nothing");
}
/// The machine's colour emoji face, if it has one (tests skip without: no font ships in the repo).
fn emoji_face() -> Option<std::sync::Arc<Face>> {
scan_system_fonts();
let mut db = db().write().unwrap();
db.emoji_fallbacks().into_iter().find(|f| f.has_color())
}
#[test]
fn colour_emoji_carry_their_own_pixels() {
let Some(face) = emoji_face() else { return };
// A grinning face: a bitmap strike (Apple, Noto) or COLR layers (Segoe) must cover it.
let gid = face.glyph_for('\u{1F600}');
assert_ne!(gid, 0, "U+1F600 is not in {}", face.family);
if let Some(layers) = face.color_layers(gid) {
assert!(!layers.is_empty());
assert!(layers.len() < 64, "{} layers", layers.len());
assert!(layers.iter().all(|l| l.gid != 0), "every layer names a glyph: {layers:?}");
return;
}
let Some(bitmap) = face.color_bitmap(gid, &no_coords(), 32.0) else {
panic!("{} has colour tables but draws nothing for U+1F600", face.family);
};
// The image is a sane size for 32 px of text and its pixels are premultiplied (what the
// renderer's surfaces hold), so no channel is brighter than its alpha.
assert!(bitmap.width >= 8 && bitmap.width <= 128, "{} px wide", bitmap.width);
assert!(bitmap.height >= 8 && bitmap.height <= 128, "{} px tall", bitmap.height);
assert_eq!(bitmap.rgba.len(), bitmap.width as usize * bitmap.height as usize * 4);
let mut opaque = 0;
for px in bitmap.rgba.chunks_exact(4) {
assert!(px[0] <= px[3] && px[1] <= px[3] && px[2] <= px[3], "premultiplied: {px:?}");
opaque += usize::from(px[3] == 255);
}
assert!(opaque > 20, "the glyph draws something: {opaque} opaque pixels");
// A bigger strike is scaled to a bigger image, and a smaller one to a smaller image.
let small = face.color_bitmap(gid, &no_coords(), 16.0);
let large = face.color_bitmap(gid, &no_coords(), 64.0);
if let (Some(small), Some(large)) = (small, large) {
assert!(small.width < bitmap.width && bitmap.width < large.width, "{} {} {}", small.width, bitmap.width, large.width);
}
}
#[test]
fn emoji_in_plain_text_draw_from_a_colour_face() {
// The style's own face (DejaVu Sans on Linux) or a symbol font among the fallbacks (Windows has
// one) draws U+1F600 in one colour: the emoji face comes first for it.
if emoji_face().is_none() {
return;
}
let l = layout(&TextData::new("Hi \u{1F600}"), None, None);
assert!(l.glyphs.iter().any(|g| g.color(1.0).is_some()), "the emoji has colour ink");
}
#[test]
fn glyphs_place_their_ink_at_a_scale() {
let Some(face) = emoji_face() else { return };
let mut t = TextData::new("\u{1F600}");
(t.style.font_family, t.style.font_style) = (face.family.clone(), face.style.clone());
t.style.font_size = 32.0;
let l = layout(&t, None, None);
let Some(g) = l.glyphs.first() else { return };
// Zoomed 2:1 the glyph asks for a 64 px raster and places it in layout coordinates.
let Some(ColorInk::Bitmap(bitmap, place)) = g.color(2.0) else {
assert!(g.color(2.0).is_some(), "the emoji glyph has ink");
return;
};
// Some emoji faces draw wider than the em (Noto Color Emoji's strikes are about 1.25 em).
assert!(bitmap.width >= 32 && bitmap.width <= 96, "{} px at 2:1", bitmap.width);
let corner = place * figcraft_geom::Point::new(0.0, 0.0);
assert!(corner.x >= g.x - g.size && corner.x <= g.x + g.size, "x {} vs pen {}", corner.x, g.x);
assert!(corner.y >= g.y - g.size && corner.y <= g.y + g.size, "y {} vs baseline {}", corner.y, g.y);
// Past the raster cap the image stops growing (a canvas zoomed far in).
let Some(ColorInk::Bitmap(huge, _)) = g.color(1000.0) else { return };
assert!(f64::from(huge.width) <= MAX_PX_PER_EM + 1.0, "{} px", huge.width);
}
#[test]
fn text_without_colour_ink_keeps_its_outlines() {
// Faces without colour tables answer "no ink" without touching the font again, so plain text
// (the whole canvas, most of the time) pays one flag per glyph.
let Some(face) = crate::fontdb::find_face("Inter", "Regular") else { return };
if face.has_color() {
return;
}
let gid = face.glyph_for('A');
assert!(gid != 0, "Inter draws A");
assert!(face.color_layers(gid).is_none() && face.color_bitmap(gid, &no_coords(), 32.0).is_none());
}
+54 -9
View File
@@ -11,6 +11,8 @@ use skrifa::raw::{FileRef, TableProvider};
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
use crate::color::{ColorLayer, Rasters};
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
pub family: &'static str,
@@ -107,6 +109,11 @@ pub struct Face {
charmap: HashMap<u32, u32>,
outlines: Mutex<HashMap<(u32, Coords), Arc<BezPath>>>,
advances: Mutex<HashMap<(u32, Coords), f64>>,
/// Colour glyph tables (see [`crate::color`]): bitmap strikes and/or COLR layers.
pub(crate) bitmap_strikes: bool,
pub(crate) colr: bool,
pub(crate) rasters: Mutex<Rasters>,
pub(crate) layers: Mutex<HashMap<u32, Option<Arc<Vec<ColorLayer>>>>>,
}
impl std::fmt::Debug for Face {
@@ -139,6 +146,9 @@ impl Face {
gsub_features.dedup();
let charmap: HashMap<u32, u32> = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect();
let (axes, instances) = variation_info(&f);
let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some();
let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT");
let colr = has_table(b"COLR");
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
let weight = {
@@ -169,6 +179,10 @@ impl Face {
bytes,
outlines: Mutex::new(HashMap::new()),
advances: Mutex::new(HashMap::new()),
bitmap_strikes,
colr,
rasters: Mutex::new(Rasters::default()),
layers: Mutex::new(HashMap::new()),
})
}
@@ -613,7 +627,6 @@ impl FontDb {
"PingFang SC",
"Noto Sans CJK SC",
"Microsoft YaHei",
"Apple Color Emoji",
"Noto Sans Arabic",
"Apple Symbols",
"Segoe UI Symbol",
@@ -625,6 +638,19 @@ impl FontDb {
out
}
/// Colour emoji faces, for characters no other fallback covers. Kept out of
/// [`fallbacks`](Self::fallbacks) because the files are large (Apple Color Emoji is ~180 MB)
/// and only texts that actually show emoji need them; each family reads one face.
pub fn emoji_fallbacks(&mut self) -> Vec<Arc<Face>> {
let mut out = Vec::new();
for fam in ["Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"] {
if let Some(f) = self.find_one(fam, "Regular") {
out.push(f);
}
}
out
}
pub fn face_count(&self) -> usize {
self.faces.len()
}
@@ -670,14 +696,27 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
{
v.push("/usr/share/fonts".into());
v.push("/usr/local/share/fonts".into());
if let Some(h) = std::env::var_os("HOME") {
v.push(std::path::PathBuf::from(&h).join(".fonts"));
v.push(std::path::PathBuf::from(h).join(".local/share/fonts"));
}
v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME")));
}
v
}
// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts.
// Ignore relative XDG paths, as required by the XDG Base Directory Specification.
#[cfg(all(unix, not(target_os = "macos")))]
fn unix_user_font_dirs(home: Option<std::ffi::OsString>, data_home: Option<std::ffi::OsString>) -> Vec<std::path::PathBuf> {
let home = home.map(std::path::PathBuf::from);
let data = data_home.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let mut dirs = Vec::new();
if let Some(home) = home {
dirs.push(home.join(".fonts"));
}
if let Some(data) = data {
dirs.push(data.join("fonts"));
}
dirs
}
fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (String, Vec<Pending>)>) {
if depth > 6 {
return;
@@ -699,8 +738,10 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
continue;
}
// Skip very large files (colour emoji); they are not outline fonts we can draw anyway.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 64 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
// buffer, so only those parts are ever touched.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
for (index, family, style) in faces_in(&bytes) {
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
}
@@ -743,7 +784,8 @@ fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
Some(buf)
}
/// (index, family, style) of each outline face (one we can draw) in a font file.
/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color
/// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all).
fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
let count = match FileRef::new(bytes) {
Ok(FileRef::Collection(c)) => c.len(),
@@ -753,7 +795,10 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> {
(0..count.min(64))
.filter_map(|i| {
let f = skrifa::FontRef::from_index(bytes, i).ok()?;
[b"glyf", b"CFF ", b"CFF2"].iter().any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()).then_some(())?;
[b"glyf", b"CFF ", b"CFF2", b"sbix", b"CBDT", b"EBDT", b"COLR"]
.iter()
.any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some())
.then_some(())?;
let (family, style) = names(&f);
let family = family.filter(|f| !f.starts_with('.'))?;
Some((i, family, style.unwrap_or_else(|| "Regular".into())))
+26
View File
@@ -67,3 +67,29 @@ fn the_scan_reads_names_like_a_whole_file_read() {
assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display());
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_follow_xdg_data_home() {
use std::ffi::OsString;
use std::path::PathBuf;
let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from));
let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::<Vec<_>>();
assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"]));
for data in [None, Some(""), Some("relative/data")] {
assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"]));
}
assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"]));
assert_eq!(dirs(None, None), paths(&[]));
assert_eq!(dirs(None, Some("relative")), paths(&[]));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_preserve_non_utf8_paths() {
use std::ffi::OsString;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec())));
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
+48 -3
View File
@@ -10,6 +10,7 @@ use figcraft_doc::{
use figcraft_geom::{Affine, BezPath, Rect};
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
use crate::color::{ColorInk, MAX_PX_PER_EM};
use crate::fontdb::{Coords, Face, db, no_coords};
/// A positioned glyph (pen position on the baseline, layout coordinates, y down).
@@ -37,11 +38,34 @@ pub struct Glyph {
impl Glyph {
/// The glyph outline placed in layout coordinates.
pub fn path(&self) -> BezPath {
self.layer_path(self.gid)
}
/// The outline of one of the face's glyphs (a COLR layer), placed like this glyph.
pub fn layer_path(&self, gid: u32) -> BezPath {
let s = self.size / self.face.upem;
let mut p = (*self.face.outline_at(self.gid, &self.coords)).clone();
let mut p = (*self.face.outline_at(gid, &self.coords)).clone();
p.apply_affine(self.warp * Affine::new([s, 0.0, 0.0, s, self.x, self.y]));
p
}
/// The glyph's colour ink at `scale` device pixels per layout unit, placed in layout
/// coordinates (a bitmap's pixels map through the affine; COLR layers are drawn like the
/// outline). `None` — the usual case — means the outline is what draws.
pub fn color(&self, scale: f64) -> Option<ColorInk> {
if !scale.is_finite() || scale <= 0.0 {
return None;
}
if let Some(layers) = self.face.color_layers(self.gid) {
return Some(ColorInk::Layers(layers));
}
// Whole device pixels per em: rasters stay on the pixel grid and the cache stays small.
let px = (self.size * scale).round().clamp(1.0, MAX_PX_PER_EM);
let bitmap = self.face.color_bitmap(self.gid, &self.coords, px)?;
let s = px / self.size;
let place = self.warp * Affine::translate((self.x + bitmap.left / s, self.y + bitmap.top / s)) * Affine::scale(1.0 / s);
Some(ColorInk::Bitmap(bitmap, place))
}
}
#[derive(Clone, Debug)]
@@ -299,11 +323,16 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
span_style.push(idx);
}
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let fallbacks = dbw.fallbacks();
let mut fallbacks = dbw.fallbacks();
let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
for s in &styles {
faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
}
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
// for a text with emoji, or with characters nothing above covers.
let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() };
fallbacks.extend(emoji.iter().cloned());
drop(dbw);
// OpenType features the user toggled (global for the node).
@@ -331,6 +360,14 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut seg_start = *start;
while seg_start < *end {
let face_for = |c: char| -> Arc<Face> {
// Emoji take a colour face before a face without colour glyphs (the style's own or a
// symbol font), which would draw them in one colour.
if is_emoji(c)
&& !primary.has_color()
&& let Some(f) = emoji.iter().find(|f| f.has_char(c))
{
return f.clone();
}
if c == '\n' || primary.has_char(c) {
return primary.clone();
}
@@ -958,6 +995,13 @@ fn sanitize_size(s: f64) -> f64 {
if s.is_finite() { s.clamp(0.5, 5000.0) } else { 12.0 }
}
/// A character drawn as a colour emoji by default: flags and the pictograph blocks.
/// shortcut: the older symbols (U+2600–U+27BF) keep their text look even before U+FE0F; read the
/// emoji presentation data if they should turn colour too.
fn is_emoji(c: char) -> bool {
matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}')
}
/// One run of chars shaped with a single face and style.
struct Segment<'a> {
start: usize,
@@ -1213,7 +1257,8 @@ impl TextLayout {
}
let (x0, _) = self.caret(a.max(line.char_start));
let x1 = if b >= line.char_end {
self.line_end_x(line) + if line.para_end && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
// The last line has no paragraph break to show, so it ends at its glyphs.
self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
} else {
self.caret(b).0
};
+2
View File
@@ -7,12 +7,14 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod breaks;
pub mod color;
pub mod display;
pub mod fontdb;
mod layout;
mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords,
scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more