Compare commits

...
Author SHA1 Message Date
bflatastic 049f97a4c3 Merge branch 'pr-597' into batch/t7
# Conflicts:
#	ROADMAP.md
2026-10-16 03:46:48 +07:00
bflatastic 62a291fcb8 Merge branch 'pr-596' into batch/t7 2026-10-16 03:46:43 +07:00
BFlat 7459cc7f86 Merge pull request #598 from storytold/community/batch-6
Community batch 6: 8 reviewed PRs
2026-10-16 03:45:01 +07:00
bflatastic 9f935718fe Merge branch 'rb-578' into batch/t6
# Conflicts:
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:37:43 +07:00
bflatastic 1c863aa9f5 Merge branch 'rb-568' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 891f0acb3c Merge branch 'rb-560' into batch/t6 2026-10-16 02:36:56 +07:00
bflatastic 55c05338a1 Merge branch 'rb-554' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 4e3fd82bd1 Merge branch 'rb-549' into batch/t6 2026-10-16 02:36:55 +07:00
bflatastic 41b08a2393 Merge branch 'rb-528' into batch/t6 2026-10-16 02:36:54 +07:00
bflatastic 730e377418 Merge branch 'rb-487' into batch/t6 2026-10-16 02:36:53 +07:00
bflatastic 175fdfa428 Merge remote-tracking branch 'origin/main' into rb-478
# Conflicts:
#	crates/ui-egui/src/idle_tests.rs
#	crates/ui-egui/src/lib.rs
2026-10-16 02:36:34 +07:00
bflatastic d60a27b675 Merge main into #554: one id map for the copy, and #562's flows, prototype and guides follow it
page.duplicate copies every layer with duplicate_all_into (navigation between
copied frames points at the copies) and builds the old-to-new list for flows
from that copy, instead of copying each layer separately.
2026-10-16 02:34:54 +07:00
bflatastic fd6a7e0ba3 Merge main into #549: keep #539's whole URL and add the empty-URL arm 2026-10-16 02:33:05 +07:00
bflatastic db8bf6ba33 Merge main into #560; normalise the replacement where findReplace reads it
Main's case-insensitive replace loop (from an earlier PR) took the place of the
loop this change edited, so the replacement text is normalised once, where
the parameter is read.
2026-10-16 02:31:34 +07:00
LosminekandClaude Opus 5.5 4d9cde8fee i18n: Czech one/few/other plurals
Czech counts take three integer forms: one (1), few (2-4) and other
(0 and 5+). Since #257 catalogs can supply `few`, so `plural()` gets a
Czech rule and the 38 plural entries in cs.toml now read naturally
("1 vrstva", "3 vrstvy", "5 vrstev") instead of the count-neutral
"Vrstvy: {n}" that #469 used for everything but 1.

Test czech_integer_plurals covers the rule (1; 2-4; 0, 5, 11, 12, 21,
22, 100, 1 000 000), a real key through counted(), and that every Czech
plural entry has one and few. Docs no longer say Czech has only one/other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:28:51 +02:00
Yarila f8458b70a8 Reduce blank canvas strips during panning with bounded overscan 2026-10-16 00:28:10 +05:00
bflatastic 0a84dee777 Merge remote-tracking branch 'origin/main' into rb-578
# Conflicts:
#	crates/text/src/fontdb.rs
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:27:50 +07:00
bflatastic 46316c9895 Merge remote-tracking branch 'origin/main' into rb-568
# Conflicts:
#	ROADMAP.md
#	crates/text/src/fontdb_tests.rs
2026-10-16 02:26:29 +07:00
bflatastic 0e1ccb55d2 Merge remote-tracking branch 'origin/main' into rb-487
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-16 02:25:11 +07:00
bflatastic ec8a506353 Merge remote-tracking branch 'origin/main' into rb-528 2026-10-16 02:23:46 +07:00
BFlat eb741a362d Community batch 5: #312's sturdier zoom-preview test; record four rebased heads (#588)
Lands #312's test-only follow-up commit and records #326, #317 and #306's rebased heads (no content change) so they show as merged.
2026-10-16 02:23:45 +07:00
BFlat 97e422226f Merge pull request #591 from storytold/community/batch-5
Community batch 5: 59 reviewed PRs (#482 and up)
2026-10-16 02:15:47 +07:00
BFlat 8ba09001cd M0.9: a control line that isn't JSON ends the connection (#589)
The control server answered a line that wasn't JSON with an error and read
on. A web page can make the browser send an HTTP request to the loopback
port; its request and header lines were skipped that way and the JSON lines
of its body ran as commands. The first line that isn't JSON now closes the
connection before anything after it is read. Clients that send JSON lines
(MCP --attach, scripts) are unaffected.
2026-10-16 02:15:18 +07:00
bflatastic ecad646330 Merge branch 'pr-584' into batch/t5 2026-10-16 01:36:24 +07:00
bflatastic 87cb799783 Merge branch 'pr-583' into batch/t5
# Conflicts:
#	docs/ui-parity.md
2026-10-16 01:36:24 +07:00
bflatastic 466fd249fe Merge branch 'pr-579' into batch/t5
# Conflicts:
#	docs/ui-parity.md
2026-10-16 01:36:18 +07:00
bflatastic a4ba67ac22 Merge branch 'pr-577' into batch/t5 2026-10-16 01:36:13 +07:00
bflatastic 99b6495b7d Merge branch 'pr-576' into batch/t5 2026-10-16 01:36:12 +07:00
bflatastic 6eb9f38487 Merge branch 'pr-566' into batch/t5 2026-10-16 01:36:12 +07:00
bflatastic bf4d9e0f6f Merge branch 'pr-565' into batch/t5 2026-10-16 01:36:11 +07:00
bflatastic 9418e2c8bb Merge branch 'pr-564' into batch/t5 2026-10-16 01:36:10 +07:00
bflatastic c4c88900c4 Merge branch 'pr-563' into batch/t5 2026-10-16 01:36:10 +07:00
bflatastic aadaa2d975 Merge branch 'pr-562' into batch/t5 2026-10-16 01:36:09 +07:00
bflatastic 580eee3572 Merge branch 'pr-561' into batch/t5 2026-10-16 01:36:08 +07:00
bflatastic e359d856f2 Merge branch 'pr-559' into batch/t5 2026-10-16 01:36:07 +07:00
bflatastic e08c1c598f Merge branch 'pr-558' into batch/t5 2026-10-16 01:36:06 +07:00
bflatastic 4df3702dbb Merge branch 'pr-556' into batch/t5 2026-10-16 01:36:05 +07:00
bflatastic b7c692171d Merge branch 'pr-551' into batch/t5 2026-10-16 01:36:03 +07:00
bflatastic d95aef1d0c Merge branch 'pr-547' into batch/t5 2026-10-16 01:36:02 +07:00
bflatastic 887e374c58 Merge branch 'pr-545' into batch/t5 2026-10-16 01:36:00 +07:00
bflatastic 34dcf852d5 Merge branch 'pr-542' into batch/t5 2026-10-16 01:35:59 +07:00
bflatastic 5e7eb51e46 Merge branch 'pr-539' into batch/t5 2026-10-16 01:35:58 +07:00
bflatastic d40fd80be7 Merge branch 'pr-536' into batch/t5 2026-10-16 01:35:58 +07:00
bflatastic 1c5b4d8450 Merge branch 'pr-534' into batch/t5 2026-10-16 01:35:57 +07:00
bflatastic ef69a7f8e9 Merge branch 'pr-533' into batch/t5 2026-10-16 01:35:57 +07:00
bflatastic a1849aae15 Merge branch 'pr-531' into batch/t5 2026-10-16 01:35:56 +07:00
bflatastic 1e25e239c2 Merge branch 'pr-527' into batch/t5 2026-10-16 01:35:55 +07:00
bflatastic 7bda68876b Merge branch 'pr-526' into batch/t5 2026-10-16 01:35:55 +07:00
bflatastic 362bdce73b Merge branch 'pr-525' into batch/t5 2026-10-16 01:35:54 +07:00
bflatastic 5e99e71728 Merge branch 'pr-523' into batch/t5 2026-10-16 01:35:53 +07:00
bflatastic 09e85240ea Merge branch 'pr-522' into batch/t5 2026-10-16 01:35:52 +07:00
bflatastic 7998845515 Merge branch 'pr-521' into batch/t5 2026-10-16 01:35:51 +07:00
bflatastic e8eca728fe Merge branch 'pr-519' into batch/t5 2026-10-16 01:35:50 +07:00
bflatastic 438d468c76 Merge branch 'pr-518' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:50 +07:00
bflatastic 07130947f9 Merge branch 'pr-517' into batch/t5 2026-10-16 01:35:45 +07:00
bflatastic 16e26b69ff Merge branch 'pr-516' into batch/t5 2026-10-16 01:35:44 +07:00
bflatastic cd5c70a244 Merge branch 'pr-515' into batch/t5 2026-10-16 01:35:44 +07:00
bflatastic e6240f6229 Merge branch 'pr-514' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:43 +07:00
bflatastic 1498c0ea52 Merge branch 'pr-513' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 67cf5ecfd5 Merge branch 'pr-512' into batch/t5 2026-10-16 01:35:39 +07:00
bflatastic 0c3c00db99 Merge branch 'pr-511' into batch/t5 2026-10-16 01:35:38 +07:00
bflatastic 7988e48b5d Merge branch 'pr-509' into batch/t5 2026-10-16 01:35:37 +07:00
bflatastic 0105724108 Merge branch 'pr-508' into batch/t5 2026-10-16 01:35:36 +07:00
bflatastic 2894195ab3 Merge branch 'pr-506' into batch/t5 2026-10-16 01:35:35 +07:00
bflatastic 02156c0edc Merge branch 'pr-505' into batch/t5 2026-10-16 01:35:35 +07:00
bflatastic 06844dc703 Merge branch 'pr-504' into batch/t5 2026-10-16 01:35:34 +07:00
bflatastic 2d1a7c973d Merge branch 'pr-503' into batch/t5 2026-10-16 01:35:33 +07:00
bflatastic 77c9ccd649 Merge branch 'pr-502' into batch/t5 2026-10-16 01:35:32 +07:00
bflatastic 95b32053d5 Merge branch 'pr-499' into batch/t5 2026-10-16 01:35:31 +07:00
bflatastic b1760ff098 Merge branch 'pr-497' into batch/t5 2026-10-16 01:35:30 +07:00
bflatastic b0aa597787 Merge branch 'pr-496' into batch/t5 2026-10-16 01:35:29 +07:00
bflatastic dcb36e376f Merge branch 'pr-495' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:28 +07:00
bflatastic 139195cb0c Merge branch 'pr-494' into batch/t5 2026-10-16 01:35:24 +07:00
bflatastic 991cb604c2 Merge branch 'pr-493' into batch/t5 2026-10-16 01:35:23 +07:00
bflatastic 1ef42e6504 Merge branch 'pr-491' into batch/t5 2026-10-16 01:35:23 +07:00
bflatastic d7de56f046 Merge branch 'pr-490' into batch/t5 2026-10-16 01:35:22 +07:00
bflatastic 4bc63d506c Merge branch 'pr-489' into batch/t5 2026-10-16 01:35:21 +07:00
bflatastic 567110f063 Merge branch 'pr-488' into batch/t5 2026-10-16 01:35:20 +07:00
bflatastic ffde863e92 Merge branch 'pr-486' into batch/t5 2026-10-16 01:35:19 +07:00
bflatastic e2b567d5b3 Merge branch 'pr-485' into batch/t5 2026-10-16 01:35:16 +07:00
bflatastic 77b6f1fc12 Merge branch 'pr-483' into batch/t5 2026-10-16 01:35:14 +07:00
bflatastic 97b10b637d Merge branch 'pr-482' into batch/t5
# Conflicts:
#	docs/localization-parity.md
#	docs/mcp.md
#	docs/translating.md
2026-10-16 01:35:12 +07:00
bflatastic 7da4cf5e31 Export frames to PDF: use the asynchronous save dialog
Main's file dialogs answer asynchronously now (they no longer re-enter the
event loop on macOS); the menu item takes the path in a callback, and the test
draws a frame to let the dialog answer.
2026-10-16 01:34:36 +07:00
bflatastic a8416b27c1 Merge remote-tracking branch 'origin/main' into fix-502 2026-10-16 01:30:26 +07:00
bflatastic 9552e530be Swedish: command labels that only repeated the command id show in English
468 of the 588 command and menu rows had the command id as their text
("align.bottom" = "align.bottom"), which the app would have shown as is.
They are left out, so English shows until they are translated. Swedish
isn't in the complete list (that would make every new UI string need a
Swedish translation in the same change); the docs say what it covers.
2026-10-16 01:30:10 +07:00
bflatastic fe5a22e468 Colour picker: record recent colours by the write's live flag
A text edit holds an interaction too, so treating any open interaction as a
drag dropped the colours clicked while editing text. Recent colours dedupe by
8-bit value: the colour picked and the one the document stores differ by a
rounding fraction.
2026-10-16 01:23:21 +07:00
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
Marco Arnulfo 4f9023d80f Cmd/Ctrl+= and - zoom the canvas only, not the whole interface (fixes #580) 2026-10-15 19:04:53 +02:00
Mateus Villain 825bb708df M7: fields select their text on focus
A click or Tab into a one-line field selects all of its text, so typing
replaces the value, as in Figma: hex and colour fields, inline fields
(a fill's hex, variable values, renames), names, the font size field,
search boxes and dialog fields (widgets::select_all_on_focus). A drag
from the press still selects only what it covers; multi-line fields
keep the caret where clicked.

The first click into an inline field (a fill's hex) now takes the
keyboard: egui dropped the focus of the editor created by that click,
so it took a second click.
2026-10-15 13:54:04 -03:00
Ivan Shishkanov 55db465d8e M7: scrub typography spacing from leading icons (#373) 2026-10-15 19:28:46 +03:00
bkimandClaude Opus 5.5 e9e2d87405 M1: Hangul falls back to a Korean system font, at the run's weight
Text in the default Inter (or any Latin font) drew the missing-glyph box for every Hangul
syllable on macOS and Windows: no Korean family was on the fallback list, so the Korean
fonts those systems ship were never tried, and there is no default-font setting to work
around it. Apple SD Gothic Neo (macOS), Malgun Gothic (Windows) and Noto Sans CJK KR /
Noto Sans KR (Linux) now come after the Chinese and Japanese entries, so Han characters
resolve as before.

Fallback faces also follow the run's style: each fallback family gives its face nearest the
run's style instead of always its Regular, so Hangul (or kana) in a bold run comes out bold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015MAPjgJo17KasfbzMTbV65
2026-10-16 01:23:06 +09:00
RealtaNuaandClaude Opus 5.5 44128f7e97 fix(render): the rim light fades out at its reach instead of stopping (#256)
Where the 512 px cap held the light short of half a level, it dropped to 0
in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300,
splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap
doesn't bite this moves the light by at most half a level.

Cut tests now also cover rounded corners and a look under the cap.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:18:45 +07:00
Mateus Villain 782c9757f3 M3: CSS colour names in hex fields (red → FF0000)
Every hex field (fills, strokes, text ranges, effects, layout grids,
colour variables, page and prototype backgrounds, the colour picker)
now takes any of the 148 CSS named colours. Case, spaces, hyphens and
underscores are ignored; hex wins where both could match; names are
opaque and keep the colour's opacity. Color::parse_css (the picker's
CSS field and command colour params) takes the full list too.
2026-10-15 13:02:30 -03:00
IanHollow 5454bdc244 M7: skip special files when scanning system fonts
Require resolved font entries to be regular files before indexing them. Preserve normal font symlinks and cover named pipes in a bounded child-process regression.

Assisted-by: OpenAI Codex (GPT-6)
Signed-off-by: IanHollow <[email protected]>
2026-10-15 08:29:11 -07:00
MarekSpodBiedry 843b00b22f text: scripted text stores CRLF, CR, U+2028 and U+2029 as LF like the editor (#427) 2026-10-15 17:20:58 +02:00
MarekSpodBiedry 9fa99f8594 Keep per-character text fills in SVG export (#429) 2026-10-15 17:20:43 +02:00
MarekSpodBiedry 85d97a831c Keep flows, prototype settings and guides when duplicating a page (#417) 2026-10-15 17:19:38 +02:00
MarekSpodBiedry 7d0f9d7518 Colour picker: record only the final colour of a drag in Recent colors (#395) 2026-10-15 17:19:35 +02:00
RealtaNuaandClaude Opus 5.5 4588832c80 test(render): name the glass cut tests for what they check
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:21 +07:00
RealtaNuaandClaude Opus 5.5 bc5b474293 perf(render): sample once where glass doesn't bend
Where nothing bends (past `depth`, or with no refraction) the three
colour channels sample the backdrop at the same point: one sample
instead of three. The rim light's facing and powers are worked out only
where the light reaches. Same bytes: an ignored `glass_digests` test
prints digests of 30 renders (5 looks, square and rounded, 3 scales),
identical before and after this change (and to main).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:00 +07:00
RealtaNuaandClaude Opus 5.5 18e3070d4c fix(render): glass looks the same wherever a render cuts it
The glass light falls off with the distance to the edge, read past
`depth` (up to ~3 × depth at full splay), but the coverage window around
a cut layer was only `depth + 2` wide. With frost and refraction at 0
the backdrop margin is 0 too, so a band or canvas patch edge running
through the pane changed the light near it: up to 12 levels between a
banded and a whole render.

The window now covers the light's reach (where it falls under half a
level, clamped at 0 and capped at 512 px) plus the span the normals are
smoothed over, and past that reach the light is 0 instead of whatever
the window happened to hold.

Tests: a cut 50–80 px inside the pane equals the whole render byte for
byte (fails on main by 12 levels); light_reach edge cases. Adds an
ignored release timing test (glass_bench_tests).

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:19:00 +07:00
MarekSpodBiedry 3062c6c4d6 Keep the selected guide selected when an earlier guide is removed (#418) 2026-10-15 17:18:38 +02:00
MarekSpodBiedry 4376f659ac Docs: FIG export guides no longer say variables and style links are omitted (#398) 2026-10-15 17:18:32 +02:00
MarekSpodBiedry 0ba8e30fbc docs: document perf --instances in the CLI reference (#391) 2026-10-15 17:17:51 +02:00
MarekSpodBiedry fdf397d7dc Refuse to create a variable collection with a name that is already taken (#416) 2026-10-15 17:17:44 +02:00
MarekSpodBiedry d1fcf69b7b Keep Mixed line height when Enter is pressed without typing (#439) 2026-10-15 17:17:30 +02:00
RealtaNuaandClaude Opus 5.5 49010614fd chore: credit Nael-Nathanael by GitHub profile name
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:16:10 +07:00
RealtaNuaandClaude Opus 5.5 778b8c6807 fix(ui): scrubbing the Scale panel's W and H scales live (#256)
W and H ignored scrubs. They now scale live in one undo step. The factor
field applies on release, because it is relative to 1.0 on every frame.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:58 +07:00
RealtaNuaandClaude Opus 5.5 1593b163fb fix(ui): scrubbing a prototype panel number sets it once (#256)
Delays, scroll offsets, overlay offsets and the overlay background alpha
were set on every frame of a scrub, an undo step each. They now set once,
when let go, like the custom size field.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:58 +07:00
RealtaNuaandClaude Opus 5.5 102429e6b5 fix(ui): scrubbing a colour picker field is one undo step (#256)
The RGB/HSL/HSB fields committed every frame of a scrub, so one drag left
about ten undo steps, and the alpha field ignored scrubs. Both now edit
live and commit when let go.

Refs #256

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:15:55 +07:00
NetterSilverandClaude Haiku 5.5 6ce65b24f4 Keep region loops in ring order when adding a point to a reversed edge
Fixes #420

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:12:06 +00:00
NetterSilverandClaude Haiku 5.5 a4f4869443 Point duplicated page navigation at the copied frames
Fixes #419

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:10:00 +02:00
NetterSilverandClaude Haiku 5.5 a57976e88f Keep frame min/max size in .fig export without an auto layout parent
Fixes #457

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:00:16 +02:00
NetterSilverandClaude Haiku 5.5 3c28f605d1 Keep cleared range hyperlinks through FIG export and import
Fixes #458

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:55:48 +00:00
NetterSilverandClaude Haiku 5.5 76a67e7ee5 Point library style variable bindings at imported library variables
Fixes #462

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:47:58 +02:00
NetterSilverandClaude Haiku 5.5 330b237c9c Refresh linked layers when a library update changes only a style
Fixes #461

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:45:47 +00:00
NetterSilverandClaude Haiku 5.5 7689af50d6 Keep character-range text styles when publishing and placing components
Fixes #463

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:31:53 +02: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
Hamza 01c0b38669 fix(rest): keep a text hyperlink's destination whole on import (#459)
A hyperlink longer than 200 characters came back as a different 200-character
URL -- one that still looks like a working link -- and the import reported
nothing. It applied to a text layer's base style and to a character style
override alike.

The destination went through `clip_str`, which exists so that names and labels
from a file stay readable when they are quoted in a warning. Every other caller
is a name, a description, a style id or a layer type; a hyperlink is data, and
clipping it silently rewrote it. Two things say so: the FIG importer already
keeps the URL whole, so the two importers disagreed, and `rest::props` bounds
numbers and list lengths but no string beyond that label helper -- the text
content itself is not clipped either.

The reporter's integration test is included, with its assertions unchanged. Its
helper is restructured to return what it found rather than unwrap: the crate
denies `unwrap_used` and `panic`, and `clippy.toml` allows them only inside a
`#[test]` function, so the original shape failed the gate before it ran. Its
failure message now reports the input and output lengths.
2026-10-15 19:03:24 +05:00
NetterSilverandClaude Haiku 5.5 b61122ccca Fix eraser missing brush crossings narrower than the sample spacing
Fixes #421

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:28:43 +02:00
bflatastic 4fbe8d2976 Merge batch 3's FreeBSD font fix 2026-10-15 20:22:41 +07:00
NetterSilverandClaude Haiku 5.5 524b538908 Report changed=false when list indentation is capped
Fixes #425

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:21:05 +00:00
NetterSilverandClaude Haiku 5.5 72b826d65b Fix cross-axis arrows and drop slot on single-item wrapped lines
Fixes #422

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:17:21 +02:00
NetterSilverandClaude Haiku 5.5 0d125374d3 Don't record an undo step for an abandoned empty text box
Fixes #423

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:05:07 +00: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 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
NetterSilverandClaude Haiku 5.5 8199623267 Include empty outlines in no-op shapeBuilder.merge response
Fixes #424

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 14:36:34 +02:00
midasmatterhorn 6ea957ec5d doc: runs of a file that reach past the text can't overflow a text edit
StyleRun start/end are usize taken straight from a .figcraft document.json
(nothing clamps them on load; only the REST and SVG importers normalize),
so TextData::replace_range's p - removed + inserted panics in a debug or
test build and wraps to zero in release, where normalize then drops the
run and the layer silently loses its formatting.

Shift the runs with saturating arithmetic; normalize drops whatever is
left. Regression test: text_runs_past_the_text_never_overflow — panics
at crates/doc/src/text.rs:605 on main, passes with the clamp.
2026-10-15 15:36:06 +03: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
NetterSilverandClaude Haiku 5.5 b29cc01d55 Fix perf failing with 'no card' on small instance counts
Fixes #394

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:34:16 +00: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
NetterSilverandClaude Haiku 5.5 d0285d236e Keep a Rectangle in the smallest synthetic perf documents
Fixes #393

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 14:23:20 +02: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
NetterSilverandClaude Haiku 5.5 908906f3f4 Close a layer's colour picker when the selection changes
Fixes #396

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:15:35 +02: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
Seun Samuel-Ipaye 2d78f40e60 Pen tool: a click's small wobble pulls out no handle 2026-10-15 08:11:40 -04:00
NetterSilverandClaude Haiku 5.5 f6437548ba Keep a styled layer's fill variable through .fig round trips
Fixes #399

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:08:55 +00: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 cf06f2ca30 Merge main and the community batch into #352 2026-10-15 19:06:22 +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 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
NetterSilverandClaude Haiku 5.5 fbe746a776 Cap startup operator problems at the latest 200
Fixes #409

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:58:59 +02: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
NetterSilverandClaude Haiku 5.5 9853ffce61 Stop a guide drag released off the canvas from continuing on the next click
Fixes #431

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:54:07 +02:00
Andrii Volkov 2f4d3894b5 contributors: add my entry 2026-10-15 14:51:08 +03:00
Andrii VolkovandClaude Opus 5.5 bf506712ba fix(ui): show error toasts raised by a slow frame for their full 3 s
A toast's clock started from the time of the frame that first drew it. That
time is taken when the frame starts, before whatever raised the toast ran:
the Open dialog and a failed open blocked that frame for longer than 3 s, so
the error expired before it was ever seen and the open failed silently. The
clock now starts on the frame after the first one that draws the toast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:51:01 +03: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
NetterSilverandClaude Haiku 5.5 7d6a3ce137 Skip divider rows in Next page and Previous page
Fixes #413

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 11:49:53 +00:00
NetterSilverandClaude Haiku 5.5 ac7c91fc9c Reject layer IDs as targets of page.rename, delete and setBackground
Fixes #414

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 13:44:17 +02: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 8bcb0f78dd #338: drop the start/end feat marker comments 2026-10-15 18:37:52 +07:00
bflatastic 54f9212a2f Merge main and the community batch into #338 2026-10-15 18:35:05 +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
Marco Arnulfo 34565964b0 Keep nested frames out of the ready-for-dev list (fixes #455) 2026-10-15 13:29:22 +02: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 9b1c79277a Merge main and the community batch into #363 2026-10-15 18:22:43 +07:00
NetterSilverandClaude Haiku 5.5 b3b6b14eb4 Anti-alias rectangular frame clips on fractional device edges
Fixes #428

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:18:59 +02:00
NetterSilverandClaude Haiku 5.5 be56ca2836 Keep CMYK conversion inside PDF vector pattern tiles
Fixes #426

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:18:33 +00:00
NetterSilverandClaude Haiku 5.5 9687c0ffc0 Draw layer blurs up to the documented 10,000 radius
Fixes #430

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:12:25 +00: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
bflatastic bebe509a25 Merge main and the community batch into #305
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-15 17:58:14 +07:00
NetterSilverandClaude Haiku 5.5 8b0649da3a Drop the draw or marquee gesture a canvas double-click starts
Fixes #432

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:52:47 +00:00
NetterSilverandClaude Haiku 5.5 3293a12542 Fix view jump when returning after a pan released over a panel
Fixes #433

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:48:25 +02:00
NetterSilverandClaude Haiku 5.5 42e6f267b8 Reveal the comment's page when opening it via ui.set commentOpen
Fixes #435

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 10:43:25 +00:00
NetterSilverandClaude Haiku 5.5 831b8fed43 Fix panic drawing a selected guide label in a zero-width canvas
Fixes #434

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:42:05 +02:00
NetterSilverandClaude Haiku 5.5 72a3c8522f Keep consecutive spaces in recent file accessible names
Fixes #436

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 12:37:55 +02:00
NetterSilverandClaude Haiku 5.5 ec9b1197c9 Save the PDF from File > Export frames to PDF
Fixes #437

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:37:47 +00:00
NetterSilverandClaude Haiku 5.5 43e6cba2ef Keep committed line height and letter spacing in the text style editor
Fixes #438

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:26:22 +02:00
NetterSilverandClaude Haiku 5.5 8e56820168 Fix variable.update scopes: null not resetting to all supported
Fixes #443

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 10:14:49 +00:00
NetterSilverandClaude Haiku 5.5 376d524a57 Keep the colour picker and open settings on their item after a removal
Fixes #442

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:09:40 +02:00
NetterSilverandClaude Haiku 5.5 61bc027072 Fix MCP layer screenshots exceeding maxSize
Fixes #445

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:56:32 +02:00
NetterSilverandClaude Haiku 5.5 4cf1da167c Default CLI render and export to the first non-divider page
Fixes #446

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:56:15 +00:00
NetterSilverandClaude Haiku 5.5 dee13aefc5 Keep hidden export roots from enlarging the exported image
Fixes #447

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:48:39 +02:00
NetterSilverandClaude Haiku 5.5 7dd71bc2da Brace variable names in expression text that would not read back
Fixes #448

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:45:13 +00:00
NetterSilverandClaude Haiku 5.5 ef10edfe29 Skip missing segments when starting a region loop walk
Fixes #449

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:39:06 +02:00
NetterSilverandClaude Haiku 5.5 c3330c4260 Leave missing vertices out of region point queries
Fixes #451

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:33:53 +00:00
NetterSilverandClaude Haiku 5.5 e4b4fb0cac Fix paint bucket leaving a face filled when its region is duplicated
Fixes #450

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:32:07 +02:00
NetterSilverandClaude Haiku 5.5 4f9cbf702f Merge text runs made identical by style updates
Fixes #452

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:23:44 +02:00
NetterSilverandClaude Haiku 5.5 6a2eeec928 Make empty ranges link and unlink text styles on all characters
Fixes #453

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:16:00 +02:00
NetterSilverandClaude Haiku 5.5 c6fdf9bd4c Keep layer text and string defaults unchanged in rem code output
Fixes #454

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:08:53 +02:00
NetterSilverandClaude Haiku 5.5 e8a30df944 Give case-colliding variant properties distinct data attributes
Fixes #456

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:57:13 +02:00
Patrik LermonandClaude Opus 5.5 ee2640d18e Swedish interface translation (sv)
Adds Swedish (Svenska) as an interface language: 2165 strings, written from the English source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:34:20 +02:00
Andrii VolkovandClaude Opus 5.5 9eadb37622 perf(ui): look for CJK names in edits, not on every pan
Each frame checks the active document's names for CJK scripts, to install
faces for them. The check was keyed on the revision, which pans and zooms
bump too, so every frame of a pan read every layer name. In a 900 000-layer
file that was ~70 ms a frame: panning ran at ~14 fps whatever the canvas cost.

It now keeps the document version it read: the same version (a pan, a zoom)
is skipped, and a new one reads only the layers that changed since
(`Document::changed_ids`, sized by the edit), or every name past 10 000
changes. On that file the UI frame while panning went from ~71 ms to ~3 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-15 10:47:23 +03:00
Andrey 8c74c72b50 Merge branch 'main' into fix/261-startup-failure-dialog 2026-10-15 10:15:53 +03:00
Andrey e4126949bf Merge branch 'main' into fix/255-shadow-behind-blur 2026-10-15 10:15:46 +03: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
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
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
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
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
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
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
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
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
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
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
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
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
174 changed files with 8137 additions and 553 deletions

No files matched your search

Generated
+1
View File
@@ -1721,6 +1721,7 @@ dependencies = [
"figcraft-collab-net",
"figcraft-engine",
"figcraft-ui-egui",
"js-sys",
"log",
"wasm-bindgen",
"wasm-bindgen-futures",
+5 -2
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 (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
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
@@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people.
| Korean | ko | 100% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
@@ -313,6 +313,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 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 |
+17 -1
View File
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
match name {
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
Some(n) => doc
.pages
.iter()
@@ -380,6 +380,22 @@ mod tests {
assert_eq!(sanitize(""), "layer");
}
#[test]
fn default_page_skips_leading_divider() {
let mut doc = Document::new("divider");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
let divider = doc.add_page("- Divider");
doc.pages.retain(|p| *p != divider);
doc.pages.insert(0, divider);
assert!(doc.is_divider(divider));
assert_eq!(page_named(&doc, None).unwrap(), page);
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
assert_eq!((pm.width(), pm.height()), (40, 30));
assert!(render_page(&doc, divider, 1.0).is_err());
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
+64 -34
View File
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
);
let doc = s.doc().map_err(failed)?.clone();
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let b = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
.ok_or_else(|| failed("no button glyph to drop on"))?;
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
);
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
} else {
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
}
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
if let Some(nav) = nav {
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
} else {
table.put(col, "reset all changes on a nav", f64::NAN);
table.put(col, "detach a nav", f64::NAN);
}
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let drop_at = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
match drop_at {
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
None => f64::NAN,
},
);
} else {
table.put(col, "push a button's changes to its main", f64::NAN);
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
}
if let Some(nav) = nav {
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
} else {
table.put(col, "copy + paste a nav", f64::NAN);
}
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
table.put(col, "redo", mean(10, |_| _ = s.redo()));
// As a library: publish it, use it from another file, publish a change, accept the update.
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
// One directory per run, so concurrent runs in a process don't delete each other's files.
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
@@ -533,6 +555,14 @@ mod tests {
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_components_with_a_single_instance() {
let mut t = super::Table { rows: Vec::new() };
super::measure_components(&mut t, 0, 1).unwrap();
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_a_small_document() {
let mut t = super::Table { rows: Vec::new() };
+12 -7
View File
@@ -347,14 +347,14 @@ impl Store {
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let (directory, problems) = Directory::open(root, cfg.directory);
let (directory, startup_problems) = Directory::open(root, cfg.directory);
let abuse = Abuse::new(cfg.hosting.abuse);
let mut store = Store {
root: root.to_path_buf(),
cfg,
key,
files: BTreeMap::new(),
problems,
problems: Vec::new(),
directory,
abuse,
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
@@ -368,6 +368,9 @@ impl Store {
free: None,
_lock: lock,
};
for problem in startup_problems {
store.note(problem);
}
store.scan(&dir);
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
@@ -385,7 +388,7 @@ impl Store {
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
Ok(list) => list,
Err(e) => {
self.problems.push(e);
self.note(e);
Vec::new()
}
}
@@ -394,7 +397,7 @@ impl Store {
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
};
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
ids.sort();
@@ -403,18 +406,18 @@ impl Store {
continue;
}
if !valid_id(&id) {
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
self.note(format!("skipped {}: not a file folder of this server", path.display()));
continue;
}
if self.files.len() >= self.cfg.max_files {
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
continue;
}
match self.check(&path) {
Ok(entry) => {
self.files.insert(id, entry);
}
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
Err(why) => self.note(format!("skipped file {id}: {why}")),
}
}
}
@@ -1489,6 +1492,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());
@@ -0,0 +1,40 @@
use figcraft_server::config::Config;
use figcraft_server::store::{FILES, Store};
#[test]
fn startup_problem_retention() {
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
assert!(!root.exists());
let result = (|| -> Result<(), String> {
for count in [3u32, 200, 201] {
let data = root.join(count.to_string());
for i in 0..count {
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
}
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
cfg.check()?;
let store = Store::open(&data, cfg.store())?;
let expected = (count as usize).min(200);
let problems = store.problems().to_vec();
let no_loaded_files = store.is_empty();
drop(store);
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
return Err("unexpected diagnostic or loaded file".into());
}
if problems.len() != expected {
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
}
// The latest problems stay, in scan order: the first folder's is the one dropped.
let first_kept = count as usize - expected;
for (n, p) in problems.iter().enumerate() {
let id = format!("{:016x}", first_kept + n);
if !p.contains(&id) {
return Err(format!("problem {n} should be about {id}: {p}"));
}
}
}
Ok(())
})();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_ok(), "{}", result.unwrap_err());
}
+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"] }
+45 -3
View File
@@ -69,9 +69,9 @@ impl eframe::App for WebShell {
}
}
/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the
/// system clipboard through egui's output; pastes arrive as paste events with their text (a page
/// can't read the clipboard when it likes).
/// Browser services. 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>,
@@ -79,6 +79,19 @@ struct WebServices {
}
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);
}
@@ -116,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 -1
View File
@@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
}
}
}
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => {
// A line that isn't JSON ends the connection: a web page can make the browser send
// an HTTP request here, and its header lines must not let the JSON lines of its body
// through as commands.
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
break;
}
};
writeln!(out, "{reply}")?;
}
+18 -2
View File
@@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
@@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
worker.join().unwrap().unwrap();
}
#[test]
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
client.write_all(http.as_bytes()).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
}
#[test]
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
let (mut client, server) = pair();
+13 -2
View File
@@ -17,6 +17,7 @@ mod gamepad;
mod graphics;
mod recovery;
mod services;
mod startup;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
@@ -30,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};
@@ -167,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);
@@ -207,7 +213,7 @@ fn main() -> eframe::Result {
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
@@ -220,5 +226,10 @@ fn main() -> eframe::Result {
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
}
+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}");
}
+5
View File
@@ -38,4 +38,9 @@ real_name = "Justin John"
[people.Losminek]
[people.RevDonz]
[people.Nael-Nathanael]
sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
+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);
}
+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();
+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.
+12 -16
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
@@ -139,7 +140,13 @@ impl Color {
Some(Self::from_rgba8(r, g, b, a))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
pub fn from_hex_or_name(s: &str) -> Option<Self> {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -166,21 +173,10 @@ impl Color {
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
let named = match t.as_str() {
"black" => Color::BLACK,
"white" => Color::WHITE,
"transparent" => Color::TRANSPARENT,
"red" => Color::from_rgb8_const(255, 0, 0),
"green" => Color::from_rgb8_const(0, 128, 0),
"lime" => Color::from_rgb8_const(0, 255, 0),
"blue" => Color::from_rgb8_const(0, 0, 255),
"yellow" => Color::from_rgb8_const(255, 255, 0),
"orange" => Color::from_rgb8_const(255, 165, 0),
"purple" => Color::from_rgb8_const(128, 0, 128),
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
_ => return Self::from_hex(&t),
};
Some(named)
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
Self::from_hex_or_name(&t)
}
/// CSS `rgba(...)` / hex text for copy-as-CSS.
+197
View File
@@ -0,0 +1,197 @@
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
//! `FF0000`.
/// Every CSS named colour except `transparent`, sorted by name for binary search.
const NAMED: [(&str, [u8; 3]); 148] = [
("aliceblue", [240, 248, 255]),
("antiquewhite", [250, 235, 215]),
("aqua", [0, 255, 255]),
("aquamarine", [127, 255, 212]),
("azure", [240, 255, 255]),
("beige", [245, 245, 220]),
("bisque", [255, 228, 196]),
("black", [0, 0, 0]),
("blanchedalmond", [255, 235, 205]),
("blue", [0, 0, 255]),
("blueviolet", [138, 43, 226]),
("brown", [165, 42, 42]),
("burlywood", [222, 184, 135]),
("cadetblue", [95, 158, 160]),
("chartreuse", [127, 255, 0]),
("chocolate", [210, 105, 30]),
("coral", [255, 127, 80]),
("cornflowerblue", [100, 149, 237]),
("cornsilk", [255, 248, 220]),
("crimson", [220, 20, 60]),
("cyan", [0, 255, 255]),
("darkblue", [0, 0, 139]),
("darkcyan", [0, 139, 139]),
("darkgoldenrod", [184, 134, 11]),
("darkgray", [169, 169, 169]),
("darkgreen", [0, 100, 0]),
("darkgrey", [169, 169, 169]),
("darkkhaki", [189, 183, 107]),
("darkmagenta", [139, 0, 139]),
("darkolivegreen", [85, 107, 47]),
("darkorange", [255, 140, 0]),
("darkorchid", [153, 50, 204]),
("darkred", [139, 0, 0]),
("darksalmon", [233, 150, 122]),
("darkseagreen", [143, 188, 143]),
("darkslateblue", [72, 61, 139]),
("darkslategray", [47, 79, 79]),
("darkslategrey", [47, 79, 79]),
("darkturquoise", [0, 206, 209]),
("darkviolet", [148, 0, 211]),
("deeppink", [255, 20, 147]),
("deepskyblue", [0, 191, 255]),
("dimgray", [105, 105, 105]),
("dimgrey", [105, 105, 105]),
("dodgerblue", [30, 144, 255]),
("firebrick", [178, 34, 34]),
("floralwhite", [255, 250, 240]),
("forestgreen", [34, 139, 34]),
("fuchsia", [255, 0, 255]),
("gainsboro", [220, 220, 220]),
("ghostwhite", [248, 248, 255]),
("gold", [255, 215, 0]),
("goldenrod", [218, 165, 32]),
("gray", [128, 128, 128]),
("green", [0, 128, 0]),
("greenyellow", [173, 255, 47]),
("grey", [128, 128, 128]),
("honeydew", [240, 255, 240]),
("hotpink", [255, 105, 180]),
("indianred", [205, 92, 92]),
("indigo", [75, 0, 130]),
("ivory", [255, 255, 240]),
("khaki", [240, 230, 140]),
("lavender", [230, 230, 250]),
("lavenderblush", [255, 240, 245]),
("lawngreen", [124, 252, 0]),
("lemonchiffon", [255, 250, 205]),
("lightblue", [173, 216, 230]),
("lightcoral", [240, 128, 128]),
("lightcyan", [224, 255, 255]),
("lightgoldenrodyellow", [250, 250, 210]),
("lightgray", [211, 211, 211]),
("lightgreen", [144, 238, 144]),
("lightgrey", [211, 211, 211]),
("lightpink", [255, 182, 193]),
("lightsalmon", [255, 160, 122]),
("lightseagreen", [32, 178, 170]),
("lightskyblue", [135, 206, 250]),
("lightslategray", [119, 136, 153]),
("lightslategrey", [119, 136, 153]),
("lightsteelblue", [176, 196, 222]),
("lightyellow", [255, 255, 224]),
("lime", [0, 255, 0]),
("limegreen", [50, 205, 50]),
("linen", [250, 240, 230]),
("magenta", [255, 0, 255]),
("maroon", [128, 0, 0]),
("mediumaquamarine", [102, 205, 170]),
("mediumblue", [0, 0, 205]),
("mediumorchid", [186, 85, 211]),
("mediumpurple", [147, 112, 219]),
("mediumseagreen", [60, 179, 113]),
("mediumslateblue", [123, 104, 238]),
("mediumspringgreen", [0, 250, 154]),
("mediumturquoise", [72, 209, 204]),
("mediumvioletred", [199, 21, 133]),
("midnightblue", [25, 25, 112]),
("mintcream", [245, 255, 250]),
("mistyrose", [255, 228, 225]),
("moccasin", [255, 228, 181]),
("navajowhite", [255, 222, 173]),
("navy", [0, 0, 128]),
("oldlace", [253, 245, 230]),
("olive", [128, 128, 0]),
("olivedrab", [107, 142, 35]),
("orange", [255, 165, 0]),
("orangered", [255, 69, 0]),
("orchid", [218, 112, 214]),
("palegoldenrod", [238, 232, 170]),
("palegreen", [152, 251, 152]),
("paleturquoise", [175, 238, 238]),
("palevioletred", [219, 112, 147]),
("papayawhip", [255, 239, 213]),
("peachpuff", [255, 218, 185]),
("peru", [205, 133, 63]),
("pink", [255, 192, 203]),
("plum", [221, 160, 221]),
("powderblue", [176, 224, 230]),
("purple", [128, 0, 128]),
("rebeccapurple", [102, 51, 153]),
("red", [255, 0, 0]),
("rosybrown", [188, 143, 143]),
("royalblue", [65, 105, 225]),
("saddlebrown", [139, 69, 19]),
("salmon", [250, 128, 114]),
("sandybrown", [244, 164, 96]),
("seagreen", [46, 139, 87]),
("seashell", [255, 245, 238]),
("sienna", [160, 82, 45]),
("silver", [192, 192, 192]),
("skyblue", [135, 206, 235]),
("slateblue", [106, 90, 205]),
("slategray", [112, 128, 144]),
("slategrey", [112, 128, 144]),
("snow", [255, 250, 250]),
("springgreen", [0, 255, 127]),
("steelblue", [70, 130, 180]),
("tan", [210, 180, 140]),
("teal", [0, 128, 128]),
("thistle", [216, 191, 216]),
("tomato", [255, 99, 71]),
("turquoise", [64, 224, 208]),
("violet", [238, 130, 238]),
("wheat", [245, 222, 179]),
("white", [255, 255, 255]),
("whitesmoke", [245, 245, 245]),
("yellow", [255, 255, 0]),
("yellowgreen", [154, 205, 50]),
];
/// Longest name in [`NAMED`]; anything longer can't match.
const LONGEST: usize = 20;
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
pub fn lookup(s: &str) -> Option<[u8; 3]> {
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
if key.is_empty() || key.len() > LONGEST {
return None;
}
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_sorted_and_unique() {
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
}
#[test]
fn every_name_is_found() {
for (name, rgb) in NAMED {
assert_eq!(lookup(name), Some(rgb), "{name}");
}
}
#[test]
fn lookup_normalises() {
assert_eq!(lookup("Red"), Some([255, 0, 0]));
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
assert_eq!(lookup(""), None);
assert_eq!(lookup("transparent"), None);
assert_eq!(lookup("reddish"), None);
assert_eq!(lookup("rédé"), None);
assert_eq!(lookup(&"a".repeat(10_000)), None);
}
}
+17
View File
@@ -17,6 +17,23 @@ fn css_parsing() {
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
}
#[test]
fn colour_fields_take_names() {
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
// Hex wins: every digit string still reads as hex.
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
// Names are opaque; `transparent` is not a field colour.
assert!(Color::from_hex_or_name("transparent").is_none());
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
}
}
#[test]
+17
View File
@@ -781,6 +781,23 @@ impl Document {
self.paste_nodes(&src_nodes, &map, id, parent, index)
}
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
/// from one copied frame to another) point at the copies.
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
let mut map = BTreeMap::new();
let mut trees = Vec::with_capacity(ids.len());
for &id in ids {
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
for n in &src_nodes {
let nid = self.alloc_id();
map.insert(n.id, nid);
}
trees.push((id, src_nodes));
}
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
}
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
/// subtree are remapped too.
+13 -1
View File
@@ -143,7 +143,7 @@ impl Expr {
return "…".into();
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
@@ -178,6 +178,18 @@ impl Expr {
}
}
/// A variable as written in an expression: its bare name when that reads back as this variable,
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
/// `"q"` don't read back as literals or errors.
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
let this = Expr::var(v.id.clone());
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
return v.name.clone();
}
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
}
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
/// spaces at its ends, quotes or brackets.
fn name_arg(name: &str) -> String {
+19
View File
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
}
}
}
#[test]
fn names_that_look_like_literals_read_back_as_variables() {
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
let mut v = vars();
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
var.name = name.into();
}
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
}
}
// Plain names stay bare, and literals still read as literals.
let v = vars();
assert_eq!(Expr::var("n").display(&v), "n-name");
assert_eq!(parse("1"), n(1.0));
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
}
+83
View File
@@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() {
assert_eq!(t.characters, "");
}
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
/// text unwritable.
#[test]
fn text_runs_past_the_text_never_overflow() {
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
let mut t = TextData::new("hi");
t.runs = vec![StyleRun { start: 0, end, style: style() }];
// Typing at the caret, and replacing a selection: both shift `end`.
t.replace_range(0, 0, "x");
assert_eq!(t.characters, "xhi", "end {end}");
t.replace_range(0, 3, "yo");
assert_eq!(t.characters, "yo", "end {end}");
// The text is still writable and the runs are back inside it.
t.replace_range(0, 0, "!");
assert_eq!(t.characters, "!yo", "end {end}");
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
}
}
#[test]
fn text_case() {
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
@@ -381,3 +402,65 @@ fn custom_easings() {
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
}
#[test]
fn text_style_updates_merge_equal_runs() {
let def = TextStyle { font_size: 14.0, ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(0, 2, &def, "S");
t.link_text_style(2, 4, &def, "S");
t.restyle(2, 4, |s| s.font_size = 24.0);
assert_eq!(t.runs.len(), 2);
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
let spans = t.spans();
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].0, spans[0].1), (0, 4));
assert_eq!(spans[0].2.font_size, 24.0);
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
// Remapping two links to one id merges the runs.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &def, "B");
t.map_text_styles(|_| Some("C".into()));
assert_eq!(t.spans().len(), 1);
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
// Different values stay separate.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
t.map_text_styles(|_| Some("C".into()));
let spans = t.spans();
assert_eq!(spans.len(), 2);
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
// Unlinking a run equal to the base drops it.
let mut t = TextData::new("ab");
let base = t.style.clone();
t.link_text_style(0, 1, &base, "S");
t.map_text_styles(|_| None);
assert!(t.runs.is_empty());
}
#[test]
fn text_style_empty_range_links_and_unlinks_all() {
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(2, 2, &def, "S");
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
t.unlink_text_style(2, 2);
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
// Partial ranges stay partial.
t.link_text_style(0, 4, &def, "S");
t.unlink_text_style(1, 3);
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
// Empty text changes the base style.
let mut e = TextData::new("");
e.link_text_style(0, 0, &def, "S");
assert_eq!(e.style.style_id.as_deref(), Some("S"));
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
+14 -2
View File
@@ -562,7 +562,7 @@ impl TextData {
}
}
/// Drop empty/out-of-range runs and runs equal to the base style.
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
pub fn normalize(&mut self) {
let n = self.char_count();
let base = self.style.clone();
@@ -571,6 +571,14 @@ impl TextData {
r.end = r.end.min(n);
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
}
}
self.runs = merged;
}
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
@@ -602,7 +610,9 @@ impl TextData {
if p <= start {
p
} else if p >= end {
p - removed + inserted
// Runs of a file that reach past the text (nothing clamps them on load)
// would otherwise overflow here; `normalize` below drops what is left.
p.saturating_sub(removed).saturating_add(inserted)
} else {
start + inserted
}
@@ -675,11 +685,13 @@ impl TextData {
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.take_text_style(def, id));
}
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.style_id = None);
}
+19 -2
View File
@@ -136,8 +136,8 @@ impl VectorNetwork {
}
let k = key(&face.loops);
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
(Some(i), None | Some(false)) => {
self.regions.remove(i);
(Some(_), None | Some(false)) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
(Some(_), Some(true)) => Some(true),
@@ -525,4 +525,21 @@ mod tests {
let _ = j.paint(Point::new(f64::NAN, 5.0));
assert!(interior_point(&BezPath::new()).is_none());
}
#[test]
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
let c = Point::new(50.0, 50.0);
let mut n = square(0.0, 0.0, 100.0);
n.regions.push(n.regions[0].clone());
assert_eq!(n.paint(c), Some(false));
assert!(!n.fill_contains(c));
assert_eq!(n.region_at(c), None);
assert_eq!(n.paint(c), Some(true));
assert!(n.fill_contains(c));
let mut e = square(0.0, 0.0, 100.0);
e.regions.push(e.regions[0].clone());
assert_eq!(e.paint_to(c, Some(false)), Some(false));
assert!(!e.fill_contains(c));
}
}
+13 -4
View File
@@ -18,8 +18,9 @@ impl VectorNetwork {
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
/// as stored. Missing segments are skipped.
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() };
let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) {
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
let Some(first) = existing.next() else { return Vec::new() };
let forward_first = match existing.next() {
Some(second) => first.b == second.a || first.b == second.b,
None => true,
};
@@ -102,10 +103,18 @@ impl VectorNetwork {
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
}
/// Every point on region `r`'s loops (holes' too), each once, sorted.
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
/// don't exist are left out).
pub fn region_points(&self, r: usize) -> Vec<usize> {
let Some(region) = self.regions.get(r) else { return Vec::new() };
let mut out: Vec<usize> = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect();
let mut out: Vec<usize> = region
.loops
.iter()
.flatten()
.filter_map(|i| self.segments.get(*i))
.flat_map(|s| [s.a, s.b])
.filter(|v| *v < self.vertices.len())
.collect();
out.sort_unstable();
out.dedup();
out
+35
View File
@@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() {
assert_eq!(n.region_at(p(300.0, 100.0)), None);
}
#[test]
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
use figcraft_geom::Shape;
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
let control = n.region_path(0).unwrap();
let control_walk = n.loop_walk(&[0, 1, 2]);
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
let path = n.region_path(0).unwrap();
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
}
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_path(0).is_none());
assert!(!n.region_contains(0, p(100.0, 100.0)));
}
#[test]
fn a_regions_points_are_listed_once() {
let mut n = nested();
@@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
}
}
#[test]
fn region_points_leave_out_segment_ends_that_do_not_exist() {
let mut n = VectorNetwork::default();
let ring = square(&mut n, 0.0, 0.0, 100.0);
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
n.segments[3] = Segment::line(3, 9);
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
assert!(n.region_path(0).is_some());
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_points(0).is_empty());
}
#[test]
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
let mut n = nested();
+21
View File
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
}
#[test]
fn single_item_wrapped_lines_move_and_drop_by_line() {
let mut s = Session::new();
// Full-width items: one per row.
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
set(&mut s, row, json!({"wrap": true}));
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
arrow(&mut s, &[k[1]], 0, -1);
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
// Full-height items in a vertical wrap: one per column.
let col = al(&mut s, None, "vertical", 200.0, 160.0);
set(&mut s, col, json!({"wrap": true}));
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
arrow(&mut s, &[c[2]], -1, 0);
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
}
#[test]
fn wrapped_rows_move_up_and_down_a_row() {
let mut s = Session::new();
+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);
}
+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");
}
+1 -1
View File
@@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if let NodeKind::Text(td) = &mut kind {
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default());
if w > 0.0 {
td.auto_resize = TextAutoResize::Height;
}
+2 -1
View File
@@ -1003,7 +1003,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
if find.is_empty() {
return Err(bad("edit.findReplace", "`find` is empty"));
}
let replace = string(p, "replace");
// Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF).
let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r));
let names = boolean(p, "layerNames").unwrap_or(false);
let page = s.page()?;
let doc = s.doc_mut()?;
+83 -12
View File
@@ -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
),
@@ -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) {
@@ -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()}))
}
@@ -762,12 +824,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
}
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
/// area), or their boxes with "include bounding box" and for slices.
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
ids.iter()
.filter_map(|i| {
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
let node = doc.node(*i);
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice {
doc.world_bounds(*i)
} else if any_visible && node.is_some_and(|n| !n.visible) {
None
} else {
figcraft_render::export_bounds(doc, *i)
}
})
.reduce(|a, b| a.union(b))
}
+35 -10
View File
@@ -1,5 +1,6 @@
//! Pages.
use figcraft_doc::proto::Flow;
use figcraft_doc::*;
use serde_json::{Value, json};
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
Ok(())
}
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
let Some(target) = id(p, "id") else { return s.page() };
if !s.doc()?.pages.contains(&target) {
return Err(bad(cmd, format!("{target} is not a page")));
}
Ok(target)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
let name = req_string("page.rename", p, "name")?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.rename", p)?;
s.doc_mut()?.get_mut(id)?.name = name;
settle(s, "page.rename")?;
ok()
}),
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.delete", p)?;
s.doc_mut()?.remove(id)?;
settle(s, "page.delete")?;
ok()
@@ -87,13 +97,25 @@ pub fn specs() -> Vec<CommandSpec> {
let doc = s.doc_mut()?;
let name = format!("Copy of {}", doc.get(src)?.name);
let np = doc.add_page(&name);
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
// One copy with one id map, so navigation between the copied frames points at the copies;
// then each layer and its copy (also those in sections), so flows can follow their start frames.
let children = doc.get(src)?.children.clone();
let copied = doc.duplicate_all_into(&children, np)?;
let mut copies = Vec::new();
for (c, copy) in children.iter().zip(&copied) {
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
}
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
{
*b = background;
}
for c in doc.get(src)?.children.clone() {
doc.duplicate_into(c, np, None)?;
*f = flows
.into_iter()
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
.collect();
*pr = prototype;
*g = guides;
}
// Keep the new page right after the source.
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
@@ -117,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.setBackground", p)?;
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
*background = c;
}
@@ -163,8 +185,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
guides.remove(k);
let ds = s.ds_mut()?;
if ds.guide == Some((owner, k)) {
ds.guide = None;
match ds.guide {
Some((o, i)) if o == owner && i == k => ds.guide = None,
// A later guide of the same owner moves up one place; keep the selection on it.
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
_ => {}
}
ok()
}),
+6 -1
View File
@@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same).
pub(crate) fn normalize_line_endings(s: &str) -> String {
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
}
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
match v {
@@ -599,7 +604,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let len = t.char_count();
t.replace_range(0, len, &s);
t.replace_range(0, len, &normalize_line_endings(&s));
n.overrides.insert("characters".into());
}
"fontFamily"
+3
View File
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
+1 -1
View File
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let scopes = match get(p, "scopes") {
let scopes = match p.get("scopes") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => {
+11 -2
View File
@@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
let pages = &ds.doc.pages;
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
let n = pages.len() as i64;
let next = (i + dir).clamp(0, n - 1) as usize;
let Some(pg) = pages.get(next).copied() else { return ok() };
// Dividers never open: step over them, and stay put when only dividers lie ahead.
let mut next = i + dir;
let pg = loop {
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
match page {
Some(p) if !ds.doc.is_divider(p) => break p,
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
_ => break ds.page,
}
};
let ds = s.ds_mut()?;
ds.page = pg;
ds.selection.clear();
@@ -334,6 +342,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);
+51 -1
View File
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
/// in "px" are left alone.
/// in "px", markup lines (layer text), string literals and comments are left alone.
pub fn px_to_rem(code: &str, root: f64) -> String {
let mut out = String::with_capacity(code.len());
let mut in_comment = false;
for line in code.split_inclusive('\n') {
if !in_comment && line.trim_start().starts_with('<') {
out.push_str(line);
continue;
}
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
let mut plain = 0;
let mut chars = line.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if in_comment {
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
chars.next();
out.push_str(line.get(plain..i + 2).unwrap_or(""));
plain = i + 2;
in_comment = false;
}
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
chars.next();
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
plain = i;
in_comment = true;
} else if c == '"' {
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
let mut end = line.len();
while let Some((j, s)) = chars.next() {
if s == '\\' {
chars.next();
} else if s == '"' {
end = j + 1;
break;
}
}
out.push_str(line.get(i..end).unwrap_or(""));
plain = end;
}
}
let rest = line.get(plain..).unwrap_or("");
if in_comment {
out.push_str(rest);
} else {
out.push_str(&lengths_to_rem(rest, root));
}
}
out
}
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
fn lengths_to_rem(code: &str, root: f64) -> String {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = String::with_capacity(code.len());
let mut rest = code;
+10 -7
View File
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
}
}
/// `data-*` attribute names for variant parameters.
fn data_attr(p: &Param) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
format!("data-{}", class_name(&p.prop, &mut used))
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
}
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, &mut Vec::new());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
let mut m = Markup::new(&vctx, lang);
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
m.root_class = Some(root_class.clone());
m.root_attrs = variants
.iter()
.map(|p| match lang {
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
.zip(&attr_names)
.map(|(p, attr)| match lang {
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
_ => format!(" {attr}={{{}}}", p.ident),
})
.collect();
m.node(c.comp, None, 2, "");
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let pairs = variant_props(&vn.name);
let attrs: String = variants
.iter()
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
.zip(&attr_names)
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
.collect();
for (path, _, decls) in pass(v) {
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
let react = code(&mut s, c, "react");
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
}
#[test]
fn case_colliding_variant_properties_get_distinct_data_attributes() {
let mut s = Session::new();
let mut variants = Vec::new();
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
let name = format!("Size={first}, size={second}");
let f = idv(&ex(
&mut s,
"frame.create",
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
for lang in ["react", "leptos"] {
let v = component_code(&mut s, set, lang);
let def = v["definition"].as_str().unwrap_or_default();
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
let attrs: Vec<&str> =
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
assert_eq!(attrs.len(), 2, "{lang}: {root}");
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
}
}
+2 -2
View File
@@ -472,11 +472,11 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
}
}
"characters" => {
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str())
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
&& t.characters != sv
{
let len = t.char_count();
t.replace_range(0, len, sv);
t.replace_range(0, len, &sv);
}
}
"mainComponent" => {
+18
View File
@@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
}
#[test]
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
// A real click: the mouse moves a pixel or two between press and release.
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
assert!(s.tool.overlay.pen_handles.is_empty());
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
assert_eq!(out, Some((4.0, 0.0)));
}
#[test]
fn the_pen_shows_what_a_click_would_do() {
// The Pen tool drawing a new path: three points, then over each of them.
+26
View File
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
}
#[test]
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
let mut s = Session::new();
let screen = frame(&mut s, 0.0, "Screen");
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
};
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
ex(&mut s, "dev.focus", json!({"id": screen}));
for direction in ["next", "previous"] {
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
}
// A frame directly inside a section still counts.
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
}
+2 -2
View File
@@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
}
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
/// in page and layer order (bounded).
/// in page and layer order (bounded). Only layers that can carry a status are listed.
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
let mut out = Vec::new();
let mut visited = 0usize;
@@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
return out;
}
let Some(n) = doc.node(id) else { continue };
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
}
stack.extend(n.children.iter().rev().copied());
+5 -2
View File
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
/// wrap in flow order, so a new row starts where a child sits before the one before.
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
/// does not follow the one before along the flow but sits past it across (one child per line).
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
match rows.last_mut() {
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
row.push((i, b))
}
_ => rows.push(vec![(i, b)]),
}
}
+40
View File
@@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
}
fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String {
let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap();
out["code"].as_str().unwrap().to_string()
}
#[test]
fn rem_preserves_text_in_web_markup() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap());
s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap();
let mut changed = Vec::new();
for language in ["html", "react", "leptos"] {
let px = web_code(&mut s, frame, language, "px");
let rem = web_code(&mut s, frame, language, "rem");
assert!(px.contains("Tile 24px"), "{language}: px control: {px}");
assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}");
assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}");
if !rem.contains("Tile 24px") {
changed.push(language);
}
}
assert!(changed.is_empty(), "text rewritten in {changed:?}");
}
#[test]
fn rem_preserves_text_property_default() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap());
let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap());
let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap());
s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap();
for unit in ["px", "rem"] {
let code = web_code(&mut s, component, "react", unit);
assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}");
if unit == "rem" {
assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}");
}
}
}
+70 -4
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 {
@@ -573,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>,
@@ -625,6 +669,7 @@ impl Default for Prefs {
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(),
@@ -1081,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
@@ -1180,8 +1236,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
pub fn normalize(doc: &mut Document, before: &Document) {
// Only what was touched since the last normalisation (edited, added or removed).
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
// A variable edit changes no layer directly, but bound layers must follow it.
if touched.is_empty() && doc.variables == before.variables {
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
return;
}
let m = measure::TextMeasure;
@@ -1406,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;
@@ -1500,8 +1558,12 @@ mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod line_endings_tests;
#[cfg(test)]
mod live_access_tests;
#[cfg(test)]
mod live_docs_tests;
@@ -1528,6 +1590,10 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_copy_navigation_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
+23 -3
View File
@@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
out
}
/// The style ids a layer links to (fill, stroke, effect, grid, text).
/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on
/// character ranges).
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
let chars =
n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref());
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars)
}
/// Style ids the layers under `roots` link to.
@@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
}
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
/// the library's are refreshed). Returns library style id → local id.
/// the library's are refreshed). The variables a written copy binds are imported too and its
/// bindings point at their copies (never at this file's variables of the same ids). Returns library
/// style id → local id.
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
let lid = lib.manifest.id.clone();
let mut out = BTreeMap::new();
let mut hashes = BTreeSet::new();
// Local ids of the copies written now, with the library style they copy.
let mut written: Vec<(String, &Style)> = Vec::new();
for key in keys {
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
@@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
{
*slot = Style { id: r.local.clone(), ..style.clone() };
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
written.push((r.local.clone(), style));
}
r.local
}
@@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
doc.styles.push(Style { id: local.clone(), ..style.clone() });
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
written.push((local.clone(), style));
local
}
};
@@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
}
out.insert(key.clone(), local);
}
let bound: BTreeSet<VariableId> = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect();
if !bound.is_empty() {
let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update);
for (local, _) in &written {
if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) {
// Bindings to variables the library lacks are dropped (the published value stays).
slot.bound =
std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
}
}
}
copy_images(doc, &lib.doc, &hashes);
out
}
@@ -0,0 +1,66 @@
//! Text styles linked on character ranges travel with components (publish and place).
use crate::Session;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn node(value: Value) -> NodeId {
NodeId(value["id"].as_u64().unwrap())
}
/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style).
fn fixture() -> (Session, NodeId, String, String) {
let mut s = Session::new();
let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40})));
let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"})));
let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned();
run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2}));
let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"})));
let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned();
run(&mut s, "style.apply", json!({"id":whole_text, "style":whole}));
let component = node(run(&mut s, "component.create", json!({"ids":[frame]})));
let source = s.doc().unwrap().node(text).unwrap();
let chars = source.kind.text().unwrap();
assert!(source.styles.text.is_none());
assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str()));
assert_eq!(chars.style_at(2).style_id, None);
(s, component, range, whole)
}
#[test]
fn component_publish_keeps_range_text_style() {
let (mut s, component, range, whole) = fixture();
run(&mut s, "library.publish", json!({"include":[component]}));
let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc;
assert!(snapshot.style(&whole).is_some());
assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot");
}
#[test]
fn component_place_imports_range_text_style() {
let (mut source, component, range, whole) = fixture();
let publication = run(&mut source, "library.publish", json!({}));
let library = source.doc().unwrap().published.as_ref().unwrap().clone();
assert!(library.doc.style(&range).is_some());
let mut target = Session::new();
let path = "figcraft://library.test/range-styles";
target.libraries.insert(path, Ok(library));
run(&mut target, "library.add", json!({"path":path}));
let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component})));
let doc = target.doc().unwrap();
let children = &doc.node(instance).unwrap().children;
let ranged = doc.node(children[0]).unwrap().kind.text().unwrap();
let full = doc.node(children[1]).unwrap().kind.text().unwrap();
let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap();
assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str()));
assert_eq!(ranged.style_at(0).font_size, 32.0);
assert!(ranged.style_at(2).style_id.is_none());
let record = doc.libraries.styles.iter().find(|r| r.key == range);
assert!(record.is_some(), "character-range style missing after placement");
assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str()));
assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some());
}
@@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() {
}
assert!(s.doc().unwrap().libraries.is_empty());
}
#[test]
fn library_style_bindings_ignore_unrelated_local_variables() {
let d = dir("style-bound");
let path = path_str(&d.join("lib.figcraft"));
let mut lib = Session::new();
ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"}));
let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone();
let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone();
ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var}));
let lid = publish_to(&mut lib, &path);
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Local"}));
let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone();
assert_eq!(local, var, "the ids must collide for this test");
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));
ex(&mut s, "library.add", json!({"path": path}));
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));
assert_eq!(fill(&s, r), "0000FF");
healthy(&s);
// An updated style follows the library's variable, not the local one.
ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, r), "00FFFF");
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
+56
View File
@@ -0,0 +1,56 @@
//! Text set by a command stores every line ending as LF, like editor insertion does, so layout
//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029.
use serde_json::json;
use crate::Session;
use crate::components::tests::{card, chars, copy_of, ex, idv};
use crate::params::id_of;
const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"];
#[test]
fn text_set_normalizes_line_endings() {
let mut s = Session::new();
let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap();
let id = id_of(&created["id"]).unwrap();
for input in INPUTS {
s.execute("text.set", &json!({"id": id, "characters": input})).unwrap();
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
assert_eq!(t.characters, "a\nb", "text.set {input:?}");
}
}
#[test]
fn text_create_normalizes_line_endings() {
let mut s = Session::new();
for input in INPUTS {
let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap();
let id = id_of(&created["id"]).unwrap();
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
assert_eq!(t.characters, "a\nb", "text.create {input:?}");
}
}
#[test]
fn find_replace_normalizes_line_endings() {
for input in INPUTS {
let mut s = Session::new();
let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0})));
ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input}));
assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}");
}
}
#[test]
fn text_property_values_normalize_line_endings() {
let mut s = Session::new();
let (c, _bg, title, _dot) = card(&mut s);
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
let copy = copy_of(&s, i, title);
for input in INPUTS {
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}}));
assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}");
}
}
+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 = [
@@ -0,0 +1,48 @@
//! Duplicating a page copies its prototype connections: navigation between two frames that are both
//! on the page leads between the copies, while a destination outside the copied contents is kept.
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn frame(s: &mut Session, x: f64) -> NodeId {
id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap()
}
fn dest(s: &Session, id: NodeId) -> NodeId {
s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap()
}
#[test]
fn page_copy_navigation_uses_the_copied_destination() {
let mut s = Session::new();
let first = frame(&mut s, 0.0);
let second = frame(&mut s, 200.0);
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let children = s.doc().unwrap().node(copy).unwrap().children.clone();
assert_eq!(children.len(), 2);
assert_eq!(dest(&s, children[0]), children[1]);
let visited = Default::default();
assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1]));
assert_eq!(dest(&s, first), second, "the original keeps its destination");
}
#[test]
fn frame_copy_keeps_an_outside_destination() {
let mut s = Session::new();
let first = frame(&mut s, 0.0);
let second = frame(&mut s, 200.0);
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
ex(&mut s, "select.set", json!({"ids": [first]}));
ex(&mut s, "edit.duplicate", json!({}));
let copy = s.selection()[0];
assert_ne!(copy, first);
assert_eq!(dest(&s, copy), second);
}
+63
View File
@@ -0,0 +1,63 @@
//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about
//! the layer changes, as `page.select` already does.
use figcraft_doc::{NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn name(s: &Session, id: NodeId) -> String {
s.doc().unwrap().get(id).unwrap().name.clone()
}
#[test]
fn page_commands_reject_a_rectangle() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let before = name(&s, r);
assert!(s.execute("page.select", &json!({"id": r})).is_err());
assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer");
assert_eq!(name(&s, r), before, "the layer keeps its name");
assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer");
assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer");
assert!(s.doc().unwrap().contains(r), "the layer is still there");
assert!(s.doc().unwrap().contains(page));
}
#[test]
fn page_delete_keeps_a_frame_and_its_child() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})));
assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f));
assert!(s.execute("page.delete", &json!({"id": f})).is_err());
assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain");
assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err());
assert_ne!(name(&s, f), "Not a page");
}
#[test]
fn page_commands_still_work_on_pages() {
let mut s = Session::new();
let first = s.page().unwrap();
let second = id_of(&ex(&mut s, "page.add", json!({})));
ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"}));
assert_eq!(name(&s, second), "Flows");
ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"}));
match s.doc().unwrap().get(second).unwrap().kind {
NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"),
_ => panic!("not a page"),
}
ex(&mut s, "page.delete", json!({"id": second}));
assert!(!s.doc().unwrap().contains(second));
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
}
@@ -425,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
}
#[test]
fn page_navigation_skips_dividers() {
// A trailing divider: Next page stays on the ordinary page.
let mut s = Session::new();
let first = s.page().unwrap();
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
let r = ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider");
assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page");
// A middle divider: Previous page crosses it, Next page crosses it back.
let later = id(&ex(&mut s, "page.add", json!({"name": "Later"})));
assert_eq!(s.page().unwrap(), later);
let r = ex(&mut s, "view.prevPage", json!({}));
assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider");
assert_eq!(id_of(&r["page"]), Some(first));
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page crosses a divider");
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
}
+1 -1
View File
@@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
if merge && whole_shape {
return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
let created = if merge {
+19 -4
View File
@@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
made += 4;
}
// A card group (5 layers).
if budget(made) >= 5 {
// The card and the instances leave one layer for the grid, which starts with a rectangle.
if budget(made) > 5 {
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
// Children are in the group's space; normalising fits the group around them.
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
@@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
}
// Three button instances (3 layers each).
for k in 0..3 {
if budget(made) < 3 {
if budget(made) < 3 + 1 {
break;
}
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
@@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
for k in 0..rest {
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
let id = match k % 5 {
// The grid starts with a rectangle so even the smallest workload has one.
let kind = (k + 2) % 5;
let id = match kind {
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
};
if k % 5 != 0 {
if kind != 0 {
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
if let Some(n) = doc.node_mut(id) {
n.corners.radii = [3.0; 4];
@@ -124,4 +127,16 @@ mod tests {
assert!(doc.page_content_bounds(page).is_some());
}
}
#[test]
fn small_documents_hold_the_benchmark_targets() {
for n in [10, 11, 12, 13, 14, 15] {
let (doc, page) = super::large_document(n).unwrap();
let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect();
let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node));
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle");
assert!(has(&|x| x.name == "Title"), "{n}: no title");
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component");
}
}
}
+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()));
}
+16 -14
View File
@@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> {
let _ = doc.remove(te.id);
s.ds_mut()?.selection.clear();
}
s.commit_interaction()?;
if empty && te.created {
// Nothing to undo for a text box that was never filled.
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
// after the layer was added, so it still holds it: re-base it on the document without the
// layer, and committing then records no step (and leaves the redo stack alone).
let ds = s.ds_mut()?;
if let Some(last) = ds.history.undo.last()
&& *last.doc == *ds.doc
{
ds.history.undo.pop();
let doc = ds.doc.clone();
if let Some(it) = ds.interaction.as_mut() {
it.doc = doc;
}
}
s.commit_interaction()?;
let ds = s.ds_mut()?;
ds.revision += 1;
Ok(())
@@ -82,12 +83,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
/// characters dropped, so pasted plain text from any platform lands cleanly.
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
let text: String = text
.replace("\r\n", "\n")
.replace(['\r', '\u{2028}', '\u{2029}'], "\n")
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.collect();
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
if text.is_empty() {
return Ok(());
}
@@ -146,9 +142,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
}
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is).
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
/// at the deepest level stay as they are).
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
list_key(s, "Tab", out)
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
let before = text_of(s, id)?;
if !list_key(s, "Tab", out)? {
return Ok(false);
}
Ok(text_of(s, id)? != before)
}
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
+46
View File
@@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_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)]);
}
#[test]
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let mut s = Session::new();
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone();
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"}));
ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true}));
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25}));
ex(&mut s, "select.none", json!({}));
let source = s.page().unwrap();
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
_ => panic!("expected a page"),
};
let (flows, prototype, guides) = state(&s, copy);
let (src_flows, src_prototype, src_guides) = state(&s, source);
assert_eq!(flows.len(), 1);
assert_eq!(flows[0].name, "Checkout");
assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame");
assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own");
assert_eq!(prototype, src_prototype);
assert_eq!(prototype.device, "iPhone 16");
assert!(prototype.responsive);
assert_eq!(guides, src_guides);
assert_eq!(guides.len(), 1);
assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0));
}
#[test]
fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() {
let mut s = Session::new();
let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
ex(&mut s, "object.wrapInSection", json!({"ids": [start]}));
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"}));
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let doc = s.doc().unwrap();
let section = doc.node(copy).unwrap().children[0];
let frame = doc.node(section).unwrap().children[0];
match &doc.node(copy).unwrap().kind {
NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::<Vec<_>>(), vec![("Onboarding", frame)]),
_ => panic!("expected a page"),
}
}
+42 -4
View File
@@ -376,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,
},
@@ -1400,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;
@@ -1444,7 +1456,8 @@ impl Session {
}
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
Gesture::PenDrag { anchor } => {
let out = ev.p() - *anchor;
// A click's wobble pulls out no handle.
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
if let Some(pen) = self.tool.pen.as_mut()
&& let Some(last) = pen.points.last_mut()
{
@@ -1818,7 +1831,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()?,
}
@@ -1844,9 +1857,10 @@ impl Session {
self.widget_click(ev.p(), ev.tol() * 2.0)?;
return Ok(());
}
// The second press began a move or a resize: a double-click isn't one.
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
// The second press began a move, a resize, a shape or a marquee: a double-click isn't one.
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) {
self.tool.gesture = Gesture::None;
self.tool.overlay.marquee = None;
self.cancel_interaction()?;
}
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
@@ -1943,15 +1957,39 @@ impl Session {
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();
@@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() {
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
assert_eq!(scope(f, "visible"), None);
}
#[test]
fn create_collection_refuses_a_taken_name() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let before = s.doc().unwrap().variables.collections.len();
assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err());
assert_eq!(s.doc().unwrap().variables.collections.len(), before);
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
}
+96 -4
View File
@@ -3,7 +3,7 @@
//! `vectoredit.rs` re-fit the node afterwards.
use figcraft_doc::*;
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use figcraft_geom::{Affine, Point, Vec2};
fn tvec(t: (f64, f64)) -> Vec2 {
@@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
let s = net.segments.get(seg).copied()?;
let c: CubicBez = net.cubic(seg)?;
// Which way each region loop walks `seg`, read before the split changes its endpoints.
let reversed: Vec<Vec<bool>> = net
.regions
.iter()
.map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect())
.collect();
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
let l = c.subsegment(0.0..t);
let r = c.subsegment(t..1.0);
@@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usiz
};
net.segments.push(new_seg);
let ni = net.segments.len() - 1;
for reg in &mut net.regions {
for lp in &mut reg.loops {
for (r, reg) in net.regions.iter_mut().enumerate() {
for (l, lp) in reg.loops.iter_mut().enumerate() {
if let Some(pos) = lp.iter().position(|x| *x == seg) {
lp.insert(pos + 1, ni);
// Walked backwards, the new half (mid→b) comes before `seg` (a→mid).
let rev = reversed.get(r).and_then(|v| v.get(l)).copied().unwrap_or(false);
lp.insert(if rev { pos } else { pos + 1 }, ni);
}
}
}
@@ -766,6 +774,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
if ia { b } else { a }
};
let n = 64;
// Upper bound on the curve's speed: no point of a sample interval is farther than the whole
// interval length from either end, so only intervals that come that close need a closer look.
let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max);
let reach = speed / f64::from(n);
// A crossing narrower than an interval whose ends are both outside: the closest point.
let dip = |a: f64, b: f64| -> Option<f64> {
if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach {
return None;
}
let near = cb.subsegment(a..b).nearest(c, 1e-12);
let m = a + (b - a) * near.t;
(m > a && m < b && inside(m)).then_some(m)
};
let mut out = Vec::new();
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
let mut prev = 0.0;
@@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
let t = f64::from(i) / f64::from(n);
match (start, inside(t)) {
(None, true) => start = Some(edge(prev, t)),
(None, false) => {
if let Some(m) = dip(prev, t) {
out.push((edge(prev, m), edge(m, t)));
}
}
(Some(s0), false) => {
out.push((s0, edge(prev, t)));
start = None;
@@ -856,6 +882,38 @@ mod tests {
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
}
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
let ends: Vec<(usize, usize)> = n
.loop_walk(lp)
.iter()
.map(|&(i, rev)| {
let s = n.segments[i];
if rev { (s.b, s.a) } else { (s.a, s.b) }
})
.collect();
(0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0)
}
#[test]
fn split_keeps_region_loop_in_ring_order() {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
let mut m = n.clone();
m.regions[0].loops = vec![lp];
let mid = split_segment(&mut m, seg, 0.5).unwrap();
assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops);
assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill");
if seg == 1 {
assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0));
}
}
}
#[test]
fn inferred_mirroring() {
let mut n = open_line();
@@ -999,4 +1057,38 @@ mod tests {
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
}
#[test]
fn eraser_catches_narrow_crossing_between_samples() {
// A radius-1 brush on a long line: the crossing fits between the sampled parameters.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}");
// A curved segment that only grazes the brush between samples.
let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
c.segments[0].ta = (0.0, 3000.0);
c.segments[0].tb = (0.0, 3000.0);
let cb = c.cubic(0).unwrap();
let t = 0.7039;
let p = cb.eval(t);
let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize();
let normal = Vec2::new(-d.y, d.x);
assert!(erase(&mut c, p + normal * 0.5, 1.0));
assert_eq!(c.segments.len(), 2);
}
#[test]
fn eraser_catches_crossing_at_full_speed_between_samples() {
// The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
let centre = n.cubic(0).unwrap().eval(32.5 / 64.0);
assert!(erase(&mut n, centre, 0.5));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
}
}
+17
View File
@@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
}
#[test]
fn removing_an_earlier_guide_keeps_the_selected_guide() {
let mut s = Session::new();
for offset in [100, 200, 300] {
ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset}));
}
let page = s.page().unwrap();
ex(&mut s, "guide.select", json!({"index": 1}));
ex(&mut s, "guide.remove", json!({"index": 0}));
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index");
assert!(s.tool_key("ArrowRight", false, false, false).unwrap());
assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour");
// A later guide leaves the selection alone.
ex(&mut s, "guide.remove", json!({"index": 1}));
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)));
}
#[test]
fn guides_snap_and_measure() {
let mut s = Session::new();
@@ -0,0 +1,60 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
doc::NodeKind,
tools::{PointerEvent, PointerKind},
};
use serde_json::json;
fn text_count(s: &Session) -> usize {
let doc = s.doc().unwrap();
doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count()
}
fn rect_count(s: &Session) -> usize {
let doc = s.doc().unwrap();
doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s)
}
fn click_and_abandon(s: &mut Session) {
s.execute("tool.text", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
assert_eq!(text_count(s), 1);
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(text_count(s), 0);
}
#[test]
fn abandoning_empty_text_does_not_create_an_undo_step() {
let mut s = Session::new();
assert_eq!(s.undo_depth(), (0, 0));
click_and_abandon(&mut s);
assert_eq!(s.undo_depth(), (0, 0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer");
}
#[test]
fn abandoning_empty_text_keeps_the_previous_undo_step_first() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
click_and_abandon(&mut s);
assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_count(&s), 0);
assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle");
}
#[test]
fn abandoning_empty_text_keeps_the_redo_stack() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.undo_depth(), (0, 1));
click_and_abandon(&mut s);
assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(rect_count(&s), 1);
}
@@ -0,0 +1,72 @@
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::{NodeId, StyleKind};
use figcraft_engine::Session;
use serde_json::{Value, json};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap()
}
fn layer_paint(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
fn style_paint(s: &Session, id: &str) -> String {
match &s.doc().unwrap().style(id).unwrap().kind {
StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(),
_ => panic!("expected paint style"),
}
}
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn accepted_library_paint_reaches_linked_layer() {
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&fixture.0);
std::fs::create_dir(&fixture.0).unwrap();
let path = fixture.0.join("library.figcraft");
let mut publisher = Session::new();
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
let publication = run(&mut publisher, "library.publish", json!({}));
assert_eq!(publication["components"], 0);
assert_eq!(publication["styles"], 1);
run(&mut publisher, "file.saveAs", json!({"path":path}));
let mut consumer = Session::new();
let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40}));
let layer = NodeId(rectangle["id"].as_u64().unwrap());
run(&mut consumer, "library.add", json!({"path":path}));
let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer}));
let style = applied["style"].as_str().unwrap();
assert_eq!(layer_paint(&consumer, layer), "0000FF");
run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"}));
run(&mut publisher, "library.publish", json!({}));
run(&mut publisher, "file.save", json!({}));
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1);
let accepted = run(&mut consumer, "library.acceptUpdates", json!({}));
assert_eq!(accepted["styles"], 1);
assert_eq!(accepted["components"], 0);
assert_eq!(accepted["variables"], 0);
assert_eq!(style_paint(&consumer, style), "00FF00");
assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style));
// Captured before any reapply so a later action can't hide a missed refresh.
let after_accept = layer_paint(&consumer, layer);
assert_eq!(after_accept, "00FF00");
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0);
consumer.undo().unwrap();
assert_eq!(style_paint(&consumer, style), "0000FF");
assert_eq!(layer_paint(&consumer, layer), "0000FF");
consumer.redo().unwrap();
assert_eq!(style_paint(&consumer, style), "00FF00");
assert_eq!(layer_paint(&consumer, layer), "00FF00");
run(&mut consumer, "style.apply", json!({"style":style,"id":layer}));
assert_eq!(layer_paint(&consumer, layer), "00FF00");
}
@@ -0,0 +1,71 @@
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
doc::{ListStyle, NodeId, NodeKind},
};
use serde_json::json;
fn editing_list(indent: u8) -> (Session, NodeId) {
let mut s = Session::new();
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
let id = NodeId(value["id"].as_u64().unwrap());
s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap();
s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap();
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
(s, id)
}
fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) {
let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") };
let p = t.para_list(0);
(p.list, p.indent)
}
#[test]
fn indent_at_cap_reports_false() {
let (mut s, id) = editing_list(8);
let before = s.doc().unwrap().clone();
let result = s.execute("text.listIndent", &json!({})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8));
assert_eq!(before, *s.doc().unwrap());
assert_eq!(result["changed"], json!(false));
}
#[test]
fn indent_from_zero_reports_true() {
let (mut s, id) = editing_list(0);
let result = s.execute("text.listIndent", &json!({})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
assert_eq!(result["changed"], json!(true));
}
#[test]
fn outdent_from_zero_reports_true() {
let (mut s, id) = editing_list(0);
let result = s.execute("text.listOutdent", &json!({})).unwrap();
assert_eq!(list_state(&s, id).0, ListStyle::None);
assert_eq!(result["changed"], json!(true));
}
#[test]
fn indent_plain_text_reports_false() {
let mut s = Session::new();
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
let id = NodeId(value["id"].as_u64().unwrap());
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
let before = s.doc().unwrap().clone();
let result = s.execute("text.listIndent", &json!({})).unwrap();
assert_eq!(before, *s.doc().unwrap());
assert_eq!(result["changed"], json!(false));
}
#[test]
fn tab_keys_still_nest_and_unnest() {
let (mut s, id) = editing_list(0);
s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0));
}
+26
View File
@@ -0,0 +1,26 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::json;
fn scopes(session: &Session, variable: &str) -> Option<Vec<String>> {
session.doc().unwrap().variables.get(variable).unwrap().scopes.clone()
}
#[test]
fn null_restores_all_supported_properties() {
let mut session = Session::new();
session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap();
let variable = result["id"].as_str().unwrap();
assert_eq!(scopes(&session, variable), None);
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
assert_eq!(scopes(&session, variable), Some(vec![]));
session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap();
assert_eq!(scopes(&session, variable), Some(vec![]));
assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true}));
assert_eq!(scopes(&session, variable), None);
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap();
assert_eq!(scopes(&session, variable), None);
}
@@ -0,0 +1,36 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::{Value, json};
fn rectangle(s: &mut Session, x: f64) -> Value {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone()
}
#[test]
fn merge_response_includes_outlines_for_no_op() {
let mut s = Session::new();
let id = rectangle(&mut s, 0.0);
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap();
assert_eq!(regions["regions"].as_array().unwrap().len(), 1);
let before = serde_json::to_value(s.doc().unwrap()).unwrap();
let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before);
assert_eq!(result["id"], Value::Null);
assert_eq!(result["changed"], json!([]));
assert_eq!(result["removed"], json!([]));
assert_eq!(result["regions"], json!([0]));
assert_eq!(result.get("outlines"), Some(&json!([])));
}
#[test]
fn merge_response_control_includes_outlines() {
let mut s = Session::new();
let a = rectangle(&mut s, 0.0);
let b = rectangle(&mut s, 50.0);
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap();
assert_eq!(regions["regions"].as_array().unwrap().len(), 3);
let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap();
assert!(result["id"].is_u64());
assert_eq!(result.get("outlines"), Some(&json!([])));
assert_eq!(result.as_object().unwrap().len(), 5);
}
+32
View File
@@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() {
assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]);
}
#[test]
fn a_styled_layers_fill_variable_survives_a_round_trip() {
let mut doc = Document::new("Overlap");
let page = doc.first_page().unwrap();
let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() };
doc.variables.collections =
vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }];
let light = Color::new(0.1, 0.3, 0.7, 1.0);
let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0));
doc.variables.variables =
vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])];
doc.styles = vec![Style {
id: "s-accent".into(),
name: "Accent".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(light)] },
bound: Default::default(),
}];
add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| {
n.fills = vec![Paint::solid(light)];
n.styles.fill = Some("s-accent".into());
n.bound.insert("fill".into(), "v-accent".into());
});
let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap();
let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone();
let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap();
let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var);
let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap();
let on_style = style.bound.get("fills/0/color") == Some(&var);
assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound);
}
#[test]
fn text_styles_on_part_of_a_text_are_written_as_values() {
let mut doc = styled();
+11 -6
View File
@@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> {
}
}
/// How a layer sizes and sits in its auto layout parent.
/// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own
/// and are written whatever its parent is.
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
let cl = &n.child_layout;
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
if w.is_some() || h.is_some() {
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
}
}
let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return };
let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill);
let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) };
@@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> {
if cl.absolute {
f.push(("stackPositioning", e("ABSOLUTE")));
}
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
if w.is_some() || h.is_some() {
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
}
}
if pl.mode == LayoutMode::Grid
&& let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id)))
{
@@ -1551,6 +1552,10 @@ impl<'d> Exporter<'d> {
.map(|(st, id)| {
let mut ef: Fields = vec![("styleID", u(id))];
style_fields(st, &mut ef);
// An empty link clears the text's own (a missing one would inherit it).
if st.link.is_none() && td.style.link.is_some() {
ef.push(("hyperlink", o(vec![("url", s(""))])));
}
if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) {
ef.push(("fillPaints", self.paints(p, true, &Bound::default())));
}
+31
View File
@@ -1014,6 +1014,37 @@ fn junk_documents_never_panic() {
}
}
/// A frame's own min/max size survives whatever its parent is, not only under auto layout.
#[test]
fn frame_size_limits_survive_without_auto_layout_parent() {
fn limits(doc: &Document) -> [Option<f64>; 4] {
let (bytes, ew) = export(doc, None).unwrap();
let (back, iw) = import(&bytes, "File").unwrap();
assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}");
let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout;
[cl.min_w, cl.max_w, cl.min_h, cl.max_h]
}
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
let node = doc.node_mut(id).unwrap();
node.layout = Some(AutoLayout::default());
node.child_layout.min_w = Some(80.0);
node.child_layout.max_w = Some(150.0);
node.child_layout.min_h = Some(60.0);
node.child_layout.max_h = Some(170.0);
let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)];
let mut nested = doc.clone();
let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap();
nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default());
nested.reparent(id, wrapper, None).unwrap();
assert_eq!(limits(&nested), expected, "auto layout parent");
nested.node_mut(wrapper).unwrap().layout = None;
assert_eq!(limits(&nested), expected, "plain frame parent");
assert_eq!(limits(&doc), expected, "page parent");
}
/// Link each instance's layers to its main's by position, as they are imported (the engine
/// links them by path after loading).
fn link(doc: &mut Document) {
+14 -4
View File
@@ -643,13 +643,17 @@ impl<'a> Mapper<'a> {
}
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).
// Paints, from the shared style when one is linked (the layer's own copy can be stale),
// unless the layer's own paints are bound to variables: those bindings must not be lost.
let mut styles = Obj::new();
let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound));
let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles);
let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
let fills = own("fillPaints")
.unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect()));
let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles);
let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
let strokes = own("strokePaints")
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect()));
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
@@ -1564,6 +1568,11 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
}
}
/// Whether a paint's colour or one of its gradient stops is bound to a variable.
fn paint_bound(p: &Kv) -> bool {
alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some())
}
fn paint(p: &Kv) -> Option<Value> {
let ty = p.get("type").and_then(Kv::str)?;
let mut o = Obj::new();
@@ -1753,7 +1762,8 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
}
}
}
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) {
// An empty URL is kept: on an override entry it clears the text's own link.
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) {
s.insert("hyperlink".into(), json!({"type": "URL", "url": u}));
}
s
+6 -1
View File
@@ -126,7 +126,12 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
}
if let Some(h) = s.get("hyperlink") {
match (text(h, "type"), text(h, "url")) {
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()),
// The destination is data, not a label: `clip_str` is for names that go into warnings,
// and clipping a URL changed it into a different, working-looking one without a word
// (#459). The FIG importer already keeps it whole.
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()),
// An empty URL clears the link the base style has.
(Some("URL") | None, Some(_)) => t.link = None,
_ => {}
}
}
@@ -0,0 +1,57 @@
//! A text hyperlink's destination survives a REST import whole, however long it is (#459).
//!
//! The assertions are the reporter's; the helper returns its findings instead of unwrapping, so
//! that the crate's `unwrap_used`/`panic` lints hold outside a `#[test]` function.
use figcraft_doc::NodeKind;
use figcraft_format::rest;
use serde_json::json;
/// The link of the imported text, and the warnings the import reported. `None` for a link means
/// the layer was missing, was not text, or carried no link at all.
fn imported_link(length: usize, override_style: bool) -> (String, Option<String>, Vec<String>) {
let url = format!("https://example.com/{}", "a".repeat(length - 20));
assert_eq!(url.len(), length);
let mut text = json!({"id":"1:2","type":"TEXT","name":"Link","characters":"A","relativeTransform":[[1,0,0],[0,1,0]]});
let style = json!({"hyperlink":{"type":"URL","url":url}});
if override_style {
text["characterStyleOverrides"] = json!([1]);
text["styleOverrideTable"] = json!({"1":style});
} else {
text["style"] = style;
}
let input = json!({"document":{"type":"DOCUMENT","children":[{"id":"1:1","type":"CANVAS","children":[text]}]}});
assert!(rest::is_rest_json(&input));
let Ok((doc, warnings)) = rest::import(&input, &rest::no_images) else {
return (url, None, vec!["import failed".into()]);
};
let link = match doc.nodes().find(|n| n.name == "Link").map(|n| &n.kind) {
Some(NodeKind::Text(data)) if override_style => data.runs.first().and_then(|r| r.style.link.clone()),
Some(NodeKind::Text(data)) => data.style.link.clone(),
_ => None,
};
(url, link, warnings)
}
/// Every length in turn: the destination comes back exactly, and nothing is reported.
fn check(override_style: bool) {
let mut mismatches = Vec::new();
for length in [200, 201, 240] {
let (url, link, warnings) = imported_link(length, override_style);
assert!(warnings.is_empty(), "import warned at {length}: {warnings:?}");
if link.as_deref() != Some(url.as_str()) {
mismatches.push((length, link.map_or(0, |l| l.len())));
}
}
assert!(mismatches.is_empty(), "URLs changed, (input length, output length): {mismatches:?}");
}
#[test]
fn base_hyperlink_preserves_url() {
check(false);
}
#[test]
fn override_hyperlink_preserves_url() {
check(true);
}
@@ -0,0 +1,38 @@
//! A hyperlink cleared on part of a text layer stays cleared through FIG export and import.
use figcraft_doc::{Document, NodeKind, TextData};
use figcraft_format::fig;
fn links(doc: &Document, name: &str) -> Vec<Option<String>> {
let Some(NodeKind::Text(text)) = doc.nodes().find(|n| n.name == name).map(|n| &n.kind) else { return Vec::new() };
text.spans().into_iter().flat_map(|(start, end, style)| std::iter::repeat_n(style.link.clone(), end - start)).collect()
}
#[test]
fn cleared_range_hyperlink_round_trip() {
let mut document = Document::new("File");
let page = document.first_page().unwrap();
let url = "https://example.com/".to_string();
let mut text = TextData { characters: "AB".into(), ..Default::default() };
text.style.link = Some(url.clone());
text.restyle(1, 2, |style| style.link = None);
document.create(page, NodeKind::Text(text), "Cleared", 0.0, 0.0, 100.0, 30.0).unwrap();
let mut control = TextData { characters: "AB".into(), ..Default::default() };
control.restyle(1, 2, |style| style.link = Some(url.clone()));
document.create(page, NodeKind::Text(control), "Explicit", 0.0, 40.0, 100.0, 30.0).unwrap();
let expected = vec![Some(url.clone()), None];
assert_eq!(links(&document, "Cleared"), expected);
let native_bytes = figcraft_format::save_bytes(&document, None).unwrap();
let native = figcraft_format::load_bytes(&native_bytes).unwrap().0;
assert_eq!(links(&native, "Cleared"), expected);
let (bytes, export_warnings) = fig::export(&document, None).unwrap();
let (loaded, import_warnings) = fig::import(&bytes, "File").unwrap();
assert!(export_warnings.is_empty(), "{export_warnings:?}");
assert!(import_warnings.is_empty(), "{import_warnings:?}");
assert_eq!(links(&loaded, "Explicit"), vec![None, Some(url)]);
assert_eq!(links(&loaded, "Cleared"), expected);
}
+14 -3
View File
@@ -128,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"])},
@@ -665,8 +665,19 @@ fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult,
let side = (x1 - x0).max(y1 - y0).max(1e-6);
scale = scale.min(max / side);
}
let bytes = b.render_png(&ids, scale)?;
let (w, h) = png_info(&bytes);
let mut bytes = b.render_png(&ids, scale)?;
let (mut w, mut h) = png_info(&bytes);
// Layer bounds leave out effects (shadows) and overflow of unclipped frames, but the render
// includes them: shrink to the measured image until it fits `maxSize` (rounding can add a pixel).
for _ in 0..4 {
let side = f64::from(w.max(h));
if side <= max {
break;
}
scale *= max / side;
bytes = b.render_png(&ids, scale)?;
(w, h) = png_info(&bytes);
}
Ok(ToolResult::image(
&bytes,
"image/png",
+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}");
}
+47
View File
@@ -0,0 +1,47 @@
//! Hidden top-level layers in an export take no room in the image (issue #447).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{Client, png_dims};
use serde_json::{Value, json};
fn dimensions(result: Value) -> (u32, u32) {
assert_eq!(result["isError"], false, "{result}");
let image = result["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap();
png_dims(image["data"].as_str().unwrap())
}
#[test]
fn hidden_root_does_not_expand_export() {
let mut client = Client::new();
let visible = client.create(json!({
"type":"frame", "x":0, "y":0, "width":100, "height":100,
"props":{"fill":"#FF0000"}
}));
let hidden = client.create(json!({
"type":"frame", "x":900, "y":0, "width":300, "height":300,
"props":{"visible":false}
}));
assert_eq!(client.node(&hidden)["visible"], false);
let control = dimensions(client.call("export", json!({"ids":[visible], "format":"png", "size":"200w"})));
assert_eq!(control, (200, 200));
let mixed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "size":"200w"})));
assert_eq!(mixed, control, "hidden roots must not enlarge the painted area");
let natural = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png"})));
assert_eq!(natural, (100, 100));
client.ok("select", json!({"ids":[visible, hidden]}));
let selected = dimensions(client.call("export", json!({"format":"png", "size":"200w"})));
assert_eq!(selected, (200, 200));
client.ok("select", json!({"ids":[]}));
let page = dimensions(client.call("export", json!({"format":"png"})));
assert_eq!(page, (100, 100));
// Controls: the bounding-box option and a hidden-only export keep their areas.
let boxed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "includeBoundingBox":true})));
assert_eq!(boxed, (1200, 300));
let alone = dimensions(client.call("export", json!({"ids":[hidden], "format":"png"})));
assert_eq!(alone, (300, 300));
}
@@ -0,0 +1,53 @@
//! `screenshot` with `ids` must honour `maxSize` for the rendered paint area (shadows, overflow).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{Client, png_dims};
use serde_json::{Value, json};
fn dimensions(client: &mut Client, args: Value) -> (u32, u32) {
let result = client.call("screenshot", args);
assert_eq!(result["isError"], false, "{result}");
let size = png_dims(result["content"][0]["data"].as_str().unwrap());
let meta: Value = serde_json::from_str(result["content"][1]["text"].as_str().unwrap()).unwrap();
assert_eq!(meta["width"], size.0, "{meta}");
assert_eq!(meta["height"], size.1, "{meta}");
size
}
#[test]
fn layer_screenshots_fit_the_requested_size() {
let mut shadow_client = Client::new();
let shadow = shadow_client.create(json!({
"type": "rectangle", "x": 0, "y": 0,
"width": 100, "height": 100,
"props": {"effects": [{
"type": "dropShadow", "color": "#00000080",
"offset": [0, 40], "radius": 60
}]}
}));
let shadow_size = dimensions(&mut shadow_client, json!({"ids": [shadow], "maxSize": 100}));
let mut frame_client = Client::new();
let frame = frame_client.create(json!({
"type": "frame", "x": 0, "y": 0,
"width": 100, "height": 100, "props": {"clip": false}
}));
frame_client.create(json!({
"type": "rectangle", "parent": frame,
"x": 900, "y": 900, "width": 100, "height": 100
}));
let frame_size = dimensions(&mut frame_client, json!({"ids": [frame], "maxSize": 100}));
assert!(shadow_size.0 <= 100 && shadow_size.1 <= 100, "shadow screenshot exceeds maxSize: {shadow_size:?}");
assert!(shadow_size.0 >= 90 && shadow_size.1 >= 90, "shadow screenshot shrank too far: {shadow_size:?}");
assert!(frame_size.0 <= 100 && frame_size.1 <= 100, "frame screenshot exceeds maxSize: {frame_size:?}");
assert!(frame_size.0 >= 90 && frame_size.1 >= 90, "frame screenshot shrank too far: {frame_size:?}");
}
#[test]
fn plain_layer_screenshot_sizes_are_unchanged() {
let mut client = Client::new();
let rect = client.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100}));
assert_eq!(dimensions(&mut client, json!({"ids": [rect], "maxSize": 100})), (100, 100));
assert_eq!(dimensions(&mut client, json!({"ids": [rect], "scale": 2})), (200, 200));
}
+7 -3
View File
@@ -35,6 +35,8 @@ pub(crate) struct Exporter<'a> {
pub links: Vec<(Rect, String)>,
/// Above zero while drawing into a soft mask (nothing there is visible or clickable).
pub(crate) masking: usize,
/// Above zero while drawing a pattern tile (links and backdrops belong to the page).
pub(crate) tiling: usize,
/// The page's frame and the bleed its fills and clip reach out to.
pub bleed_root: Option<(NodeId, f64)>,
pub(crate) fonts: HashMap<u32, Option<krilla::text::Font>>,
@@ -215,6 +217,7 @@ impl<'a> Exporter<'a> {
page_ids: Vec::new(),
links: Vec::new(),
masking: 0,
tiling: 0,
bleed_root: None,
fonts: HashMap::new(),
images: HashMap::new(),
@@ -246,7 +249,8 @@ impl<'a> Exporter<'a> {
let op = n.opacity.clamp(0.0, 1.0);
// Background blur and glass: an image drawn from what lies under the node, under its
// content (nothing in a soft mask is seen).
let back = if !as_mask && self.masking == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None };
let back =
if !as_mask && self.masking == 0 && self.tiling == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None };
if fx.iter().any(|k| matches!(k, EffectKind::LayerBlur { .. } | EffectKind::Noise(_) | EffectKind::Texture(_))) {
// Effects that change every pixel: the renderer draws the whole node.
if let Some(b) = &back {
@@ -521,14 +525,14 @@ impl<'a> Exporter<'a> {
let mut sb = s.stream_builder();
let mut ps = sb.surface();
// Links and backdrops belong to the page, not to a tile.
self.masking += 1;
self.tiling += 1;
let _ = figcraft_render::pattern::nested(|| {
for (tx, ty) in &cell.tiles {
self.node(&mut ps, src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0, false);
}
Some(())
});
self.masking -= 1;
self.tiling -= 1;
ps.finish();
sb.finish()
};
+1 -1
View File
@@ -41,7 +41,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
if !affine_ok(gxf) {
return;
}
if ex.masking == 0 {
if ex.masking == 0 && ex.tiling == 0 {
record_links(ex, &layout, gxf);
}
let (w, h) = (n.width, n.height);
+60
View File
@@ -0,0 +1,60 @@
//! CMYK export converts the colours inside vector pattern tiles (#426).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeKind, PrintSettings};
use figcraft_pdf::{PdfOptions, export_pdf};
use std::io::Read;
/// Every zlib stream of `pdf`, inflated and joined.
fn streams(pdf: &[u8]) -> String {
let mut out = String::new();
let mut rest = pdf;
while let Some(i) = rest.windows(7).position(|w| w == b"stream\n") {
rest = &rest[i + 7..];
let end = rest.windows(9).position(|w| w == b"endstream").unwrap();
let mut bytes = Vec::new();
if flate2::read::ZlibDecoder::new(&rest[..end]).read_to_end(&mut bytes).is_ok() {
out.push_str(&String::from_utf8_lossy(&bytes));
out.push('\n');
}
rest = &rest[end + 9..];
}
out
}
fn export(pattern: bool, cmyk: bool) -> (String, String) {
let mut doc = Document::new("fixture");
let page = doc.first_page().unwrap();
let source = doc.create(page, NodeKind::Rectangle, "Source", 40.0, 0.0, 10.0, 10.0).unwrap();
let green = Paint::solid(Color::from_hex("00FF00").unwrap());
doc.get_mut(source).unwrap().fills = vec![green.clone()];
let target = doc.create(page, NodeKind::Rectangle, "Target", 0.0, 0.0, 20.0, 20.0).unwrap();
doc.get_mut(target).unwrap().fills = vec![if pattern { Paint::pattern(source.0) } else { green }];
let opts = PdfOptions { print: PrintSettings { cmyk, ..Default::default() }, ..Default::default() };
let pdf = export_pdf(&doc, &[target], &opts).unwrap();
(String::from_utf8_lossy(&pdf).into_owned(), streams(&pdf))
}
fn has_rgb_op(content: &str) -> bool {
content.split_whitespace().any(|op| op == "rg" || op == "RG")
}
#[test]
fn pattern_cmyk_controls() {
let (_, solid) = export(false, true);
assert!(solid.contains("1 0 1 0 k"), "{solid}");
assert!(!has_rgb_op(&solid), "{solid}");
let (pdf, pattern) = export(true, false);
assert!(pdf.contains("/PatternType 1"));
assert!(pattern.contains("0 1 0 rg"), "{pattern}");
}
#[test]
fn pattern_colors_follow_cmyk_setting() {
let (pdf, content) = export(true, true);
assert!(pdf.contains("/PatternType 1"));
assert!(!has_rgb_op(&content), "RGB operator remains in CMYK pattern: {content}");
assert!(content.contains("1 0 1 0 k"), "green must convert to CMYK: {content}");
}
@@ -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:?}");
}
+80 -29
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) {
@@ -694,13 +735,17 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
// layers behind the frame.
let Some(mut layer) = pix.clone_rect(ir) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
// The clip (a rectangular one is the layer's own box) is known before the children draw, so
// their backdrops are clipped the way the layer will be.
let square_aligned = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
let rect_clip = square_aligned && on_whole_pixels(dev);
// The clip (a rectangular one on whole pixels is the layer's own box) is known before the
// children draw, so their backdrops are clipped the way the layer will be. An axis-aligned
// square frame with fractional edges gets an anti-aliased mask of its box so its edge pixels
// are only partly covered.
let mask = if rect_clip {
None
} else {
clip.and_then(ts_path).and_then(|cp| {
let own_box = (square_aligned && clip.is_none()).then(|| n.local_rect().to_path(0.1));
clip.or(own_box.as_ref()).and_then(ts_path).and_then(|cp| {
let mut m = Mask::new(ir.width(), ir.height())?;
m.fill_path(&cp, FillRule::Winding, true, ts_transform(lxf));
Some(m)
@@ -741,3 +786,9 @@ fn is_axis_aligned(xf: Affine) -> bool {
fn fract_ok(_v: f64) -> bool {
true
}
/// Whether all four edges of the device rectangle `r` lie on whole pixels, up to the float noise
/// `snap_px` forgives.
fn on_whole_pixels(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].into_iter().all(|v| (snap_px(v) - v.round()).abs() < 1e-9)
}
@@ -377,3 +377,29 @@ fn exports_include_what_effects_paint_outside_the_layer() {
d.get_mut(f).unwrap().effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))];
assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported");
}
/// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×.
fn blurred_frame(radius: f64) -> Pixmap {
let mut d = Document::new("Blur range");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Square", 156.0, 156.0, 200.0, 200.0).unwrap();
let n = d.get_mut(f).unwrap();
n.fills = vec![Paint::solid(Color::BLACK)];
n.effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(radius)))];
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 512, height: 512 };
render_view(&d, p, &view, &Options::default()).unwrap()
}
#[test]
fn layer_blur_radii_up_to_the_documented_limit_all_draw() {
// The documented blur range runs to 10,000: a larger radius in it blurs further, it doesn't
// stop at some smaller one.
let differ = |a: &Pixmap, b: &Pixmap| a.data().chunks_exact(4).zip(b.data().chunks_exact(4)).filter(|(a, b)| a != b).count();
let sharp = blurred_frame(0.0);
let small = blurred_frame(100.0);
let medium = blurred_frame(1_000.0);
let large = blurred_frame(10_000.0);
assert!(differ(&sharp, &small) > 0, "radius 0 draws the sharp shape");
assert!(differ(&small, &medium) > 0, "100 vs 1000");
assert!(differ(&medium, &large) > 0, "radii 1000 and 10000 draw the same pixels");
}
+5 -2
View File
@@ -70,8 +70,11 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
}
}
/// The largest blur standard deviation drawn (pixels): wider blurs look the same.
pub const MAX_SIGMA: f64 = 500.0;
/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384
/// pixels a side), so wider blurs look the same (everything averages out to nothing). The box
/// passes cost the same at any radius, so the documented blur radii (up to 10,000 units) draw
/// in full at any zoom.
pub const MAX_SIGMA: f64 = 1_000_000.0;
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
pub fn blur(p: &mut Pixmap, sigma: f64) {
+55 -13
View File
@@ -177,19 +177,34 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
})
}
/// The furthest in (pixels) the rim light is worth half a level or more: where
/// `255 × intensity × e^(−d / band)` falls to 0.5, at most [`MAX_LIGHT_PX`] (0 for no light).
fn light_reach(band: f64, intensity: f64) -> f64 {
let k = 255.0 * intensity / 0.5;
if k > 1.0 && band.is_finite() { (band * k.ln()).min(MAX_LIGHT_PX) } else { 0.0 }
}
/// The light reaches no further in than this many pixels (a wide light on a deep pane at a high
/// zoom would otherwise need a huge margin of coverage around every cut).
const MAX_LIGHT_PX: f64 = 512.0;
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
#[allow(clippy::too_many_arguments)]
pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option<Pixmap> {
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
// Coverage with a margin, so distances aren't cut short where the layer is cropped (past the
// margin the edge is further than `depth` away and nothing bends).
let m = depth.ceil() as u32 + 2;
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
let reach = light_reach(band, g.light_intensity);
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
// smoothed from (2r + 1 further).
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
let dist = inside_distance(&cov);
let (cw, ch) = (cov.width() as usize, cov.height() as usize);
let normals = smooth_normals(&dist, cw, ch, (depth * 0.1).clamp(1.0, 8.0) as usize);
let normals = smooth_normals(&dist, cw, ch, r);
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
@@ -197,7 +212,11 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let bend_max = (g.refraction * depth) as f32;
let fringe = (g.dispersion * 0.25) as f32;
let power = (6.0 - 5.0 * g.splay) as f32;
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0) as f32;
let (band, reach) = (band as f32, reach as f32);
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
// fades out to 0 at `reach` instead of stopping there.
let floor = (-reach / band).exp();
let fall = 1.0 - floor;
let intensity = g.light_intensity as f32;
let depth = depth as f32;
let mut out = Pixmap::new(w, h)?;
@@ -216,14 +235,26 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let t = (d / depth).min(1.0);
let bend = bend_max * (1.0 - t) * (1.0 - t);
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
let base = [rs[0].min(a), gs[1].min(a), bs[2].min(a), a];
// The rim catches the light where it faces it, and half as much opposite.
let facing = -(inward.0 * light.0 + inward.1 * light.1);
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
let hl = (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0);
let base = if bend == 0.0 {
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
// at the same point, so one sample.
let s = sample(&frosted, bx, by);
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
} else {
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
};
// The rim catches the light where it faces it, and half as much opposite. It fades out
// by `reach` and is none past it, wherever the layer is cut.
let hl = if d < reach && fall > 0.0 {
let facing = -(inward.0 * light.0 + inward.1 * light.1);
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
(intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0)
} else {
0.0
};
let i = (y * w as usize + x) * 4;
if let Some(p) = dst.get_mut(i..i + 4) {
for (o, v) in p.iter_mut().zip(base) {
@@ -252,4 +283,15 @@ mod tests {
// A pixel on the edge is half a pixel from it.
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
}
#[test]
fn light_reach_is_where_the_light_falls_under_half_a_level() {
let r = light_reach(10.0, 1.0);
assert!((255.0 * (-r / 10.0).exp() - 0.5).abs() < 1e-9, "{r}");
// No light, too faint to show, or a band that isn't a number: it reaches nowhere.
for (band, intensity) in [(10.0, 0.0), (10.0, 0.5 / 255.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0)] {
assert_eq!(light_reach(band, intensity), 0.0, "{band} {intensity}");
}
assert_eq!(light_reach(1e6, 1.0), MAX_LIGHT_PX);
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Glass render timings: a 600 × 360 rounded glass pane over coloured stripes, 2400 × 1600 device
//! pixels. Run in release:
//! `cargo test --release -p figcraft-render --lib glass_render_timings -- --ignored --nocapture`.
//!
//! "export" rows render the whole view with `render_view` (exports, thumbnails, the first canvas
//! frame); "canvas" rows go through `Retained`, which re-renders only what changed, as the canvas
//! does; "single" renders without parallel bands (as on the web).
use std::sync::Arc;
use std::time::Instant;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use crate::{Options, Retained, View, Work, render_view, render_view_single};
const RUNS: u32 = 8;
/// Stripes, a card under the pane and the glass pane: (document, page, pane, card).
fn scene() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new("glass bench");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let card = d.create(p, NodeKind::Rectangle, "Card", 420.0, 300.0, 160.0, 120.0).unwrap();
d.get_mut(card).unwrap().fills = vec![Paint::solid(Color::WHITE)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [40.0; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(Glass::default()), visible: true }];
(d, p, pane, card)
}
fn view(origin: (f64, f64), scale: f64) -> View {
View { origin, scale, width: 2400, height: 1600 }
}
/// Mean milliseconds of `RUNS` whole-view renders (`render` of the document and view `step(i)`
/// leaves) after one warm-up.
fn time(
d: &mut Document,
p: NodeId,
render: fn(&Document, NodeId, &View, &Options) -> Option<tiny_skia::Pixmap>,
mut step: impl FnMut(&mut Document, u32) -> View,
) -> f64 {
let v = step(d, 0);
let _ = render(d, p, &v, &Options::default());
let t = Instant::now();
for i in 1..=RUNS {
let v = step(d, i);
assert!(render(d, p, &v, &Options::default()).is_some());
}
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
}
/// Mean milliseconds of `RUNS` canvas frames at `scale`, each after `edit(document, i)`.
fn canvas(d: &Document, p: NodeId, scale: f64, edit: impl Fn(&mut Document, u32)) -> f64 {
let mut r = Retained::default();
let mut doc = Arc::new(d.clone());
let v = view((0.0, 0.0), scale);
let _ = r.render(&doc, p, &v, &Options::default());
let t = Instant::now();
for i in 1..=RUNS {
let mut next = (*doc).clone();
edit(&mut next, i);
doc = Arc::new(next);
let (_, _, work) = r.render(&doc, p, &v, &Options::default()).unwrap();
assert!(matches!(work, Work::Partial { .. }), "{work:?}");
}
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
}
fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
}
#[test]
#[ignore = "timing: run in release with --ignored --nocapture"]
fn glass_render_timings() {
let (mut d, p, pane, card) = scene();
let move_card = move |d: &mut Document, i: u32| d.get_mut(card).unwrap().transform = Affine::translate((420.0 + 4.0 * f64::from(i), 300.0));
let move_pane = move |d: &mut Document, i: u32| d.get_mut(pane).unwrap().transform = Affine::translate((300.0 + 3.0 * f64::from(i), 220.0));
let rows: Vec<(&str, f64)> = vec![
("export: same view again", time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0))),
(
"export: move the card under the glass",
time(&mut d, p, render_view, |d, i| {
move_card(d, i);
view((0.0, 0.0), 2.0)
}),
),
("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))),
("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))),
("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)),
(
"canvas: drag a glass slider (light angle)",
canvas(&d, p, 2.0, |d, i| set_glass(d, pane, Glass { light_angle: -45.0 + f64::from(i), ..Glass::default() })),
),
("canvas: drag the pane (zoom 200%)", canvas(&d, p, 2.0, move_pane)),
("canvas: drag the pane (zoom 137%)", canvas(&d, p, 1.37, move_pane)),
(
"canvas: resize the pane",
canvas(&d, p, 2.0, |d, i| {
let n = d.get_mut(pane).unwrap();
n.width = 600.0 + 3.0 * f64::from(i);
}),
),
];
d.get_mut(pane).unwrap().effects.iter_mut().for_each(|e| e.visible = false);
let without = time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0));
println!("glass render timings ({} build, 2400 × 1600 px, mean of {RUNS}, ms)", if cfg!(debug_assertions) { "debug" } else { "release" });
println!("| render | ms |\n|---|---:|");
for (name, ms) in rows {
println!("| {name} | {ms:.1} |");
}
println!("| export: same view, glass hidden | {without:.1} |");
}
+135
View File
@@ -0,0 +1,135 @@
//! Glass renders the same however a render is cut: a band or a canvas patch whose edge runs
//! through a pane shows exactly the pixels of the whole render.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::{Options, View, render_view_single};
/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page, pane).
fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [radius; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
(d, p, pane)
}
/// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel
/// difference and how many bytes differ.
fn diff_rows(full: &Pixmap, y0: u32, part: &Pixmap) -> (u8, usize) {
let row = full.width() as usize * 4;
let a = &full.data()[y0 as usize * row..][..part.data().len()];
a.iter().zip(part.data()).fold((0, 0), |(m, n), (x, y)| (m.max(x.abs_diff(*y)), n + usize::from(x != y)))
}
/// A strong, wide light and nothing that blurs or bends: the light reaches furthest in, and the
/// backdrop effects read nothing past the layer, so a cut through the pane gets no margin.
fn wide_light() -> Glass {
Glass { splay: 1.0, frost: 0.0, refraction: 0.0, light_intensity: 1.0, ..Glass::default() }
}
/// The largest channel difference and bytes differing between the whole render of `d` and renders
/// cut 50 to 80 px inside the pane's top edge (y = 440 px), as bands and canvas patches cut it:
/// rendered with the backdrop reach as a margin above and below, keeping 400 rows from the cut.
fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> {
let opts = Options::default();
let full = render_view_single(d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 2400, height: 1600 }, &opts).unwrap();
let margin = crate::backdrop_reach(crate::max_backdrop_blur(d), 2.0).ceil() as u32 + 1;
[490u32, 505, 520]
.into_iter()
.map(|cut| {
let top = cut - margin;
let v = View { origin: (0.0, f64::from(top) / 2.0), scale: 2.0, width: 2400, height: 400 + 2 * margin };
let part = render_view_single(d, p, &v, &opts).unwrap();
let kept = part.clone_rect(tiny_skia::IntRect::from_xywh(0, margin as i32, 2400, 400).unwrap()).unwrap();
(cut, diff_rows(&full, cut, &kept))
})
.collect()
}
/// A pane deeper than the light may reach, over black and lit from the left: across its middle row
/// each pixel is the light alone. It fades out smoothly where it stops, with no ring.
#[test]
fn the_light_fades_out_where_it_stops() {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap();
d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap();
let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
let n = d.get_mut(pane).unwrap();
n.fills.clear();
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap();
let row = &full.data()[700 * 1400 * 4..][..1400 * 4];
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect();
let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels");
assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]);
}
/// Digests of glass renders over a spread of settings, scales and corners: compare two builds'
/// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`.
#[test]
#[ignore = "prints digests to compare builds"]
fn glass_digests() {
use std::hash::{DefaultHasher, Hash, Hasher};
let looks = [
Glass::default(),
wide_light(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { refraction: 1.0, dispersion: 1.0, frost: 10.0, depth: 60.0, light_intensity: 0.0, ..Glass::default() },
Glass { depth: 1.0, splay: 0.3, light_angle: 120.0, ..Glass::default() },
];
let mut all = DefaultHasher::new();
for (i, g) in looks.into_iter().enumerate() {
for radius in [0.0, 40.0] {
let (d, p, _) = pane_over_stripes(g.clone(), radius);
for scale in [1.0, 1.37, 2.0] {
let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 };
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
let mut h = DefaultHasher::new();
pix.data().hash(&mut h);
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
pix.data().hash(&mut all);
}
}
}
println!("all: {:016x}", all.finish());
}
#[test]
fn a_cut_through_the_pane_matches_the_whole_render() {
// The last look is deep enough that the light is held to `MAX_LIGHT_PX`.
let looks = [
wide_light(),
Glass::default(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { depth: 300.0, ..wide_light() },
];
// Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a
// cut, and that is the fill, not the glass.
for radius in [0.0, 40.0] {
for g in &looks {
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
if radius > 0.0 {
d.get_mut(pane).unwrap().fills.clear();
}
for (cut, diff) in cut_diffs(&d, p) {
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}");
}
}
}
}
+6
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)]
@@ -438,6 +440,10 @@ mod effects_behaviour_tests;
#[cfg(test)]
mod gif_tests;
#[cfg(test)]
mod glass_bench_tests;
#[cfg(test)]
mod glass_cut_tests;
#[cfg(test)]
mod grain_tests;
#[cfg(test)]
mod group_effects_tests;
+25
View File
@@ -330,3 +330,28 @@ fn inner_shadow_reaches_every_edge() {
assert!(px(&pix, 11, 25)[0] < 235, "{:?}", px(&pix, 11, 25));
assert!(px(&pix, 35, 30)[0] > 245);
}
#[test]
fn rect_clip_frame_matches_same_rect_fill_on_fractional_edges() {
let white = Options { background: Some(Color::WHITE), ..Default::default() };
for (x, y, w, h) in [(0.5, 0.5, 10.0, 10.0), (0.0, 0.0, 10.5, 10.5), (0.0, 0.0, 10.0, 10.0)] {
let mut clipped = figcraft_doc::Document::new("clipped");
let p = clipped.first_page().unwrap();
let f = clipped.create(p, NodeKind::Frame, "F", x, y, w, h).unwrap();
clipped.get_mut(f).unwrap().fills.clear();
clipped.get_mut(f).unwrap().clip = true;
let c = clipped.create(f, NodeKind::Rectangle, "C", -5.0, -5.0, w + 10.0, h + 10.0).unwrap();
clipped.get_mut(c).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let mut direct = figcraft_doc::Document::new("direct");
let dp = direct.first_page().unwrap();
let r = direct.create(dp, NodeKind::Rectangle, "R", x, y, w, h).unwrap();
direct.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let a = render_view(&clipped, p, &view(12, 12), &white).unwrap();
let e = render_view(&direct, dp, &view(12, 12), &white).unwrap();
for (px_x, px_y) in [(0, 0), (0, 5), (5, 0), (5, 5), (10, 5), (5, 10), (10, 10), (11, 11)] {
assert_eq!(px(&a, px_x, px_y), px(&e, px_x, px_y), "frame {x},{y} {w}x{h} at ({px_x},{px_y})");
}
}
}
+68
View File
@@ -431,8 +431,19 @@ impl W<'_> {
};
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
let run_paths = if live_text.is_none() { text_run_paths(n) } else { None };
if let Some(t) = &live_text {
self.body.push_str(t);
} else if let Some(runs) = &run_paths {
// Character ranges with their own fills: one path per style, like the canvas.
for (path, fills) in runs {
for p in fills {
if let Some(a) = self.paint_attr(p, w, h) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}/>", matrix(xf), path_d(path));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
} else if let Some((path, rule)) = &outline
&& closed
{
@@ -559,6 +570,36 @@ impl W<'_> {
if let Some((layout, dy)) = figcraft_text::layout_node(n) {
gxf = xf * Affine::translate((0.0, dy));
for line in &layout.lines {
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
let run_fills = !line.rtl
&& !glyphs.iter().any(|g| g.rtl)
&& glyphs.iter().any(|g| layout.styles.get(g.style).is_some_and(|s| s.fills.is_some()));
if run_fills {
// One tspan per run of glyphs in the same style, each in its own fill.
let mut i = 0;
let mut start = line.char_start;
while i < glyphs.len() {
let style = glyphs[i].style;
let mut j = i + 1;
while j < glyphs.len() && glyphs[j].style == style {
j += 1;
}
let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
Some(fills) => {
let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
format!(" {a}")
}
None => String::new(),
};
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\"{own}>{}</tspan>", num(glyphs[i].x), num(glyphs[i].y), esc(&text));
start = end;
i = j;
}
continue;
}
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
@@ -979,6 +1020,33 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu
))
}
/// Text layer `n`'s glyph outlines grouped by style, each with the fills it paints in (its own, else
/// the layer's), in layer space. `None` unless some character range has fills of its own.
fn text_run_paths(n: &Node) -> Option<Vec<(BezPath, Vec<Paint>)>> {
let NodeKind::Text(_) = &n.kind else { return None };
let (layout, dy) = figcraft_text::layout_node(n)?;
if !layout.styles.iter().any(|s| s.fills.is_some()) {
return None;
}
let mut by_style: std::collections::BTreeMap<usize, BezPath> = std::collections::BTreeMap::new();
for g in &layout.glyphs {
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());
}
Some(
by_style
.into_iter()
.map(|(k, mut path)| {
path.apply_affine(Affine::translate((0.0, dy)));
let fills = layout.styles.get(k).and_then(|s| s.fills.clone()).unwrap_or_else(|| n.fills.clone());
(path, fills)
})
.collect(),
)
}
#[cfg(test)]
mod depth_tests;
#[cfg(test)]
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more