egui-winit swallows the Cmd/Ctrl+V press and sends a paste event only
when the clipboard holds text, so with an image on the clipboard only
the key's release arrives and the image fallback, which waited for the
press, never ran. Paste from a V release whose press never came and
brought no paste event; the press and paste event are tracked from the
raw events at the start of each frame.
On Wayland arboard read the X11 clipboard, which the compositor does
not fill for a native Wayland window, so every system paste found it
empty. Enable arboard's wayland-data-control: it uses the Wayland
clipboard when WAYLAND_DISPLAY is set and falls back to X11 otherwise.
Right-click > Paste here now follows the same newest-wins rule, so a
newer image beats copied layers there too, and lands at the point.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shift on a selected layer took it out of the selection on press, so the
drag that followed had nothing to move, and with several layers selected
the grabbed one stayed behind. It now leaves the selection on release,
and only when the press didn't become a drag. Where no move can start
(a version being viewed), it still leaves on press.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`node.create` read its `parent` as `id(p, "parent").unwrap_or(page)`, so a `parent` that isn't a
layer id — a `{"$step": n}` nothing had resolved, a name, a negative number — quietly meant "no
parent" and the layer landed on the page, with the call reporting success. The same shape sat on
`image.place` and `instance.create`, on `index` (a string index appended where it was asked to
insert), and on the `ids` of every command that targets layers (an entry that wasn't an id was
dropped and the rest ran). A number that names nothing was reported; anything that isn't a number at
all was not.
`params::opt_id` and `params::opt_index` now say what the commands mean: missing and `null` are
"none", and anything else has to be a layer id (or a whole, non-negative number) and is named when it
isn't. `req_targets` refuses an `ids` entry that isn't an id, before falling back to the selection. A
number that names no layer still reports `no such node`, and a boolean is still a number to `f64_of`,
as it is for `x`, `width` and the rest.
This turns a silently wrong result into an error on purpose, so a caller that passed something that
isn't an id and relied on the fallback now hears about it.
`figcraft-cli run` ran each step of a script on its own, so a script's `{"$step": n}` reached
`parent` unresolved and the layer landed on the page while every step reported success — the same
commands through `command.batch` (the MCP `batch` tool, the control channel, plugins) put it inside
the frame. One `edit.undo` took back one step of a script rather than the script, and a failing step
left the earlier ones applied.
`Session::batch_step` is now the one path both take — one step of a batch, its `$step` references
resolved against the results before it — and `run_script` wraps its steps in `Session::grouped`: the
whole script is one undo step, and a step that fails takes the script back. The result lines come out
once the script is through, so none of them reports work a later failure took back; a failure prints
one line with `rolledBack: true` and exits 1. `docs/cli.md` documents the reference and the output.
Duplicate repeats the last duplicate's offset, but of the ways to move the copy
before the second Cmd+D only `node.set` and Alt-drag were covered. Dragging it
with the mouse is how almost everyone does it, and is what #567 describes; an
arrow-key nudge is how a small offset is usually made exact. Both work, because
the step is measured from where the copy ended up rather than recorded from the
gesture, but nothing held them there.
The drag test also asserts the drag moved the copy rather than making another
one, left it selected, and left the original in place: the ways the feature
could break without the step arithmetic breaking.
Both fail if the step lookup in `edit.duplicate` is stubbed out.
Node lookups in the document's B-tree were still a third of a render on a
900 000-layer file: a small subtree was walked for its bounds, again to
see whether it could be splatted, and again to splat it, and each child
of a drawn layer was looked up by paint_runs, by the mask check and by
draw_node.
- The render's table also keeps the layers it looked up, keyed with a
multiply hash instead of SipHash (ids are counters), and so do the
bounds it keeps.
- draw_children looks each child up once, works out the paint runs from
those (Document::paint_runs_of) and draws the child it has.
- Whether a layer can be splatted is kept like its bounds: layers with
children only, answers the nesting cap decided aren't kept, and kept
answers are read only where their levels fit under the cap.
Ctx gets a second lifetime, for the document the kept layers come from.
Single-threaded cycles per 1440x900 render fitted to the page: 0.39 ->
0.34 G on a page of 246 204 layers, 0.26 -> 0.22 G on one of 118 358.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
Culling a layer asked for its paint bounds, which walk its whole subtree,
and then did the same for each child: every layer's subtree was walked
again at each level above it, and once more in each parallel band. The
bounds worked out during a render are now kept for it (layers with
children only; bounds the 64-level cap cut short are not kept, and kept
ones are read only where their levels still fit under the cap, so culling
is exactly as before).
Every render also looked through every layer of the document for the
largest background blur, to size the overlap of its bands: 900 000 layers
on one large file, on the main thread before the bands start. The
document now works it out when first asked after a change, like whether
it has masks.
A 1440x900 render on a 900 000-layer file, page of 118 358 layers: at
100% 13.0 -> 0.6 ms, at 25% 16.5 -> 3.4 ms, fitted to the page 56 -> 31 ms;
page of 246 204 layers: 12.6 -> 2.5 ms, 12.1 -> 2.0 ms, 69 -> 62 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
Hovering a family in the Typography font dropdown previews it on the
canvas via text.preview; closing without picking restores the original,
clicking applies it as one undo step.
In vector edit mode a point dragged onto another point of its path
snapped there but stayed a separate point, so a path couldn't be closed
or joined by dragging. A single dragged point dropped within the point
hit radius (6 px) of another point now merges into it, in the same undo
step as the move, and that point stays selected.
Closes#401
Copies written by plugins and tools carry their image bytes in the
message: a paint's image.dataBlob indexes the message's blobs. A bare
canvas (what the clipboard holds, see #647) has no images/, so these
fills came in as grey placeholders. The importer now falls back to the
embedded bytes, keyed by image hash, after the archive's images/.
Blobs over the image size cap and indexes past the blobs are skipped and
stay placeholders. Tests: a bare canvas carrying its image in a blob, and
a dataBlob past the blobs.
Found while testing crates/engine/src/cmd/edit.rs:1180
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The pages list took a fixed 40% of the left panel (#272). The line under
it now drags to make the list taller or shorter: at least three rows, and
always leaving the layers their header and three rows. A double-click puts
back the default. The main window keeps the height in the preferences, as
it does the panel widths, saved once the drag is over.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
Flatten (Cmd+E, Alt+Shift+F) and Join selection (Cmd+J) built the result
from each layer's fill outline, so open paths, which have none, were
dropped: two pen paths flattened to "nothing to flatten", and next to a
closed shape they vanished. Vectors now bring their own network (open
paths, unfilled loops and each region's winding stay), mapped into the
parent's space; other layers still flatten to their outline.
Closes#377
`document.json` was read under the 512 MB cap for any archive member, so a
large imported document saved but didn't open again: 900 000 layers save
as a 1.48 GB document.json, packed into 244 MB. It may now unpack to 16
times its packed size (counted up to the archive's own size), within
512 MB and 4 GB; images and other members keep 512 MB. A small archive
still can't unpack into gigabytes.
That saved file reopens in 13 s with a 5.9 GB peak.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.
The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
import's 200 000 and at most 2 000 000. A small or crafted file can't
expand its instances into millions of layers, however far it unpacks; the
REST JSON import keeps 200 000.
Memory:
- The mapper no longer passes the document through `json!`, which serialises
its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
as soon as the next stage is built.
On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
A library file is cached by its length and modification time. File systems keep the time in coarse ticks, so a same-length rewrite right after a read can keep the stamp, and library.updates then reports no change. A stamp now proves the file unchanged only once its modification time lies at least two seconds before the read; otherwise the file is read again.
Figma puts copied layers in the clipboard's HTML as a base64 .fig canvas
between (figma) markers. edit.pasteFromFigma reads it with the .fig import,
turns the layers into the session clipboard and pastes them as edit.paste
does (selected frame, in place or view centre), with the import's warnings.
The desktop app reads the clipboard's HTML (arboard) and Paste prefers a
Figma copy; the browser's paste event carries plain text only.
Tested on copies written by our own .fig export; not yet on real copies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The [commands] and [menu] rows held their command id as the text
("align.bottom" = "align.bottom"), which the menus and the command
palette would show as is. They now translate the English labels the
code gives those ids (spec! and m! in crates/engine), e.g.
"align.bottom" = "Justera nederkant". Also the interface strings that
are new on upstream main (23bdebd).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Straight connectors saved with a manual routing snapshot (connector and derived-frame version 1) now come in as an editable vector plus their label text layers, instead of an empty frame. Label placement composes the saved background and text transforms, and label paints and text styles use the existing import path. Connectors of other versions or line styles keep the existing fallback with their compatibility notes. At the layer depth or node limit the saved vector is kept without extra label layers, and skipped labels are reported once. Live endpoint bindings and routing are not retained.
Text layers, text styles and instance overrides in .fig files can carry list spacing, hanging list, hanging punctuation and leading trim. The importer now reads them from the highest layer with a valid value, so an invalid instance override row falls through to the valid value below it, while explicit zero, false and NONE values stay resets. Both text layers and text styles share one mapping. The native text data stores these options once per text node, so differing values on separate runs are not represented, and the experimental .fig exporter keeps its existing note that these settings are left out.
Text layers, styled runs and text styles in .fig files carry a skip-ink boolean for their decoration. The importer now reads it into the native decoration look: an omitted run value inherits the layer's, a local value overrides a linked shared text style without changing the style itself, and instance overrides keep an explicit false over a true main. A malformed value counts as unset. The exporter writes the actual value of the layer and of each styled run instead of a constant true, and a decoration look that differs from the default only in skip ink no longer triggers the unsupported-decoration note, so default native text exports its real false value.
Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main.
Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed.
Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata.
Found while testing crates/engine/src/cmd/arrange.rs:132
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A text layer that links a shared text style keeps its own, possibly stale, copy
of the typography. The style's typography now beats that copy, a case,
decoration, paragraph spacing or paragraph indent set on the layer itself beats
the style (so "original", "none" and zero clear the style's value), and an
override row of an instance beats both. An explicit "no style" on a text layer
unlinks it even when the older style field still names one.
Letter spacing given as a percentage of the font size stays a percentage. A text
property value on an instance no longer inherits the character styles written
for the main's text, which count other characters.
Override rows of an instance can name a layer of its main by the key the layer
had before the main was edited. The key now resolves to the layer's current id
when it is unambiguous and the current id does not exist already. Rows of an
enclosing instance win over those of the instances nested in it, whatever order
the wire lists them in, and an explicit "no style" on an override detaches the
style instead of falling back to the older field.
Paints bound to variables take the colours of the active modes when a document
loads, before the first edit, without touching other pending changes.
A stroke-only vector that imports as the outline of its stroke now moves the
stroke's shared style and colour variables to the fill it becomes, and vectors
whose single segments carry their own stroke style say so in a warning.
A .fig import now links the layers of an instance to the layers of the
component they came from by the ids the file stores, instead of leaving it to
the engine to guess from names and places. Renamed slots and layers, siblings
that share a name and swapped mains keep their source link and the override
they carry.
Components that live only on the file's hidden internal pages (cached copies of
library components) are kept in the hidden library store when a visible layer
uses them, so their instances stay instances. The read-only guard for library
copies now compares the copies themselves rather than the dirty set, which an
import leaves full until the first edit.
Rebasing an instance (after detaching an outer instance or restoring a main)
follows the source chain inside the right enclosing instance, so contextual
links survive it. The last change of a GUID wins and a removed change deletes
it. Ordinary file JSON never reads the private provenance.
Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target.
Clicking the + button to add a fill or stroke in the Design panel now
immediately opens the colour picker dialog for the newly added paint,
matching Figma's behaviour.
The canvas renderer and PDF export stroked text with the alignment
forced to centre, so Inside and Outside on text did nothing (SVG export
already honoured them). Glyph outlines are closed, so text strokes now
go through stroke_area like other shapes' strokes, with the same fill
rule: inside clips to the glyphs, outside to around them.
Opening or importing a .fig or REST JSON file, or an SVG that has notes,
from the app (File > Open or Import, a recent file, a drop, a paste, the
system's open event) now shows an Import report window with the
document's name, its page, layer and image counts and the compatibility
notes the importer collected, instead of leaving them only in the command
result. Commands sent over the control channel or MCP never open it.
file.open and file.import return the counts as `summary`; ui.inspect
reports the open report as `importReport`. The fixed notes the importers
emit are translated (German and Spanish) and shown with a capital first
letter; other notes, such as SVG's, show as written. The report goes
away with its document. Escape or Close dismisses it.
Save as now offers .figcraft and .fig as separate file types. A .fig
destination, chosen in the dialog or passed to file.saveAs and file.save,
writes the existing experimental .fig export of the document that is shown
and reports how much was left out. The file's own path, unsaved changes,
versions, branches and undo history are not touched, and a shown earlier
version stays shown while the copy is written or a Save as choice is
pending.
Plain Save no longer writes over a file whose saved path ends in .fig;
earlier builds could write native data under that name. It asks for a
destination instead.
Figma's schema has leadingTrim on NodeChange (NONE, CAP_HEIGHT), and
the REST importer already reads style.leadingTrim, but the .fig mapping
never copied it, so text set to "Cap height to baseline" imported
untrimmed in its trimmed box. text_style now copies it with the other
text fields, from the layer or its text style.
Vector points carry their own stroke join in a .fig file. Import now keeps each point's join as the point's own override instead of dropping it for the layer's join.
Joins equal to the layer join no longer force styled stroke runs, and an absent layer join counts as miter. Unresolved point styles, unknown joins, and joins that dashed or variable-width strokes cannot draw each produce a warning.
Pasting or dropping a file runs file.import with the whole file as Base64
data, and every successful command was journaled with its parameters, so
each imported file stayed in memory a second time for the session. Byte
imports, including ones wrapped in command.batch, are no longer journaled.
Imports by path and all other commands are unchanged.