From 1147d3357f52c83f2b77e9491c1d1089a098f407 Mon Sep 17 00:00:00 2001
From: Patrik Lermon
Date: Mon, 26 Oct 2026 21:08:43 +0100
Subject: [PATCH 01/60] i18n(sv): translate 47 new strings
Brings the Swedish catalog up to date with upstream main (740d1173).
Co-Authored-By: Claude Opus 5.5
---
crates/ui-egui/locales/sv.toml | 77 +++++++++++++++++++++++++++-------
1 file changed, 62 insertions(+), 15 deletions(-)
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index aeac2327..84de643f 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -7,9 +7,6 @@
name = "Svenska"
[ui]
-"Relative to" = "Relativt till"
-"Trigger layer" = "Utlösande lager"
-"Whole screen" = "Hela skärmen"
"% of all commits" = "% av alla commits"
"(no longer published)" = "(publiceras inte längre)"
@@ -112,6 +109,7 @@ name = "Svenska"
"An invite link that opens FigCraft and joins" = "En inbjudningslänk som öppnar FigCraft och ansluter"
"Anchor" = "Ankare"
"Android" = "Android"
+"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Vinkelmått"
"Animation" = "Animering"
"Annotations" = "Annoteringar"
@@ -169,6 +167,7 @@ name = "Svenska"
"Basics" = "Grunder"
"Before" = "Före"
"Bend" = "Böj"
+"Best effort" = "Bästa möjliga"
"Between the selected layers" = "Mellan de markerade lagren"
"Bevel" = "Fasning"
"Binary assets added" = "Binära resurser tillagda"
@@ -215,6 +214,9 @@ name = "Svenska"
"CMYK colour" = "CMYK-färg"
"Can view" = "Kan visa"
"Check" = "Bock"
+"Check for Updates" = "Sök efter uppdateringar"
+"Check for updates…" = "Sök efter uppdateringar…"
+"Checking for updates…" = "Söker efter uppdateringar…"
"Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…"
"Checking the server…" = "Kontrollerar servern…"
"Chevron" = "Vinkel"
@@ -224,7 +226,12 @@ name = "Svenska"
"Code or invite link" = "Kod eller inbjudningslänk"
"Color profile: {name}" = "Färgprofil: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Färg- och textstilar, ljusa och mörka variabler, knappar, inmatningsfält och kort"
+"Compatibility" = "Kompatibilitet"
+"Connector" = "Koppling"
+"Convert to frame" = "Konvertera till frame"
+"Convert to section" = "Konvertera till avsnitt"
"CSS custom properties…" = "Anpassade CSS-egenskaper…"
+"Dart constants for Flutter…" = "Dart-konstanter för Flutter…"
"Can edit" = "Kan redigera"
"Can't read this library: {e}" = "Det går inte att läsa det här biblioteket: {e}"
"Cancel" = "Avbryt"
@@ -292,6 +299,7 @@ name = "Svenska"
"Commits" = "Commits"
"Company" = "Företag"
"Compare changes" = "Jämför ändringar"
+"Compare with" = "Jämför med"
"Compatibility notes" = "Kompatibilitetsanteckningar"
"Completed" = "Slutförd"
"Component" = "Komponent"
@@ -307,7 +315,6 @@ name = "Svenska"
"Connected" = "Kopplad"
"Connected to code: {from}" = "Kopplad till kod: {from}"
"Connection lost, reconnecting (attempt {attempt})…" = "Anslutningen bröts, återansluter (försök {attempt})…"
-"Connector" = "Koppling"
"Constrain proportions" = "Behåll proportioner"
"Constraints" = "Constraints"
"Content" = "Innehåll"
@@ -317,8 +324,6 @@ name = "Svenska"
"Contributors" = "Bidragsgivare"
"Contributors: {people} · Commits: {commits}" = "Bidragsgivare: {people} · Commits: {commits}"
"Controller" = "Handkontroll"
-"Convert to frame" = "Konvertera till frame"
-"Convert to section" = "Konvertera till avsnitt"
"Cool" = "Kall"
"Copied as CSS" = "Kopierat som CSS"
"Copied as PNG" = "Kopierat som PNG"
@@ -331,12 +336,17 @@ name = "Svenska"
"Copy as PNG" = "Kopiera som PNG"
"Copy as SVG" = "Kopiera som SVG"
"Copy code" = "Kopiera kod"
+"Copy effect" = "Kopiera effekt"
+"Copy fill" = "Kopiera fyllning"
"Copy link" = "Kopiera länk"
+"Copy stroke" = "Kopiera kontur"
"Copy the {label}" = "Kopiera {label}"
"Copy {value}" = "Kopiera {value}"
"Corner radius" = "Hörnradie"
"Corner smoothing" = "Hörnutjämning"
+"Could not check for updates:\n{error}" = "Det gick inte att söka efter uppdateringar:\n{error}"
"Couldn't download the variables" = "Det gick inte att hämta variablerna"
+"Couldn't download {name}" = "Det gick inte att hämta {name}"
"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns."
"Couldn't read {name}." = "Det gick inte att läsa {name}."
"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}"
@@ -410,6 +420,7 @@ name = "Svenska"
"Detach variable" = "Koppla bort variabel"
"Details" = "Detaljer"
"Dev Mode" = "Utvecklarläge"
+"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Utvecklarläget kan bara granska: växla till Design (Shift+D) för att redigera"
"Dev resources" = "Utvecklarresurser"
"Development" = "Utveckling"
"Device" = "Enhet"
@@ -427,6 +438,7 @@ name = "Svenska"
"Distribute vertical spacing" = "Fördela vertikalt avstånd"
"Divider" = "Avdelare"
"Don't Save" = "Spara inte"
+"Don't show this again" = "Visa inte detta igen"
"Don't truncate" = "Korta inte av"
"Done" = "Klart"
"Done editing" = "Klar med redigeringen"
@@ -482,6 +494,7 @@ name = "Svenska"
"Essential" = "Grundläggande"
"Every conflict has a choice." = "Alla konflikter har ett val."
"Every top-level frame already starts a flow" = "Varje frame på översta nivån startar redan ett flöde"
+"Exact only" = "Endast exakt"
"Exact opacity (4 then 5: 45%)" = "Exakt opacitet (4 och sedan 5: 45 %)"
"Exclude" = "Uteslut"
"Excluded" = "Utesluten"
@@ -500,8 +513,10 @@ name = "Svenska"
"Export {n} layers" = { one = "Exportera {n} lager", other = "Exportera {n} lager" }
"Exported {file}" = "Exporterade {file}"
"Exported {name}" = "Exporterade {name}"
+"Exported {name}; {n} kinds of content were changed or left out" = { one = "Exporterade {name}; {n} typ av innehåll ändrades eller utelämnades", other = "Exporterade {name}; {n} typer av innehåll ändrades eller utelämnades" }
"Exported {name}; {n} kinds of content were left out" = { one = "Exporterade {name}; {n} typ av innehåll utelämnades", other = "Exporterade {name}; {n} typer av innehåll utelämnades" }
"Exported {n} files" = { one = "Exporterade {n} fil", other = "Exporterade {n} filer" }
+"Exported {n} files; some content was changed or left out" = { one = "Exporterade {n} fil; en del innehåll ändrades eller utelämnades", other = "Exporterade {n} filer; en del innehåll ändrades eller utelämnades" }
"Export…" = "Exportera…"
"Expose properties to instances" = "Exponera egenskaper för instanser"
"Exposure" = "Exponering"
@@ -516,6 +531,7 @@ name = "Svenska"
"Fetching from the server…" = "Hämtar från servern…"
"FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad."
+"FigCraft {version} is currently the newest version available." = "FigCraft {version} är den senaste tillgängliga versionen."
"File" = "Fil"
"File name" = "Filnamn"
"File not found" = "Filen hittades inte"
@@ -566,6 +582,7 @@ name = "Svenska"
"Font size" = "Teckenstorlek"
"Font style" = "Teckenstil"
"Forget on this computer" = "Glöm på den här datorn"
+"Format" = "Format"
"Four screens of a trail app with components and a prototype flow" = "Fyra skärmar i en vandringsapp med komponenter och ett prototypflöde"
"Fractions" = "Bråk"
"Frame" = "Frame"
@@ -643,6 +660,7 @@ name = "Svenska"
"If {condition}" = "Om {condition}"
"Ignore auto layout (absolute position)" = "Ignorera auto layout (absolut position)"
"Ignore overlapping layers" = "Ignorera överlappande lager"
+"Ignore unsupported" = "Ignorera det som inte stöds"
"Image" = "Bild"
"Images" = "Bilder"
"Import" = "Importera"
@@ -734,6 +752,7 @@ name = "Svenska"
"Layout grids" = "Layoutrutnät"
"Leave session" = "Lämna sessionen"
"Leaves" = "Löv"
+"Leaving the prototype" = "Du lämnar prototypen"
"Left" = "Vänster"
"Left & right" = "Vänster och höger"
"Left padding" = "Vänster padding"
@@ -853,8 +872,8 @@ name = "Svenska"
"New board" = "Ny whiteboard"
"New design file" = "Ny designfil"
"Newest first" = "Nyaste först"
-"Next frame" = "Nästa frame"
"Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen"
+"Next frame" = "Nästa frame"
"Next ready for dev" = "Nästa redo för utveckling"
"Next size option" = "Nästa storleksalternativ"
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Inga resurser: inga exportinställningar, ikoner eller bilder. Lägg till exportinställningar på fliken Design."
@@ -872,8 +891,8 @@ name = "Svenska"
"No conflicts." = "Inga konflikter."
"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."
"No device" = "Ingen enhet"
-"No files or templates match your search" = "Inga filer eller mallar matchar sökningen"
"No fonts match" = "Inga typsnitt matchar"
+"No files or templates match your search" = "Inga filer eller mallar matchar sökningen"
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället."
"No layer changes to show." = "Inga lagerändringar att visa."
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Inga bibliotek än. Lägg till en .figcraft-fil som har publicerats som bibliotek, eller klistra in länken till ett teambibliotek."
@@ -918,6 +937,7 @@ name = "Svenska"
"Not read yet" = "Inte läst än"
"Not shared" = "Inte delad"
"Note {n}" = "Anteckning {n}"
+"Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen."
"Nothing changed since this was marked ready for dev." = "Inget har ändrats sedan detta markerades som klart för utveckling."
"Nothing here yet. Set a frame's status to list it." = "Inget här än. Ange en frames status för att lista den."
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Inget online än. Använd Dela på en fil för att lägga ut den online, så visas den här."
@@ -972,8 +992,8 @@ name = "Svenska"
"Options" = "Alternativ"
"Orange" = "Orange"
"Order" = "Ordning"
-"Other" = "Annat"
"Other apps may not open every .fig file exported here." = "Andra appar kanske inte kan öppna alla .fig-filer som exporteras här."
+"Other" = "Annat"
"Others lose access and keep their copy; the file stays on the server for you" = "Andra förlorar åtkomst och behåller sin kopia; filen ligger kvar på servern åt dig"
"Others type this under Share ▸ Join with a code" = "Andra skriver in den under Dela ▸ Anslut med en kod"
"Outline stroke" = "Konvertera kontur till form"
@@ -1009,7 +1029,10 @@ name = "Svenska"
"Pass through" = "Genomsläpp"
"Paste a link (https://…)" = "Klistra in en länk (https://…)"
"Paste a team library link (a view link)" = "Klistra in en länk till ett teambibliotek (en visningslänk)"
+"Paste effect" = "Klistra in effekt"
+"Paste fill" = "Klistra in fyllning"
"Paste here" = "Klistra in här"
+"Paste stroke" = "Klistra in kontur"
"Paste to replace" = "Klistra in och ersätt"
"Pasted design layers; {n} kinds of content were left out" = { one = "Designlager inklistrade; {n} typ av innehåll utelämnades", other = "Designlager inklistrade; {n} typer av innehåll utelämnades" }
"Pattern" = "Mönster"
@@ -1035,9 +1058,9 @@ name = "Svenska"
"Place automatically" = "Placera automatiskt"
"Place image" = "Placera bild"
"Plugin" = "Plugin"
-"Plugins & widgets" = "Plugins och widgetar"
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins körs i FigCrafts sandlåda, utan filer, nätverk eller klocka. Installera bara plugins du litar på."
"Plugins" = "Plugins"
+"Plugins & widgets" = "Plugins och widgetar"
"Plus darker" = "Plus mörkare"
"Plus lighter" = "Plus ljusare"
"Plus one" = "Plus ett"
@@ -1107,6 +1130,7 @@ name = "Svenska"
"Reduce motion" = "Minska rörelse"
"Refraction" = "Brytning"
"Regular expression" = "Reguljärt uttryck"
+"Relative to" = "Relativt till"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativt systemets skalning av skärmen (100 % är systemets egen storlek). Dokumentets zoom är separat."
"Remove" = "Ta bort"
"Remove all" = "Ta bort alla"
@@ -1139,6 +1163,7 @@ name = "Svenska"
"Removed" = "Borttaget"
"Removed from the server" = "Borttagen från servern"
"Removed since it was marked" = "Borttaget sedan det märktes"
+"Removed since this version" = "Borttaget sedan den här versionen"
"Rename" = "Byt namn"
"Rename duplicated layers" = "Byt namn på duplicerade lager"
"Rename folder" = "Byt namn på mapp"
@@ -1211,9 +1236,6 @@ name = "Svenska"
"Run {label}" = "Kör {label}"
"Save file" = "Spara fil"
"Save files automatically" = "Spara filer automatiskt"
-"Search files and templates" = "Sök efter filer och mallar"
-"Search pages" = "Sök sidor"
-"Search results" = "Sökresultat"
"Search templates" = "Sök mallar"
"See all templates" = "Se alla mallar"
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
@@ -1240,8 +1262,11 @@ name = "Svenska"
"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg"
"Search assets" = "Sök resurser"
"Search comments" = "Sök i kommentarer"
+"Search files and templates" = "Sök efter filer och mallar"
+"Search results" = "Sökresultat"
"Search fonts" = "Sök typsnitt"
"Search layers" = "Sök lager"
+"Search pages" = "Sök sidor"
"Search shortcuts" = "Sök kortkommandon"
"Section" = "Avsnitt"
"Section color" = "Avsnittsfärg"
@@ -1257,10 +1282,10 @@ name = "Svenska"
"Select overlapping shapes" = "Markera överlappande former"
"Select points to edit their position, radius and mirroring" = "Markera punkter för att redigera deras position, radie och spegling"
"Select slot" = "Markera plats"
+"Selection colors" = "Markeringsfärger"
"Selection" = "Markering"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta, {w} gånger {h} vid {x}, {y}: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
-"Selection colors" = "Markeringsfärger"
"Send backward" = "Flytta bakåt"
"Send report" = "Skicka rapport"
"Send to back" = "Flytta längst bak"
@@ -1268,7 +1293,9 @@ name = "Svenska"
"Server address (ws://host:47900/)" = "Serveradress (ws://host:47900/)"
"Server address" = "Serveradress"
"Server for online sharing" = "Server för delning online"
+"Server key" = "Servernyckel"
"Set as default" = "Ange som standard"
+"Set corner smoothing to 60%" = "Ställ in hörnutjämning på 60 %"
"Set variable" = "Ange variabel"
"Set variable mode" = "Ange variabelläge"
"Set variable {name}" = "Ange variabel {name}"
@@ -1424,6 +1451,7 @@ name = "Svenska"
"Text" = "Text"
"Text align" = "Textjustering"
"Text content" = "Textinnehåll"
+"Text on a path" = "Text längs en bana"
"Text size" = "Textstorlek"
"Text style" = "Textstil"
"Text styles" = "Textstilar"
@@ -1453,15 +1481,18 @@ name = "Svenska"
"This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck"
"This file" = "Den här filen"
"This file has unsaved changes" = "Filen har osparade ändringar"
-"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem."
+"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
"This frame is new on the branch." = "Den här framen är ny på grenen."
"This frame isn't on the branch." = "Den här framen finns inte på grenen."
+"This link opens a website outside the prototype:" = "Den här länken öppnar en webbplats utanför prototypen:"
"This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)."
"This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat."
"Thumbs up" = "Tummen upp"
+"This version can't be read." = "Den här versionen kan inte läsas."
+"This version doesn't have it: all of it is new." = "Den här versionen saknar det: allt är nytt."
"Tidy up" = "Snygga till"
"Tile" = "Sida vid sida"
"Tile type" = "Upprepningstyp"
@@ -1484,6 +1515,7 @@ name = "Svenska"
"Triangle" = "Triangel"
"Triangle arrow" = "Triangelpil"
"Trigger" = "Utlösare"
+"Trigger layer" = "Utlösande lager"
"True" = "Sant"
"Truncate text" = "Korta av text"
"Truncate with an ellipsis" = "Korta av med ellips"
@@ -1511,6 +1543,7 @@ name = "Svenska"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem"
"Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem"
"Up" = "Upp"
+"Update check is not supported in the web browser." = "Sökning efter uppdateringar stöds inte i webbläsaren."
"Update plugin" = "Uppdatera plugin"
"Update" = "Uppdatera"
"Update from main" = "Uppdatera från main"
@@ -1576,15 +1609,18 @@ name = "Svenska"
"Wedge" = "Kil"
"Weekly planner" = "Veckoplanering"
"Weight" = "Tjocklek"
+"What changed since a saved version" = "Vad som har ändrats sedan en sparad version"
"What you try here changes this preview and the code below, not the file" = "Det du provar här ändrar den här förhandsgranskningen och koden nedan, inte filen"
"What changed since it was marked ready for dev" = "Vad som har ändrats sedan den markerades som klar för utveckling"
"What is wrong with this file? The server's operator gets your report." = "Vad är fel med den här filen? Serverns operatör får din rapport."
"What it's for" = "Vad den är till för"
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vad detta ändrades från är okänt: ändringen gjordes innan FigCraft började spara en kopia av frames som är klara för utveckling. Lös ändringen (eller markera den som klar igen) för att kunna jämföra från och med nu."
+"When marked ready" = "När den märktes som klar"
"While hovering" = "Vid hovring"
"While pressing" = "Vid nedtryckning"
"White" = "Vit"
"Who runs it, its terms and how long it keeps files" = "Vem som driver den, dess villkor och hur länge den sparar filer"
+"Whole screen" = "Hela skärmen"
"Why it changed" = "Varför det ändrades"
"Widget" = "Widget"
"Widgets" = "Widgetar"
@@ -1663,10 +1699,12 @@ name = "Svenska"
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bildfiltren är tvetydiga eftersom båda filterbehållarna finns; filterkonverteringen hoppades över"
"image filters use FigCraft's approximate renderer" = "bildfilter använder FigCrafts ungefärliga rendering"
"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över"
+"instance swap properties whose default component was past the layer limit were skipped" = "egenskaper för instansbyte vars standardkomponent låg över lagergränsen hoppades över"
"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som frames"
"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager"
"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som frames"
"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som frames"
+"instances whose component was past the layer limit were imported as frames" = "instanser vars komponent låg över lagergränsen importerades som frames"
"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma frames"
"invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades"
"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former"
@@ -1947,6 +1985,7 @@ name = "Svenska"
"component.resetInstance" = "Återställ alla ändringar"
"component.resetOverride" = "Återställ override"
"component.restoreMain" = "Återställ huvudkomponent"
+"data.fill" = "Fyll kopior med data"
"dev.compare" = "Jämför ändringar"
"dev.focus" = "Fokusvy"
"dev.resolveChange" = "Lös ändring"
@@ -1987,7 +2026,9 @@ name = "Svenska"
"edit.setDefaultProperties" = "Ange som standardegenskaper"
"edit.undo" = "Ångra"
"effect.add" = "Lägg till effekt"
+"effect.copy" = "Kopiera effekt"
"effect.move" = "Flytta effekt"
+"effect.paste" = "Klistra in effekt"
"effect.remove" = "Ta bort effekt"
"effect.set" = "Ange effekt"
"export.add" = "Lägg till export"
@@ -2001,6 +2042,7 @@ name = "Svenska"
"file.exportLayers" = "Exportera lager"
"file.exportSvg" = "Exportera som SVG"
"file.import" = "Importera"
+"file.markSaved" = "Märk som sparad"
"file.moveTab" = "Flytta flik"
"file.new" = "Nytt"
"file.newBoard" = "Ny whiteboard"
@@ -2016,7 +2058,9 @@ name = "Svenska"
"file.saveVersion" = "Spara version"
"file.versionHistory" = "Versionshistorik"
"fill.add" = "Lägg till fyllning"
+"fill.copy" = "Kopiera fyllning"
"fill.move" = "Flytta fyllning"
+"fill.paste" = "Klistra in fyllning"
"fill.pattern" = "Mönster"
"fill.remove" = "Ta bort fyllning"
"fill.set" = "Ange fyllning"
@@ -2208,7 +2252,9 @@ name = "Svenska"
"shapeBuilder.remove" = "Ta bort områden"
"stroke.add" = "Lägg till kontur"
"stroke.brush" = "Pensel"
+"stroke.copy" = "Kopiera kontur"
"stroke.move" = "Flytta kontur"
+"stroke.paste" = "Klistra in kontur"
"stroke.remove" = "Ta bort kontur"
"stroke.set" = "Ange kontur"
"stroke.widthProfile" = "Breddprofil"
@@ -2247,6 +2293,7 @@ name = "Svenska"
"text.edit.selectWord" = "Markera ord"
"text.edit.selection" = "Textmarkering"
"text.endPreview" = "Avsluta förhandsgranskning av text"
+"text.fit" = "Anpassa texten till rutan"
"text.fonts" = "Lista typsnitt"
"text.italic" = "Kursiv"
"text.lineHeightDown" = "Minska radhöjden"
From 6b73c590f8be91e2364b98e806edef106f241aab Mon Sep 17 00:00:00 2001
From: samhyun
Date: Tue, 27 Oct 2026 19:43:45 +0900
Subject: [PATCH 02/60] M9: a ping every keep-alive interval, whatever else the
client sends
A viewer moving the pointer sends presence about 30 times a second, and every message sent put
the next ping off by the keep-alive interval, so no ping went out while presence did. The host
answers pings and nothing else to a client alone in a session (presence goes to the other peers),
so nothing was heard for the idle timeout (45 s): the client dropped a healthy connection and
reconnected, over and over. Seen in the browser build; the desktop app's guest has the same
timing (its reader times out the same way) and gets the same change.
Pings are now timed from the last ping, not from the last message sent: one goes out every 15 s
whatever else is on the wire. Regression test: 90 s of presence at 10 a second with the host
answering only the pings keeps the connection (it closed before the change).
Co-Authored-By: Claude Fable 5.1
---
crates/collab-net/src/driver.rs | 41 +++++++++++++++------------
crates/collab-net/src/driver_tests.rs | 36 +++++++++++++++++++++--
crates/collab-net/src/guest.rs | 27 ++++++++++--------
crates/collab-net/src/limits.rs | 2 +-
4 files changed, 73 insertions(+), 33 deletions(-)
diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs
index 4ebbec15..84801c4d 100644
--- a/crates/collab-net/src/driver.rs
+++ b/crates/collab-net/src/driver.rs
@@ -2,9 +2,10 @@
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
-//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
-//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
-//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
+//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
+//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
+//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
+//! WebSocket's events and wakes it at [`Driver::deadline`].
//!
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
//! connection of its own, then joins that file as with a link; a server that doesn't know the
@@ -36,13 +37,15 @@ struct Conn {
opened: bool,
welcomed: bool,
seen: Seen,
- last_sent: Instant,
+ /// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
+ /// else is sent, since presence gets no answer and only what the host sends counts as heard.
+ last_ping: Instant,
last_heard: Instant,
}
impl Conn {
fn new(now: Instant) -> Self {
- Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
+ Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
}
}
@@ -106,7 +109,7 @@ impl Driver {
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
None => hello(&self.cfg, &self.resume),
};
- if !self.write(&hello, now) {
+ if !self.write(&hello) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -173,7 +176,7 @@ impl Driver {
return;
}
if let Some(p) = self.throttle.offer(p, now)
- && !self.write(&ClientMsg::Presence(p), now)
+ && !self.write(&ClientMsg::Presence(p))
{
self.end(Outcome::Retry("the connection dropped".into()), now);
}
@@ -186,7 +189,7 @@ impl Driver {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
- if !self.write(&msg, now) {
+ if !self.write(&msg) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
@@ -204,7 +207,7 @@ impl Driver {
let l = &self.cfg.limits;
let conn = self.conn.as_ref().map(|c| {
if c.opened {
- [Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
+ [Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
} else {
[Some(c.started + self.cfg.connect_timeout), None, None]
}
@@ -223,7 +226,7 @@ impl Driver {
}
let l = self.cfg.limits;
let Some(c) = self.conn.as_ref() else { return };
- let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
+ let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
if !opened {
if now >= started + self.cfg.connect_timeout {
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
@@ -233,21 +236,23 @@ impl Driver {
if now >= last_heard + l.idle_timeout {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
- let ok = match self.throttle.take_due(now) {
- Some(p) => self.write(&ClientMsg::Presence(p), now),
- None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
- None => true,
- };
+ let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
+ let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
- /// Send one message; false when it couldn't go.
- fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
+ /// Send a ping; false when it couldn't go.
+ fn ping(&mut self, now: Instant) -> bool {
if let Some(c) = self.conn.as_mut() {
- c.last_sent = now;
+ c.last_ping = now;
}
+ self.write(&ClientMsg::Ping)
+ }
+
+ /// Send one message; false when it couldn't go.
+ fn write(&mut self, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
}
diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs
index 5e395b98..a94ff064 100644
--- a/crates/collab-net/src/driver_tests.rs
+++ b/crates/collab-net/src/driver_tests.rs
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
d.tick(due);
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
let n = d.sock.sent.len();
- // Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
+ // A ping every keep-alive interval from the connection's start, presence or not (the host
+ // answers, which counts as traffic).
let ping_at = d.deadline().unwrap();
- assert_eq!(ping_at, due + Duration::from_secs(15));
+ assert_eq!(ping_at, t0 + Duration::from_secs(15));
d.tick(ping_at - ms(1));
assert_eq!(d.sock.sent.len(), n, "not yet");
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
}
+/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
+/// pings still go out, and the host's pongs keep the connection (before, each presence put the
+/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
+#[test]
+fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
+ let t0 = Instant::now();
+ let (mut d, inbox) = start(Script::default(), t0);
+ d.opened(t0);
+ d.message(&welcome(), t0);
+ inbox.drain();
+ let mut answered = 0;
+ for i in 1..=900u32 {
+ let now = t0 + ms(100 * u64::from(i));
+ d.presence(cursor(f64::from(i)), now);
+ while let Some(due) = d.deadline().filter(|t| *t <= now) {
+ d.tick(due);
+ }
+ // The host answers every ping (and nothing else) at once.
+ let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
+ answered = d.sock.sent.len();
+ if pinged {
+ d.message(&encode(&ServerMsg::Pong), now);
+ }
+ }
+ assert!(!d.done());
+ assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
+ assert!(statuses(&inbox.drain()).is_empty());
+ let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
+ assert!(pings >= 5, "a ping every 15 s ({pings})");
+}
+
#[test]
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
let t0 = Instant::now();
diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs
index 2e1d5046..91e72cf6 100644
--- a/crates/collab-net/src/guest.rs
+++ b/crates/collab-net/src/guest.rs
@@ -1,7 +1,8 @@
//! Joining a session: the client end of the transport, with reconnect.
//!
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
-//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
+//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the
+//! host sends.
//! When the connection drops after having been live, it reconnects with growing pauses; a
//! refusal or the host ending the session is final.
//!
@@ -280,13 +281,15 @@ fn session(
}
}
};
- let mut last_sent = Instant::now();
+ // A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the
+ // reader counts only what the host sends.
+ let mut last_ping = Instant::now();
let hello = hello(cfg, resume);
let mut welcomed = false;
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
- let outcome = if send(&mut writer, &hello, &mut last_sent) {
+ let outcome = if send(&mut writer, &hello) {
loop {
- let ping_at = last_sent + cfg.limits.keepalive;
+ let ping_at = last_ping + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
@@ -312,16 +315,17 @@ fn session(
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Err(RecvTimeoutError::Timeout) => {}
}
- if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
+ if !now_send.iter().all(|m| send(&mut writer, m)) {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
- let ok = match out {
- Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
- None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
- None => true,
- };
+ let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
+ let ok = ok
+ && (now < last_ping + cfg.limits.keepalive || {
+ last_ping = now;
+ send(&mut writer, &ClientMsg::Ping)
+ });
if !ok {
break Outcome::Retry("the connection dropped".into());
}
@@ -335,8 +339,7 @@ fn session(
}
/// Send one message; false when the connection is gone.
-fn send(ws: &mut WebSocket, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
- *last_sent = Instant::now();
+fn send(ws: &mut WebSocket, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
}
diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs
index 0321b36c..301c4e78 100644
--- a/crates/collab-net/src/limits.rs
+++ b/crates/collab-net/src/limits.rs
@@ -30,7 +30,7 @@ pub struct NetLimits {
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
/// easily need several minutes).
pub request_reply_timeout: Duration,
- /// A quiet client pings this often (keeps it under `idle_timeout`).
+ /// A client pings this often, whatever else it sends (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
pub queue: usize,
From d17d3f4860fefcb4755e51873ccfc948b87417fe Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Mon, 26 Oct 2026 22:18:44 +0000
Subject: [PATCH 03/60] feat(installer): default to a per-user ArtCraft folder
---
.github/workflows/packaging-lint.yml | 19 ++
packaging/windows/INSTALLATION.md | 35 ++++
packaging/windows/check-install-policy.ps1 | 195 +++++++++++++++++++++
packaging/windows/figcraft.wxs | 87 +++------
packaging/windows/install-policy.json | 126 +++++++++++++
packaging/windows/install-policy.wxi | 54 ++++++
packaging/windows/package.ps1 | 2 +
packaging/windows/test-install-policy.ps1 | 54 ++++++
8 files changed, 514 insertions(+), 58 deletions(-)
create mode 100644 packaging/windows/INSTALLATION.md
create mode 100644 packaging/windows/check-install-policy.ps1
create mode 100644 packaging/windows/install-policy.json
create mode 100644 packaging/windows/install-policy.wxi
create mode 100644 packaging/windows/test-install-policy.ps1
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 38698902..c1394c40 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -23,3 +23,22 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
with:
extra-checks-script: packaging/check-app-specific.sh
+
+ windows-install-policy:
+ timeout-minutes: 10
+ name: Windows installation policy (no app build)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Install the pinned packaging toolchain
+ shell: pwsh
+ run: |
+ $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
+ dotnet tool install --tool-path $tools wix --version 5.0.2
+ if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
+ $tools >> $env:GITHUB_PATH
+ & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
+ if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' }
+ - name: Build and check non-executable installer fixtures
+ shell: pwsh
+ run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md
new file mode 100644
index 00000000..982a5b42
--- /dev/null
+++ b/packaging/windows/INSTALLATION.md
@@ -0,0 +1,35 @@
+# Windows installation policy checks
+
+New installations default to the current account under `Programs\ArtCraft\`.
+The first setup page offers installation for everyone. Scope is selected before duplicate
+checks and directory costing. An update uses the existing context's remembered exact folder;
+`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the
+chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`.
+A cross-context copy is refused; changing context means uninstalling the old copy first.
+
+App registration follows the selected context. Existing optional shortcuts and file-handler
+choices stay app-owned. No new cleanup operation or model/settings migration is introduced.
+Packaged resources remain package-owned; separately downloaded shared models are not caches
+and are outside this installer's ownership.
+
+## Repeatable metadata tests
+
+On Windows with WiX 5.0.2 and its matching UI/Util extensions:
+
+```powershell
+./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests
+```
+
+The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It
+uses the actual package source and compiled database. A safe Windows Installer session checks
+24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing,
+upgrade identity and the original registration inventory recorded in `install-policy.json`.
+The release packaging script runs the compiled checker before signing. Packaging CI runs the
+small fixture builds without compiling the Rust application.
+
+Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks
+does not prove real installation, elevation, legacy-version upgrades, registry writes, optional
+shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real
+payload in an authorised clean Windows test environment. Cross-architecture metadata is not
+execution evidence on those architectures. Existing open installer/association PRs must be
+reconciled before publication. Nothing here changes macOS/Linux native packaging conventions.
diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1
new file mode 100644
index 00000000..dd289564
--- /dev/null
+++ b/packaging/windows/check-install-policy.ps1
@@ -0,0 +1,195 @@
+<#
+.SYNOPSIS
+ Check the compiled installation policy without installing any product.
+.DESCRIPTION
+ Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom
+ actions are exercised. No AppSearch, install, repair, removal or file-writing action runs.
+#>
+param([Parameter(Mandatory = $true)] [string] $MsiPath)
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
+function Read-Rows($Database, [string] $Sql, [int] $Columns) {
+ $view = $Database.OpenView($Sql)
+ try {
+ [void]$view.Execute()
+ while ($null -ne ($record = $view.Fetch())) {
+ try {
+ $values = @(for ($i = 1; $i -le $Columns; $i++) {
+ $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i))
+ })
+ ,$values
+ } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) }
+ }
+ } finally {
+ [void]$view.Close()
+ [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view)
+ }
+}
+function Set-SessionProperty($Session, [string] $Name, [string] $Value) {
+ [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value))
+}
+function Get-SessionProperty($Session, [string] $Name) {
+ $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name))
+}
+$installer = $null; $database = $null; $session = $null
+try {
+ $installer = New-Object -ComObject WindowsInstaller.Installer
+ $file = (Resolve-Path -LiteralPath $MsiPath).Path
+ $database = $installer.OpenDatabase($file, 0)
+ $session = $installer.OpenPackage($file, 1)
+ $properties = @{}
+ foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] }
+ $secureProperties = @($properties['SecureCustomProperties'] -split ';')
+ if ('INSTALLFOLDER' -notin $secureProperties) {
+ throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).'
+ }
+ if ($policy.mode -eq 'perUser') {
+ if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' }
+ if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' }
+ $dirs = @{}
+ foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) }
+ if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or
+ $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') {
+ throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.'
+ }
+ foreach ($explicit in @('', 'F:\Chosen exact launcher')) {
+ Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher'
+ Set-SessionProperty $session 'INSTALLFOLDER' $explicit
+ $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0]
+ if ($session.EvaluateCondition($row[0]) -eq 1) {
+ if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' }
+ }
+ $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' }
+ if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' }
+ }
+ $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
+ if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' }
+ foreach ($entry in $policy.registry) {
+ if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) {
+ throw 'Launcher registration or shortcut preference changed unexpectedly.'
+ }
+ }
+ Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.'
+ return
+ }
+ if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') {
+ throw 'Policy regression: new packages must default to the per-user dual-scope context.'
+ }
+ if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' }
+ $directories = @{}
+ foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) {
+ $directories[$row[0]] = @($row[1], $row[2])
+ }
+ if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or
+ $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' }
+ $programs = $directories['CraftSuiteFolder'][0]
+ if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] }
+ if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') {
+ throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.'
+ }
+ $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 |
+ Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' })
+ if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' }
+ $cases = 0
+ foreach ($scope in @('', '2', '1')) {
+ foreach ($user in @('', 'D:\Existing user app')) {
+ foreach ($machine in @('', 'E:\Existing machine app')) {
+ foreach ($installed in @('', '1')) {
+ Set-SessionProperty $session 'ALLUSERS' $scope
+ Set-SessionProperty $session 'Installed' $installed
+ Set-SessionProperty $session 'CRAFT_USER_DIR' $user
+ Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine
+ $allowed = $true
+ foreach ($guard in $guards) {
+ $value = $session.EvaluateCondition($guard[0])
+ if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' }
+ if ($value -eq 0) { $allowed = $false }
+ }
+ $opposite = if ($scope -eq '1') { $user } else { $machine }
+ $expected = [bool]$installed -or -not [bool]$opposite
+ if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." }
+ $cases++
+ }
+ }
+ }
+ }
+ # Execute the actual authored type-51 actions in a safe session, not copied resolver logic.
+ foreach ($scope in @('', '2', '1')) {
+ foreach ($explicit in @('', 'F:\Chosen exact app')) {
+ Set-SessionProperty $session 'ALLUSERS' $scope
+ Set-SessionProperty $session 'INSTALLFOLDER' $explicit
+ Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app'
+ Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
+ $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' }
+ if (-not $explicit) {
+ if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' }
+ }
+ $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' }
+ if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' }
+ }
+ }
+ $ui = @{}
+ foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] }
+ if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and
+ $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) {
+ throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.'
+ }
+ $execute = @{}
+ foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
+ foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) {
+ if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) {
+ throw 'Silent updates must restore the folder before directory costing.'
+ }
+ }
+ # Switching the first-page choice must reset BOTH properties, including a back-and-forth change.
+ $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4)
+ foreach ($choice in @('user','machine','user')) {
+ Set-SessionProperty $session 'CRAFT_SCOPE' $choice
+ foreach ($event in $events) {
+ if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue }
+ $property = $Matches[1]
+ if ($session.EvaluateCondition($event[2]) -eq 1) {
+ Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] })
+ }
+ }
+ $allUsers = Get-SessionProperty $session 'ALLUSERS'
+ $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER'
+ if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or
+ ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) {
+ throw 'Scope selector does not reset the requested installation context.'
+ }
+ }
+ $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4)
+ $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' })
+ foreach ($option in $savedOptions) {
+ if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' }
+ $number = [regex]::Match($option[0], '[0-9]+').Value
+ foreach ($scope in @('2','1')) {
+ Set-SessionProperty $session 'ALLUSERS' $scope
+ Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice'
+ Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice'
+ Set-SessionProperty $session $option[1] ''
+ $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' })
+ if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' }
+ $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' }
+ if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' }
+ }
+ }
+ $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
+ if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' }
+ if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) {
+ throw 'HKCR keys must be relative to Classes.'
+ }
+ foreach ($entry in $policy.registry) {
+ $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] })
+ if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" }
+ }
+ $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' })
+ if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' }
+ Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed."
+} finally {
+ foreach ($com in @($session, $database, $installer)) {
+ if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) }
+ }
+}
diff --git a/packaging/windows/figcraft.wxs b/packaging/windows/figcraft.wxs
index 4cc564f2..905e3a06 100644
--- a/packaging/windows/figcraft.wxs
+++ b/packaging/windows/figcraft.wxs
@@ -1,39 +1,14 @@
-
-
+
-
-
+
+
+
-
+
-
-
-
-
+
@@ -67,8 +41,7 @@
-
+
@@ -92,14 +65,12 @@
-
+
-
+
@@ -110,27 +81,27 @@
-
-
-
-
-
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json
new file mode 100644
index 00000000..2b4e01f0
--- /dev/null
+++ b/packaging/windows/install-policy.json
@@ -0,0 +1,126 @@
+{
+ "id": "figcraft",
+ "name": "FigCraft",
+ "upgradeCode": "7F8B9827-D065-41B7-A514-2A454B21F484",
+ "sources": [
+ "figcraft.wxs"
+ ],
+ "icon": "assets/app-icon/figcraft.ico",
+ "localization": [
+ "figcraft.en-us.wxl"
+ ],
+ "extensions": [
+ "UI",
+ "Util"
+ ],
+ "payloads": [
+ "NOTICE",
+ "figcraft-cli.exe",
+ "figcraft.exe"
+ ],
+ "registry": [
+ [
+ "-1",
+ "Software\\Classes\\Figcraft.Image\\shell\\open\\command",
+ "",
+ "\"[#FigcraftExe]\" \"%1\""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\.svg\\OpenWithProgids",
+ "Figcraft.Image",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\.png\\OpenWithProgids",
+ "Figcraft.Image",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\.jpg\\OpenWithProgids",
+ "Figcraft.Image",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\.jpeg\\OpenWithProgids",
+ "Figcraft.Image",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Figcraft.Sketch\\shell\\open\\command",
+ "",
+ "\"[#FigcraftExe]\" \"%1\""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\.sketch\\OpenWithProgids",
+ "Figcraft.Sketch",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe",
+ "FriendlyAppName",
+ "FigCraft"
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\shell\\open\\command",
+ "",
+ "\"[#FigcraftExe]\" \"%1\""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes",
+ ".figcraft",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes",
+ ".svg",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes",
+ ".png",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes",
+ ".jpg",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes",
+ ".jpeg",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Classes\\Applications\\figcraft.exe\\SupportedTypes",
+ ".sketch",
+ ""
+ ],
+ [
+ "-1",
+ "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\figcraft.exe",
+ "",
+ "[#FigcraftExe]"
+ ],
+ [
+ "-1",
+ "Software\\FigCraft",
+ "InstallDir",
+ "[INSTALLFOLDER]"
+ ]
+ ],
+ "mode": "dualScope"
+}
diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi
new file mode 100644
index 00000000..c7cdb271
--- /dev/null
+++ b/packaging/windows/install-policy.wxi
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1
index dd3a17b8..a4ba751b 100644
--- a/packaging/windows/package.ps1
+++ b/packaging/windows/package.ps1
@@ -165,6 +165,8 @@ Invoke-Native 'wix build' {
Invoke-Native 'MSI shortcut icon validation (ICE50)' {
wix msi validate $Msi -ice ICE50 -intermediateFolder (Join-Path $Stage 'msi-validation')
}
+# Check the compiled scope, path and registration contract before signing.
+& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1
new file mode 100644
index 00000000..21199ca7
--- /dev/null
+++ b/packaging/windows/test-install-policy.ps1
@@ -0,0 +1,54 @@
+<#
+.SYNOPSIS
+ Build non-executable installer metadata fixtures; never install them.
+#>
+param(
+ [Parameter(Mandatory = $true)] [string] $OutputDirectory,
+ [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64')
+)
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
+$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
+[void](New-Item -ItemType Directory -Force -Path $OutputDirectory)
+$output = (Resolve-Path -LiteralPath $OutputDirectory).Path
+Add-Type -AssemblyName System.Drawing
+foreach ($arch in $Architectures) {
+ $stage = Join-Path $output $arch
+ [void](New-Item -ItemType Directory -Force -Path $stage)
+ foreach ($name in $policy.payloads) {
+ $destination = Join-Path $stage $name
+ [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination)))
+ Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE'
+ }
+ foreach ($folder in @('docs','models','notices')) {
+ $path = Join-Path $stage $folder
+ [void](New-Item -ItemType Directory -Force -Path $path)
+ Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.'
+ }
+ foreach ($model in @('metadata-one.rten','metadata-two.rten')) {
+ Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE'
+ }
+ foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) {
+ $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb)
+ try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) }
+ finally { $bitmap.Dispose() }
+ }
+ Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}'
+ $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ })
+ $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi"
+ $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" })
+ & wix build @sources -arch $arch -wx @extensions `
+ -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' `
+ -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" `
+ -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" `
+ -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' `
+ -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" `
+ -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" `
+ -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ })
+ if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." }
+ $shell = (Get-Process -Id $PID).Path
+ & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi
+ if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." }
+ Write-Output "PASS $arch metadata-only package. This is not an executable installer."
+}
From 1288e40a5381913a2593465a3c764f0769b38d41 Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Tue, 27 Oct 2026 10:50:10 +0000
Subject: [PATCH 04/60] fix(installer): address scope and ARM64 review feedback
---
.github/workflows/windows-arm64.yml | 6 +-
packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++-
packaging/windows/install-policy.wxi | 6 +-
3 files changed, 72 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 4cceb922..00011442 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
- # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
- uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
+ # Immutable supporting workflow revision until the new install contract lands upstream.
+ uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276
with:
app-name: figcraft
app-dir: FigCraft
+ install-relative-path: Programs/ArtCraft/FigCraft
+ install-scope: perUser
diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1
index dd289564..00b683d7 100644
--- a/packaging/windows/check-install-policy.ps1
+++ b/packaging/windows/check-install-policy.ps1
@@ -131,9 +131,12 @@ try {
}
$ui = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] }
- if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and
+ if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and
+ $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and
+ $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and
+ $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) {
- throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.'
+ throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.'
}
$execute = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
@@ -144,7 +147,18 @@ try {
}
# Switching the first-page choice must reset BOTH properties, including a back-and-forth change.
$events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4)
- foreach ($choice in @('user','machine','user')) {
+ # Model the full-UI upgrade entry with an existing machine-wide copy. Run
+ # the actual preference action, then the compiled Next events before discovery.
+ Set-SessionProperty $session 'ALLUSERS' '2'
+ Set-SessionProperty $session 'CRAFT_SCOPE' 'user'
+ Set-SessionProperty $session 'CRAFT_USER_DIR' ''
+ Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
+ $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0]
+ if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or
+ (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') {
+ throw 'Full-UI machine upgrades must initially select the existing machine context.'
+ }
+ foreach ($choice in @('machine','user','machine','user')) {
Set-SessionProperty $session 'CRAFT_SCOPE' $choice
foreach ($event in $events) {
if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue }
@@ -176,6 +190,52 @@ try {
if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' }
}
}
+ # Follow the compiled control conditions, not the source's intended appearance.
+ $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5)
+ $conditions = @()
+ if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) {
+ $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4)
+ }
+ function Test-ControlVisible($Control) {
+ $visible = ([int]$Control[3] -band 1) -ne 0
+ foreach ($condition in $conditions) {
+ if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) {
+ if ($condition[2] -eq 'Show') { $visible = $true }
+ if ($condition[2] -eq 'Hide') { $visible = $false }
+ }
+ }
+ return $visible
+ }
+ foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) {
+ $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') })
+ if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' }
+ foreach ($scope in @('', '2', '1')) {
+ Set-SessionProperty $session 'ALLUSERS' $scope
+ if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) {
+ throw 'The elevation shield must follow the selected scope.'
+ }
+ }
+ $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1]
+ $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
+ $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
+ if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' }
+ }
+ foreach ($scope in @('', '2', '1')) {
+ Set-SessionProperty $session 'ALLUSERS' $scope
+ foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) {
+ if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' }
+ }
+ }
+ if ($policy.id -in 'deckcraft','filmcraft','soundcraft') {
+ foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) {
+ $dialog = $route[0]; $control = $route[1]
+ $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2)
+ Set-SessionProperty $session 'Installed' ''
+ $destination = ''
+ foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } }
+ if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' }
+ }
+ }
$registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' }
if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) {
diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi
index c7cdb271..64bf3935 100644
--- a/packaging/windows/install-policy.wxi
+++ b/packaging/windows/install-policy.wxi
@@ -50,5 +50,9 @@
-
+
+
+
+
+
From 8d1460c3c03414607a3ee251007b68b4994ca97b Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Tue, 27 Oct 2026 11:45:56 +0000
Subject: [PATCH 05/60] fix(installer): harden install-policy checks and
packaging-lint workflow
---
.github/workflows/packaging-lint.yml | 9 +++++++--
packaging/windows/check-install-policy.ps1 | 13 ++++++++++---
packaging/windows/test-install-policy.ps1 | 2 +-
3 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index c1394c40..da85536c 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -30,6 +30,8 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
@@ -37,8 +39,11 @@ jobs:
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
- & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' }
+ $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
+ foreach ($extension in @($policy.extensions)) {
+ & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
+ if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
+ }
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1
index 00b683d7..451295a2 100644
--- a/packaging/windows/check-install-policy.ps1
+++ b/packaging/windows/check-install-policy.ps1
@@ -122,9 +122,11 @@ try {
Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app'
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' }
- if (-not $explicit) {
- if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' }
- }
+ # Evaluate the compiled condition, so the test exercises it instead of calling the action directly.
+ $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0]
+ $selected = $session.EvaluateCondition($restore[0]) -eq 1
+ if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." }
+ if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' }
$expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' }
}
@@ -145,6 +147,11 @@ try {
throw 'Silent updates must restore the folder before directory costing.'
}
}
+ foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) {
+ if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) {
+ throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.'
+ }
+ }
# Switching the first-page choice must reset BOTH properties, including a back-and-forth change.
$events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4)
# Model the full-UI upgrade entry with an existing machine-wide copy. Run
diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1
index 21199ca7..d455d19d 100644
--- a/packaging/windows/test-install-policy.ps1
+++ b/packaging/windows/test-install-policy.ps1
@@ -38,7 +38,7 @@ foreach ($arch in $Architectures) {
$sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ })
$msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi"
$extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" })
- & wix build @sources -arch $arch -wx @extensions `
+ & wix build @sources -arch $arch @extensions `
-culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' `
-d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" `
-d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" `
From bdb7e846060964510dcef1e49d235d0c74dda30b Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Tue, 27 Oct 2026 10:28:10 -0400
Subject: [PATCH 06/60] fix(ui): dropdown menus grow back after a search with
no matches (#1429)
* feat(ui-egui): font family menu keeps its search field and grows back after a filter clears
Repin craft-libs to the shared dropdown (craft-ui), move the font family menu to
craft_ui::combo::Menu with the search in a fixed header, and use craft_ui::ComboBox
for the credits sort. clippy bans egui::ComboBox.
* chore(deps): craft-libs 60d60d48 (dropdown fixes)
* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)
* chore(deps): craft-libs 92d392cf (#387 merged)
* fix(ui-egui): craft-ui on every target (wasm uses the shared ComboBox too)
---
Cargo.lock | 231 +++++++++++--
Cargo.toml | 10 +-
clippy.toml | 3 +
crates/ui-egui/Cargo.toml | 1 +
crates/ui-egui/src/dialogs/credits.rs | 2 +-
crates/ui-egui/src/font_menu_regrow_tests.rs | 122 +++++++
crates/ui-egui/src/lib.rs | 2 +
.../ui-egui/src/panels/typography/fields.rs | 10 +-
.../ui-egui/src/panels/typography/glyphs.rs | 31 +-
.../ui-egui/src/panels/typography/picker.rs | 322 ++++++++++--------
crates/ui-egui/src/widgets/style_picker.rs | 35 +-
11 files changed, 579 insertions(+), 190 deletions(-)
create mode 100644 crates/ui-egui/src/font_menu_regrow_tests.rs
diff --git a/Cargo.lock b/Cargo.lock
index 413011ab..555ed9af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -942,12 +942,60 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+
+[[package]]
+name = "craft-fonts-build"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+
+[[package]]
+name = "craft-i18n"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "fixed_decimal",
+ "icu_decimal",
+ "icu_locale_core",
+ "icu_locale_fallback",
+ "icu_normalizer",
+ "icu_plurals",
+ "icu_properties",
+ "icu_provider",
+ "icu_provider_adapters",
+ "icu_provider_blob",
+ "log",
+ "objc2-foundation 0.3.2",
+ "sys-locale",
+ "unicode-bidi",
+ "windows-registry",
+ "writeable",
+]
+
+[[package]]
+name = "craft-ui"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "craft-fonts-build",
+ "craft-i18n",
+ "egui",
+ "epaint",
+ "js-sys",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "skrifa 0.44.0",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"chrono",
"dunce",
@@ -972,7 +1020,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"wayland-client",
]
@@ -1844,6 +1892,7 @@ version = "0.11.2"
dependencies = [
"base64 0.22.1",
"chrono",
+ "craft-ui",
"craft-update",
"egui",
"egui_extras",
@@ -1913,6 +1962,17 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+[[package]]
+name = "fixed_decimal"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
+dependencies = [
+ "displaydoc",
+ "smallvec",
+ "writeable",
+]
+
[[package]]
name = "fixedbitset"
version = "0.5.7"
@@ -2399,9 +2459,9 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.4.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
@@ -2412,43 +2472,110 @@ dependencies = [
]
[[package]]
-name = "icu_locale_core"
-version = "2.4.0"
+name = "icu_decimal"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
+checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_locale_core",
+ "icu_pattern",
+ "icu_plurals",
+ "icu_provider",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
+ "serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
-name = "icu_normalizer"
-version = "2.4.0"
+name = "icu_locale_fallback"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
+checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
+dependencies = [
+ "icu_locale_core",
+ "icu_locale_fallback_data",
+ "icu_provider",
+ "potential_utf",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_fallback_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
+ "utf16_iter",
+ "utf8_iter",
+ "write16",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
-version = "2.4.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_pattern"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
+dependencies = [
+ "displaydoc",
+ "either",
+ "serde",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_plurals"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
+dependencies = [
+ "displaydoc",
+ "fixed_decimal",
+ "icu_provider",
+ "serde",
+ "zerovec",
+]
[[package]]
name = "icu_properties"
-version = "2.4.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
@@ -2461,18 +2588,21 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.4.0"
+version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
-version = "2.4.0"
+version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
+ "postcard",
+ "serde",
+ "stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -2480,6 +2610,30 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_provider_adapters"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
+dependencies = [
+ "icu_locale_fallback",
+ "icu_provider",
+]
+
+[[package]]
+name = "icu_provider_blob"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
+dependencies = [
+ "icu_provider",
+ "postcard",
+ "serde",
+ "writeable",
+ "zerotrie",
+ "zerovec",
+]
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -2731,7 +2885,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
-source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -2844,6 +2998,9 @@ name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "litrs"
@@ -3684,6 +3841,8 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
+ "serde_core",
+ "writeable",
"zerovec",
]
@@ -4681,6 +4840,18 @@ dependencies = [
"syn 3.0.9",
]
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "js-sys",
+ "libc",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "tar"
version = "0.4.46"
@@ -4855,6 +5026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
+ "serde_core",
"zerovec",
]
@@ -5165,6 +5337,12 @@ dependencies = [
"xmlwriter",
]
+[[package]]
+name = "utf16_iter"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+
[[package]]
name = "utf8-zero"
version = "0.8.1"
@@ -6152,11 +6330,20 @@ dependencies = [
"read-fonts 0.39.2",
]
+[[package]]
+name = "write16"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
+
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+dependencies = [
+ "either",
+]
[[package]]
name = "x11-dl"
@@ -6424,8 +6611,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
+ "litemap",
+ "serde_core",
"yoke",
"zerofrom",
+ "zerovec",
]
[[package]]
@@ -6434,6 +6624,7 @@ version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
+ "serde",
"yoke",
"zerofrom",
"zerovec-derive",
diff --git a/Cargo.toml b/Cargo.toml
index f6fca285..fd2fbb81 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,13 +35,15 @@ figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# Which paths stored in a document may be followed (craft-libs).
-craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
-craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
+craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
+# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
+craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -160,4 +162,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
-krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
diff --git a/clippy.toml b/clippy.toml
index 724e05c5..36cc87a4 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -4,3 +4,6 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
+disallowed-types = [
+ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
+]
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index 04f09a37..78447d7b 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -9,6 +9,7 @@ description = "FigCraft egui frontend (the Figma-style UI: canvas, toolbar, laye
workspace = true
[dependencies]
+craft-ui = { workspace = true }
figcraft-geom = { workspace = true }
figcraft-color = { workspace = true }
figcraft-doc = { workspace = true }
diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs
index 90df5ee3..957c86b2 100644
--- a/crates/ui-egui/src/dialogs/credits.rs
+++ b/crates/ui-egui/src/dialogs/credits.rs
@@ -267,7 +267,7 @@ fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commit
}
ui.separator();
ui.label(RichText::new(tr("Sort")).color(t.text_secondary));
- egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
+ craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
v.key = k;
diff --git a/crates/ui-egui/src/font_menu_regrow_tests.rs b/crates/ui-egui/src/font_menu_regrow_tests.rs
new file mode 100644
index 00000000..b4a9d880
--- /dev/null
+++ b/crates/ui-egui/src/font_menu_regrow_tests.rs
@@ -0,0 +1,122 @@
+//! The font family menu follows its list's height while it is open: filtering to nothing shrinks
+//! it, clearing the filter grows it back, and the search field stays where it is (egui#5225).
+use egui::accesskit::{Node, Role};
+use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2};
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::{FigApp, NoServices};
+
+struct Panel {
+ app: FigApp,
+ ctx: egui::Context,
+ time: f64,
+}
+
+impl Panel {
+ fn new() -> Self {
+ let mut s = Session::new();
+ let text = s.execute("text.create", &json!({"text": "Typography"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("select.set", &json!({"ids": [text]})).unwrap();
+ let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0 };
+ crate::theme::install_fonts(&p.ctx, false, 0);
+ crate::theme::apply(&p.ctx, crate::theme::Tokens::light());
+ p.ctx.enable_accesskit();
+ for _ in 0..3 {
+ p.frame(vec![]);
+ }
+ p
+ }
+
+ fn frame(&mut self, events: Vec) -> Vec {
+ self.time += 1.0 / 60.0;
+ let input =
+ RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 800.0))), time: Some(self.time), events, ..Default::default() };
+ let mut out = self.ctx.run_ui(input, |ui| {
+ let ds = self.app.session.ds().unwrap();
+ let doc = ds.doc.clone();
+ let sel = ds.selection.clone();
+ crate::typography::typography(&mut self.app, ui, &doc, &sel);
+ });
+ out.textures_delta.clear();
+ out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default()
+ }
+
+ /// Settled frames; returns the last frame's accessibility tree.
+ fn settle(&mut self, events: Vec) -> Vec {
+ let mut nodes = self.frame(events);
+ for _ in 0..4 {
+ nodes = self.frame(vec![]);
+ }
+ nodes
+ }
+
+ fn click(&mut self, at: Pos2) {
+ self.frame(vec![Event::PointerMoved(at)]);
+ let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
+ self.frame(vec![button(true)]);
+ self.settle(vec![button(false)]);
+ }
+
+ /// The popup's rectangle: the one foreground area that wasn't there before the menu opened.
+ fn menu_rect(&self, before: &[egui::LayerId]) -> Rect {
+ let layers: Vec = self.ctx.memory(|m| m.layer_ids().collect());
+ layers
+ .into_iter()
+ .filter(|l| l.order == egui::Order::Foreground && !before.contains(l))
+ .filter_map(|l| self.ctx.memory(|m| m.area_rect(l.id)))
+ .max_by(|a, b| a.height().total_cmp(&b.height()))
+ .expect("the font menu is open")
+ }
+}
+
+fn centre(n: &Node) -> Pos2 {
+ let b = n.bounds().unwrap();
+ Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
+}
+
+/// The top of the search field: the text input as wide as `menu` (the panel's own fields are narrower).
+fn search_top(nodes: &[Node], menu: Rect) -> f32 {
+ let field = nodes
+ .iter()
+ .filter(|n| n.role() == Role::TextInput)
+ .filter_map(|n| n.bounds())
+ .find(|b| menu.contains(Pos2::new(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0)) && b.x1 - b.x0 > f64::from(menu.width()) - 40.0)
+ .expect("the menu has a search field");
+ field.y0 as f32
+}
+
+#[test]
+fn the_font_menu_regrows_after_a_search_matches_nothing() {
+ figcraft_text::scan_system_fonts();
+ if figcraft_text::family_names().len() < 3 {
+ eprintln!("fewer than three font families; skipping");
+ return;
+ }
+ let mut p = Panel::new();
+ let nodes = p.settle(vec![]);
+ let family =
+ nodes.iter().find(|n| n.role() == Role::ComboBox && n.value() == Some("Inter")).expect("the Typography section has a font family field");
+ let before: Vec = p.ctx.memory(|m| m.layer_ids().collect());
+ p.click(centre(family));
+ let nodes = p.settle(vec![]);
+ let open = p.menu_rect(&before);
+ let top = search_top(&nodes, open);
+ assert!(open.height() > 100.0, "the menu lists fonts: {open:?}");
+ assert!(open.width() >= 240.0, "the menu keeps the 240 minimum width under a narrower field: {open:?}");
+
+ let type_text = |p: &mut Panel, s: &str| p.settle(vec![Event::Text(s.into())]);
+ let nodes = type_text(&mut p, "zzzzqqqq");
+ let none = p.menu_rect(&before);
+ assert!(none.height() < open.height() - 20.0, "no match shrinks the menu: {open:?} → {none:?}");
+ assert_eq!(search_top(&nodes, none), top, "the search field stays put while the list is empty");
+
+ let mut nodes = nodes;
+ for _ in 0.."zzzzqqqq".len() {
+ let key = Event::Key { key: Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
+ nodes = p.settle(vec![key]);
+ }
+ let again = p.menu_rect(&before);
+ assert!((again.height() - open.height()).abs() < 1.0, "clearing the search grows the menu back: {open:?} → {again:?}");
+ assert_eq!(search_top(&nodes, again), top, "the search field stays put when the list grows back");
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 1eb714e9..4c1ed337 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -78,6 +78,8 @@ mod export_preview_tests;
#[cfg(test)]
mod field_tests;
#[cfg(test)]
+mod font_menu_regrow_tests;
+#[cfg(test)]
mod frame_label_tests;
#[cfg(test)]
mod gap_handle_tests;
diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs
index fb5d7ad8..86fae14e 100644
--- a/crates/ui-egui/src/panels/typography/fields.rs
+++ b/crates/ui-egui/src/panels/typography/fields.rs
@@ -22,21 +22,17 @@ pub(super) fn font_rows(
row(ui, 32.0, |ui| {
let w = ui.available_width();
let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing);
- menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
- if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) {
- return;
- }
- let pick = font_picker(ui, family.as_deref(), resp.clicked());
+ let before_search = |ui: &mut Ui| !editing && apply_variable_item(ui, ids, "fontFamily", &resp);
+ if let Some(pick) = font_picker(ui, &resp, family.as_deref(), w.max(240.0), before_search) {
if let Some(f) = pick.picked {
remember_family(ui.ctx(), &f);
apply_style(app, "fontFamily", json!(f));
- ui.close();
} else if let Some(h) = pick.hovered.or(pick.highlighted) {
// The canvas shows the hovered family (or the one the arrow keys are on);
// closing without picking ends the preview.
app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h})));
}
- });
+ }
});
// Style + size.
diff --git a/crates/ui-egui/src/panels/typography/glyphs.rs b/crates/ui-egui/src/panels/typography/glyphs.rs
index 13e0f6ef..3b71b7c4 100644
--- a/crates/ui-egui/src/panels/typography/glyphs.rs
+++ b/crates/ui-egui/src/panels/typography/glyphs.rs
@@ -191,12 +191,33 @@ mod tests {
assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None);
}
- /// One frame of the font picker with `events`.
+ /// One frame of the font picker under a field, with `events`. `opened` clicks the field first
+ /// (a press frame, then the release frame this returns).
fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec) -> FontPick {
- let mut out = FontPick::default();
- let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened));
- shown.textures_delta.clear();
- out
+ let field = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(200.0, 24.0));
+ let frame = |events: Vec| {
+ let mut out = FontPick::default();
+ let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
+ let resp = ui.interact(field, egui::Id::unique("font_field"), egui::Sense::click());
+ out = font_picker(ui, &resp, Some(current), 240.0, |_| false).unwrap_or_default();
+ });
+ shown.textures_delta.clear();
+ out
+ };
+ if opened {
+ let button = |pressed| egui::Event::PointerButton {
+ pos: field.center(),
+ button: egui::PointerButton::Primary,
+ pressed,
+ modifiers: egui::Modifiers::NONE,
+ };
+ // The field is laid out once before the pointer can hit it.
+ frame(vec![]);
+ frame(vec![egui::Event::PointerMoved(field.center())]);
+ frame(vec![button(true)]);
+ return frame(vec![button(false)]);
+ }
+ frame(events)
}
fn key(key: egui::Key) -> egui::Event {
diff --git a/crates/ui-egui/src/panels/typography/picker.rs b/crates/ui-egui/src/panels/typography/picker.rs
index 824fc43e..c7d8bea2 100644
--- a/crates/ui-egui/src/panels/typography/picker.rs
+++ b/crates/ui-egui/src/panels/typography/picker.rs
@@ -41,149 +41,199 @@ pub(crate) struct FontPick {
pub hovered: Option,
}
-/// The searchable family list: click a family, or step through it with Up and Down and press Enter.
-/// Hovering a family previews it on the canvas (closing without picking restores the original).
-pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick {
+/// The most the font menu is tall (its search field and the "Apply variable" row above the list
+/// included); the list scrolls past that.
+const MENU_HEIGHT: f32 = LIST_HEIGHT + 40.0;
+
+/// What the menu's header worked out for its list.
+#[derive(Default)]
+struct Header {
+ rows: Vec,
+ /// The row Up and Down are on.
+ hi: Option,
+ /// The rows Up and Down moved this frame (the list scrolls to the row they are on).
+ step: i64,
+ /// Enter picked this family.
+ entered: Option,
+}
+
+/// The searchable family menu under `anchor`: click a family, or step through it with Up and Down
+/// and press Enter. Hovering a family previews it on the canvas (closing without picking restores
+/// the original). `before_search` draws the rows above the search field and returns true when one
+/// of them handled the menu (it closed it). None while the menu is closed.
+///
+/// The search field and those rows stay in place; only the list scrolls. Headers ("Recent",
+/// "All fonts") are as tall as family rows, so the list is uniform and virtualised.
+pub(crate) fn font_picker(
+ ui: &Ui,
+ anchor: &egui::Response,
+ current: Option<&str>,
+ width: f32,
+ before_search: impl FnOnce(&mut Ui) -> bool,
+) -> Option {
let t = Tokens::get(ui.ctx());
+ let just_opened = anchor.clicked();
+ let hi_id = Id::unique("font_highlight");
let q_id = Id::unique("font_query");
- // The row Up and Down are on, and the search it is a row of.
- let hi_id = Id::unique("font_highlight");
- let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
- if just_opened {
- q.clear();
- ui.data_mut(|d| d.remove::<(String, usize)>(hi_id));
- }
- // Taken before the search field sees them (it would move its cursor).
- let step = ui.input_mut(|i| {
- i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64
- - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64
- });
- let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5)));
- crate::widgets::select_all_on_focus(ui, &search, &q);
- if just_opened {
- search.request_focus();
- }
- ui.data_mut(|d| d.insert_temp(q_id, q.clone()));
- let ql = q.trim().to_lowercase();
- let all = figcraft_text::family_names();
- let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
- let mut rows: Vec = Vec::new();
- if ql.is_empty() {
- let recent: Vec = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect();
- if !recent.is_empty() {
- rows.push(PickRow::Header(tr("Recent")));
- rows.extend(recent.into_iter().map(PickRow::Family));
- rows.push(PickRow::Header(tr("All fonts")));
- }
- }
- if matches.is_empty() {
- widgets::label(ui, tr("No fonts match"), t.text_tertiary);
- return FontPick::default();
- }
- rows.extend(matches.into_iter().map(PickRow::Family));
- let family = |i: usize| match rows.get(i) {
- Some(PickRow::Family(f)) => Some(f.clone()),
- _ => None,
- };
- // A new search starts again; the first step starts from the current family.
- let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i);
- let stride = ROW + ui.spacing().item_spacing.y;
- let mut offset = None;
- if step != 0 {
- let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) };
- let mut from =
- hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f)))));
- for _ in 0..step.unsigned_abs() {
- let mut at = match from {
- Some(i) => next(i),
- None if step > 0 => Some(0),
- None => rows.len().checked_sub(1),
- };
- // Past the headers; at either end it stays where it was.
- while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) {
- at = next(i);
- }
- if let Some(i) = at.filter(|i| family(*i).is_some()) {
- from = Some(i);
- hi = Some(i);
- }
- }
- if let Some(i) = hi {
- // Scrolled just enough to show it (the list is shorter than its tallest where the
- // window has less room).
- let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT));
- let top = i as f32 * stride;
- offset = Some(seen.max(top + stride - height).min(top));
- }
- }
- match hi {
- Some(i) => ui.data_mut(|d| {
- d.insert_temp(hi_id, (q.clone(), i));
- }),
- None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)),
- }
- if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
- let first = rows.iter().find_map(|r| match r {
- PickRow::Family(f) => Some(f.clone()),
- PickRow::Header(_) => None,
- });
- return FontPick { picked: hi.and_then(family).or(first), highlighted: None, hovered: None };
- }
- let mut budget = 4;
- let mut picked = None;
- let mut hovered_family = None;
- let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]);
- if let Some(y) = offset {
- area = area.vertical_scroll_offset(y);
- }
- let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| {
- for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) {
- let w = ui.available_width();
- let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() });
- match r {
- PickRow::Header(h) => {
- ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary);
+ craft_ui::combo::Menu::new(ui, anchor)
+ .height(MENU_HEIGHT)
+ .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
+ .style(crate::right::menu_style)
+ .show_rows_with_header(
+ |ui| {
+ // The menu is at least `width` wide, even under a narrower field.
+ ui.set_min_width(width);
+ if before_search(ui) {
+ return Header::default();
+ }
+ let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
+ if just_opened {
+ q.clear();
+ ui.data_mut(|d| d.remove::<(String, usize)>(hi_id));
+ }
+ // Taken before the search field sees them (it would move its cursor).
+ let step = ui.input_mut(|i| {
+ i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64
+ - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64
+ });
+ let search =
+ ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5)));
+ crate::widgets::select_all_on_focus(ui, &search, &q);
+ if just_opened {
+ search.request_focus();
}
- PickRow::Family(f) => {
- let hovered = resp.hovered() || hi == Some(i);
- if hovered {
- ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
+ ui.data_mut(|d| d.insert_temp(q_id, q.clone()));
+ let ql = q.trim().to_lowercase();
+ let all = figcraft_text::family_names();
+ let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
+ let mut rows: Vec = Vec::new();
+ if ql.is_empty() {
+ let recent: Vec =
+ recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect();
+ if !recent.is_empty() {
+ rows.push(PickRow::Header(tr("Recent")));
+ rows.extend(recent.into_iter().map(PickRow::Family));
+ rows.push(PickRow::Header(tr("All fonts")));
}
- let col = if hovered { t.accent_on } else { t.text };
- if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) {
- icons::paint(
- ui.painter(),
- Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)),
- Icon::Check,
- col,
- );
+ }
+ if matches.is_empty() {
+ widgets::label(ui, tr("No fonts match"), t.text_tertiary);
+ return Header::default();
+ }
+ rows.extend(matches.into_iter().map(PickRow::Family));
+ let family = |i: usize| match rows.get(i) {
+ Some(PickRow::Family(f)) => Some(f.clone()),
+ _ => None,
+ };
+ // A new search starts again; the first step starts from the current family.
+ let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i);
+ if step != 0 {
+ let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) };
+ let mut from = hi
+ .or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f)))));
+ for _ in 0..step.unsigned_abs() {
+ let mut at = match from {
+ Some(i) => next(i),
+ None if step > 0 => Some(0),
+ None => rows.len().checked_sub(1),
+ };
+ // Past the headers; at either end it stays where it was.
+ while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) {
+ at = next(i);
+ }
+ if let Some(i) = at.filter(|i| family(*i).is_some()) {
+ from = Some(i);
+ hi = Some(i);
+ }
}
- let text_min = pos2(rect.min.x + 22.0, rect.min.y);
- match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) {
- Some(tex) => {
- crate::font_preview::paint(
- ui.painter(),
- &tex,
- pos2(text_min.x, rect.center().y),
- rect.max.x - text_min.x - 6.0,
- ROW,
- col,
- );
+ }
+ match hi {
+ Some(i) => ui.data_mut(|d| {
+ d.insert_temp(hi_id, (q.clone(), i));
+ }),
+ None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)),
+ }
+ let mut entered = None;
+ if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+ let first = rows.iter().find_map(|r| match r {
+ PickRow::Family(f) => Some(f.clone()),
+ PickRow::Header(_) => None,
+ });
+ entered = hi.and_then(family).or(first);
+ ui.close();
+ }
+ Header { rows, hi, step, entered }
+ },
+ ROW,
+ |h| h.rows.len(),
+ |ui, range, h| {
+ if let Some(f) = h.entered {
+ return FontPick { picked: Some(f), highlighted: None, hovered: None };
+ }
+ if let (Some(i), true) = (h.hi, h.step != 0) {
+ // Scrolled just enough to show the row Up or Down moved to; it may be out of
+ // the range laid out, so its rect comes from the list's top.
+ let stride = ROW + ui.spacing().item_spacing.y;
+ let top = ui.max_rect().top() - range.start as f32 * stride + i as f32 * stride;
+ ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), ROW)), None);
+ }
+ let mut budget = 4;
+ let mut picked = None;
+ let mut hovered_family = None;
+ let hi = h.hi;
+ for (i, r) in h.rows.iter().enumerate().skip(range.start).take(range.len()) {
+ let w = ui.available_width();
+ let (rect, resp) =
+ ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() });
+ match r {
+ PickRow::Header(label) => {
+ ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *label, semibold(10.5), t.text_secondary);
}
- None => {
- ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col);
+ PickRow::Family(f) => {
+ let hovered = resp.hovered() || hi == Some(i);
+ if hovered {
+ ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
+ }
+ let col = if hovered { t.accent_on } else { t.text };
+ if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) {
+ icons::paint(
+ ui.painter(),
+ Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)),
+ Icon::Check,
+ col,
+ );
+ }
+ let text_min = pos2(rect.min.x + 22.0, rect.min.y);
+ match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) {
+ Some(tex) => {
+ crate::font_preview::paint(
+ ui.painter(),
+ &tex,
+ pos2(text_min.x, rect.center().y),
+ rect.max.x - text_min.x - 6.0,
+ ROW,
+ col,
+ );
+ }
+ None => {
+ ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col);
+ }
+ }
+ if resp.clicked() {
+ picked = Some(f.clone());
+ ui.close();
+ }
+ if resp.hovered() {
+ hovered_family = Some(f.clone());
+ }
}
- }
- if resp.clicked() {
- picked = Some(f.clone());
- }
- if resp.hovered() {
- hovered_family = Some(f.clone());
}
}
- }
- }
- });
- ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.clamped_offset().y, shown.inner_rect.height())));
- FontPick { highlighted: hi.and_then(family), picked, hovered: hovered_family }
+ let highlighted = hi.and_then(|i| match h.rows.get(i) {
+ Some(PickRow::Family(f)) => Some(f.clone()),
+ _ => None,
+ });
+ FontPick { highlighted, picked, hovered: hovered_family }
+ },
+ )
}
diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs
index f97dfeb9..04e29397 100644
--- a/crates/ui-egui/src/widgets/style_picker.rs
+++ b/crates/ui-egui/src/widgets/style_picker.rs
@@ -857,25 +857,26 @@ fn text_property_fields(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSt
/// The font menu: Apply variable, then the installed fonts. Picking a font keeps its style when the
/// family has one, else falls back to Regular or the first style.
-fn font_family_menu(id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) {
- crate::right::menu_popup(family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
- if crate::right::menu_item(ui, tr("Apply variable"), "") {
+fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) {
+ let apply_variable = |ui: &mut Ui| {
+ let picked = crate::right::menu_item(ui, tr("Apply variable"), "");
+ if picked {
crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom());
ui.close();
- return;
+ } else {
+ ui.separator();
}
- ui.separator();
- if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
- set_text(def, id, out, |d| {
- d.font_family = f.clone();
- let styles = figcraft_text::family_styles(&f);
- if !styles.is_empty() && !styles.contains(&d.font_style) {
- d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
- }
- });
- ui.close();
- }
- });
+ picked
+ };
+ if let Some(f) = crate::typography::font_picker(ui, family_resp, Some(&def.font_family), 260.0, apply_variable).and_then(|p| p.picked) {
+ set_text(def, id, out, |d| {
+ d.font_family = f.clone();
+ let styles = figcraft_text::family_styles(&f);
+ if !styles.is_empty() && !styles.contains(&d.font_style) {
+ d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
+ }
+ });
+ }
}
/// The font style menu: Apply variable, then the styles of the current font, the current one checked.
@@ -909,7 +910,7 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle,
// The dropdowns' menus open outside the dark scope (they're regular popups).
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| text_property_fields(ui, doc, style, def, out));
if let Some(family_resp) = family_resp {
- font_family_menu(id, def, &family_resp, out);
+ font_family_menu(ui, id, def, &family_resp, out);
}
if let Some(sr) = style_resp {
font_style_menu(id, def, &sr, out);
From 2893518c843130ffda10313626c38118b3d16dca Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Tue, 27 Oct 2026 15:32:22 +0000
Subject: [PATCH 07/60] fix(installer): resolve the wizard scope and folder
correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:
- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
per-machine and skipped the existing per-user copy. An interactive upgrade
left two registered products and uninstall left files behind. The page now
leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
so choosing "Everyone" in the wizard installed into the user profile with a
machine-wide registration. The wizard now sets the new-install folder itself
after the choice (Program Files, Program Files (x86) for 32-bit, or the
per-user Programs folder). Silent installs are unchanged.
check-install-policy.ps1 now fails if either regresses.
---
packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++-
packaging/windows/install-policy.wxi | 15 +++++++++++++-
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1
index 451295a2..a4a14bc6 100644
--- a/packaging/windows/check-install-policy.ps1
+++ b/packaging/windows/check-install-policy.ps1
@@ -140,6 +140,28 @@ try {
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) {
throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.'
}
+ # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice.
+ # The wizard therefore sets the new-install folder itself, and only in the wizard.
+ $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' })
+ if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' }
+ foreach ($row in $defaults) {
+ if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." }
+ }
+ if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) {
+ throw 'Silent installs must not use the wizard-only default folder actions.'
+ }
+ foreach ($scope in @('1', '')) {
+ Set-SessionProperty $session 'ALLUSERS' $scope
+ Set-SessionProperty $session 'INSTALLFOLDER' ''
+ Set-SessionProperty $session 'CRAFT_MACHINE_DIR' ''
+ Set-SessionProperty $session 'CRAFT_USER_DIR' ''
+ foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) {
+ if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
+ }
+ $folder = Get-SessionProperty $session 'INSTALLFOLDER'
+ $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' }
+ if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." }
+ }
$execute = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) {
@@ -177,7 +199,7 @@ try {
$allUsers = Get-SessionProperty $session 'ALLUSERS'
$perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER'
if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or
- ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) {
+ ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) {
throw 'Scope selector does not reset the requested installation context.'
}
}
diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi
index 64bf3935..3a212ef3 100644
--- a/packaging/windows/install-policy.wxi
+++ b/packaging/windows/install-policy.wxi
@@ -1,6 +1,9 @@
+
@@ -25,6 +28,16 @@
+
+
+
+
+
+
+
+
+
@@ -42,7 +55,7 @@
-
+
From adb1b4303322b245817fa3bf9e8a172dea311075 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 27 Oct 2026 12:41:39 +0100
Subject: [PATCH 08/60] Speed up raster export: faster blur and spread passes
Shadows and layer blurs dominated PNG/JPG/WebP export time (gdb stack
sampling of the example files: about 45% in fx::box_blur_h, 13% in
fx::morph). Both now produce the same bytes, faster:
- fx::blur runs its three box passes per row while the row is in cache,
works on whole pixels instead of bounds-checked bytes, skips fully
transparent rows, transposes in cache-friendly column blocks, and
splits rows and transposes across rayon on desktop for pixmaps of
64K pixels and more, only when not already on a rayon worker (a band
of render_view stays on its thread: no nested parallelism).
- fx::morph (shadow spread on layers that aren't one outline) uses the
van Herk / Gil-Werman running max/min: constant work per pixel instead
of 2r + 1.
Integer arithmetic and pass order are unchanged; new tests compare both
against the previous implementations byte for byte, including sizes on
the parallel path.
An ignored timing test exports every top-level frame of examples/ as
PNG, JPG and WebP at 2x through export_file:
cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture
Ryzen, 16 threads, Linux; median of 7 (min):
PNG 2195 ms (2182) -> 992 ms (989) -55%
JPG 2880 ms (2560) -> 1550 ms (1535) -46%
WebP 1964 ms (1901) -> 1022 ms (1018) -48%
total 7035 ms (6677) -> 3567 ms (3555) -49%
All 80 exported files (PNG, JPG, WebP, PDF, SVG) are byte-identical.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/export_bench_tests.rs | 96 ++++++
crates/engine/src/lib.rs | 2 +
crates/render/src/fx.rs | 410 +++++++++++++++++++-----
3 files changed, 428 insertions(+), 80 deletions(-)
create mode 100644 crates/engine/src/export_bench_tests.rs
diff --git a/crates/engine/src/export_bench_tests.rs b/crates/engine/src/export_bench_tests.rs
new file mode 100644
index 00000000..56c6c40f
--- /dev/null
+++ b/crates/engine/src/export_bench_tests.rs
@@ -0,0 +1,96 @@
+//! Export timings over the example files: every top-level frame of each `examples/*.figcraft`
+//! exported as PNG, JPG and WebP at 2x through [`export_file`], as Export and `figcraft-cli export`
+//! do. Run in release:
+//! `cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture`.
+//!
+//! `FIGCRAFT_EXPORT_BENCH_RUNS` sets the timed runs (default 7; one warm-up run first),
+//! `FIGCRAFT_EXPORT_BENCH_FORMATS` the formats (default `png,jpg,webp`) and
+//! `FIGCRAFT_EXPORT_BENCH_OUT` a folder to write the files of the last run to (to compare outputs).
+
+use std::time::Instant;
+
+use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId};
+
+use crate::cmd::file::{export_file, load_path};
+
+/// The example documents and their top-level frames.
+fn examples() -> Vec<(String, Document, Vec)> {
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
+ let mut paths: Vec<_> =
+ std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "figcraft")).collect();
+ paths.sort();
+ paths
+ .into_iter()
+ .map(|p| {
+ let (loaded, _) = load_path(&p.to_string_lossy(), None).unwrap();
+ let doc = loaded.doc;
+ let frames: Vec = doc.pages.iter().flat_map(|pg| doc.top_frames(*pg)).collect();
+ (p.file_stem().unwrap().to_string_lossy().into_owned(), doc, frames)
+ })
+ .collect()
+}
+
+fn format(name: &str) -> ExportFormat {
+ match name {
+ "png" => ExportFormat::Png,
+ "jpg" => ExportFormat::Jpg,
+ "webp" => ExportFormat::Webp,
+ "svg" => ExportFormat::Svg,
+ "pdf" => ExportFormat::Pdf,
+ other => panic!("unknown format {other}"),
+ }
+}
+
+/// Export every frame in `format` at 2x: (milliseconds, files).
+fn run(docs: &[(String, Document, Vec)], format: ExportFormat) -> (f64, Vec<(String, Vec)>) {
+ let setting = ExportSetting { format, ..Default::default() };
+ let mut files = Vec::new();
+ let t = Instant::now();
+ for (name, doc, frames) in docs {
+ for f in frames {
+ let (bytes, _) = export_file(doc, &[*f], &setting, 2.0).unwrap();
+ files.push((format!("{name}-{}.{}", f.0, format.ext()), bytes));
+ }
+ }
+ (t.elapsed().as_secs_f64() * 1000.0, files)
+}
+
+#[test]
+#[ignore = "timing: run in release with --ignored --nocapture"]
+fn export_timings() {
+ let runs: usize = std::env::var("FIGCRAFT_EXPORT_BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1);
+ let formats = std::env::var("FIGCRAFT_EXPORT_BENCH_FORMATS").unwrap_or_else(|_| "png,jpg,webp".into());
+ let out = std::env::var("FIGCRAFT_EXPORT_BENCH_OUT").ok();
+ let docs = examples();
+ let frames: usize = docs.iter().map(|d| d.2.len()).sum();
+ println!("{} documents, {frames} frames, {runs} runs", docs.len());
+ let mut total = vec![0.0; runs];
+ for name in formats.split(',').map(str::trim).filter(|f| !f.is_empty()) {
+ let fmt = format(name);
+ let _ = run(&docs, fmt);
+ let mut times = Vec::with_capacity(runs);
+ let mut last = Vec::new();
+ for t in &mut total {
+ let (ms, files) = run(&docs, fmt);
+ *t += ms;
+ times.push(ms);
+ last = files;
+ }
+ if let Some(dir) = &out {
+ std::fs::create_dir_all(dir).unwrap();
+ for (file, bytes) in &last {
+ std::fs::write(std::path::Path::new(dir).join(file), bytes).unwrap();
+ }
+ }
+ report(name, &mut times);
+ }
+ report("total", &mut total);
+}
+
+/// Print the median and minimum of `times` (and every run).
+fn report(name: &str, times: &mut [f64]) {
+ let runs: Vec = times.iter().map(|t| format!("{t:.1}")).collect();
+ times.sort_by(f64::total_cmp);
+ let median = times[times.len() / 2];
+ println!("{name:>6}: median {median:8.1} ms min {:8.1} ms runs [{}]", times[0], runs.join(", "));
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 60f240cb..ea135ca1 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -256,6 +256,8 @@ mod effects_tests;
#[cfg(test)]
mod export_behaviour_tests;
#[cfg(test)]
+mod export_bench_tests;
+#[cfg(test)]
mod export_depth_tests;
#[cfg(test)]
mod extended_chain_tests;
diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs
index 5d65667c..fd5f3764 100644
--- a/crates/render/src/fx.rs
+++ b/crates/render/src/fx.rs
@@ -21,53 +21,107 @@ fn box_sizes(sigma: f64) -> [usize; 3] {
[r(0), r(1), r(2)]
}
-fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
+/// Pixmaps from this many pixels blur and transpose in parallel (desktop).
+#[cfg(not(target_arch = "wasm32"))]
+const PARALLEL_PIXELS: usize = 1 << 16;
+
+/// Whether `pixels` go across rayon: large pixmaps, and only from outside its pool. A blur on a
+/// pool thread (a band of `render_view`) is already one of several running in parallel.
+#[cfg(not(target_arch = "wasm32"))]
+fn parallel(pixels: usize) -> bool {
+ pixels >= PARALLEL_PIXELS && rayon::current_thread_index().is_none()
+}
+
+/// Rows (or transposed columns) per parallel job.
+const ROWS_PER_JOB: usize = 16;
+
+/// Run `job` on matching chunks of ROWS_PER_JOB rows of `src` and `dst` (`w` pixels each), in
+/// parallel for large pixmaps on desktop (see [`parallel`]).
+fn row_jobs(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, job: impl Fn(&[[u8; 4]], &mut [[u8; 4]]) + Sync + Send) {
+ let n = w.saturating_mul(ROWS_PER_JOB).max(1);
+ #[cfg(not(target_arch = "wasm32"))]
+ if parallel(dst.len()) {
+ use rayon::prelude::*;
+ src.par_chunks(n).zip(dst.par_chunks_mut(n)).for_each(|(s, d)| job(s, d));
+ return;
+ }
+ src.chunks(n).zip(dst.chunks_mut(n)).for_each(|(s, d)| job(s, d));
+}
+
+/// One row box-blurred by radius `r` (transparent past its ends) into `dst`, the same length.
+fn box_blur_row(src: &[[u8; 4]], dst: &mut [[u8; 4]], r: usize) {
if r == 0 {
dst.copy_from_slice(src);
return;
}
let div = (2 * r + 1) as u32;
- for y in 0..h {
- let row = y * w * 4;
- let mut acc = [0u32; 4];
- // Edge: transparent outside.
- for x in 0..=r.min(w.saturating_sub(1)) {
- for c in 0..4 {
- acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0));
+ let mut acc = [0u32; 4];
+ for p in src.iter().take(r + 1) {
+ for (a, c) in acc.iter_mut().zip(p) {
+ *a += u32::from(*c);
+ }
+ }
+ for (x, d) in dst.iter_mut().enumerate() {
+ *d = acc.map(|a| (a / div) as u8);
+ if let Some(p) = src.get(x + r + 1) {
+ for (a, c) in acc.iter_mut().zip(p) {
+ *a += u32::from(*c);
}
}
- for x in 0..w {
- for c in 0..4 {
- if let Some(d) = dst.get_mut(row + x * 4 + c) {
- *d = (acc[c] / div) as u8;
- }
- }
- let add = x + r + 1;
- if add < w {
- for c in 0..4 {
- acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0));
- }
- }
- if x >= r {
- let sub = x - r;
- for c in 0..4 {
- acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0)));
- }
+ if let Some(p) = x.checked_sub(r).and_then(|i| src.get(i)) {
+ for (a, c) in acc.iter_mut().zip(p) {
+ *a = a.saturating_sub(u32::from(*c));
}
}
}
}
-fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
- for y in 0..h {
- for x in 0..w {
- let s = (y * w + x) * 4;
- let d = (x * h + y) * 4;
- if let (Some(sp), Some(dp)) = (src.get(s..s + 4), dst.get_mut(d..d + 4)) {
- dp.copy_from_slice(sp);
+/// Box-blur each `w`-pixel row of `src` by each of `radii` in turn into `dst`. A row is blurred
+/// through all passes while it is in cache; a transparent row stays transparent.
+fn box_blur_rows(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, radii: [usize; 3]) {
+ if w == 0 {
+ return;
+ }
+ row_jobs(src, dst, w, |s, d| {
+ let mut t = vec![[0u8; 4]; w];
+ for (s, d) in s.chunks(w).zip(d.chunks_mut(w)) {
+ if s.iter().all(|p| *p == [0; 4]) {
+ d.fill([0; 4]);
+ continue;
+ }
+ let t = t.get_mut(..s.len()).unwrap_or_default();
+ box_blur_row(s, d, radii[0]);
+ box_blur_row(d, t, radii[1]);
+ box_blur_row(t, d, radii[2]);
+ }
+ });
+}
+
+/// `src` (`w` × `h` pixels) transposed into `dst` (`h` × `w`), a block of columns at a time.
+fn transpose(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, h: usize) {
+ if w == 0 || h == 0 {
+ return;
+ }
+ // Job `i` writes rows `x0..` of `dst` (ROWS_PER_JOB of them): columns `x0..` of `src`.
+ let job = |(i, d): (usize, &mut [[u8; 4]])| {
+ let (x0, cols) = (i * ROWS_PER_JOB, d.len() / h);
+ for (y, row) in src.chunks(w).enumerate() {
+ let Some(part) = row.get(x0..x0 + cols) else { continue };
+ for (k, p) in part.iter().enumerate() {
+ if let Some(o) = d.get_mut(k * h + y) {
+ *o = *p;
+ }
}
}
+ };
+ let n = h * ROWS_PER_JOB;
+ #[cfg(not(target_arch = "wasm32"))]
+ if parallel(dst.len()) {
+ use rayon::prelude::*;
+ dst.par_chunks_mut(n).enumerate().for_each(job);
+ return;
}
+ dst.chunks_mut(n).enumerate().for_each(job);
}
/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384
@@ -76,7 +130,8 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
/// 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).
+/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels): three box
+/// passes along the rows, then three down the columns (as rows of the transposed pixmap).
pub fn blur(p: &mut Pixmap, sigma: f64) {
if !(sigma.is_finite() && sigma > 0.3) {
return;
@@ -84,20 +139,49 @@ pub fn blur(p: &mut Pixmap, sigma: f64) {
let sigma = sigma.min(MAX_SIGMA);
let (w, h) = (p.width() as usize, p.height() as usize);
let radii = box_sizes(sigma);
- let mut a = p.data().to_vec();
- let mut b = vec![0u8; a.len()];
- for r in radii {
- box_blur_h(&a, &mut b, w, h, r);
- std::mem::swap(&mut a, &mut b);
- }
+ let n = p.data().len() / 4;
+ let mut a = vec![[0u8; 4]; n];
+ let mut b = vec![[0u8; 4]; n];
+ box_blur_rows(p.data().as_chunks::<4>().0, &mut a, w, radii);
transpose(&a, &mut b, w, h);
- std::mem::swap(&mut a, &mut b);
- for r in radii {
- box_blur_h(&a, &mut b, h, w, r);
- std::mem::swap(&mut a, &mut b);
+ box_blur_rows(&b, &mut a, h, radii);
+ transpose(&a, p.data_mut().as_chunks_mut::<4>().0, h, w);
+}
+
+/// For each `x`, the largest (`grow`) or smallest value of `src` over `x - r ..= x + r` (clipped
+/// to its ends) into `out`, in constant time per value whatever `r` (van Herk / Gil-Werman: the
+/// window spans at most two blocks of `2r + 1`, from the running extremes within each block).
+/// `scratch` holds the working lines.
+fn window_extremes(src: &[u8], out: &mut [u8], r: usize, grow: bool, scratch: &mut [Vec; 3]) {
+ let op = |a: u8, b: u8| if grow { a.max(b) } else { a.min(b) };
+ // Past the ends: the value that changes nothing (the window is clipped there).
+ let id = if grow { 0 } else { 255 };
+ let [pad, fwd, back] = scratch;
+ pad.clear();
+ pad.resize(r, id);
+ pad.extend_from_slice(src);
+ pad.resize(src.len() + 2 * r, id);
+ let k = 2 * r + 1;
+ fwd.clear();
+ for block in pad.chunks(k) {
+ let mut m = id;
+ fwd.extend(block.iter().map(|v| {
+ m = op(m, *v);
+ m
+ }));
+ }
+ back.clear();
+ back.resize(pad.len(), id);
+ for (block, b) in pad.chunks(k).zip(back.chunks_mut(k)) {
+ let mut m = id;
+ for (v, o) in block.iter().zip(b.iter_mut()).rev() {
+ m = op(m, *v);
+ *o = m;
+ }
+ }
+ for (x, o) in out.iter_mut().enumerate() {
+ *o = op(back.get(x).copied().unwrap_or(id), fwd.get(x + 2 * r).copied().unwrap_or(id));
}
- transpose(&a, &mut b, h, w);
- p.data_mut().copy_from_slice(&b);
}
/// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring
@@ -109,47 +193,38 @@ pub fn morph(p: &mut Pixmap, r: i32) {
let grow = r > 0;
let r = r.unsigned_abs().min(400) as usize;
let (w, h) = (p.width() as usize, p.height() as usize);
- let alpha: Vec = p.data().as_chunks::<4>().0.iter().map(|c| c.get(3).copied().unwrap_or(0)).collect();
- let pass = |src: &[u8], w: usize, h: usize, horizontal: bool| -> Vec {
- let mut out = vec![0u8; src.len()];
- for y in 0..h {
- for x in 0..w {
- let mut v: u8 = if grow { 0 } else { 255 };
- let (lo, hi) = (x.saturating_sub(r), (x + r).min(w.saturating_sub(1)));
- for k in lo..=hi {
- let idx = if horizontal { y * w + k } else { k * h + y };
- let s = src.get(idx).copied().unwrap_or(0);
- v = if grow { v.max(s) } else { v.min(s) };
- }
- // Shrinking treats outside as empty.
- if !grow && (x < r || x + r >= w) {
- v = 0;
- }
- let o = if horizontal { y * w + x } else { x * h + y };
- if let Some(d) = out.get_mut(o) {
- *d = v;
+ if w == 0 || h == 0 {
+ return;
+ }
+ let alpha: Vec = p.data().as_chunks::<4>().0.iter().map(|c| c[3]).collect();
+ let mut scratch: [Vec; 3] = Default::default();
+ // Shrinking treats outside as empty: the first and last `r` of each line go.
+ let edges = |line: &mut [u8]| {
+ if !grow {
+ let n = line.len();
+ for (i, v) in line.iter_mut().enumerate() {
+ if i < r || i + r >= n {
+ *v = 0;
}
}
}
- out
};
- let a1 = pass(&alpha, w, h, true);
- // Vertical pass: treat columns as rows by swapping roles.
+ let mut a1 = vec![0u8; alpha.len()];
+ for (s, o) in alpha.chunks(w).zip(a1.chunks_mut(w)) {
+ window_extremes(s, o, r, grow, &mut scratch);
+ edges(o);
+ }
+ // Down the columns.
+ let (mut col, mut out) = (vec![0u8; h], vec![0u8; h]);
let mut a2 = vec![0u8; a1.len()];
for x in 0..w {
- for y in 0..h {
- let mut v: u8 = if grow { 0 } else { 255 };
- let (lo, hi) = (y.saturating_sub(r), (y + r).min(h.saturating_sub(1)));
- for k in lo..=hi {
- let s = a1.get(k * w + x).copied().unwrap_or(0);
- v = if grow { v.max(s) } else { v.min(s) };
- }
- if !grow && (y < r || y + r >= h) {
- v = 0;
- }
- if let Some(d) = a2.get_mut(y * w + x) {
- *d = v;
- }
+ for (c, v) in col.iter_mut().zip(a1.iter().skip(x).step_by(w)) {
+ *c = *v;
+ }
+ window_extremes(&col, &mut out, r, grow, &mut scratch);
+ edges(&mut out);
+ for (d, v) in a2.iter_mut().skip(x).step_by(w).zip(&out) {
+ *d = *v;
}
}
for (px, a) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(a2) {
@@ -236,6 +311,181 @@ pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32)
mod tests {
use super::*;
+ /// The blur as first written (one box pass at a time over the whole pixmap, bounds-checked
+ /// byte by byte): the faster one must give the same bytes.
+ fn reference_blur(p: &mut Pixmap, sigma: f64) {
+ fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
+ if r == 0 {
+ dst.copy_from_slice(src);
+ return;
+ }
+ let div = (2 * r + 1) as u32;
+ for y in 0..h {
+ let row = y * w * 4;
+ let mut acc = [0u32; 4];
+ for x in 0..=r.min(w.saturating_sub(1)) {
+ for c in 0..4 {
+ acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0));
+ }
+ }
+ for x in 0..w {
+ for c in 0..4 {
+ if let Some(d) = dst.get_mut(row + x * 4 + c) {
+ *d = (acc[c] / div) as u8;
+ }
+ }
+ let add = x + r + 1;
+ if add < w {
+ for c in 0..4 {
+ acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0));
+ }
+ }
+ if x >= r {
+ let sub = x - r;
+ for c in 0..4 {
+ acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0)));
+ }
+ }
+ }
+ }
+ }
+ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
+ for y in 0..h {
+ for x in 0..w {
+ let (s, d) = ((y * w + x) * 4, (x * h + y) * 4);
+ dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
+ }
+ }
+ }
+ if !(sigma.is_finite() && sigma > 0.3) {
+ return;
+ }
+ let (w, h) = (p.width() as usize, p.height() as usize);
+ let radii = box_sizes(sigma.min(MAX_SIGMA));
+ let mut a = p.data().to_vec();
+ let mut b = vec![0u8; a.len()];
+ for r in radii {
+ box_blur_h(&a, &mut b, w, h, r);
+ std::mem::swap(&mut a, &mut b);
+ }
+ transpose(&a, &mut b, w, h);
+ std::mem::swap(&mut a, &mut b);
+ for r in radii {
+ box_blur_h(&a, &mut b, h, w, r);
+ std::mem::swap(&mut a, &mut b);
+ }
+ transpose(&a, &mut b, h, w);
+ p.data_mut().copy_from_slice(&b);
+ }
+
+ /// The spread as first written (every pixel looks at its whole window).
+ fn reference_morph(p: &mut Pixmap, r: i32) {
+ if r == 0 {
+ return;
+ }
+ let grow = r > 0;
+ let r = r.unsigned_abs().min(400) as usize;
+ let (w, h) = (p.width() as usize, p.height() as usize);
+ let alpha: Vec = p.data().chunks(4).map(|c| c[3]).collect();
+ let pick = |v: u8, s: u8| if grow { v.max(s) } else { v.min(s) };
+ let mut a1 = vec![0u8; alpha.len()];
+ for y in 0..h {
+ for x in 0..w {
+ let mut v: u8 = if grow { 0 } else { 255 };
+ for k in x.saturating_sub(r)..=(x + r).min(w - 1) {
+ v = pick(v, alpha[y * w + k]);
+ }
+ a1[y * w + x] = if !grow && (x < r || x + r >= w) { 0 } else { v };
+ }
+ }
+ let mut a2 = vec![0u8; a1.len()];
+ for x in 0..w {
+ for y in 0..h {
+ let mut v: u8 = if grow { 0 } else { 255 };
+ for k in y.saturating_sub(r)..=(y + r).min(h - 1) {
+ v = pick(v, a1[k * w + x]);
+ }
+ a2[y * w + x] = if !grow && (y < r || y + r >= h) { 0 } else { v };
+ }
+ }
+ for (px, a) in p.data_mut().chunks_mut(4).zip(a2) {
+ px.copy_from_slice(&[a, a, a, a]);
+ }
+ }
+
+ /// A `w` × `h` pixmap of premultiplied noise in a few rectangles, transparent elsewhere (whole
+ /// transparent rows and columns, as layers have around their content).
+ fn noise(w: u32, h: u32, seed: u32) -> Pixmap {
+ let mut p = Pixmap::new(w, h).unwrap();
+ let mut s = seed.wrapping_mul(2_654_435_761).max(1);
+ let mut next = || {
+ s ^= s << 13;
+ s ^= s >> 17;
+ s ^= s << 5;
+ s
+ };
+ let (wu, hu) = (w as usize, h as usize);
+ for (x0, y0, x1, y1) in [(wu / 8, hu / 6, wu / 2, hu / 2), (wu / 3, hu / 2, wu.saturating_sub(3), hu * 5 / 6), (0, 0, 2, hu)] {
+ for y in y0..y1.min(hu) {
+ for x in x0..x1.min(wu) {
+ let a = (next() % 256) as u8;
+ let c = |v: u32| ((v % 256) * u32::from(a) / 255) as u8;
+ let i = (y * wu + x) * 4;
+ p.data_mut()[i..i + 4].copy_from_slice(&[c(next()), c(next()), c(next()), a]);
+ }
+ }
+ }
+ p
+ }
+
+ #[test]
+ fn blur_matches_the_reference_bytes() {
+ // Small (one thread), wide, tall and large enough to blur in parallel.
+ for (i, (w, h)) in [(1, 1), (7, 3), (40, 31), (301, 17), (13, 290), (300, 230)].into_iter().enumerate() {
+ for sigma in [0.2, 0.5, 1.0, 2.5, 7.3, 24.0, 160.0, 5000.0] {
+ let mut fast = noise(w, h, i as u32 + 1);
+ let mut slow = fast.clone();
+ blur(&mut fast, sigma);
+ reference_blur(&mut slow, sigma);
+ assert!(fast.data() == slow.data(), "{w}×{h} sigma {sigma}");
+ }
+ }
+ }
+
+ /// On a rayon pool thread (a band of a parallel render) the blur stays on that thread and
+ /// gives the same bytes.
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn blur_on_a_pool_thread_matches_the_reference_bytes() {
+ let (fast, slow) = rayon::join(
+ || {
+ assert!(rayon::current_thread_index().is_some());
+ let mut p = noise(300, 230, 3);
+ blur(&mut p, 9.5);
+ p
+ },
+ || {
+ let mut p = noise(300, 230, 3);
+ reference_blur(&mut p, 9.5);
+ p
+ },
+ );
+ assert!(fast.data() == slow.data());
+ }
+
+ #[test]
+ fn morph_matches_the_reference_bytes() {
+ for (i, (w, h)) in [(1, 1), (5, 9), (40, 31), (301, 17), (13, 290), (300, 230)].into_iter().enumerate() {
+ for r in [-500, -40, -6, -2, -1, 1, 2, 5, 33, 400, 1000] {
+ let mut fast = noise(w, h, i as u32 + 7);
+ let mut slow = fast.clone();
+ morph(&mut fast, r);
+ reference_morph(&mut slow, r);
+ assert!(fast.data() == slow.data(), "{w}×{h} r {r}");
+ }
+ }
+ }
+
#[test]
fn blur_spreads_and_conserves() {
let mut p = Pixmap::new(64, 64).unwrap();
From 738b21883788de8183664c6f25d5616d4bfce812 Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Tue, 27 Oct 2026 19:08:54 +0000
Subject: [PATCH 09/60] fix(installer): check the exact architecture-specific
machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
---
packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1
index a4a14bc6..cc9416e9 100644
--- a/packaging/windows/check-install-policy.ps1
+++ b/packaging/windows/check-install-policy.ps1
@@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) {
function Get-SessionProperty($Session, [string] $Name) {
$Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name))
}
+function Get-PackageTemplate($Database) {
+ # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033".
+ $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0))
+ try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) }
+ finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) }
+}
$installer = $null; $database = $null; $session = $null
try {
$installer = New-Object -ComObject WindowsInstaller.Installer
@@ -150,6 +156,12 @@ try {
if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) {
throw 'Silent installs must not use the wizard-only default folder actions.'
}
+ # The machine default comes from the environment of the package architecture, not from a folder name.
+ # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root.
+ $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') {
+ if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles }
+ } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
+ $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\'
foreach ($scope in @('1', '')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' ''
@@ -159,8 +171,8 @@ try {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
- $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' }
- if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." }
+ $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' }
+ if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." }
}
$execute = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
From 2ed7aee28d9f365232e1e26db105f2546abf351e Mon Sep 17 00:00:00 2001
From: aiongg
Date: Tue, 27 Oct 2026 20:51:59 +0000
Subject: [PATCH 10/60] ci: use storytold/craft-common-actions at the merged
installer-policy commit
---
.github/workflows/windows-arm64.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 00011442..267479eb 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -27,7 +27,7 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
- uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276
+ uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: figcraft
app-dir: FigCraft
From 610f40bd7823cd1ff975e158c6ab1eaa061d4cb9 Mon Sep 17 00:00:00 2001
From: Davit Matchakhelidze
Date: Wed, 28 Oct 2026 04:00:21 +0400
Subject: [PATCH 11/60] i18n(ka): Georgian for 76 strings added since the
language landed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Click = დაწკაპეთ in the voting hint, as in the catalog's other 24 click hints.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
---
crates/ui-egui/locales/ka.toml | 78 +++++++++++++++++++++++++++++++++-
1 file changed, 77 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index bf4d51c4..73adfe4e 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -186,6 +186,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Both directions" = "ორივე მიმართულება"
"Bottom" = "ქვემოთ"
"Bottom left radius" = "ქვედა მარცხენა რადიუსი"
+"Bottom padding" = "ქვედა შიდა დაშორება"
"Bottom right radius" = "ქვედა მარჯვენა რადიუსი"
"Brainstorm" = "გონებრივი იერიში"
"Branch" = "განშტოება"
@@ -219,10 +220,11 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Chevron" = "შევრონი"
"Clear from this list" = "ამ სიიდან ამოღება"
"Clear results" = "შედეგების გასუფთავება"
-"Click objects to vote; click your badge to take a vote back" = "ხმის მისაცემად დააწკაპუნეთ ობიექტებზე; ხმის დასაბრუნებლად დააწკაპუნეთ თქვენს ნიშანზე"
+"Click objects to vote; click your badge to take a vote back" = "ხმის მისაცემად დაწკაპეთ ობიექტებზე; ხმის დასაბრუნებლად დაწკაპეთ თქვენს ნიშანზე"
"Code or invite link" = "კოდი ან მოსაწვევი ბმული"
"Color profile: {name}" = "ფერის პროფილი: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები"
+"Compare with" = "შესადარებელი ვერსია"
"Connector" = "შემაერთებელი"
"Convert to frame" = "ჩარჩოდ გარდაქმნა"
"Convert to section" = "სექციად გარდაქმნა"
@@ -337,6 +339,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Corner smoothing" = "კუთხეების დარბილება"
"Couldn't download the variables" = "ცვლადების ჩამოტვირთვა ვერ მოხერხდა"
"Couldn't reach {server}: showing what this computer remembers." = "{server}-თან დაკავშირება ვერ მოხერხდა: ნაჩვენებია ის, რაც ამ კომპიუტერს ახსოვს."
+"Couldn't read {name}." = "{name}-ის წაკითხვა ვერ მოხერხდა."
"Couldn't save automatically: {error}" = "ავტომატურად შენახვა ვერ მოხერხდა: {error}"
"Couldn't save the file" = "ფაილის შენახვა ვერ მოხერხდა"
"Count" = "რაოდენობა"
@@ -408,6 +411,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Detach variable" = "ცვლადის გამოყოფა"
"Details" = "დეტალები"
"Dev Mode" = "დეველოპერის რეჟიმი"
+"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "დეველოპერის რეჟიმში მხოლოდ ინსპექტირებაა შესაძლებელი: რედაქტირებისთვის გადაერთეთ დიზაინზე (Shift+D)"
"Dev resources" = "დეველოპერის რესურსები"
"Development" = "დეველოპმენტი"
"Device" = "მოწყობილობა"
@@ -660,6 +664,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Included" = "ჩართული"
"Included in layout" = "განლაგებაში ჩართული"
"Individual corners" = "ცალკეული კუთხეები"
+"Individual padding" = "ცალკეული შიდა დაშორებები"
"Individual strokes" = "ცალკეული კონტურები"
"Ink" = "მელანი"
"Inner shadow" = "შიდა ჩრდილი"
@@ -681,6 +686,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Interaction" = "ურთიერთქმედება"
"Interactions" = "ურთიერთქმედებები"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ინტერფეისის დიზაინი და პროტოტიპირება. უფასო, ღია კოდის, დამოუკიდებლად (clean-room) შექმნილი დიზაინის ხელსაწყო, ნულიდან დაწერილი Rust-ზე, ArtCraft Crafting Apps-ის ნაწილი."
+"Interface size" = "ინტერფეისის ზომა"
"Intersect" = "გადაკვეთა"
"Intersects" = "გადაკვეთები"
"Invalid color" = "არასწორი ფერი"
@@ -732,6 +738,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Leaves" = "ფოთლები"
"Left" = "მარცხნივ"
"Left & right" = "მარცხნივ და მარჯვნივ"
+"Left padding" = "მარცხენა შიდა დაშორება"
"Left side" = "მარცხენა მხარე"
"Left side width" = "მარცხენა მხარის სიგანე"
"Letter case" = "ასოთა რეგისტრი"
@@ -913,6 +920,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Not read yet" = "ჯერ არ არის წაკითხული"
"Not shared" = "არ არის გაზიარებული"
"Note {n}" = "შენიშვნა {n}"
+"Nothing changed since this version." = "ამ ვერსიის შემდეგ არაფერი შეცვლილა."
"Nothing changed since this was marked ready for dev." = "მას შემდეგ, რაც მზად დეველოპერისთვის მოინიშნა, არაფერი შეცვლილა."
"Nothing here yet. Set a frame's status to list it." = "აქ ჯერ არაფერია. ჩარჩოს სიაში გამოსაჩენად მიანიჭეთ სტატუსი."
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "ონლაინ ჯერ არაფერია. ფაილის ონლაინ განსათავსებლად გამოიყენეთ „გაზიარება“ და ის აქ გამოჩნდება."
@@ -956,6 +964,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Open link" = "ბმულის გახსნა"
"Open link {url}" = "ბმულის გახსნა {url}"
"Open overlay" = "გადაფარვის გახსნა"
+"Open presentations" = "გახსნილი პრეზენტაციები"
"Open prototype preview" = "პროტოტიპის წინასწარი ნახვის გახსნა"
"Open variables ({count})" = "ცვლადების გახსნა ({count})"
"Opened as a new document" = "გაიხსნა ახალ დოკუმენტად"
@@ -985,6 +994,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"PDF print settings" = "PDF-ის ბეჭდვის პარამეტრები"
"Padding" = "შიდა დაშორება"
"Page" = "გვერდი"
+"Page deleted" = "გვერდი წაიშალა"
"Pages" = "გვერდები"
"Pages added to the document" = "გვერდები დაემატა დოკუმენტს"
"Pause" = "პაუზა"
@@ -1101,6 +1111,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Refraction" = "გარდატეხა"
"Regular expression" = "რეგულარული გამოსახულება"
"Relative to" = "მიმართ"
+"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "სისტემის ეკრანის მასშტაბირებასთან მიმართებით (100% სისტემის საკუთარი ზომაა). დოკუმენტის მასშტაბი ცალკე რეგულირდება."
"Remove" = "წაშლა"
"Remove all" = "ყველას წაშლა"
"Remove annotation" = "ანოტაციის წაშლა"
@@ -1132,6 +1143,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Removed" = "წაშლილია"
"Removed from the server" = "წაიშალა სერვერიდან"
"Removed since it was marked" = "წაიშალა მონიშვნის შემდეგ"
+"Removed since this version" = "წაშლილია ამ ვერსიის შემდეგ"
"Rename" = "სახელის შეცვლა"
"Rename duplicated layers" = "დუბლირებული შრეების სახელის შეცვლა"
"Rename folder" = "საქაღალდის სახელის შეცვლა"
@@ -1142,6 +1154,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Rename on the server…" = "სახელის შეცვლა სერვერზე…"
"Rename page" = "გვერდის სახელის შეცვლა"
"Rename to" = "ახალი სახელი"
+"Reorder selection" = "მონიშნულის თანმიმდევრობის შეცვლა"
"Replace all" = "ყველას ჩანაცვლება"
"Replace mixed export settings" = "შერეული ექსპორტის პარამეტრების ჩანაცვლება"
"Replace with" = "ჩანაცვლება:"
@@ -1180,6 +1193,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Review and merge" = "განხილვა და შერწყმა"
"Ribbon" = "ლენტი"
"Right" = "მარჯვნივ"
+"Right padding" = "მარჯვენა შიდა დაშორება"
"Right side" = "მარჯვენა მხარე"
"Right side width" = "მარჯვენა მხარის სიგანე"
"Rotate 90°" = "90°-ით შემობრუნება"
@@ -1202,6 +1216,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Run {label}" = "გაშვება: {label}"
"Save file" = "ფაილის შენახვა"
"Save files automatically" = "ფაილების ავტომატურად შენახვა"
+"Search pages" = "გვერდების ძიება"
"Search templates" = "შაბლონების ძიება"
"See all templates" = "ყველა შაბლონის ნახვა"
"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება"
@@ -1350,6 +1365,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Start from a blank canvas" = "ცარიელი ტილოთი დაწყება"
"Start from a template" = "შაბლონით დაწყება"
"Start on path" = "დასაწყისი ტრაექტორიაზე"
+"Start on the Home screen" = "საწყისი ეკრანით გაშვება"
"Start point" = "საწყისი წერტილი"
"Start sharing" = "გაზიარების დაწყება"
"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი"
@@ -1431,6 +1447,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"The selection uses several text styles: click to apply one" = "მონიშნული ტექსტის რამდენიმე სტილს იყენებს: დაწკაპეთ ერთის გამოსაყენებლად"
"The server deletes this file if nobody opens it before {date}." = "სერვერი ამ ფაილს წაშლის, თუ მას {date}-მდე არავინ გახსნის."
"The whole file" = "მთელი ფაილი"
+"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "ფანჯარა {size}%-ისთვის ძალიან პატარაა, ამიტომ ინტერფეისი {drawn}%-ით არის დახატული. {size}%-ის გამოსაყენებლად გაადიდეთ ფანჯარა."
"Theme" = "თემა"
"There's no destination for that action" = "ამ მოქმედებას დანიშნულება არ აქვს"
"Thickness" = "სისქე"
@@ -1441,6 +1458,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება"
"This file" = "ეს ფაილი"
"This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს"
+"This file is too large to join here (up to {mb} MB)" = "ეს ფაილი აქ შესაერთებლად ძალიან დიდია (მაქსიმუმ {mb} მბ)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ."
"This frame is new on the branch." = "ეს ჩარჩო განშტოებაში ახალია."
@@ -1448,6 +1466,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"This server is open to the public: no key needed." = "ეს სერვერი საჯაროა: გასაღები საჭირო არ არის."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "ამ სერვერს გასაღები სჭირდება: მისამართს დაუმატეთ ?key=… (`figcraft-server key` გამოიტანს მას სერვერის კომპიუტერზე)."
"This server takes files only with its key: see Advanced." = "ეს სერვერი ფაილებს მხოლოდ გასაღებით იღებს: იხილეთ „დამატებითი“."
+"This version can't be read." = "ამ ვერსიის წაკითხვა ვერ ხერხდება."
+"This version doesn't have it: all of it is new." = "ეს ამ ვერსიაში არ არის: მთლიანად ახალია."
"Thumbs up" = "ცერი ზემოთ"
"Tidy up" = "მოწესრიგება"
"Tile" = "ფილებად"
@@ -1465,6 +1485,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Top" = "ზემოთ"
"Top & bottom" = "ზემოთ და ქვემოთ"
"Top left radius" = "ზედა მარცხენა რადიუსი"
+"Top padding" = "ზედა შიდა დაშორება"
"Top right radius" = "ზედა მარჯვენა რადიუსი"
"Transform" = "გარდაქმნა"
"Triangle" = "სამკუთხედი"
@@ -1515,6 +1536,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Use number keys for opacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება"
"Use the .figcraft or .fig extension." = "გამოიყენეთ .figcraft ან .fig გაფართოება."
"Use the layer's fill" = "შრის შევსების გამოყენება"
+"Use your browser's zoom to change the interface size." = "ინტერფეისის ზომის შესაცვლელად გამოიყენეთ ბრაუზერის მასშტაბირება."
"Used in {files} · {instances}" = "გამოიყენება: {files} · {instances}"
"Username" = "მომხმარებლის სახელი"
"Value" = "მნიშვნელობა"
@@ -1562,11 +1584,13 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Wedge" = "სოლი"
"Weekly planner" = "კვირის გეგმა"
"Weight" = "სისქე"
+"What changed since a saved version" = "რა შეიცვალა შენახული ვერსიის შემდეგ"
"What you try here changes this preview and the code below, not the file" = "რასაც აქ ცდით, ცვლის ამ გადახედვას და ქვემოთ კოდს და არა ფაილს"
"What changed since it was marked ready for dev" = "რა შეიცვალა მზად დეველოპერისთვის მონიშვნის შემდეგ"
"What is wrong with this file? The server's operator gets your report." = "რა არის ამ ფაილში არასწორი? სერვერის ოპერატორი მიიღებს თქვენს საჩივარს."
"What it's for" = "რისთვისაა"
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "არ არის ცნობილი, რისგან შეიცვალა: ცვლილება მოხდა მანამ, სანამ FigCraft მზად დეველოპერისთვის ჩარჩოების ასლებს შეინახავდა. გადაწყვიტეთ ცვლილება (ან ხელახლა მონიშნეთ მზადად), რომ ამიერიდან შედარდეს."
+"When marked ready" = "მზადად მონიშვნისას"
"While hovering" = "მაჩვენებლის მიტანისას"
"While pressing" = "დაჭერისას"
"White" = "თეთრი"
@@ -1745,6 +1769,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"{name} ({layers} layers)" = "{name} (შრე: {layers})"
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ნაგულისხმევი {default}. სტილის მნიშვნელობაზე დასაბრუნებლად ორჯერ დაწკაპეთ სლაიდერზე; Escape გადათრევისას აუქმებს."
"{name} from {path}" = "{name} — {path}"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} ბრაუზერში გასახსნელად ძალიან დიდია (512 მბ-ზე მეტი)."
"{name} was removed from the server" = "{name} წაიშალა სერვერიდან"
"{name} · deleted on main" = "{name} · წაშლილია მთავარში"
"{name} · deleted on the branch" = "{name} · წაშლილია განშტოებაში"
@@ -1794,6 +1819,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"{n}m" = "{n} წთ"
"{n}w" = "{n} კვ"
"{page}, nothing selected" = "{page}, არაფერია მონიშნული"
+"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (მიმდინარე)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} მისი მნიშვნელობაა; {property ? \"a\" : \"b\"} ირჩევს სტრიქონს. ნებისმიერი სხვა {…} რჩება დაწერილი სახით."
"{server} can't say what became of your files (it is older than this app)." = "{server} ვერ გეტყვით, რა მოუვიდა თქვენს ფაილებს (ის ამ აპზე ძველია)."
@@ -1812,6 +1838,36 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"⌘ click" = "⌘ დაწკაპუნება"
"⌘ scroll" = "⌘ გადახვევა"
"⌥ drag" = "⌥ გადათრევა"
+"Android Vector Drawable" = "Android Vector Drawable"
+"Best effort" = "მაქსიმალური მიახლოება"
+"Check for Updates" = "განახლებების შემოწმება"
+"Check for updates…" = "განახლებების შემოწმება…"
+"Checking for updates…" = "განახლებების შემოწმება…"
+"Compatibility" = "თავსებადობა"
+"Dart constants for Flutter…" = "Dart-ის კონსტანტები Flutter-ისთვის…"
+"Copy effect" = "ეფექტის კოპირება"
+"Copy fill" = "შევსების კოპირება"
+"Copy stroke" = "კონტურის კოპირება"
+"Could not check for updates:\n{error}" = "განახლებების შემოწმება ვერ მოხერხდა:\n{error}"
+"Couldn't download {name}" = "{name} ვერ ჩამოიტვირთა"
+"Don't show this again" = "აღარ აჩვენო"
+"Exact only" = "მხოლოდ ზუსტი"
+"Exported {name}; {n} kinds of content were changed or left out" = { one = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}", other = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}" }
+"Exported {n} files; some content was changed or left out" = { one = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია", other = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია" }
+"FigCraft {version} is currently the newest version available." = "FigCraft {version} ამჟამად უახლესი ხელმისაწვდომი ვერსიაა."
+"Format" = "ფორმატი"
+"Ignore unsupported" = "მხარდაუჭერლის უგულებელყოფა"
+"Leaving the prototype" = "პროტოტიპიდან გასვლა"
+"Paste effect" = "ეფექტის ჩასმა"
+"Paste fill" = "შევსების ჩასმა"
+"Paste stroke" = "კონტურის ჩასმა"
+"Server key" = "სერვერის გასაღები"
+"Set corner smoothing to 60%" = "კუთხეების დარბილების 60%-ზე დაყენება"
+"Text on a path" = "ტექსტი ტრაექტორიაზე"
+"This link opens a website outside the prototype:" = "ეს ბმული პროტოტიპის გარეთ ვებსაიტს ხსნის:"
+"Update check is not supported in the web browser." = "ვებბრაუზერში განახლებების შემოწმება მხარდაჭერილი არ არის."
+"instance swap properties whose default component was past the layer limit were skipped" = "ინსტანსის ჩანაცვლების თვისებები, რომელთა ნაგულისხმევი კომპონენტი შრეების ზღვარს მიღმა იყო, გამოტოვებულია"
+"instances whose component was past the layer limit were imported as frames" = "ინსტანსები, რომელთა კომპონენტი შრეების ზღვარს მიღმა იყო, ჩარჩოებად იმპორტირდა"
[commands]
"align.bottom" = "ქვემოთ სწორება"
@@ -1836,6 +1892,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"arrange.front" = "წინა პლანზე გადმოტანა"
"arrange.packH" = "ჰორიზონტალურად შეკვრა"
"arrange.packV" = "ვერტიკალურად შეკვრა"
+"arrange.reorder" = "მონიშნულის თანმიმდევრობის შეცვლა"
"arrange.roundToPixels" = "პიქსელებამდე დამრგვალება"
"arrange.spacing" = "ინტერვალი"
"arrange.tidy" = "მოწესრიგება"
@@ -1984,6 +2041,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"file.exportLayers" = "შრეების ექსპორტირება"
"file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება"
"file.import" = "იმპორტი"
+"file.moveTab" = "ჩანართის გადატანა"
"file.new" = "ახალი დიზაინის ფაილი"
"file.newBoard" = "ახალი დაფა"
"file.newFromTemplate" = "შაბლონიდან შექმნა"
@@ -2127,6 +2185,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"pref.reduceMotion" = "მოძრაობის შემცირება"
"pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა"
"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე"
+"pref.startOnHome" = "საწყისი ეკრანით გაშვება"
+"pref.uiScale" = "ინტერფეისის ზომა"
"pref.userName" = "თქვენი სახელი კომენტარებში"
"proto.action.add" = "პროტოტიპის მოქმედების დამატება"
"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება"
@@ -2235,6 +2295,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"text.listIndent" = "სიის შეწევის გაზრდა"
"text.listOutdent" = "სიის შეწევის შემცირება"
"text.listStyle" = "სიის სტილი"
+"text.loadFont" = "შრიფტის ფაილის ჩატვირთვა"
"text.maxLines" = "მაქს. სტრიქონები"
"text.missingFonts" = "მიუწვდომელი შრიფტები"
"text.numberList" = "დანომრილი სია"
@@ -2243,7 +2304,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"text.paragraphSpacing" = "აბზაცთა ინტერვალი"
"text.pathOptions" = "ტრაექტორიაზე ტექსტის პარამეტრები"
"text.preview" = "ტექსტის ცვლილების გადახედვა"
+"text.refit" = "ტექსტის მორგება შრიფტებზე"
"text.removeFromPath" = "ტექსტის ტრაექტორიიდან მოხსნა"
+"text.rescanFonts" = "დაინსტალირებული შრიფტების ხელახლა სკანირება"
"text.set" = "ტექსტის დაყენება"
"text.setFeatures" = "OpenType შესაძლებლობები"
"text.setVariations" = "ცვლადი შრიფტის ღერძები"
@@ -2344,11 +2407,13 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"vector.edit.tool" = "ვექტორის რედაქტირების ხელსაწყო"
"vector.edit.transform" = "წერტილების გარდაქმნა"
"vector.edit.widthPoint" = "სიგანის წერტილი"
+"vector.fillRule" = "შევსების წესის დაყენება"
"vector.flatten" = "გაბრტყელება"
"vector.join" = "მონიშნულის შეერთება"
"vector.offset" = "ტრაექტორიის წანაცვლება"
"vector.outlineStroke" = "კონტურის ფიგურად გარდაქმნა"
"vector.reverse" = "ტრაექტორიის მიმართულების შებრუნება"
+"vector.reverseLoop" = "ჩაკეტილი ტრაექტორიის შებრუნება"
"vector.simplify" = "ვექტორის გამარტივება"
"vector.smoothJoin" = "მონიშნულის გლუვი შეერთება"
"version.autosave" = "ვერსიის ავტოშენახვა"
@@ -2414,6 +2479,17 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"widget.setText" = "ვიჯეტის ველის რედაქტირება"
"window.focus" = "ფანჯრის წინა პლანზე გადმოტანა"
"window.new" = "ახალი ფანჯარა"
+"fill.copy" = "შევსების კოპირება"
+"fill.paste" = "შევსების ჩასმა"
+"stroke.copy" = "კონტურის კოპირება"
+"stroke.paste" = "კონტურის ჩასმა"
+"effect.copy" = "ეფექტის კოპირება"
+"effect.paste" = "ეფექტის ჩასმა"
+"proto.linkNotice" = "გაფრთხილება ბმულზე გადასვლამდე"
+"tool.textOnPath" = "ტექსტი ტრაექტორიაზე"
+"file.markSaved" = "შენახულად მარკირება"
+"text.fit" = "ტექსტის ჩატევა თავის საზღვრებში"
+"data.fill" = "ასლების მონაცემებით შევსება"
[menu]
"bool.exclude" = "გამორიცხვა"
From a7ea510c65fae3f1e900e9c1307a6c8c84476772 Mon Sep 17 00:00:00 2001
From: Davit Matchakhelidze
Date: Wed, 28 Oct 2026 05:11:34 +0400
Subject: [PATCH 12/60] =?UTF-8?q?i18n(ka):=20=E1=83=9B=E1=83=AE=E1=83=90?=
=?UTF-8?q?=E1=83=A0=E1=83=93=E1=83=90=E1=83=A3=E1=83=AD=E1=83=94=E1=83=A0?=
=?UTF-8?q?=E1=83=94=E1=83=9A=E1=83=98=E1=83=A1=20in=20"Ignore=20unsupport?=
=?UTF-8?q?ed"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
---
crates/ui-egui/locales/ka.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index 73adfe4e..915a55f6 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -1856,7 +1856,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Exported {n} files; some content was changed or left out" = { one = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია", other = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია" }
"FigCraft {version} is currently the newest version available." = "FigCraft {version} ამჟამად უახლესი ხელმისაწვდომი ვერსიაა."
"Format" = "ფორმატი"
-"Ignore unsupported" = "მხარდაუჭერლის უგულებელყოფა"
+"Ignore unsupported" = "მხარდაუჭერელის უგულებელყოფა"
"Leaving the prototype" = "პროტოტიპიდან გასვლა"
"Paste effect" = "ეფექტის ჩასმა"
"Paste fill" = "შევსების ჩასმა"
From 636096fad3a5ec86e0073fd6ea341d4e8095962a Mon Sep 17 00:00:00 2001
From: hussain-hz <114607571+hussain-hz@users.noreply.github.com>
Date: Wed, 28 Oct 2026 15:16:31 +0300
Subject: [PATCH 13/60] M10: add Arabic draft interface catalog
Translate 2508 interface entries from English, keep count-neutral plurals, and gate the web catalog on Arabic font readiness. Record AI assistance and pending native review.
AI-assisted-by: OpenAI Codex
---
ATTRIBUTION.md | 6 +
ROADMAP.md | 9 +-
crates/ui-egui/locales/ar.toml | 2528 ++++++++++++++++++++++++++++++
crates/ui-egui/src/cjk.rs | 13 +
crates/ui-egui/src/i18n/tests.rs | 44 +
docs/gaps.md | 5 +-
docs/localization-parity.md | 5 +-
docs/roadmap.md | 7 +-
docs/target-app-parity.md | 5 +-
docs/translating.md | 18 +
10 files changed, 2627 insertions(+), 13 deletions(-)
create mode 100644 crates/ui-egui/locales/ar.toml
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 46b789a8..cfcdbf06 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
+
+## Arabic interface catalog
+
+| Path | Contributor | Source | Licence | Review |
+|---|---|---|---|---|
+| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
diff --git a/ROADMAP.md b/ROADMAP.md
index 74a617f3..39ac20c1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
-| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
+| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people.
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
| Hindi | hi | 0% | none |
-| Arabic | ar | 0% | none |
+| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
| French | fr | 95% | draft (needs native review) |
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
| Indonesian | id | 99% | draft (needs native review) |
@@ -127,7 +127,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
-4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
+4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
@@ -348,6 +348,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml
new file mode 100644
index 00000000..f252e114
--- /dev/null
+++ b/crates/ui-egui/locales/ar.toml
@@ -0,0 +1,2528 @@
+# FigCraft interface strings: Arabic (العربية).
+# Draft translation from the meaning of FigCraft's English catalog and command labels.
+# Contributed by @hussain-hz with OpenAI Codex; not yet reviewed by a native speaker.
+# No proprietary localisation tables or translated interfaces were consulted.
+# Modern Standard Arabic; product names, keyboard glyphs and placeholders are preserved.
+# Count-neutral wording (e.g. "عدد الطبقات: {n}") is deliberate: the current runtime has
+# no Arabic zero/two categories. Each English plural entry uses only `other`, correct for
+# every integer, without misclassifying Arabic counts or changing other languages' rules.
+# Stored in logical Unicode order; panel mirroring remains separate work.
+# MIT OR Apache-2.0. See ATTRIBUTION.md and docs/translating.md.
+
+[meta]
+name = "العربية"
+status = "draft — needs native review"
+
+[ui]
+"% of all commits" = "% من إجمالي الإيداعات"
+"(no longer published)" = "(لم يعد منشورًا)"
+"({n} warnings: some entries were skipped)" = { other = "(عدد التحذيرات: {n}؛ تم تخطي بعض الإدخالات)" }
+"+ Create variable" = "+ إنشاء متغير"
+".fig (experimental)" = ".fig (تجريبي)"
+"1…9, 0" = "1…9, 0"
+"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: طلبات السحب: {prs}، الإيداعات: {commits}، +{added} / −{deleted} سطر (Δ {delta})، +{bin_added} / −{bin_deleted} أصل ثنائي، {first} – {last}"
+"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "مجموعة من هذا الملف بأوضاعها ومتغيراتها: تجاوز قيمها لكل وضع وأضف متغيرات خاصة بها"
+"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "يوجد ملف .figcraft بهذا الاسم. اختر اسمه الكامل لتأكيد الاستبدال."
+"A layer on its own aligns to its frame: select more layers, or one in a frame" = "تُحاذى الطبقة المنفردة مع إطارها: حدد عدة طبقات أو طبقة داخل إطار"
+"A responsive product page: hero, features, figures, a quote and a footer" = "صفحة منتج متجاوبة: قسم رئيسي ومزايا وأرقام واقتباس وتذييل"
+"A spring plays until it settles: its mass, stiffness and damping set how long" = "تستمر حركة النابض حتى يستقر؛ وتحدد كتلته وصلابته وتخميده مدتها"
+"A title, an agenda, a big figure, a chart and a closing slide" = "عنوان وجدول أعمال ورقم بارز ومخطط وشريحة ختامية"
+"A to Z" = "من A إلى Z"
+"About" = "حول"
+"About FigCraft" = "حول FigCraft"
+"About this server" = "حول هذا الخادم"
+"Action" = "إجراء"
+"Actions" = "الإجراءات"
+"Actions for {name}" = "إجراءات {name}"
+"Actions…" = "الإجراءات…"
+"Active" = "نشط"
+"Actual size (100%)" = "الحجم الفعلي (100%)"
+"Add a category" = "إضافة فئة"
+"Add a comment" = "إضافة تعليق"
+"Add a description" = "إضافة وصف"
+"Add a frame (F) or a shape (R) to get started" = "أضف إطارًا (F) أو شكلًا (R) للبدء"
+"Add a layer or component to tile first" = "أضف طبقة أو مكوّنًا أولًا لتكراره بنمط متجاور"
+"Add a minute" = "إضافة دقيقة"
+"Add a note for developers" = "إضافة ملاحظة للمطورين"
+"Add action" = "إضافة إجراء"
+"Add actions for when no condition holds" = "إضافة إجراءات عند عدم تحقق أي شرط"
+"Add an annotation for developers" = "إضافة تعليق توضيحي للمطورين"
+"Add another condition" = "إضافة شرط آخر"
+"Add auto layout" = "إضافة تخطيط تلقائي"
+"Add background behind overlay" = "إضافة خلفية وراء الطبقة العائمة"
+"Add column" = "إضافة عمود"
+"Add content to this slot" = "إضافة محتوى إلى هذه الخانة"
+"Add dev resource" = "إضافة مورد للمطور"
+"Add effect" = "إضافة تأثير"
+"Add export setting" = "إضافة إعداد تصدير"
+"Add fill" = "إضافة تعبئة"
+"Add interaction" = "إضافة تفاعل"
+"Add layout grid" = "إضافة شبكة تخطيط"
+"Add library" = "إضافة مكتبة"
+"Add library file…" = "إضافة ملف مكتبة…"
+"Add max height" = "إضافة حد أقصى للارتفاع"
+"Add max width" = "إضافة حد أقصى للعرض"
+"Add measurement" = "إضافة قياس"
+"Add min height" = "إضافة حد أدنى للارتفاع"
+"Add min width" = "إضافة حد أدنى للعرض"
+"Add mode" = "إضافة وضع"
+"Add new page" = "إضافة صفحة جديدة"
+"Add or update collections from a variables JSON" = "إضافة مجموعات أو تحديثها من ملف JSON للمتغيرات"
+"Add properties to make instances configurable" = "أضف خصائص لتتيح ضبط النسخ المثيلة"
+"Add reaction" = "إضافة تفاعل تعبيري"
+"Add row" = "إضافة صف"
+"Add starting point" = "إضافة نقطة بداية"
+"Add stroke" = "إضافة حد خارجي"
+"Add variant" = "إضافة تنويعة"
+"Add variant with a new value" = "إضافة تنويعة بقيمة جديدة"
+"Added" = "تمت الإضافة"
+"Added the library \"{name}\"" = "أُضيفت المكتبة «{name}»"
+"Adding to {name}" = "جارٍ الإضافة إلى {name}"
+"Adjust" = "ضبط"
+"Adjustment presets" = "إعدادات الضبط المسبقة"
+"Advanced" = "متقدم"
+"Advanced stroke" = "إعدادات متقدمة للحد الخارجي"
+"After delay" = "بعد تأخير"
+"After delay {ms}ms" = "بعد تأخير {ms} مللي ثانية"
+"Align" = "محاذاة"
+"Align bottom" = "محاذاة لأسفل"
+"Align center" = "محاذاة للوسط أفقيًا"
+"Align horizontal centers" = "محاذاة المراكز الأفقية"
+"Align left" = "محاذاة لليسار"
+"Align middle" = "محاذاة للوسط رأسيًا"
+"Align right" = "محاذاة لليمين"
+"Align text baselines" = "محاذاة خطوط أساس النص"
+"Align top" = "محاذاة لأعلى"
+"Align vertical centers" = "محاذاة المراكز الرأسية"
+"Alignment" = "المحاذاة"
+"All" = "الكل"
+"All categories" = "جميع الفئات"
+"All comments" = "جميع التعليقات"
+"All components" = "جميع المكونات"
+"All fonts" = "جميع الخطوط"
+"All objects" = "جميع الكائنات"
+"All supported properties" = "جميع الخصائص المدعومة"
+"All the fonts this file uses are installed" = "جميع الخطوط المستخدمة في هذا الملف مثبتة"
+"All variables" = "جميع المتغيرات"
+"Along the bottom" = "على طول الأسفل"
+"Along the left" = "على طول اليسار"
+"Along the right" = "على طول اليمين"
+"Along the top" = "على طول الأعلى"
+"Alpha" = "ألفا"
+"Alphabetical" = "أبجدي"
+"Alt+drag" = "Alt+سحب"
+"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "شاشة تحليلات شبكية: أرقام ومخططات مرسومة بمتجهات وجدول"
+"An invite link that opens FigCraft and joins" = "رابط دعوة يفتح FigCraft وينضم إلى الجلسة"
+"Anchor" = "مرساة"
+"Android" = "Android"
+"Android Vector Drawable" = "Android Vector Drawable"
+"Angular" = "زاوي"
+"Animation" = "التحريك"
+"Annotations" = "التعليقات التوضيحية"
+"Annotations shown on the canvas" = "التعليقات التوضيحية المعروضة على لوحة الرسم"
+"Another server, or one that needs its key. The usual server is set in Preferences." = "خادم آخر أو خادم يتطلب مفتاحه. يُحدد الخادم المعتاد في التفضيلات."
+"Any status" = "أي حالة"
+"Anyone on your network with this code can edit the file, while this app stays open." = "يمكن لأي شخص على شبكتك يحمل هذا الرمز تحرير الملف ما دام التطبيق مفتوحًا."
+"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "يتيح رابط التحرير تغيير الملف، ويتيح رابط العرض الاطلاع عليه فقط. تتضمن الروابط مفاتيحها السرية؛ شاركها ككلمات المرور. يُنشئ `figcraft-server links` روابط أخرى ويلغيها."
+"Anyone with this code can edit the file." = "يمكن لأي شخص يحمل هذا الرمز تحرير الملف."
+"Anyone with this code can look at the file, not change it." = "يمكن لأي شخص يحمل هذا الرمز عرض الملف دون تغييره."
+"Appearance" = "المظهر"
+"Apply a component property" = "تطبيق خاصية مكوّن"
+"Apply variable" = "تطبيق متغير"
+"Apply variable to text" = "تطبيق متغير على النص"
+"Apply variable to visibility" = "تطبيق متغير على الظهور"
+"Apps" = "التطبيقات"
+"Apr" = "أبريل"
+"Arc" = "قوس"
+"Arc ratio" = "نسبة القوس"
+"Arc start" = "بداية القوس"
+"Arc sweep" = "امتداد القوس"
+"Archive" = "أرشفة"
+"Archived" = "مؤرشف"
+"Arrange" = "ترتيب"
+"Arrow" = "سهم"
+"Arrow at the end" = "سهم عند النهاية"
+"As typed" = "كما أُدخل"
+"Asking the server…" = "جارٍ الاستعلام من الخادم…"
+"Assets" = "الأصول"
+"Aug" = "أغسطس"
+"Auto" = "تلقائي"
+"Auto (space around)" = "تلقائي (مسافة حول العناصر)"
+"Auto (space between)" = "تلقائي (مسافة بين العناصر)"
+"Auto (space evenly)" = "تلقائي (مسافات متساوية)"
+"Auto ({mode})" = "تلقائي ({mode})"
+"Auto columns" = "أعمدة تلقائية"
+"Auto gap" = "فاصل تلقائي"
+"Auto height" = "ارتفاع تلقائي"
+"Auto layout" = "تخطيط تلقائي"
+"Auto rows" = "صفوف تلقائية"
+"Auto width" = "عرض تلقائي"
+"Back to the FigCraft server" = "العودة إلى خادم FigCraft"
+"Back to the font's value" = "العودة إلى قيمة الخط"
+"Back" = "رجوع"
+"Back to Ready for dev" = "العودة إلى حالة جاهز للتطوير"
+"Back to Ready for dev: the next change compares with how it is now" = "العودة إلى جاهز للتطوير: ستُقارن التغييرات القادمة بالحالة الحالية"
+"Back to assets" = "العودة إلى الأصول"
+"Back to current" = "العودة إلى الحالي"
+"Back to styles" = "العودة إلى الأنماط"
+"Back to the canvas from a focused region" = "العودة إلى لوحة الرسم من المنطقة المركّز عليها"
+"Back to the current version" = "العودة إلى الإصدار الحالي"
+"Back to the values the font style implies" = "العودة إلى القيم التي يحددها نمط الخط"
+"Background" = "الخلفية"
+"Background blur" = "تمويه الخلفية"
+"Basics" = "الأساسيات"
+"Before" = "قبل"
+"Bend" = "ثني"
+"Best effort" = "أفضل محاولة ممكنة"
+"Between the selected layers" = "بين الطبقات المحددة"
+"Bevel" = "شطف"
+"Binary assets added" = "الأصول الثنائية المضافة"
+"Binary assets removed" = "الأصول الثنائية المحذوفة"
+"Black & white" = "أبيض وأسود"
+"Bleed" = "نزف الطباعة"
+"Bleed marks" = "علامات نزف الطباعة"
+"Bleed marks need a bleed" = "تتطلب علامات النزف هوامش نزف"
+"Blend" = "مزج"
+"Blend mode" = "وضع المزج"
+"Blue" = "أزرق"
+"Blur" = "تمويه"
+"Boolean" = "منطقي"
+"Boolean groups" = "المجموعات المنطقية"
+"Both directions" = "كلا الاتجاهين"
+"Bottom" = "أسفل"
+"Bottom left radius" = "نصف قطر الزاوية السفلية اليسرى"
+"Bottom padding" = "الحشو السفلي"
+"Bottom right radius" = "نصف قطر الزاوية السفلية اليمنى"
+"Brainstorm" = "عصف ذهني"
+"Branch" = "فرع"
+"Branch archived" = "أُرشف الفرع"
+"Branch merged" = "دُمج الفرع"
+"Branch name" = "اسم الفرع"
+"Branch of {file}" = "فرع من {file}"
+"Branch of {file} · merged or archived" = "فرع من {file} · مدمج أو مؤرشف"
+"Branch updated from main" = "حُدّث الفرع من الملف الرئيسي"
+"Branch {name}" = "الفرع {name}"
+"Branches" = "الفروع"
+"Bound to {name}. Click to detach the variable." = "مرتبط بـ {name}. انقر لفصل المتغير."
+"Bring forward" = "تقديم مستوى واحد"
+"Bring the main file's changes into the branch" = "جلب تغييرات الملف الرئيسي إلى الفرع"
+"Bring to front" = "إحضار إلى المقدمة"
+"Bristle" = "شعيرات"
+"Browse" = "استعراض"
+"Brush" = "فرشاة"
+"Brush rotation jitter" = "عشوائية دوران الفرشاة"
+"Brush scatter" = "تناثر الفرشاة"
+"Brush size jitter" = "عشوائية حجم الفرشاة"
+"Brush spacing" = "تباعد الفرشاة"
+"Bulleted list" = "قائمة نقطية"
+"C or Shift+C" = "C أو Shift+C"
+"C or ⇧C" = "C أو ⇧C"
+"CMYK colour" = "لون CMYK"
+"Can view" = "يمكنه العرض"
+"Check" = "تحقق"
+"Check for Updates" = "التحقق من التحديثات"
+"Check for updates…" = "التحقق من التحديثات…"
+"Checking for updates…" = "جارٍ التحقق من التحديثات…"
+"Checking the fonts installed on this computer…" = "جارٍ فحص الخطوط المثبتة على هذا الحاسوب…"
+"Checking the server…" = "جارٍ التحقق من الخادم…"
+"Chevron" = "شارة زاوية"
+"Clear from this list" = "إزالة من هذه القائمة"
+"Clear results" = "مسح النتائج"
+"Click objects to vote; click your badge to take a vote back" = "انقر على الكائنات للتصويت؛ وانقر على شارتك لسحب صوت"
+"Code or invite link" = "رمز أو رابط دعوة"
+"Color profile: {name}" = "ملف تعريف اللون: {name}"
+"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "أنماط ألوان ونصوص ومتغيرات فاتحة وداكنة وأزرار وحقول إدخال وبطاقات"
+"Compatibility" = "التوافق"
+"Connector" = "موصل"
+"Convert to frame" = "تحويل إلى إطار"
+"Convert to section" = "تحويل إلى قسم"
+"CSS custom properties…" = "خصائص CSS المخصصة…"
+"Dart constants for Flutter…" = "ثوابت Dart لـ Flutter…"
+"Can edit" = "يمكنه التحرير"
+"Can't read this library: {e}" = "تعذرت قراءة هذه المكتبة: {e}"
+"Cancel" = "إلغاء"
+"Canvas" = "لوحة الرسم"
+"Canvas stacking" = "تكديس العناصر على لوحة الرسم"
+"Cap height to baseline" = "من أعلى الحرف الكبير إلى خط الأساس"
+"Case" = "حالة الأحرف"
+"Case-sensitive forms" = "أشكال حسب حالة الأحرف"
+"Categories" = "الفئات"
+"Center" = "الوسط"
+"Change alias" = "تغيير الاسم المستعار"
+"Change resolved" = "تحديد التغيير كمحلول"
+"Change style" = "تغيير النمط"
+"Change to" = "تغيير إلى"
+"Change to needs a layer inside a variant" = "يتطلب «تغيير إلى» طبقة داخل تنويعة"
+"Changed" = "تم التغيير"
+"Changed on both sides: keep one" = "تغيّر على الجانبين: احتفظ بأحدهما"
+"Changes on main" = "التغييرات في الملف الرئيسي"
+"Changes on the branch" = "التغييرات في الفرع"
+"Changes unknown" = "التغييرات غير معروفة"
+"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "اختر React أو Leptos أو Vue أو SwiftUI أو Compose أو ArkUI أعلاه لربط هذا المكوّن بكوده."
+"Choose another profile to change it." = "اختر ملف تعريف آخر لتغييره."
+"Choose collection" = "اختيار مجموعة"
+"Choose image" = "اختيار صورة"
+"Choose mode" = "اختيار وضع"
+"Choose variable" = "اختيار متغير"
+"Circle" = "دائرة"
+"Clear recent" = "مسح الملفات الأخيرة"
+"Click + to replace mixed content" = "انقر على + لاستبدال المحتوى المختلط"
+"Click anywhere on the canvas to leave one." = "انقر في أي موضع على لوحة الرسم لإضافة تعليق."
+"Click or drag to merge · {alt} removes" = "انقر أو اسحب للدمج · {alt} للإزالة"
+"Click or drag to remove · {alt} merges" = "انقر أو اسحب للإزالة · {alt} للدمج"
+"Click to record a key" = "انقر لتسجيل مفتاح"
+"Clip content" = "قص المحتوى عند الحدود"
+"Clip to shape" = "قص وفق الشكل"
+"Close" = "إغلاق"
+"Close overlay" = "إغلاق الطبقة العائمة"
+"Close preview" = "إغلاق المعاينة"
+"Close tab" = "إغلاق علامة التبويب"
+"Close version history" = "إغلاق سجل الإصدارات"
+"Close when clicking outside" = "إغلاق عند النقر خارجها"
+"Code" = "الكود"
+"Code syntax" = "صياغة الكود"
+"Collapse" = "طي"
+"Collapse panel" = "طي اللوحة"
+"Collection" = "مجموعة"
+"Collections" = "المجموعات"
+"Color" = "اللون"
+"Color burn" = "حرق اللون"
+"Color dodge" = "تفتيح اللون"
+"Color format" = "تنسيق اللون"
+"Color profile" = "ملف تعريف اللون"
+"Color styles" = "أنماط الألوان"
+"Color values in this file are in this color space." = "تُعبّر قيم الألوان في هذا الملف وفق هذا الفضاء اللوني."
+"Color variables" = "متغيرات الألوان"
+"Colour" = "اللون"
+"Column" = "عمود"
+"Column gap" = "الفاصل بين الأعمدة"
+"Column sizes" = "أحجام الأعمدة"
+"Column span" = "امتداد الأعمدة"
+"Columns" = "أعمدة"
+"Columns {count}" = "الأعمدة {count}"
+"Comment" = "تعليق"
+"Comments" = "التعليقات"
+"Commits" = "الإيداعات"
+"Company" = "الشركة"
+"Compare changes" = "مقارنة التغييرات"
+"Compare with" = "مقارنة مع"
+"Compatibility notes" = "ملاحظات التوافق"
+"Completed" = "مكتمل"
+"Component" = "مكوّن"
+"Component name" = "اسم المكوّن"
+"Component property" = "خاصية المكوّن"
+"Component set" = "مجموعة مكونات"
+"Component sets" = "مجموعات المكونات"
+"Components" = "المكونات"
+"Conditional" = "شرطي"
+"Confetti" = "قصاصات احتفالية"
+"Conflicts" = "التعارضات"
+"Connect to code" = "ربط بالكود"
+"Connected" = "متصل"
+"Connected to code: {from}" = "مرتبط بالكود: {from}"
+"Connection lost, reconnecting (attempt {attempt})…" = "انقطع الاتصال، جارٍ إعادة الاتصال (المحاولة {attempt})…"
+"Constrain proportions" = "الحفاظ على النسب"
+"Constraints" = "القيود"
+"Content" = "المحتوى"
+"Contents hidden" = "المحتويات مخفية"
+"Contextual alternates" = "بدائل سياقية"
+"Contrast" = "التباين"
+"Contributors" = "المساهمون"
+"Contributors: {people} · Commits: {commits}" = "المساهمون: {people} · الإيداعات: {commits}"
+"Controller" = "وحدة تحكم"
+"Cool" = "بارد"
+"Copied as CSS" = "نُسخ بصيغة CSS"
+"Copied as PNG" = "نُسخ بصيغة PNG"
+"Copied as SVG" = "نُسخ بصيغة SVG"
+"Copied as text" = "نُسخ كنص"
+"Copied {value}" = "نُسخ {value}"
+"Copy" = "نسخ"
+"Copy as" = "نسخ بصيغة"
+"Copy as CSS" = "نسخ بصيغة CSS"
+"Copy as PNG" = "نسخ بصيغة PNG"
+"Copy as SVG" = "نسخ بصيغة SVG"
+"Copy code" = "نسخ الكود"
+"Copy effect" = "نسخ التأثير"
+"Copy fill" = "نسخ التعبئة"
+"Copy link" = "نسخ الرابط"
+"Copy stroke" = "نسخ الحد الخارجي"
+"Copy the {label}" = "نسخ {label}"
+"Copy {value}" = "نسخ {value}"
+"Corner radius" = "نصف قطر الزوايا"
+"Corner smoothing" = "تنعيم الزوايا"
+"Could not check for updates:\n{error}" = "تعذر التحقق من التحديثات:\n{error}"
+"Couldn't download the variables" = "تعذر تنزيل المتغيرات"
+"Couldn't download {name}" = "تعذر تنزيل {name}"
+"Couldn't reach {server}: showing what this computer remembers." = "تعذر الاتصال بـ {server}؛ تُعرض البيانات المحفوظة على هذا الحاسوب."
+"Couldn't read {name}." = "تعذرت قراءة {name}."
+"Couldn't save automatically: {error}" = "تعذر الحفظ التلقائي: {error}"
+"Couldn't save the file" = "تعذر حفظ الملف"
+"Count" = "العدد"
+"Create" = "إنشاء"
+"Create a variable collection first" = "أنشئ مجموعة متغيرات أولًا"
+"Create a variable first" = "أنشئ متغيرًا أولًا"
+"Create alias" = "إنشاء اسم مستعار"
+"Create branch" = "إنشاء فرع"
+"Create collection" = "إنشاء مجموعة"
+"Create component" = "إنشاء مكوّن"
+"Create component property" = "إنشاء خاصية مكوّن"
+"Create components, styles or variables to publish them as a library." = "أنشئ مكونات أو أنماطًا أو متغيرات لنشرها كمكتبة."
+"Create property…" = "إنشاء خاصية…"
+"Create style" = "إنشاء نمط"
+"Create style from the selected text" = "إنشاء نمط من النص المحدد"
+"Created" = "أُنشئ"
+"Crop" = "اقتصاص"
+"Crop marks" = "علامات الاقتصاص"
+"Ctrl+arrows" = "Ctrl+الأسهم"
+"Ctrl+click" = "Ctrl+نقر"
+"Ctrl+scroll" = "Ctrl+تمرير"
+"Current name" = "الاسم الحالي"
+"Current version" = "الإصدار الحالي"
+"Cursor chat" = "دردشة المؤشر"
+"Custom" = "مخصص"
+"Custom size" = "حجم مخصص"
+"Cut" = "قص"
+"Dark" = "داكن"
+"Darken" = "تعتيم"
+"Dash" = "شرطة"
+"Dashboard" = "لوحة المعلومات"
+"Date created" = "تاريخ الإنشاء"
+"Dec" = "ديسمبر"
+"Decoration style" = "نمط زخرفة النص"
+"Decoration" = "زخرفة النص"
+"Deep select" = "تحديد عميق"
+"Default" = "افتراضي"
+"Default content" = "المحتوى الافتراضي"
+"Default value (the top-left variant)" = "القيمة الافتراضية (التنويعة في أعلى اليسار)"
+"Delay" = "تأخير"
+"Delete" = "حذف"
+"Delete category (its annotations stay)" = "حذف الفئة (تبقى تعليقاتها التوضيحية)"
+"Delete collection" = "حذف المجموعة"
+"Delete group" = "حذف مجموعة العناصر"
+"Delete mode" = "حذف الوضع"
+"Delete page" = "حذف الصفحة"
+"Delete preset" = "حذف الإعداد المسبق"
+"Delete property" = "حذف الخاصية"
+"Delete the style (layers keep their values)" = "حذف النمط (تحتفظ الطبقات بقيمها)"
+"Delete thread" = "حذف سلسلة التعليقات"
+"Delete variable" = "حذف المتغير"
+"Delete “{name}”? This can't be undone." = "هل تريد حذف «{name}»؟ لا يمكن التراجع عن ذلك."
+"Density" = "الكثافة"
+"Depth" = "العمق"
+"Describe how and when to use this component" = "صِف كيفية استخدام هذا المكوّن ومتى يُستخدم"
+"Describe what changed" = "صِف ما تغير"
+"Description" = "الوصف"
+"Description (optional)" = "الوصف (اختياري)"
+"Design" = "التصميم"
+"Design panel" = "لوحة التصميم"
+"Design system starter" = "نقطة بدء لنظام تصميم"
+"Desktop" = "سطح المكتب"
+"Destination" = "الوجهة"
+"Detach alias" = "فصل الاسم المستعار"
+"Detach instance" = "فصل النسخة المثيلة"
+"Detach property" = "فصل الخاصية"
+"Detach style" = "فصل النمط"
+"Detach styles" = "فصل الأنماط"
+"Detach variable" = "فصل المتغير"
+"Details" = "التفاصيل"
+"Dev Mode" = "وضع المطور"
+"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "وضع المطور للفحص فقط؛ انتقل إلى التصميم (Shift+D) للتحرير"
+"Dev resources" = "موارد المطورين"
+"Development" = "التطوير"
+"Device" = "الجهاز"
+"Device at 100%" = "الجهاز بحجم 100%"
+"Diamond" = "مُعيّن"
+"Difference" = "الفرق"
+"Dimensions" = "الأبعاد"
+"Direction" = "الاتجاه"
+"Disconnect from code" = "فصل الربط بالكود"
+"Dispersion" = "التشتت"
+"Display name" = "اسم العرض"
+"Dissolve" = "تلاشٍ"
+"Distribute and tidy" = "توزيع وترتيب"
+"Distribute horizontal spacing" = "توزيع المسافات الأفقية"
+"Distribute vertical spacing" = "توزيع المسافات الرأسية"
+"Divider" = "فاصل"
+"Don't Save" = "عدم الحفظ"
+"Don't show this again" = "عدم إظهار هذا مجددًا"
+"Don't truncate" = "عدم اختصار النص"
+"Done" = "تم"
+"Done editing" = "إنهاء التحرير"
+"Dots" = "نقاط"
+"Dotted" = "منقّط"
+"Down" = "لأسفل"
+"Drafts" = "المسودات"
+"Drag the stroke to add a width point" = "اسحب الحد الخارجي لإضافة نقطة عرض"
+"Draw" = "رسم"
+"Draw a frame" = "رسم إطار"
+"Drawing tools" = "أدوات الرسم"
+"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "أفلت ملف .figcraft أو .fig أو .sketch أو .json أو .svg أو ملف صورة."
+"Drop to import" = "أفلت للاستيراد"
+"Drop to open" = "أفلت للفتح"
+"Drop shadow" = "ظل خارجي"
+"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "مرخّص بموجب MIT أو Apache-2.0. من تطوير فريق ArtCraft والمجتمع."
+"Duo" = "ثنائي"
+"Duplicate" = "تكرار"
+"Duplicate by dragging" = "تكرار بالسحب"
+"Duplicate mode" = "تكرار الوضع"
+"Duplicate page" = "تكرار الصفحة"
+"Edit" = "تحرير"
+"Edit as an expression" = "تحرير كتعبير"
+"Edit colour" = "تحرير اللون"
+"Edit comment" = "تحرير التعليق"
+"Edit dev resource" = "تحرير مورد المطور"
+"Edit link" = "رابط التحرير"
+"Edit link copied" = "نُسخ رابط التحرير"
+"Edit style" = "تحرير النمط"
+"Edit variable" = "تحرير المتغير"
+"Edit with the builder" = "تحرير باستخدام أداة البناء"
+"Edited {when}" = "عُدّل {when}"
+"Effect settings" = "إعدادات التأثير"
+"Effect styles" = "أنماط التأثيرات"
+"Effects" = "التأثيرات"
+"Curved" = "منحني"
+"Elbow" = "بزاوية قائمة"
+"Ellipse" = "شكل بيضاوي"
+"Ellipses" = "أشكال بيضاوية"
+"Else" = "وإلا"
+"Else if" = "وإلا إذا"
+"Embedded profile" = "ملف تعريف مضمّن"
+"Empty" = "فارغ"
+"End" = "النهاية"
+"End point" = "نقطة النهاية"
+"End vote" = "إنهاء التصويت"
+"Ends the session; everyone keeps their copy" = "ينهي الجلسة؛ ويحتفظ الجميع بنسخهم"
+"Enter" = "Enter"
+"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "أدخل عنوان خادم figcraft-server واضغط على «استعراض» لعرض إضافاته."
+"Enter to save · Esc to cancel" = "Enter للحفظ · Esc للإلغاء"
+"Eraser" = "ممحاة"
+"Esc" = "Esc"
+"Essential" = "أساسي"
+"Every conflict has a choice." = "اختير حل لكل تعارض."
+"Every top-level frame already starts a flow" = "كل إطار من المستوى الأعلى يبدأ تدفقًا بالفعل"
+"Exact only" = "تطابق تام فقط"
+"Exact opacity (4 then 5: 45%)" = "عتامة دقيقة (4 ثم 5: 45%)"
+"Exclude" = "استبعاد"
+"Excluded" = "مستبعد"
+"Excluded from layout" = "مستبعد من التخطيط"
+"Excludes" = "يستبعد"
+"Exclusion" = "استثناء"
+"Exit focus view (Esc)" = "الخروج من عرض التركيز (Esc)"
+"Expand" = "توسيع"
+"Export" = "تصدير"
+"Export all" = "تصدير الكل"
+"Export frames and layers as PNG, JPG, SVG or PDF" = "تصدير الإطارات والطبقات بصيغة PNG أو JPG أو SVG أو PDF"
+"Export page as PNG" = "تصدير الصفحة بصيغة PNG"
+"Export settings" = "إعدادات التصدير"
+"Export variables" = "تصدير المتغيرات"
+"Export {name}" = "تصدير {name}"
+"Export {n} layers" = { other = "تصدير الطبقات، العدد: {n}" }
+"Exported {file}" = "صُدّر {file}"
+"Exported {name}" = "صُدّر {name}"
+"Exported {name}; {n} kinds of content were changed or left out" = { other = "صُدّر {name}؛ عدد أنواع المحتوى التي عُدّلت أو أُهملت: {n}" }
+"Exported {name}; {n} kinds of content were left out" = { other = "صُدّر {name}؛ عدد أنواع المحتوى التي أُهملت: {n}" }
+"Exported {n} files" = { other = "اكتمل التصدير؛ عدد الملفات: {n}" }
+"Exported {n} files; some content was changed or left out" = { other = "اكتمل تصدير الملفات وعددها {n}؛ عُدّل بعض المحتوى أو أُهمل" }
+"Export…" = "تصدير…"
+"Expose properties to instances" = "إتاحة الخصائص للنسخ المثيلة"
+"Exposure" = "التعريض"
+"Expression" = "تعبير"
+"Extend collection" = "توسيع مجموعة"
+"Extends \"{name}\": overridden values are marked" = "يوسّع «{name}»: القيم المتجاوزة مميزة"
+"Eye" = "عين"
+"F" = "F"
+"Fade" = "تلاشي"
+"False" = "خطأ"
+"Feb" = "فبراير"
+"Fetching from the server…" = "جارٍ الجلب من الخادم…"
+"FigCraft couldn't open its window: {error}" = "تعذر على FigCraft فتح نافذته: {error}"
+"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "لا يدعم FigCraft جميع مزايا هذه الملفات بعد. تسرد الملاحظات ما أُهمل أو عُدّل في النسخة المستوردة؛ ويظل الملف الأصلي دون تغيير."
+"FigCraft {version} is currently the newest version available." = "FigCraft {version} هو أحدث إصدار متاح حاليًا."
+"File" = "ملف"
+"File name" = "اسم الملف"
+"File not found" = "لم يُعثر على الملف"
+"File published" = "نُشر الملف"
+"Files aren't end-to-end encrypted: the server's operator can read them." = "الملفات غير مشفرة من طرف إلى طرف؛ يمكن لمشغّل الخادم قراءتها."
+"Files nobody opens for {n} days are deleted." = { other = "تُحذف الملفات إذا لم يفتحها أحد طوال مدة بالأيام قدرها {n}." }
+"Fill" = "تعبئة"
+"Fill (fr)" = "ملء (fr)"
+"Fill container" = "ملء الحاوية"
+"Fill window" = "ملء النافذة"
+"Fills" = "التعبئات"
+"Fill|sizing" = "ملء"
+"Filter and sort" = "تصفية وفرز"
+"Find" = "بحث"
+"Find and replace" = "بحث واستبدال"
+"Find layers" = "بحث عن الطبقات"
+"First" = "الأول"
+"First commit" = "أول إيداع"
+"First on top" = "الأول في الأعلى"
+"Fit" = "ملاءمة"
+"Fit device to window" = "ملاءمة الجهاز مع النافذة"
+"Fit to contents" = "ملاءمة مع المحتويات"
+"Fit to window" = "ملاءمة مع النافذة"
+"Fit width" = "ملاءمة العرض"
+"Fixed" = "ثابت"
+"Fixed gap" = "فاصل ثابت"
+"Fixed size" = "حجم ثابت"
+"Flatten" = "تسطيح"
+"Flip" = "قلب"
+"Flip horizontal" = "قلب أفقي"
+"Flip text to the other side" = "قلب النص إلى الجانب الآخر"
+"Flip vertical" = "قلب رأسي"
+"Flip width profile" = "قلب منحنى العرض"
+"Flow chart" = "مخطط تدفق"
+"Flow starting point" = "نقطة بداية التدفق"
+"Flow starting points" = "نقاط بداية التدفقات"
+"Flows" = "التدفقات"
+"Focus view" = "عرض التركيز"
+"Fold autosaves" = "طي نسخ الحفظ التلقائي"
+"Folder: click to collapse" = "مجلد: انقر للطي"
+"Folder: click to expand" = "مجلد: انقر للتوسيع"
+"Follow" = "متابعة"
+"Follow {name}" = "متابعة {name}"
+"Following" = "جارٍ المتابعة"
+"Following {name} · {key} to stop" = "جارٍ متابعة {name} · {key} للإيقاف"
+"Font" = "الخط"
+"Font not installed" = "الخط غير مثبت"
+"Font size" = "حجم الخط"
+"Font style" = "نمط الخط"
+"Forget on this computer" = "نسيان على هذا الحاسوب"
+"Format" = "التنسيق"
+"Four screens of a trail app with components and a prototype flow" = "أربع شاشات لتطبيق مسارات مشي، بمكونات وتدفق نموذج تفاعلي"
+"Fractions" = "كسور"
+"Frame" = "إطار"
+"Frame selection" = "إحاطة التحديد بإطار"
+"Frame tools" = "أدوات الإطارات"
+"Frames" = "الإطارات"
+"From the library \"{name}\": read-only" = "من المكتبة «{name}»: للقراءة فقط"
+"From {name}" = "من {name}"
+"From {server}" = "من {server}"
+"Frost" = "صقيع"
+"Gap" = "الفاصل"
+"Gap between columns" = "الفاصل بين الأعمدة"
+"Gap between items" = "الفاصل بين العناصر"
+"Gap between rows" = "الفاصل بين الصفوف"
+"Getting started" = "بدء الاستخدام"
+"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "أدخل عنوان خادم متاح للعامة، أو عنوان خادم مع مفتاحه (يعرضه `figcraft-server key`)."
+"Glass" = "زجاج"
+"Go to main component" = "الانتقال إلى المكوّن الرئيسي"
+"Go to {name}" = "الانتقال إلى {name}"
+"Grab bag" = "تشكيلة متنوعة"
+"Gradient" = "تدرج لوني"
+"Gray" = "رمادي"
+"Green" = "أخضر"
+"Greyscale components and two example screens to sketch a flow with" = "مكونات رمادية وشاشتان توضيحيتان لتخطيط تدفق"
+"Grid" = "شبكة"
+"Grid ({size})" = "شبكة ({size})"
+"Grid styles" = "أنماط الشبكات"
+"Grid {size}" = "شبكة {size}"
+"Group" = "مجموعة"
+"Group selection" = "تجميع التحديد"
+"Groups" = "المجموعات"
+"Gutter" = "الفاصل بين المساحات"
+"Hand tool" = "أداة اليد"
+"Hanging list" = "قائمة معلّقة"
+"Hanging punctuation" = "علامات ترقيم معلّقة"
+"Hard light" = "ضوء شديد"
+"Heart" = "قلب"
+"Height" = "الارتفاع"
+"Help" = "مساعدة"
+"Help and resources" = "مساعدة وموارد"
+"Help page" = "صفحة المساعدة"
+"Here: {names}" = "الموجودون هنا: {names}"
+"Hidden" = "مخفي"
+"Hide contents" = "إخفاء المحتويات"
+"Hide from publishing" = "إخفاء من النشر"
+"Hide section contents" = "إخفاء محتويات القسم"
+"Hide these tips (Getting started in the Help menu shows them again)" = "إخفاء هذه التلميحات (يعيد «بدء الاستخدام» في قائمة المساعدة إظهارها)"
+"Hide when publishing" = "إخفاء عند النشر"
+"Highlight layers on hover" = "تمييز الطبقات عند التحويم"
+"Highlighter" = "قلم تمييز"
+"Highlights" = "المناطق المضيئة"
+"Hold Alt" = "اضغط مطولًا على Alt"
+"Hold Alt+Z" = "اضغط مطولًا على Alt+Z"
+"Hold Ctrl" = "اضغط مطولًا على Ctrl"
+"Hold Z" = "اضغط مطولًا على Z"
+"Hold ⌃ or ⌘" = "اضغط مطولًا على ⌃ أو ⌘"
+"Hold ⌥" = "اضغط مطولًا على ⌥"
+"Hold ⌥Z" = "اضغط مطولًا على ⌥Z"
+"Home" = "الرئيسية"
+"Horizontal" = "أفقي"
+"Horizontal align" = "محاذاة أفقية"
+"Horizontal hex" = "سداسي أفقي"
+"Horizontal padding" = "الحشو الأفقي"
+"Horizontal spacing" = "التباعد الأفقي"
+"Hosted by {operator}" = "بإدارة {operator}"
+"Hover a layer to see its properties" = "مرر المؤشر فوق طبقة لعرض خصائصها"
+"Hover other layers to measure the distance" = "مرر المؤشر فوق طبقات أخرى لقياس المسافة"
+"Hue" = "درجة اللون"
+"Hug" = "احتواء المحتوى"
+"Hug contents" = "احتواء المحتويات"
+"Icon components on a 24 px grid with keylines and a size preview" = "مكونات أيقونات على شبكة 24 بكسل مع خطوط مرجعية ومعاينة للحجم"
+"Icon set starter" = "نقطة بدء لمجموعة أيقونات"
+"If it keeps happening, please report it at {link} with this message." = "إذا استمرت المشكلة، يُرجى الإبلاغ عنها في {link} مع هذه الرسالة."
+"If" = "إذا"
+"If {condition}" = "إذا {condition}"
+"Ignore auto layout (absolute position)" = "تجاهل التخطيط التلقائي (موضع مطلق)"
+"Ignore overlapping layers" = "تجاهل الطبقات المتداخلة"
+"Ignore unsupported" = "تجاهل غير المدعوم"
+"Image" = "صورة"
+"Images" = "الصور"
+"Import" = "استيراد"
+"Import path" = "مسار الاستيراد"
+"Import plugin from manifest" = "استيراد إضافة من ملف التعريف"
+"Import plugin from manifest…" = "استيراد إضافة من ملف التعريف…"
+"Import report" = "تقرير الاستيراد"
+"Import variables" = "استيراد المتغيرات"
+"Imported document" = "المستند المستورد"
+"Imported {name}" = "استُورد {name}"
+"Imported {n} new variables, updated {updated}" = { other = "استُوردت متغيرات جديدة عددها {n}، وحُدّث {updated}" }
+"Import…" = "استيراد…"
+"In an instance, this comes from the main component" = "في النسخة المثيلة، تأتي هذه القيمة من المكوّن الرئيسي"
+"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "يمكنك في المتصفح الانضمام إلى جلسات يستضيفها تطبيق سطح المكتب وملفات على خادم FigCraft. لا تتصل الصفحة المفتوحة عبر https بحواسيب أخرى إلا بروابط wss:// (خادم يعمل خلف TLS)."
+"Include bounding box" = "تضمين مربع الإحاطة"
+"Include id attribute" = "تضمين السمة id"
+"Included" = "مضمّن"
+"Included in layout" = "مضمّن في التخطيط"
+"Individual corners" = "زوايا منفصلة"
+"Individual padding" = "حشو منفصل لكل جانب"
+"Individual strokes" = "حدود خارجية منفصلة"
+"Ink" = "حبر"
+"Inner shadow" = "ظل داخلي"
+"Inside" = "داخل"
+"Inspect" = "فحص"
+"Inspect the layer under the pointer: size, colours, typography" = "فحص الطبقة تحت المؤشر: الحجم والألوان والطباعة"
+"Install plugin" = "تثبيت إضافة"
+"Install" = "تثبيت"
+"Installed {name}" = "ثُبّت {name}"
+"Installed" = "مثبّت"
+"Installing…" = "جارٍ التثبيت…"
+"Instance" = "نسخة مثيلة"
+"Instance properties" = "خصائص النسخة المثيلة"
+"Instance swap" = "استبدال النسخة المثيلة"
+"Instance swap (no other component)" = "استبدال النسخة المثيلة (لا يوجد مكوّن آخر)"
+"Instances" = "النسخ المثيلة"
+"Instant" = "فوري"
+"Intensity" = "الشدة"
+"Interaction" = "تفاعل"
+"Interactions" = "التفاعلات"
+"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "تصميم الواجهات والنماذج التفاعلية. أداة تصميم حرة ومفتوحة المصدر، مكتوبة من الصفر بلغة Rust وفق نهج التطوير المستقل، ضمن تطبيقات ArtCraft."
+"Interface size" = "حجم الواجهة"
+"Intersect" = "تقاطع"
+"Intersects" = "يتقاطع"
+"Invalid color" = "لون غير صالح"
+"Invite link" = "رابط الدعوة"
+"Invite link copied" = "نُسخ رابط الدعوة"
+"It asks permission to:" = "تطلب الإذن بما يلي:"
+"Jan" = "يناير"
+"Join" = "انضمام"
+"Join code copied" = "نُسخ رمز الانضمام"
+"Join code or invite link" = "رمز انضمام أو رابط دعوة"
+"Join live session" = "الانضمام إلى جلسة مباشرة"
+"Join us on Discord" = "انضم إلينا على Discord"
+"Join with a code" = "الانضمام برمز"
+"Joined" = "تم الانضمام"
+"Joined {file}" = "تم الانضمام إلى {file}"
+"Joining…" = "جارٍ الانضمام…"
+"Join|stroke" = "وصلة الحد الخارجي"
+"Jul" = "يوليو"
+"Jun" = "يونيو"
+"Just now" = "الآن"
+"Justify" = "ضبط المحاذاة"
+"Kanban board" = "لوحة كانبان"
+"Keep a measurement on the canvas for developers" = "إبقاء قياس على لوحة الرسم للمطورين"
+"Keep appearance" = "الحفاظ على المظهر"
+"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "يحوّل «الحفاظ على المظهر» كل قيمة لونية إلى {profile} لتبقى الألوان كما تبدو. ويُبقي «الحفاظ على القيم» الأرقام كما هي، فتتغير الألوان."
+"Keep tool selected after use" = "إبقاء الأداة محددة بعد الاستخدام"
+"Keep values" = "الحفاظ على القيم"
+"Key/Gamepad" = "مفتاح/ذراع ألعاب"
+"Key/Gamepad {key}" = "مفتاح/ذراع ألعاب {key}"
+"Keyboard shortcuts" = "اختصارات لوحة المفاتيح"
+"Kits" = "الأطقم"
+"Landing page" = "صفحة هبوط"
+"Language" = "اللغة"
+"Lasso" = "تحديد حر"
+"Last" = "الأخير"
+"Last change: {note}" = "آخر تغيير: {note}"
+"Last commit" = "آخر إيداع"
+"Last modified" = "آخر تعديل"
+"Last on top" = "الأخير في الأعلى"
+"Layer blur" = "تمويه الطبقة"
+"Layer name" = "اسم الطبقة"
+"Layers" = "الطبقات"
+"Layers in an instance take their position from the main component" = "تأخذ الطبقات داخل نسخة مثيلة مواضعها من المكوّن الرئيسي"
+"Layout" = "التخطيط"
+"Layout grid" = "شبكة التخطيط"
+"Layout grid settings" = "إعدادات شبكة التخطيط"
+"Layout grids" = "شبكات التخطيط"
+"Leave session" = "مغادرة الجلسة"
+"Leaves" = "أوراق"
+"Leaving the prototype" = "مغادرة النموذج التفاعلي"
+"Left" = "يسار"
+"Left & right" = "يسار ويمين"
+"Left padding" = "الحشو الأيسر"
+"Left side" = "الجانب الأيسر"
+"Left side width" = "عرض الجانب الأيسر"
+"Letter case" = "حالة الأحرف"
+"Letter spacing" = "تباعد الحروف"
+"Libraries" = "المكتبات"
+"Libraries in this file" = "المكتبات في هذا الملف"
+"Library updates available" = "تحديثات المكتبات متاحة"
+"Library: click to collapse" = "مكتبة: انقر للطي"
+"Library: click to expand" = "مكتبة: انقر للتوسيع"
+"Library: its variables are read-only here; edit them in the library file and publish it" = "مكتبة: متغيراتها للقراءة فقط هنا؛ حررها في ملف المكتبة وانشره"
+"Light" = "فاتح"
+"Lighten" = "تفتيح"
+"Light|glass" = "الإضاءة"
+"Line" = "خط"
+"Line arrow" = "سهم خطي"
+"Line delta" = "فرق عدد الأسطر"
+"Line height" = "ارتفاع السطر"
+"Linear" = "خطي"
+"Lines" = "الأسطر"
+"Lines +/−" = "الأسطر +/−"
+"Lines added" = "الأسطر المضافة"
+"Lines deleted" = "الأسطر المحذوفة"
+"Link" = "رابط"
+"Link copied" = "نُسخ الرابط"
+"Link docs, code or tickets for developers." = "اربط مستندات أو كودًا أو تذاكر للمطورين."
+"Link frames on the Prototype tab, then present the flow" = "اربط الإطارات في علامة تبويب النموذج التفاعلي، ثم اعرض التدفق"
+"Link this component to its code component: instances then show your real snippet with their values." = "اربط هذا المكوّن بمكوّنه البرمجي؛ لتعرض نسخه المثيلة المقتطف الفعلي بقيمها."
+"List spacing" = "تباعد القائمة"
+"List style" = "نمط القائمة"
+"Live session" = "جلسة مباشرة"
+"Live · {people}" = "مباشر · {people}"
+"Local components" = "المكونات المحلية"
+"Local network" = "الشبكة المحلية"
+"Local styles" = "الأنماط المحلية"
+"Local variables" = "المتغيرات المحلية"
+"Lock/Unlock" = "قفل/إلغاء القفل"
+"Locked" = "مقفل"
+"Lowercase" = "أحرف صغيرة"
+"Luminance" = "النصوع"
+"Luminosity" = "الإضاءة اللونية"
+"Main" = "الرئيسي"
+"Main component deleted" = "حُذف المكوّن الرئيسي"
+"Main menu" = "القائمة الرئيسية"
+"Make a component" = "إنشاء مكوّن"
+"Make slot" = "إنشاء خانة محتوى"
+"Mar" = "مارس"
+"Margin" = "الهامش"
+"Mark all as read" = "تحديد الكل كمقروء"
+"Mark as resolved" = "تحديد كمحلول"
+"Mark as unread" = "تحديد كغير مقروء"
+"Mark as unresolved" = "تحديد كغير محلول"
+"Marker" = "قلم تحديد"
+"Mask" = "قناع"
+"Mass · Stiffness · Damping" = "الكتلة · الصلابة · التخميد"
+"Match (empty: the whole name)" = "المطابقة (فارغ: الاسم كاملًا)"
+"Max H" = "أقصى ارتفاع"
+"Max W" = "أقصى عرض"
+"Max lines" = "أقصى عدد للأسطر"
+"Maximize" = "تكبير النافذة"
+"May" = "مايو"
+"Measure to the selection" = "قياس المسافة إلى التحديد"
+"Measurements" = "القياسات"
+"Merge into main" = "دمج في الملف الرئيسي"
+"Merge regions" = "دمج المناطق"
+"Merge the branch into the main file (one undo step) and archive it" = "دمج الفرع في الملف الرئيسي (خطوة تراجع واحدة) وأرشفته"
+"Merged" = "مدمج"
+"Merged PRs" = "طلبات السحب المدمجة"
+"Min H" = "أدنى ارتفاع"
+"Min W" = "أدنى عرض"
+"Minimize" = "تصغير النافذة"
+"Minutes" = "الدقائق"
+"Mirror angle" = "تماثل الزاوية"
+"Mirror angle and length" = "تماثل الزاوية والطول"
+"Mirroring" = "التماثل"
+"Missing" = "مفقود"
+"Missing fonts" = "خطوط مفقودة"
+"Missing layer" = "طبقة مفقودة"
+"Missing style" = "نمط مفقود"
+"Missing variable" = "متغير مفقود"
+"Miter" = "وصلة حادة"
+"Miter angle" = "زاوية الوصلة الحادة"
+"Mixed" = "مختلط"
+"Mixed fonts" = "خطوط مختلطة"
+"Mixed interactions: click + to replace them" = "تفاعلات مختلطة: انقر على + لاستبدالها"
+"Mobile app" = "تطبيق جوال"
+"Mode" = "الوضع"
+"Model" = "النموذج"
+"Models" = "النماذج"
+"Mono" = "أحادي"
+"More" = "المزيد"
+"More instance actions" = "مزيد من إجراءات النسخة المثيلة"
+"Mouse down" = "ضغط زر الفأرة"
+"Mouse enter" = "دخول المؤشر"
+"Mouse leave" = "مغادرة المؤشر"
+"Mouse up" = "إفلات زر الفأرة"
+"Move along the toolbar or the layer rows" = "التنقل على شريط الأدوات أو صفوف الطبقات"
+"Move" = "نقل"
+"Move column left" = "نقل العمود لليسار"
+"Move column right" = "نقل العمود لليمين"
+"Move down" = "نقل لأسفل"
+"Move in" = "نقل للداخل"
+"Move out" = "نقل للخارج"
+"Move tools" = "أدوات النقل"
+"Move up" = "نقل لأعلى"
+"Moved to {name}" = "نُقل إلى {name}"
+"Multi" = "متعدد"
+"Multiply" = "ضرب"
+"Muted" = "هادئ"
+"Name" = "الاسم"
+"Name (A–Z)" = "الاسم (A–Z)"
+"Named instance" = "نسخة مسماة"
+"Navigate to" = "الانتقال إلى"
+"Nested instance" = "نسخة مثيلة متداخلة"
+"New board" = "لوحة جديدة"
+"New design file" = "ملف تصميم جديد"
+"Newest first" = "الأحدث أولًا"
+"Next or previous control in the focused region" = "عنصر التحكم التالي أو السابق في المنطقة المركّز عليها"
+"Next frame" = "الإطار التالي"
+"Next ready for dev" = "التالي الجاهز للتطوير"
+"Next size option" = "خيار الحجم التالي"
+"No assets: no export settings, icons or images. Add export settings in the Design tab." = "لا توجد أصول: لا إعدادات تصدير ولا أيقونات ولا صور. أضف إعدادات تصدير في علامة تبويب التصميم."
+"No boolean variables fit here." = "لا توجد متغيرات منطقية مناسبة هنا."
+"No boolean variables yet. Create them in Local variables." = "لا توجد متغيرات منطقية بعد. أنشئها في «المتغيرات المحلية»."
+"No branches. A branch is a copy of the file to try changes in, then review and merge." = "لا توجد فروع. الفرع نسخة من الملف لتجربة التغييرات ثم مراجعتها ودمجها."
+"No category" = "دون فئة"
+"No changes" = "لا توجد تغييرات"
+"No color styles" = "لا توجد أنماط ألوان"
+"No colour variables fit here." = "لا توجد متغيرات ألوان مناسبة هنا."
+"No colour variables yet. Create them in Local variables." = "لا توجد متغيرات ألوان بعد. أنشئها في «المتغيرات المحلية»."
+"No comments yet" = "لا توجد تعليقات بعد"
+"No compatibility warnings reported" = "لم تُسجل تحذيرات توافق"
+"No components yet. Select layers and press {keys}." = "لا توجد مكونات بعد. حدد طبقات واضغط على {keys}."
+"No conflicts." = "لا توجد تعارضات."
+"No contributor data was built into this copy." = "لم تُضمّن بيانات المساهمين في هذه النسخة."
+"No device" = "دون جهاز"
+"No fonts match" = "لا توجد خطوط مطابقة"
+"No files or templates match your search" = "لا توجد ملفات أو قوالب تطابق بحثك"
+"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "لا يتوفر رمز انضمام هنا (الخادم أقدم من دعم الرموز، أو يتعذر تضمين عنوانه في رمز)؛ شارك الرابط بدلًا منه."
+"No layer changes to show." = "لا توجد تغييرات في الطبقات لعرضها."
+"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "لا توجد مكتبات بعد. أضف ملف .figcraft منشورًا كمكتبة، أو الصق رابط مكتبة فريق."
+"No list" = "دون قائمة"
+"No matching comments" = "لا توجد تعليقات مطابقة"
+"No matching components" = "لا توجد مكونات مطابقة"
+"No matching layers" = "لا توجد طبقات مطابقة"
+"No matching styles" = "لا توجد أنماط مطابقة"
+"No matching variables" = "لا توجد متغيرات مطابقة"
+"No mirroring" = "دون تماثل"
+"No model credits were built into this copy." = "لم تُضمّن أسماء النماذج المساهمة في هذه النسخة."
+"No number variables fit here." = "لا توجد متغيرات رقمية مناسبة هنا."
+"No number variables yet. Create them in Local variables." = "لا توجد متغيرات رقمية بعد. أنشئها في «المتغيرات المحلية»."
+"No other pages" = "لا توجد صفحات أخرى"
+"No paints" = "دون تلوين"
+"No plugins on {server}." = "لا توجد إضافات على {server}."
+"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "لا توجد إضافات بعد. استورد إضافة تطوير من ملف figcraft-plugin.json الخاص بها."
+"No preferred components" = "لا توجد مكونات مفضلة"
+"No preset" = "دون إعداد مسبق"
+"No saved versions yet. Save a version to mark a point in your work you can come back to." = "لا توجد إصدارات محفوظة بعد. احفظ إصدارًا لتحدد مرحلة من عملك يمكنك العودة إليها."
+"No scrolling" = "دون تمرير"
+"No shortcuts match" = "لا توجد اختصارات مطابقة"
+"No status" = "دون حالة"
+"No string variables fit here." = "لا توجد متغيرات نصية مناسبة هنا."
+"No string variables yet. Create them in Local variables." = "لا توجد متغيرات نصية بعد. أنشئها في «المتغيرات المحلية»."
+"No styles yet." = "لا توجد أنماط بعد."
+"No styles yet. Click + to create one from the selection." = "لا توجد أنماط بعد. انقر على + لإنشاء نمط من التحديد."
+"No templates match your search" = "لا توجد قوالب تطابق بحثك"
+"No text styles yet. Click + to create one from the selected text." = "لا توجد أنماط نصوص بعد. انقر على + لإنشاء نمط من النص المحدد."
+"No variables fit here." = "لا توجد متغيرات مناسبة هنا."
+"No variables yet. Create them in Local variables." = "لا توجد متغيرات بعد. أنشئها في «المتغيرات المحلية»."
+"No votes yet" = "لا توجد أصوات بعد"
+"Nobody else is here yet" = "لم ينضم أحد غيرك بعد"
+"Noise" = "تشويش"
+"None" = "بلا"
+"None." = "لا شيء."
+"Normal" = "عادي"
+"Not bound" = "غير مرتبط"
+"Not in a live session" = "لست في جلسة مباشرة"
+"Not in this font" = "غير متاح في هذا الخط"
+"Not properties, kept as written: {names}" = "ليست خصائص، أُبقيت كما كُتبت: {names}"
+"Not read yet" = "لم تُقرأ بعد"
+"Not shared" = "غير مشارك"
+"Note {n}" = "الملاحظة {n}"
+"Nothing changed since this version." = "لم يتغير شيء منذ هذا الإصدار."
+"Nothing changed since this was marked ready for dev." = "لم يتغير شيء منذ تحديد هذا العنصر كجاهز للتطوير."
+"Nothing here yet. Set a frame's status to list it." = "لا يوجد شيء هنا بعد. عيّن حالة إطار لإدراجه."
+"Nothing online yet. Use Share on a file to put it online and it is listed here." = "لا توجد ملفات على الإنترنت بعد. استخدم «مشاركة» على ملف لإتاحته على الإنترنت وإدراجه هنا."
+"Nothing to export" = "لا يوجد ما يُصدّر"
+"Nov" = "نوفمبر"
+"Now" = "الآن"
+"Nudge (Shift: big nudge)" = "إزاحة طفيفة (Shift: إزاحة أكبر)"
+"Nudge amount" = "مقدار الإزاحة الطفيفة"
+"Number" = "رقم"
+"Number ↑" = "الرقم ↑"
+"Number ↓" = "الرقم ↓"
+"Numbered list" = "قائمة مرقمة"
+"Object" = "كائن"
+"Oct" = "أكتوبر"
+"Off" = "إيقاف"
+"Offered first when filling the slot (any content is allowed)" = "يُقترح أولًا عند ملء الخانة (يُسمح بأي محتوى)"
+"Offered first when swapping the instance (any component is allowed)" = "يُقترح أولًا عند استبدال النسخة المثيلة (يُسمح بأي مكوّن)"
+"Offset" = "الإزاحة"
+"Oldest first" = "الأقدم أولًا"
+"On click" = "عند النقر"
+"On drag" = "عند السحب"
+"On this page" = "في هذه الصفحة"
+"One announcement in five common sizes: square, portrait, story, link and banner" = "إعلان واحد بخمسة أحجام شائعة: مربع وطولي وقصة ورابط ولافتة"
+"Online" = "على الإنترنت"
+"Online files" = "ملفات الإنترنت"
+"Online sharing is off: files are shared on the local network only." = "المشاركة عبر الإنترنت متوقفة؛ تُشارك الملفات على الشبكة المحلية فقط."
+"Only the first changed layers are listed." = "تُسرد الطبقات الأولى التي تغيرت فقط."
+"Only the first {n} changed layers are listed." = "تُسرد أول {n} من الطبقات التي تغيرت فقط."
+"Only your threads" = "سلاسل تعليقاتك فقط"
+"Opacity" = "العتامة"
+"Opacity & blend" = "العتامة والمزج"
+"Opacity 10%…90%, 0: 100%" = "عتامة 10%…90%، 0: 100%"
+"Open" = "فتح"
+"Open .figcraft, .svg or images" = "فتح ملفات .figcraft أو .svg أو صور"
+"Open branch" = "فتح الفرع"
+"Open file" = "فتح ملف"
+"Open files" = "فتح ملفات"
+"Open presentations" = "فتح العروض التقديمية"
+"Open in focus view" = "فتح في عرض التركيز"
+"Open in new window" = "فتح في نافذة جديدة"
+"Open invite links in FigCraft" = "فتح روابط الدعوة في FigCraft"
+"Open link" = "فتح الرابط"
+"Open link {url}" = "فتح الرابط {url}"
+"Open overlay" = "فتح طبقة عائمة"
+"Open prototype preview" = "فتح معاينة النموذج التفاعلي"
+"Open variables ({count})" = "فتح المتغيرات ({count})"
+"Opened as a new document" = "فُتح كمستند جديد"
+"Opened “{name}” in a new tab" = "فُتح «{name}» في علامة تبويب جديدة"
+"Opening the file…" = "جارٍ فتح الملف…"
+"Opening {n} files as new files" = { other = "جارٍ فتح ملفات جديدة، العدد: {n}" }
+"Open|comments" = "مفتوحة"
+"Options" = "الخيارات"
+"Orange" = "برتقالي"
+"Order" = "الترتيب"
+"Other apps may not open every .fig file exported here." = "قد لا تتمكن التطبيقات الأخرى من فتح كل ملف .fig يُصدّر هنا."
+"Other" = "أخرى"
+"Others lose access and keep their copy; the file stays on the server for you" = "يفقد الآخرون الوصول ويحتفظون بنسخهم؛ ويبقى الملف لك على الخادم"
+"Others type this under Share ▸ Join with a code" = "يدخل الآخرون هذا الرمز ضمن مشاركة ▸ الانضمام برمز"
+"Outline stroke" = "تحويل الحد الخارجي إلى شكل"
+"Outline text" = "تحويل النص إلى مسارات"
+"Outlines" = "المخططات الخارجية"
+"Outside" = "خارج"
+"Overflow" = "التجاوز"
+"Overlay" = "طبقة عائمة"
+"Overlay position" = "موضع الطبقة العائمة"
+"Overridden here (the collection it extends has another value)" = "متجاوز هنا (للمجموعة التي يوسّعها قيمة أخرى)"
+"Overrides" = "التجاوزات"
+"Owner token" = "رمز المالك"
+"Owner token copied" = "نُسخ رمز المالك"
+"PDF print options" = "خيارات طباعة PDF"
+"PDF print settings" = "إعدادات طباعة PDF"
+"Padding" = "الحشو"
+"Page" = "صفحة"
+"Page deleted" = "حُذفت الصفحة"
+"Pages" = "الصفحات"
+"Pages added to the document" = "أُضيفت الصفحات إلى المستند"
+"Pause" = "إيقاف مؤقت"
+"Pink" = "وردي"
+"Placed on the current page" = "وُضع في الصفحة الحالية"
+"Pages: {pages}" = "الصفحات: {pages}"
+"Paint bucket" = "دلو الطلاء"
+"Pan the canvas" = "تحريك لوحة الرسم"
+"Pan with nothing selected (Shift: farther)" = "تحريك العرض دون تحديد (Shift: مسافة أكبر)"
+"Panels" = "اللوحات"
+"Paper" = "الورق"
+"Paragraph indent" = "المسافة البادئة للفقرة"
+"Paragraph indent applies to left-aligned text" = "تُطبق المسافة البادئة على النص المحاذى لليسار"
+"Paragraph spacing" = "تباعد الفقرات"
+"Pass through" = "تمرير عبر"
+"Paste a link (https://…)" = "الصق رابطًا (https://…)"
+"Paste a team library link (a view link)" = "الصق رابط مكتبة فريق (رابط عرض)"
+"Paste effect" = "لصق التأثير"
+"Paste fill" = "لصق التعبئة"
+"Paste here" = "لصق هنا"
+"Paste stroke" = "لصق الحد الخارجي"
+"Paste to replace" = "لصق للاستبدال"
+"Pasted design layers; {n} kinds of content were left out" = { other = "لُصقت طبقات التصميم؛ عدد أنواع المحتوى التي أُهملت: {n}" }
+"Pattern" = "نمط متكرر"
+"Pattern alignment" = "محاذاة النمط المتكرر"
+"Pattern scale" = "مقياس النمط المتكرر"
+"Pattern source" = "مصدر النمط المتكرر"
+"Pen" = "قلم"
+"Pencil" = "قلم رصاص"
+"People in this file" = "الأشخاص في هذا الملف"
+"People join from anywhere with a code. The file is kept on {server} while you share it." = "يمكن الانضمام من أي مكان برمز. يُحفظ الملف على {server} أثناء مشاركته."
+"People on your network join with a code. Nothing goes through a server." = "ينضم الأشخاص على شبكتك برمز. لا تمر البيانات عبر خادم."
+"Phone" = "هاتف"
+"Pick color" = "التقاط لون"
+"Pick color (I)" = "التقاط لون (I)"
+"Pick one font family first" = "اختر عائلة خط واحدة أولًا"
+"Pick the Frame tool, then drag on the canvas or choose a device size" = "اختر أداة الإطار، ثم اسحب على لوحة الرسم أو اختر حجم جهاز"
+"Pin a property" = "تثبيت خاصية"
+"Pinch" = "قرص"
+"Pixel grid" = "شبكة البكسلات"
+"Pixel preview 1x" = "معاينة البكسلات 1x"
+"Pixel preview 2x" = "معاينة البكسلات 2x"
+"Place a shape or frame (its tool chosen)" = "وضع شكل أو إطار (بعد اختيار أداته)"
+"Place automatically" = "وضع تلقائي"
+"Place image" = "وضع صورة"
+"Plugin" = "إضافة"
+"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "تعمل الإضافات داخل بيئة FigCraft المعزولة دون ملفات أو شبكة أو ساعة. لا تثبّت إلا إضافات تثق بها."
+"Plugins" = "الإضافات"
+"Plugins & widgets" = "الإضافات وعناصر الواجهة"
+"Plus darker" = "جمع مع تعتيم"
+"Plus lighter" = "جمع مع تفتيح"
+"Plus one" = "إضافة واحد"
+"Point" = "نقطة"
+"Points" = "نقاط"
+"Polygon" = "مضلع"
+"Polygons" = "مضلعات"
+"Port" = "المنفذ"
+"Position" = "الموضع"
+"Post" = "نشر"
+"Preferences" = "التفضيلات"
+"Preferred" = "مفضل"
+"Present" = "عرض"
+"Present or preview" = "عرض أو معاينة"
+"Present this flow" = "عرض هذا التدفق"
+"Presentation" = "العرض التقديمي"
+"Presentation slides" = "شرائح عرض تقديمي"
+"Presenting: comments" = "العرض: التعليقات"
+"Presenting: cycle the size options" = "العرض: التبديل بين خيارات الحجم"
+"Presenting: full screen" = "العرض: ملء الشاشة"
+"Presenting: next frame" = "العرض: الإطار التالي"
+"Presenting: previous frame" = "العرض: الإطار السابق"
+"Presenting: restart" = "العرض: إعادة البدء"
+"Preserve scroll position" = "الحفاظ على موضع التمرير"
+"Presets" = "الإعدادات المسبقة"
+"Press a key…" = "اضغط على مفتاح…"
+"Playground" = "ساحة التجربة"
+"Preview" = "معاينة"
+"Preview this flow" = "معاينة هذا التدفق"
+"Preview this frame" = "معاينة هذا الإطار"
+"Previous frame" = "الإطار السابق"
+"Previous ready for dev" = "السابق الجاهز للتطوير"
+"Progressive" = "تدريجي"
+"Properties" = "الخصائص"
+"Prototype" = "النموذج التفاعلي"
+"Prototype preview" = "معاينة النموذج التفاعلي"
+"Prototype settings" = "إعدادات النموذج التفاعلي"
+"Publish" = "نشر"
+"Publish changes" = "نشر التغييرات"
+"Publish to another server…" = "النشر إلى خادم آخر…"
+"Publish to server" = "النشر إلى الخادم"
+"Published version {v}" = "الإصدار المنشور {v}"
+"Published version {v} to the server" = "نُشر الإصدار {v} إلى الخادم"
+"Published version {v}: save the file so other files can use it" = "نُشر الإصدار {v}: احفظ الملف لتتمكن ملفات أخرى من استخدامه"
+"Published · {people}" = "منشور · {people}"
+"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "يتطلب النشر إلى خادم تطبيق سطح المكتب؛ ويمكن الانضمام إلى ملفات الخادم من المتصفح"
+"Publishing with the server's key." = "جارٍ النشر باستخدام مفتاح الخادم."
+"Publishing…" = "جارٍ النشر…"
+"Push" = "دفع"
+"Push changes to main component" = "دفع التغييرات إلى المكوّن الرئيسي"
+"Put text on path" = "وضع النص على مسار"
+"Question" = "سؤال"
+"R" = "R"
+"Radial" = "شعاعي"
+"Radius" = "نصف القطر"
+"Ratio" = "النسبة"
+"Reading the library…" = "جارٍ قراءة المكتبة…"
+"Ready for dev" = "جاهز للتطوير"
+"Real name" = "الاسم الحقيقي"
+"Recent" = "الأخيرة"
+"Recent files" = "الملفات الأخيرة"
+"Recents" = "الأخيرة"
+"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "استُعيدت ملفات غير محفوظة بعد إغلاق التطبيق المفاجئ، عددها {n}؛ احفظها للاحتفاظ بها" }
+"Rectangle" = "مستطيل"
+"Rectangles" = "مستطيلات"
+"Red" = "أحمر"
+"Reduce motion" = "تقليل الحركة"
+"Refraction" = "الانكسار"
+"Regular expression" = "تعبير نمطي"
+"Relative to" = "بالنسبة إلى"
+"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "بالنسبة إلى مقياس عرض النظام (100% هو الحجم الأصلي للنظام). تكبير المستند مستقل عن ذلك."
+"Remove" = "إزالة"
+"Remove all" = "إزالة الكل"
+"Remove annotation" = "إزالة التعليق التوضيحي"
+"Remove auto layout" = "إزالة التخطيط التلقائي"
+"Remove column" = "إزالة العمود"
+"Remove dev resource" = "إزالة مورد المطور"
+"Remove export setting" = "إزالة إعداد التصدير"
+"Remove from recents" = "إزالة من الملفات الأخيرة"
+"Remove from server" = "إزالة من الخادم"
+"Remove from server…" = "إزالة من الخادم…"
+"Remove guide" = "إزالة الدليل"
+"Remove interaction" = "إزالة التفاعل"
+"Remove library from this file" = "إزالة المكتبة من هذا الملف"
+"Remove measurement" = "إزالة القياس"
+"Remove min and max" = "إزالة الحدين الأدنى والأقصى"
+"Remove min and max height" = "إزالة الحدين الأدنى والأقصى للارتفاع"
+"Remove min and max width" = "إزالة الحدين الأدنى والأقصى للعرض"
+"Remove regions" = "إزالة المناطق"
+"Remove row" = "إزالة الصف"
+"Remove slot" = "إزالة خانة المحتوى"
+"Remove starting point" = "إزالة نقطة البداية"
+"Remove stop" = "إزالة نقطة التدرج"
+"Remove text from path" = "إزالة النص من المسار"
+"Remove the else and its actions" = "إزالة «وإلا» وإجراءاتها"
+"Remove this condition and its actions" = "إزالة هذا الشرط وإجراءاته"
+"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "هل تريد إزالة هذا الملف من الخادم؟ لن يمكن مشاركته أو فتحه من هناك مجددًا. تبقى نسختك مفتوحة هنا."
+"Remove {name} from session" = "إزالة {name} من الجلسة"
+"Remove {name} from {server}?" = "هل تريد إزالة {name} من {server}؟"
+"Removed" = "أُزيل"
+"Removed from the server" = "أُزيل من الخادم"
+"Removed since it was marked" = "أُزيل منذ تحديد الحالة"
+"Removed since this version" = "أُزيل منذ هذا الإصدار"
+"Rename" = "إعادة تسمية"
+"Rename duplicated layers" = "إعادة تسمية الطبقات المكررة"
+"Rename folder" = "إعادة تسمية المجلد"
+"Rename group" = "إعادة تسمية مجموعة العناصر"
+"Rename layers" = "إعادة تسمية الطبقات"
+"Rename mode" = "إعادة تسمية الوضع"
+"Rename on the server" = "إعادة التسمية على الخادم"
+"Rename on the server…" = "إعادة التسمية على الخادم…"
+"Rename page" = "إعادة تسمية الصفحة"
+"Rename to" = "إعادة التسمية إلى"
+"Reorder selection" = "إعادة ترتيب التحديد"
+"Replace all" = "استبدال الكل"
+"Replace mixed export settings" = "استبدال إعدادات التصدير المختلطة"
+"Replace with" = "استبدال بـ"
+"Replace with a new interaction" = "استبدال بتفاعل جديد"
+"Reply" = "رد"
+"Report" = "إبلاغ"
+"Report this file…" = "الإبلاغ عن هذا الملف…"
+"Reset" = "إعادة تعيين"
+"Reset adjustments" = "إعادة تعيين الضبط"
+"Reset all changes" = "إعادة تعيين جميع التغييرات"
+"Reset all overrides on this layer" = "إعادة تعيين جميع التجاوزات على هذه الطبقة"
+"Reset overrides" = "إعادة تعيين التجاوزات"
+"Reset slot" = "إعادة تعيين خانة المحتوى"
+"Reset this override" = "إعادة تعيين هذا التجاوز"
+"Reset to style" = "إعادة إلى النمط"
+"Reset to the inherited value" = "إعادة إلى القيمة الموروثة"
+"Resize by the nudge amount" = "تغيير الحجم بمقدار الإزاحة الطفيفة"
+"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "تغيير حجم الإطارات ليلائم شاشة الجهاز (أو النافذة عند غياب الجهاز)؛ تستجيب قيودها وتخطيطها التلقائي لذلك"
+"Resize to fit" = "تغيير الحجم للملاءمة"
+"Resizing" = "تغيير الحجم"
+"Resolve" = "حل"
+"Resolve change" = "حل التغيير"
+"Resolved" = "محلول"
+"Resources" = "الموارد"
+"Responsive" = "متجاوب"
+"Restart" = "إعادة البدء"
+"Restore" = "استعادة"
+"Restore main component" = "استعادة المكوّن الرئيسي"
+"Restore this version" = "استعادة هذا الإصدار"
+"Restored “{name}”" = "استُعيد «{name}»"
+"Retrospective" = "مراجعة ختامية"
+"Results" = "النتائج"
+"Resume" = "استئناف"
+"Reverse the order" = "عكس الترتيب"
+"Reversed triangle" = "مثلث معكوس"
+"Review and merge" = "مراجعة ودمج"
+"Ribbon" = "شريط"
+"Right" = "يمين"
+"Right padding" = "الحشو الأيمن"
+"Right side" = "الجانب الأيمن"
+"Right side width" = "عرض الجانب الأيمن"
+"Rotate 90°" = "تدوير 90°"
+"Rotate 90° right" = "تدوير 90° لليمين"
+"Rotate to landscape" = "تدوير إلى الاتجاه الأفقي"
+"Rotate to portrait" = "تدوير إلى الاتجاه العمودي"
+"Rotation" = "الدوران"
+"Rotation jitter" = "عشوائية الدوران"
+"Round" = "دائري"
+"Rounded" = "مستدير"
+"Rounded rectangle" = "مستطيل مستدير الزوايا"
+"Row" = "صف"
+"Row gap" = "الفاصل بين الصفوف"
+"Row sizes" = "أحجام الصفوف"
+"Row span" = "امتداد الصفوف"
+"Rows" = "الصفوف"
+"Rows {count}" = "الصفوف {count}"
+"Rulers" = "المساطر"
+"Run" = "تشغيل"
+"Run {label}" = "تشغيل {label}"
+"Save file" = "حفظ الملف"
+"Save files automatically" = "حفظ الملفات تلقائيًا"
+"Search templates" = "بحث في القوالب"
+"See all templates" = "عرض جميع القوالب"
+"Select layers and they space themselves as their content changes" = "حدد طبقات لتتباعد تلقائيًا مع تغير محتواها"
+"SHA-256: {sha}…" = "SHA-256: {sha}…"
+"Same as layer" = "مثل الطبقة"
+"Saturation" = "التشبع"
+"Save" = "حفظ"
+"Save as" = "حفظ باسم"
+"Save as preset…" = "حفظ كإعداد مسبق…"
+"Save version {key}" = "حفظ إصدار {key}"
+"Saved" = "محفوظ"
+"Saved {name}" = "حُفظ {name}"
+"Say something" = "اكتب شيئًا"
+"Scale" = "المقياس"
+"Scatter" = "تناثر"
+"Scoping: where pickers offer it" = "النطاق: مواضع عرضه في أدوات الاختيار"
+"Screen" = "شاشة"
+"Scroll behavior" = "سلوك التمرير"
+"Scroll to" = "التمرير إلى"
+"Scroll to layer" = "التمرير إلى الطبقة"
+"Scroll with parent" = "التمرير مع العنصر الأب"
+"Search plugins" = "بحث في الإضافات"
+"Search" = "بحث"
+"Search actions, layers and tools" = "بحث عن إجراءات وطبقات وأدوات"
+"Search assets" = "بحث في الأصول"
+"Search comments" = "بحث في التعليقات"
+"Search files and templates" = "بحث عن ملفات وقوالب"
+"Search results" = "نتائج البحث"
+"Search fonts" = "بحث في الخطوط"
+"Search layers" = "بحث في الطبقات"
+"Search pages" = "بحث في الصفحات"
+"Search shortcuts" = "بحث في الاختصارات"
+"Section" = "قسم"
+"Section color" = "لون القسم"
+"Sections" = "الأقسام"
+"Select a frame and add a starting point to present it as a flow." = "حدد إطارًا وأضف نقطة بداية لعرضه كتدفق."
+"Select a frame to preview it" = "حدد إطارًا لمعاينته"
+"Select a layer to see its properties and code" = "حدد طبقة لعرض خصائصها وكودها"
+"Select all" = "تحديد الكل"
+"Select all with same" = "تحديد كل ما له نفس"
+"Select layer" = "تحديد طبقة"
+"Select layers to apply the style to" = "حدد الطبقات التي تريد تطبيق النمط عليها"
+"Select layers with this color" = "تحديد الطبقات بهذا اللون"
+"Select overlapping shapes" = "حدد أشكالًا متداخلة"
+"Select points to edit their position, radius and mirroring" = "حدد نقاطًا لتحرير مواضعها وأنصاف أقطارها وتماثلها"
+"Select slot" = "تحديد خانة المحتوى"
+"Selection colors" = "ألوان التحديد"
+"Selection" = "التحديد"
+"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "مربع التحديد، {w} × {h} عند {x}، {y}: الأسهم للنقل، Shift+الأسهم أو {cmd}+الأسهم للحجم، Enter للتحديد، Escape للإلغاء"
+"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "مربع التحديد: الأسهم للنقل، Shift+الأسهم أو {cmd}+الأسهم للحجم، Enter للتحديد، Escape للإلغاء"
+"Send backward" = "إرجاع مستوى واحد"
+"Send report" = "إرسال البلاغ"
+"Send to back" = "إرسال إلى الخلف"
+"Sep" = "سبتمبر"
+"Server address (ws://host:47900/)" = "عنوان الخادم (ws://host:47900/)"
+"Server address" = "عنوان الخادم"
+"Server for online sharing" = "خادم المشاركة عبر الإنترنت"
+"Server key" = "مفتاح الخادم"
+"Set as default" = "تعيين كافتراضي"
+"Set corner smoothing to 60%" = "تعيين تنعيم الزوايا إلى 60%"
+"Set variable" = "تعيين متغير"
+"Set variable mode" = "تعيين وضع المتغير"
+"Set variable {name}" = "تعيين المتغير {name}"
+"Set {name} to {value}" = "تعيين {name} إلى {value}"
+"Shadows" = "الظلال"
+"Shape" = "شكل"
+"Shape builder" = "باني الأشكال"
+"Shape tools" = "أدوات الأشكال"
+"Shapes" = "الأشكال"
+"Share" = "مشاركة"
+"Share this file" = "مشاركة هذا الملف"
+"Share with a code" = "المشاركة برمز"
+"Share: live session, export" = "المشاركة: جلسة مباشرة، تصدير"
+"Shared" = "مشارك"
+"Shared: copy its code from the file's menu" = "مشارك: انسخ رمزه من قائمة الملف"
+"Sharing a file needs the desktop app; here you can join one." = "تتطلب مشاركة ملف تطبيق سطح المكتب؛ ويمكنك الانضمام إلى ملف هنا."
+"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "المشاركة على الشبكة المحلية (المشاركة عبر الإنترنت متوقفة: لم يُحدد خادم في التفضيلات)."
+"Sharing on your network · {people}" = "مشارك على شبكتك · {people}"
+"Sharing online · {people}" = "مشارك على الإنترنت · {people}"
+"Sharing stopped: the file stays on the server, open only to you" = "توقفت المشاركة؛ يبقى الملف على الخادم متاحًا لك وحدك"
+"Show" = "إظهار"
+"Show all templates" = "إظهار جميع القوالب"
+"Show as grid" = "عرض كشبكة"
+"Show as list" = "عرض كقائمة"
+"Show autosaves" = "إظهار نسخ الحفظ التلقائي"
+"Show behind transparent areas" = "إظهار خلف المناطق الشفافة"
+"Show comments (Shift+C)" = "إظهار التعليقات (Shift+C)"
+"Show device frame" = "إظهار إطار الجهاز"
+"Show dimensions on objects" = "إظهار الأبعاد على الكائنات"
+"Show hotspot hints" = "إظهار تلميحات مناطق التفاعل"
+"Show in Explorer" = "إظهار في المستكشف"
+"Show in Finder" = "إظهار في Finder"
+"Show in folder" = "إظهار في المجلد"
+"Show only this on the canvas" = "إظهار هذا فقط على لوحة الرسم"
+"Show open" = "إظهار المفتوحة"
+"Show section contents" = "إظهار محتويات القسم"
+"Show this layer" = "إظهار هذه الطبقة"
+"Show usage" = "إظهار الاستخدام"
+"Show/Hide" = "إظهار/إخفاء"
+"Show/Hide UI" = "إظهار/إخفاء الواجهة"
+"Side by side" = "جنبًا إلى جنب"
+"Sidebar" = "الشريط الجانبي"
+"Simplify stroke" = "تبسيط الحد الخارجي"
+"Size" = "الحجم"
+"Size jitter" = "عشوائية الحجم"
+"Skip ink" = "تجنب حبر الحروف"
+"Slashed zero" = "صفر مشطوب"
+"Slice" = "شريحة"
+"Slices" = "الشرائح"
+"Slide in" = "انزلاق للداخل"
+"Slide out" = "انزلاق للخارج"
+"Slot" = "خانة محتوى"
+"Slot content" = "محتوى الخانة"
+"Small capitals" = "أحرف كبيرة مصغّرة"
+"Small caps" = "أحرف كبيرة مصغّرة"
+"Smart animate" = "تحريك ذكي"
+"Snap to pixel grid" = "محاذاة تلقائية إلى شبكة البكسلات"
+"Social and slides" = "وسائل التواصل والشرائح"
+"Social media" = "وسائل التواصل الاجتماعي"
+"Social post sizes" = "أحجام منشورات التواصل"
+"Soft light" = "ضوء ناعم"
+"Solid" = "مصمت"
+"Some variants have the same property values: give each one a unique combination" = "لبعض التنويعات قيم خصائص متطابقة؛ أعطِ كلًا منها تركيبة فريدة"
+"Sort" = "فرز"
+"Sort by" = "فرز حسب"
+"Sort by date" = "فرز حسب التاريخ"
+"Sort by page" = "فرز حسب الصفحة"
+"Sort by {column}" = "فرز حسب {column}"
+"Space around" = "مسافة حول العناصر"
+"Space between" = "مسافة بين العناصر"
+"Space drag" = "Space مع السحب"
+"Space evenly" = "مسافات متساوية"
+"Space+drag" = "Space+سحب"
+"Spacing" = "التباعد"
+"Spam, stolen work, abuse…" = "رسائل مزعجة أو عمل مسروق أو إساءة…"
+"Speech bubble" = "فقاعة كلام"
+"Splay" = "انفراج"
+"Spotlight me" = "توجيه الأنظار إليّ"
+"Spread" = "الانتشار"
+"Square" = "مربع"
+"Stamp" = "ختم"
+"Stamps" = "الأختام"
+"Standard" = "قياسي"
+"Standard ligatures" = "وصلات حروف قياسية"
+"Star" = "نجمة"
+"Stars" = "النجوم"
+"Start" = "بدء"
+"Start ascending from / stop descending at" = "بداية التصاعد من / نهاية التنازل عند"
+"Start from a blank canvas" = "البدء بلوحة رسم فارغة"
+"Start from a template" = "البدء بقالب"
+"Start on path" = "البداية على المسار"
+"Start on the Home screen" = "البدء من الشاشة الرئيسية"
+"Start point" = "نقطة البداية"
+"Start sharing" = "بدء المشاركة"
+"Start sharing with the Share button, then pass on the code it gives" = "ابدأ المشاركة بزر «مشاركة»، ثم أرسل الرمز الذي يظهر"
+"Start vote" = "بدء التصويت"
+"Starting to share…" = "جارٍ بدء المشاركة…"
+"Status" = "الحالة"
+"Sticky" = "ثابت عند التمرير"
+"Sticky note" = "ملاحظة لاصقة"
+"Sticky note colors" = "ألوان الملاحظات اللاصقة"
+"Sticky notes" = "الملاحظات اللاصقة"
+"Sticky notes, shapes and connectors" = "ملاحظات لاصقة وأشكال وموصلات"
+"Stop" = "إيقاف"
+"Stop following {name}" = "إيقاف متابعة {name}"
+"Stop session" = "إنهاء الجلسة"
+"Stop sharing" = "إيقاف المشاركة"
+"Stop spotlighting" = "إيقاف توجيه الأنظار"
+"Stop timer" = "إيقاف المؤقت"
+"Stop {n} {hex}" = "نقطة التدرج {n} {hex}"
+"Straight" = "مستقيم"
+"Stretch" = "تمديد"
+"Strikethrough" = "شطب"
+"String" = "نص"
+"Stroke" = "الحد الخارجي"
+"Stroke style" = "نمط الحد الخارجي"
+"Stroke weight" = "سُمك الحد الخارجي"
+"Strokes" = "الحدود الخارجية"
+"Style" = "النمط"
+"Styles" = "الأنماط"
+"Stylistic set 1" = "المجموعة الأسلوبية 1"
+"Stylistic set 2" = "المجموعة الأسلوبية 2"
+"Stylistic set 3" = "المجموعة الأسلوبية 3"
+"Stylistic set 4" = "المجموعة الأسلوبية 4"
+"Stylistic set 5" = "المجموعة الأسلوبية 5"
+"Subtract" = "طرح"
+"Subtracts" = "يطرح"
+"Suffix" = "لاحقة"
+"Suspend snapping while dragging" = "تعليق المحاذاة التلقائية أثناء السحب"
+"Swap instance" = "استبدال النسخة المثيلة"
+"Swap library: use another library's assets of the same names" = "استبدال المكتبة: استخدام أصول بنفس الأسماء من مكتبة أخرى"
+"Swap orientation" = "تبديل الاتجاه"
+"Swap overlay" = "استبدال الطبقة العائمة"
+"Swap to {name}" = "استبدال بـ {name}"
+"Swap width and height" = "تبديل العرض والارتفاع"
+"Swapped instance" = "استُبدلت النسخة المثيلة"
+"Swapped {n} assets" = { other = "استُبدلت أصول عددها {n}" }
+"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "استُبدلت أصول عددها {n}؛ بقي {missed} دون تطابق وتستخدم المكتبة القديمة" }
+"Sweep" = "امتداد"
+"System" = "النظام"
+"Tab / Shift+Tab" = "Tab / Shift+Tab"
+"Table" = "جدول"
+"Tablet" = "جهاز لوحي"
+"Tabular numbers" = "أرقام جدولية"
+"Take the values from the first selected layer" = "أخذ القيم من أول طبقة محددة"
+"Taper" = "تضييق تدريجي"
+"Taper both ends" = "تضييق الطرفين تدريجيًا"
+"Tell the server's operator what is wrong with it" = "أبلغ مشغّل الخادم بالمشكلة فيه"
+"Temperature" = "درجة الحرارة"
+"Template" = "قالب"
+"Templates" = "القوالب"
+"Terms" = "الشروط"
+"Text" = "النص"
+"Text align" = "محاذاة النص"
+"Text content" = "محتوى النص"
+"Text on a path" = "نص على مسار"
+"Text size" = "حجم النص"
+"Text style" = "نمط النص"
+"Text styles" = "أنماط النص"
+"Texts" = "النصوص"
+"Texture" = "النسيج"
+"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "تُعاين الألوان على لوحة الرسم بفضاء sRGB؛ وتُوسم ملفات PNG وJPG وWebP المصدّرة بملف Display P3."
+"The destination document is unavailable. Nothing was imported." = "المستند الوجهة غير متاح. لم يُستورد شيء."
+"The destination document was closed. Nothing was imported." = "أُغلق المستند الوجهة. لم يُستورد شيء."
+"The destination page was removed. Nothing was imported." = "أُزيلت الصفحة الوجهة. لم يُستورد شيء."
+"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "يُحذف الملف من الخادم؛ فلا يمكن لأحد فتحه أو الانضمام إليه من هناك مجددًا، ولا يمكن التراجع عن ذلك. تبقى النسخ المفتوحة لدى أصحابها."
+"The file now holds another library" = "يحتوي الملف الآن على مكتبة أخرى"
+"The file now holds another library (\"{name}\")" = "يحتوي الملف الآن على مكتبة أخرى («{name}»)"
+"The files you shared or published online from this computer. They stay on their server until you remove them." = "الملفات التي شاركتها أو نشرتها على الإنترنت من هذا الحاسوب. تبقى على خادمها حتى تزيلها."
+"The link carries the session's secret: share it only with people you invite." = "يحمل الرابط سر الجلسة؛ لا تشاركه إلا مع من تدعوهم."
+"The name in your online files and on the server. The document inside keeps its own name." = "الاسم في ملفاتك عبر الإنترنت وعلى الخادم. يحتفظ المستند الداخلي باسمه الخاص."
+"The selection uses several text styles: click to apply one" = "يستخدم التحديد عدة أنماط نصية؛ انقر لتطبيق نمط واحد"
+"The server deletes this file if nobody opens it before {date}." = "يحذف الخادم هذا الملف إن لم يفتحه أحد قبل {date}."
+"The whole file" = "الملف كاملًا"
+"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "النافذة أصغر من أن تستوعب {size}%، لذا تُعرض الواجهة بنسبة {drawn}%. كبّر النافذة لاستخدام {size}%."
+"Theme" = "السمة"
+"There's no destination for that action" = "لا توجد وجهة لهذا الإجراء"
+"Thickness" = "السُّمك"
+"This branch can't be reviewed: {e}" = "تعذرت مراجعة هذا الفرع: {e}"
+"This branch was merged or archived." = "دُمج هذا الفرع أو أُرشف."
+"This computer only" = "هذا الحاسوب فقط"
+"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "يحفظ هذا الحاسوب رمز المالك لإدارة الملف من قائمة «على الإنترنت» في الشاشة الرئيسية. انسخه لإدارة الملف من مكان آخر؛ فهو كلمة مرور لهذا الملف ولن يُعرض مجددًا."
+"This condition is edited as an expression" = "يُحرر هذا الشرط كتعبير"
+"This file" = "هذا الملف"
+"This file has unsaved changes" = "يحتوي هذا الملف على تغييرات غير محفوظة"
+"This file is too large to join here (up to {mb} MB)" = "هذا الملف أكبر من أن يمكن الانضمام إليه هنا (الحد {mb} ميغابايت)"
+"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "يستخدم هذا الملف خطوطًا غير مثبتة على هذا الحاسوب. يُعرض نصها بخط بديل حتى تثبتها."
+"This file is on {server}, open only to you. Share it to let others in with a new code." = "هذا الملف على {server} ومتاح لك وحدك. شاركه ليتمكن الآخرون من الانضمام برمز جديد."
+"This frame is new on the branch." = "هذا الإطار جديد في الفرع."
+"This frame isn't on the branch." = "هذا الإطار غير موجود في الفرع."
+"This link opens a website outside the prototype:" = "يفتح هذا الرابط موقعًا خارج النموذج التفاعلي:"
+"This server is open to the public: no key needed." = "هذا الخادم متاح للعامة؛ لا يتطلب مفتاحًا."
+"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "يتطلب هذا الخادم مفتاحه: أضف ?key=… إلى العنوان (يعرضه `figcraft-server key` على جهاز الخادم)."
+"This server takes files only with its key: see Advanced." = "لا يقبل هذا الخادم الملفات إلا بمفتاحه؛ راجع «متقدم»."
+"Thumbs up" = "إعجاب"
+"This version can't be read." = "تعذرت قراءة هذا الإصدار."
+"This version doesn't have it: all of it is new." = "لا يحتوي هذا الإصدار عليه؛ فهو جديد بالكامل."
+"Tidy up" = "ترتيب منظم"
+"Tile" = "تجانب"
+"Tile type" = "نوع التجانب"
+"Timer" = "المؤقت"
+"Tint" = "صبغة"
+"Title" = "العنوان"
+"Title case" = "حالة أحرف العنوان"
+"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "لمشاركة هذه المكتبة مع فريقك، انشر الملف إلى خادم (مشاركة ▸ النشر إلى الخادم…)، ثم انشر المكتبة هناك."
+"To the layer around it" = "إلى الطبقة المحيطة به"
+"Toggle" = "تبديل"
+"Toggle light/dark theme" = "تبديل السمة الفاتحة/الداكنة"
+"Toggle visibility" = "تبديل الظهور"
+"Tools" = "الأدوات"
+"Top" = "أعلى"
+"Top & bottom" = "أعلى وأسفل"
+"Top left radius" = "نصف قطر الزاوية العلوية اليسرى"
+"Top padding" = "الحشو العلوي"
+"Top right radius" = "نصف قطر الزاوية العلوية اليمنى"
+"Transform" = "تحويل"
+"Triangle" = "مثلث"
+"Triangle arrow" = "سهم مثلث"
+"Trigger" = "المحفّز"
+"Trigger layer" = "طبقة التحفيز"
+"True" = "صحيح"
+"Truncate text" = "اختصار النص"
+"Truncate with an ellipsis" = "اختصار بعلامة حذف"
+"Try again" = "إعادة المحاولة"
+"Try another search or filter." = "جرّب بحثًا أو مرشحًا آخر."
+"Try another search, or browse templates." = "جرّب بحثًا آخر أو استعرض القوالب."
+"Try another word, or look at every kind." = "جرّب كلمة أخرى أو اعرض جميع الأنواع."
+"Turn a layer into a component, reuse it, and change every copy at once" = "حوّل طبقة إلى مكوّن وأعد استخدامه لتغيّر جميع نسخه في آن واحد"
+"Two digits" = "رقمان"
+"Type" = "النوع"
+"Type settings" = "إعدادات الطباعة"
+"Type the join code you were given, or paste an invite link." = "أدخل رمز الانضمام الذي تلقيته أو الصق رابط دعوة."
+"Typography" = "الطباعة"
+"Underline" = "تسطير"
+"Undo takes back only your own changes; what others changed since stays." = "يلغي التراجع تغييراتك وحدها؛ وتبقى تغييرات الآخرين اللاحقة."
+"Ungroup" = "فك التجميع"
+"Uniform" = "موحّد"
+"Union" = "اتحاد"
+"Unions" = "اتحادات"
+"Unknown — treated as sRGB" = "غير معروف — يُعامل كـ sRGB"
+"Unread" = "غير مقروء"
+"Unresolve" = "إعادة فتح"
+"Unsaved changes" = "تغييرات غير محفوظة"
+"Unsaved changes in {names}. Save them before closing?" = "تغييرات غير محفوظة في {names}. هل تريد حفظها قبل الإغلاق؟"
+"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "تغييرات غير محفوظة: احفظ أولًا ({save})، أو أغلق علامة التبويب بالنقر مع {alt} لتجاهلها"
+"Unsaved changes: save first, or close again with {key} to discard" = "تغييرات غير محفوظة: احفظ أولًا، أو أغلق مجددًا مع {key} لتجاهلها"
+"Up" = "لأعلى"
+"Update check is not supported in the web browser." = "التحقق من التحديثات غير مدعوم في المتصفح."
+"Update plugin" = "تحديث الإضافة"
+"Update" = "تحديث"
+"Update from main" = "تحديث من الملف الرئيسي"
+"Update from selection" = "تحديث من التحديد"
+"Updated {components} and {styles}" = "حُدّث {components} و{styles}"
+"Updated {name}" = "حُدّث {name}"
+"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "غالبًا ما يفيد تحديث تعريف الرسوميات. لاستخدام OpenGL وحده، شغّل FigCraft من موجه الأوامر باستخدام:"
+"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "غالبًا ما يفيد تحديث تعريف الرسوميات. يحدد WGPU_BACKEND (vulkan أو gl أو metal) واجهة رسوميات أخرى لتجربتها."
+"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "يرفع هذا الملف إلى خادم FigCraft ويبقي علامة التبويب متزامنة معه. تحصل على رابط للتحرير وآخر للعرض."
+"Uppercase" = "أحرف كبيرة"
+"Use a fixed value" = "استخدام قيمة ثابتة"
+"Use an expression, e.g. count + 1" = "استخدام تعبير، مثل count + 1"
+"Use as mask" = "استخدام كقناع"
+"Use auto layout" = "استخدام التخطيط التلقائي"
+"Use number keys for opacity" = "استخدام مفاتيح الأرقام للعتامة"
+"Use the .figcraft or .fig extension." = "استخدم الامتداد .figcraft أو .fig."
+"Use the layer's fill" = "استخدام تعبئة الطبقة"
+"Use your browser's zoom to change the interface size." = "استخدم تكبير المتصفح لتغيير حجم الواجهة."
+"Used in {files} · {instances}" = "مستخدم في {files} · {instances}"
+"Username" = "اسم المستخدم"
+"Value" = "القيمة"
+"Uses variables: each layer shows them in its own mode" = "يستخدم متغيرات؛ تعرضها كل طبقة وفق وضعها الخاص"
+"Variable" = "متغير"
+"Variable collections" = "مجموعات المتغيرات"
+"Variable modes" = "أوضاع المتغيرات"
+"Variable width" = "عرض متغير"
+"Variables" = "المتغيرات"
+"Variables JSON…" = "ملف JSON للمتغيرات…"
+"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "تخزن المتغيرات قيمًا قابلة لإعادة الاستخدام (ألوانًا وأرقامًا ونصوصًا وقيمًا منطقية) بأوضاع مثل الفاتح والداكن."
+"Variant" = "تنويعة"
+"Vector" = "متجه"
+"Vectors" = "المتجهات"
+"Version" = "الإصدار"
+"Version history" = "سجل الإصدارات"
+"Version {version}" = "الإصدار {version}"
+"Version {v} · {components} · {styles} · {variables}" = "الإصدار {v} · {components} · {styles} · {variables}"
+"Vertical" = "رأسي"
+"Vertical align" = "محاذاة رأسية"
+"Vertical hex" = "سداسي رأسي"
+"Vertical padding" = "الحشو الرأسي"
+"Vertical spacing" = "التباعد الرأسي"
+"Vertical trim" = "القص الرأسي"
+"View" = "عرض"
+"View link" = "رابط العرض"
+"View link copied" = "نُسخ رابط العرض"
+"View only" = "للعرض فقط"
+"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "للعرض فقط: ترى كل تغيير فور حدوثه دون إمكانية التحرير. غادر للاحتفاظ بنسخة يمكنك تحريرها."
+"Viewing “{name}”" = "جارٍ عرض «{name}»"
+"Violet" = "بنفسجي"
+"Visibility" = "الظهور"
+"Visible" = "ظاهر"
+"Vivid" = "زاهٍ"
+"Votes per person" = "الأصوات لكل شخص"
+"Voting session" = "جلسة تصويت"
+"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "المتغير WGPU_BACKEND مضبوط ويحدد واجهة الرسوميات المستخدمة؛ ألغِ ضبطه ليختار FigCraft تلقائيًا."
+"Warm" = "دافئ"
+"Watch" = "ساعة يد"
+"Wavy" = "متموج"
+"Web" = "الويب"
+"Websites" = "المواقع"
+"WebSocket URL" = "عنوان WebSocket"
+"WebSocket URL copied" = "نُسخ عنوان WebSocket"
+"Wedge" = "إسفين"
+"Weekly planner" = "مخطط أسبوعي"
+"Weight" = "الوزن"
+"What changed since a saved version" = "ما تغير منذ إصدار محفوظ"
+"What you try here changes this preview and the code below, not the file" = "تُغيّر تجاربك هنا المعاينة والكود أدناه، ولا تُغيّر الملف"
+"What changed since it was marked ready for dev" = "ما تغير منذ تحديده كجاهز للتطوير"
+"What is wrong with this file? The server's operator gets your report." = "ما المشكلة في هذا الملف؟ يتلقى مشغّل الخادم بلاغك."
+"What it's for" = "الغرض منه"
+"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "الحالة السابقة لهذا التغيير غير معروفة؛ فقد حدث قبل أن يحتفظ FigCraft بنسخة من الإطارات الجاهزة للتطوير. حُلّ التغيير أو حدده كجاهز مجددًا لتبدأ المقارنة من الآن."
+"When marked ready" = "عند تحديده كجاهز"
+"While hovering" = "أثناء التحويم"
+"While pressing" = "أثناء الضغط"
+"White" = "أبيض"
+"Who runs it, its terms and how long it keeps files" = "الجهة المشغّلة وشروطها ومدة احتفاظها بالملفات"
+"Whole screen" = "الشاشة كاملة"
+"Why it changed" = "سبب التغيير"
+"Widget" = "عنصر واجهة"
+"Widgets" = "عناصر الواجهة"
+"Width" = "العرض"
+"Width point" = "نقطة عرض"
+"Width profile" = "منحنى العرض"
+"Wireframe kit" = "طقم مخطط هيكلي"
+"With the layers' export settings" = "بإعدادات تصدير الطبقات"
+"Wrap" = "التفاف"
+"Wrap in new section" = "إحاطة بقسم جديد"
+"Yellow" = "أصفر"
+"Yesterday" = "أمس"
+"You" = "أنت"
+"You'll get the shared file as a new tab and edit it together." = "ستحصل على الملف المشارك في علامة تبويب جديدة لتحريره مع الآخرين."
+"You're spotlighting: everyone follows your view" = "توجّه الأنظار إليك؛ يتابع الجميع عرضك"
+"Your copy of the file stays open" = "تبقى نسختك من الملف مفتوحة"
+"Your copy stays open; the file stays on the server" = "تبقى نسختك مفتوحة؛ ويبقى الملف على الخادم"
+"Your name" = "اسمك"
+"Your name in comments" = "اسمك في التعليقات"
+"Your system asks for reduced motion: animations stay off." = "يطلب نظامك تقليل الحركة؛ تبقى التحريكات متوقفة."
+"Z" = "Z"
+"Z to A" = "من Z إلى A"
+"Zoom" = "التكبير"
+"Zoom around the pointer" = "التكبير حول المؤشر"
+"Zoom device to fill window" = "تكبير الجهاز لملء النافذة"
+"Zoom in" = "تكبير"
+"Zoom out" = "تصغير"
+"Zoom to 100%" = "تكبير إلى 100%"
+"Zoom to 200%" = "تكبير إلى 200%"
+"Zoom to 50%" = "تكبير إلى 50%"
+"Zoom to fit" = "تكبير للملاءمة"
+"Zoom to selection" = "تكبير إلى التحديد"
+"Zoom tool: click to zoom in, drag an area" = "أداة التكبير: انقر للتكبير أو اسحب منطقة"
+"Zoom tool: zoom out" = "أداة التكبير: تصغير"
+"and {n} more" = { other = "والمزيد، العدد: {n}" }
+"bleed marks" = "علامات نزف الطباعة"
+"by {author}" = "بواسطة {author}"
+"call any command its access allows" = "استدعاء أي أمر تسمح به صلاحياتها"
+"call only: {commands}" = "استدعاء هذه الأوامر فقط: {commands}"
+"can edit the document" = "يمكنها تحرير المستند"
+"can save files" = "يمكنها حفظ الملفات"
+"conditional prototype actions nested too deeply were skipped" = "تم تخطي إجراءات النموذج التفاعلي الشرطية ذات التداخل العميق جدًا"
+"conditional prototype actions with conditions that can't be evaluated were skipped" = "تم تخطي إجراءات النموذج التفاعلي الشرطية التي يتعذر تقييم شروطها"
+"connector label background was skipped: style data is missing" = "تم تخطي خلفية تسمية الموصل لغياب بيانات النمط"
+"connector label was skipped: background bounds are invalid" = "تم تخطي تسمية الموصل لعدم صلاحية حدود الخلفية"
+"connector label was skipped: background transform is invalid" = "تم تخطي تسمية الموصل لعدم صلاحية تحويل الخلفية"
+"connector label was skipped: composed text transform is invalid" = "تم تخطي تسمية الموصل لعدم صلاحية تحويل النص المركب"
+"connector label was skipped: derived background bounds are missing" = "تم تخطي تسمية الموصل لغياب حدود الخلفية المشتقة"
+"connector label was skipped: derived text bounds are missing" = "تم تخطي تسمية الموصل لغياب حدود النص المشتقة"
+"connector label was skipped: text bounds are invalid" = "تم تخطي تسمية الموصل لعدم صلاحية حدود النص"
+"connector label was skipped: text style data is missing" = "تم تخطي تسمية الموصل لغياب بيانات نمط النص"
+"connector label was skipped: text transform is invalid" = "تم تخطي تسمية الموصل لعدم صلاحية تحويل النص"
+"connector labels were skipped" = "تم تخطي تسميات الموصلات"
+"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "تُستورد طبقات الموصلات كلقطات ثابتة من إطارات أو متجهات مع تسمياتها إن وجدت؛ ولا تُحفظ روابط نقاط النهاية الحية"
+"connector snapshot vector data is missing; connector was left empty" = "بيانات متجه لقطة الموصل مفقودة؛ تُرك الموصل فارغًا"
+"connectors whose ends can't be placed are empty" = "الموصلات التي يتعذر تحديد مواضع نهاياتها فارغة"
+"crop marks" = "علامات الاقتصاص"
+"damaged vector geometry was skipped" = "تم تخطي هندسة المتجه التالفة"
+"damaged vector networks import as outlines" = "تُستورد شبكات المتجهات التالفة كمخططات خارجية"
+"development: {path}" = "التطوير: {path}"
+"document children other than pages were skipped" = "تم تخطي عناصر المستند التابعة التي ليست صفحات"
+"duplicate grid track sizing entries use equal tracks" = "تستخدم إدخالات أحجام مسارات الشبكة المكررة مسارات متساوية"
+"e.g. count + 1" = "مثل count + 1"
+"e.g. count >= 3 and not done" = "مثل count >= 3 and not done"
+"elbowed and curved connectors without a saved path import as straight lines" = "تُستورد الموصلات الزاوية والمنحنية التي ليس لها مسار محفوظ كخطوط مستقيمة"
+"entries without a node type were skipped" = "تم تخطي الإدخالات التي ليس لها نوع عقدة"
+"figcraft:// links from a browser, chat or email open this app and join the session." = "تفتح روابط figcraft:// من المتصفح أو الدردشة أو البريد هذا التطبيق وتنضم إلى الجلسة."
+"filled areas of a vector with their own fill use the layer's fill" = "تستخدم مناطق المتجه المعبأة ذات التعبئة الخاصة تعبئة الطبقة"
+"from {server}" = "من {server}"
+"gamepad triggers without exactly one known controller button were skipped" = "تم تخطي محفزات ذراع الألعاب التي لا تحدد زرًا معروفًا واحدًا بالضبط"
+"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "تتجاوز قائمة مسارات الشبكة 1000 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"
+"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "أحجام مسارات الشبكة غير مدعومة أو غير سليمة؛ تستخدم المسارات المتأثرة أحجامًا متساوية"
+"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "تتجاوز قائمة أحجام مسارات الشبكة 1000 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية"
+"hidden" = "مخفي"
+"iOS" = "iOS"
+"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "مرشحات الصورة ملتبسة لوجود حاويتي المرشحات معًا؛ تم تخطي تحويل المرشحات"
+"image filters use FigCraft's approximate renderer" = "تستخدم مرشحات الصورة مصيّر FigCraft التقريبي"
+"instance swap properties whose default component isn't in the file were skipped" = "تم تخطي خصائص استبدال النسخة المثيلة التي لا يوجد مكوّنها الافتراضي في الملف"
+"instance swap properties whose default component was past the layer limit were skipped" = "تم تخطي خصائص استبدال النسخة المثيلة التي تجاوز مكوّنها الافتراضي حد الطبقات"
+"instances inside their own main component were imported as frames" = "استُوردت النسخ المثيلة الموجودة داخل مكوناتها الرئيسية كإطارات"
+"instances of components that aren't in the file show no layers" = "لا تعرض النسخ المثيلة لمكونات غير موجودة في الملف أي طبقات"
+"instances of components that aren't in the file were imported as frames" = "استُوردت النسخ المثيلة لمكونات غير موجودة في الملف كإطارات"
+"instances that would nest a component inside itself were imported as frames" = "استُوردت النسخ المثيلة التي قد تُداخل المكوّن داخل نفسه كإطارات"
+"instances whose component was past the layer limit were imported as frames" = "استُوردت النسخ المثيلة التي تجاوز مكوّنها حد الطبقات كإطارات"
+"instances without a main component were imported as empty frames" = "استُوردت النسخ المثيلة التي ليس لها مكوّن رئيسي كإطارات فارغة"
+"invalid internal .fig text path data was ignored" = "تم تجاهل بيانات مسار النص الداخلية غير الصالحة في .fig"
+"invalid or oversized vector networks import as outlines" = "تُستورد شبكات المتجهات غير الصالحة أو الكبيرة جدًا كمخططات خارجية"
+"invalid or oversized vector path data was skipped" = "تم تخطي بيانات مسارات المتجهات غير الصالحة أو الكبيرة جدًا"
+"just now" = "الآن"
+"key triggers without a supported key were skipped" = "تم تخطي محفزات المفاتيح التي لا تتضمن مفتاحًا مدعومًا"
+"key up triggers import as key presses" = "تُستورد محفزات إفلات المفتاح كضغطات مفتاح"
+"layers without a position were placed at their parent's origin" = "وُضعت الطبقات التي ليس لها موضع عند نقطة أصل العنصر الأب"
+"layers without a type were skipped" = "تم تخطي الطبقات التي ليس لها نوع"
+"legacy image filter container is unsupported; filter conversion was skipped" = "حاوية مرشحات الصورة القديمة غير مدعومة؛ تم تخطي تحويل المرشحات"
+"locked" = "مقفل"
+"malformed image filters container was skipped" = "تم تخطي حاوية مرشحات الصورة غير السليمة"
+"malformed image paintFilter container was skipped" = "تم تخطي حاوية paintFilter غير السليمة للصورة"
+"malformed slot content assignment was skipped" = "تم تخطي تعيين محتوى الخانة غير السليم"
+"no bleed" = "دون نزف طباعة"
+"nobody else here yet" = "لم ينضم أحد غيرك بعد"
+"non-default slot settings are not represented and were skipped" = "إعدادات الخانات غير الافتراضية غير ممثلة وتم تخطي"
+"now" = "الآن"
+"offer files to save if it asks to (you choose where each one goes)" = "عرض ملفات للحفظ عند طلبها (تختار وجهة كل ملف)"
+"only in a text style or variable" = "في نمط نص أو متغير فقط"
+"or" = "أو"
+"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "حُفظت وصلات حدود النقاط؛ وتُرسم الحدود المتقطعة أو متغيرة العرض بوصلة الطبقة"
+"preferred slot component keys cannot be resolved to file nodes and were skipped" = "تم تخطي مفاتيح المكونات المفضلة للخانات لتعذر ربطها بعقد الملف"
+"prototype actions of unknown types were skipped" = "تم تخطي إجراءات النموذج التفاعلي ذات الأنواع غير المعروفة"
+"prototype actions without a destination in the file were skipped" = "تم تخطي إجراءات النموذج التفاعلي التي لا وجهة لها في الملف"
+"prototype expressions on variables that aren't in the variables JSON were skipped" = "تم تخطي تعبيرات النموذج التفاعلي على متغيرات غير موجودة في ملف JSON للمتغيرات"
+"prototype expressions with unsupported functions were skipped" = "تم تخطي تعبيرات النموذج التفاعلي ذات الدوال غير المدعومة"
+"prototype interactions without supported actions were skipped" = "تم تخطي تفاعلات النموذج التي لا تتضمن إجراءات مدعومة"
+"prototype navigations of unknown types were skipped" = "تم تخطي انتقالات النموذج التفاعلي ذات الأنواع غير المعروفة"
+"prototype transitions of unknown types play instantly" = "تعمل انتقالات النموذج التفاعلي ذات الأنواع غير المعروفة فورًا"
+"prototype triggers of unknown types were skipped" = "تم تخطي محفزات النموذج التفاعلي ذات الأنواع غير المعروفة"
+"put a widget on the canvas" = "وضع عنصر واجهة على لوحة الرسم"
+"read and edit the document" = "قراءة المستند وتحريره"
+"read the document" = "قراءة المستند"
+"reads the document" = "تقرأ المستند"
+"set-variable actions on variables that aren't in the variables JSON were skipped" = "تم تخطي إجراءات تعيين المتغيرات غير الموجودة في ملف JSON للمتغيرات"
+"set-variable actions with values that can't be evaluated were skipped" = "تم تخطي إجراءات تعيين المتغيرات التي يتعذر تقييم قيمها"
+"set-variable actions with values that don't fit the variable were skipped" = "تم تخطي إجراءات تعيين المتغيرات التي لا تناسب قيمها المتغير"
+"set-variable actions without a value were skipped" = "تم تخطي إجراءات تعيين المتغيرات التي لا قيمة لها"
+"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "تم تخطي إجراءات تعيين أوضاع المتغيرات لمجموعات أو أوضاع غير موجودة في ملف JSON للمتغيرات"
+"slot content assignment target is missing or is not slot content" = "هدف تعيين محتوى الخانة مفقود أو ليس محتوى خانة"
+"slot content assignment uses the empty GUID sentinel and was skipped" = "يستخدم تعيين محتوى الخانة علامة GUID فارغة وتم تخطي"
+"slot layers without a matching slot property were imported as frames" = "استُوردت طبقات الخانات التي ليس لها خاصية خانة مطابقة كإطارات"
+"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "تشير بعض نقاط المتجهات إلى إعدادات نقاط مفقودة من الملف؛ تستخدم وصلات الطبقة وزوايا عادية"
+"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "تشير بعض مقاطع المتجهات إلى إعدادات أنماط مفقودة من الملف؛ تستخدم الحد الخارجي للطبقة"
+"stale or invalid grid track sizing entries were ignored" = "تم تجاهل إدخالات أحجام مسارات الشبكة القديمة أو غير الصالحة"
+"stars and polygons without point counts or geometry use the default point count" = "تستخدم النجوم والمضلعات التي لا تحدد عدد نقاط أو هندسة عدد النقاط الافتراضي"
+"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "إعدادات نمط بعض مقاطع المتجهات غير مدعومة؛ تستخدم هذه المقاطع الحد الخارجي للطبقة"
+"text path underline or strikethrough decoration is not rendered" = "لا تُرسم زخرفة التسطير أو الشطب للنص على المسار"
+"text style overrides refer to missing table entries" = "تشير تجاوزات نمط النص إلى إدخالات جدول مفقودة"
+"the file has no pages" = "لا يحتوي الملف على صفحات"
+"the plugin" = "الإضافة"
+"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "لا يتضمن ملف JSON للمتغيرات `variableCollections` (المتوقع استجابة المتغيرات المحلية)، لذا تم تجاهله"
+"this session only" = "هذه الجلسة فقط"
+"unknown component property types were skipped" = "تم تخطي أنواع خصائص المكونات غير المعروفة"
+"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "تم تخطي الأسماء المستعارة لمتغيرات غير موجودة في ملف JSON للمتغيرات أو ذات نوع مختلف"
+"variable bindings to properties the layer doesn't have were skipped" = "تم تخطي روابط المتغيرات بخصائص لا تملكها الطبقة"
+"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "تم تخطي روابط المتغيرات بمتغيرات غير موجودة في ملف JSON للمتغيرات (متغيرات المكتبة)"
+"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "تم تخطي روابط المتغيرات؛ لا يحتوي JSON الملف على تعريفات متغيرات (مرر JSON المتغيرات المحلية كـ `variables`، أو ضمّن `meta` الخاص به بجوار JSON الملف)"
+"variable bindings whose variable type doesn't fit the property were skipped" = "تم تخطي روابط المتغيرات التي لا يناسب نوعها الخاصية"
+"variable collections without modes were skipped" = "تم تخطي مجموعات المتغيرات التي ليس لها أوضاع"
+"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "تم تخطي عمليات البحث عن أوضاع المتغيرات في تعبيرات النموذج التي لا تحدد متغيرًا وأحد أوضاعه"
+"variable modes of collections that aren't in the file were skipped" = "تم تخطي أوضاع متغيرات مجموعات غير موجودة في الملف"
+"variable modes of collections that aren't in the variables JSON were skipped" = "تم تخطي أوضاع متغيرات مجموعات غير موجودة في ملف JSON للمتغيرات"
+"variable values that don't match their type (or composed colours) were skipped" = "تم تخطي قيم المتغيرات غير المطابقة لأنواعها أو الألوان المركّبة"
+"variables of unknown types were skipped" = "تم تخطي المتغيرات ذات الأنواع غير المعروفة"
+"variables whose collection isn't in the variables JSON were skipped" = "تم تخطي المتغيرات التي لا توجد مجموعاتها في ملف JSON للمتغيرات"
+"variables without readable values were skipped" = "تم تخطي المتغيرات التي لا تتوفر لها قيم مقروءة"
+"vector points with unknown stroke joins use the layer join" = "تستخدم نقاط المتجهات ذات وصلات الحدود غير المعروفة وصلة الطبقة"
+"vectors without geometry are empty" = "المتجهات التي ليس لها هندسة فارغة"
+"vectors without geometry are empty (export the file JSON with geometry=paths)" = "المتجهات التي ليس لها هندسة فارغة (صدّر JSON الملف مع geometry=paths)"
+"very long text was cut to 1,048,576 characters" = "اختُصر النص الطويل جدًا إلى 1,048,576 حرفًا"
+"very long vector paths were skipped" = "تم تخطي مسارات المتجهات الطويلة جدًا"
+"video actions are not supported and were skipped" = "إجراءات الفيديو غير مدعومة وتم تخطي"
+"video triggers are not supported and were skipped" = "محفزات الفيديو غير مدعومة وتم تخطي"
+"yesterday" = "أمس"
+"{instances} in {files}" = "{instances} في {files}"
+"{language}: draft translation, waiting for a native speaker's review." = "{language}: مسودة ترجمة تنتظر مراجعة متحدث أصلي باللغة."
+"{length} bleed" = "نزف طباعة {length}"
+"{month} {day}, {year}" = "{day} {month}، {year}"
+"{name} cleared from the list" = "أُزيل {name} من القائمة"
+"{name} is forgotten on this computer; the file stays on {server}" = "نُسي {name} على هذا الحاسوب؛ يبقى الملف على {server}"
+"{name} is on the server." = "{name} على الخادم."
+"{name} is spotlighting" = "{name} يوجّه الأنظار إليه"
+"{name} is too large to open in the browser (over 512 MB)." = "{name} أكبر من أن يُفتح في المتصفح (يتجاوز 512 ميغابايت)."
+"{name}\nClick to apply to the selection" = "{name}\nانقر للتطبيق على التحديد"
+"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nاسحب إلى لوحة الرسم أو انقر للوضع في وسط العرض.\nاضغط على Alt (Option) أثناء الإفلات على نسخة مثيلة لاستبدالها."
+"{name} (from {library})" = "{name} (من {library})"
+"{name} (you)" = "{name} (أنت)"
+"{name} ({layers} layers)" = "{name} (الطبقات: {layers})"
+"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}، الافتراضي {default}. انقر على شريط التمرير مرتين للعودة إلى قيمة النمط؛ واضغط Escape أثناء السحب للإلغاء."
+"{name} from {path}" = "{name} من {path}"
+"{name} was removed from the server" = "أُزيل {name} من الخادم"
+"{name} · deleted on main" = "{name} · محذوف في الملف الرئيسي"
+"{name} · deleted on the branch" = "{name} · محذوف في الفرع"
+"{name} · not published" = "{name} · غير منشور"
+"{name} · on a server" = "{name} · على خادم"
+"{name} · published version {v}" = "{name} · الإصدار المنشور {v}"
+"{name} · version {v} on the server" = "{name} · الإصدار {v} على الخادم"
+"{name}, in a live session" = "{name}، في جلسة مباشرة"
+"{name}, online, open only to you" = "{name}، على الإنترنت ومتاح لك وحدك"
+"{name}, {kind}, {w} by {h}, selected" = "{name}، {kind}، {w} × {h}، محدد"
+"{n} added" = { other = "عدد العناصر المضافة: {n}" }
+"{n} autosave versions" = { other = "عدد إصدارات الحفظ التلقائي: {n}" }
+"{n} changed" = { other = "عدد العناصر المتغيرة: {n}" }
+"{n} columns" = { other = "عدد الأعمدة: {n}" }
+"{n} components" = { other = "عدد المكونات: {n}" }
+"{n} components and styles have newer versions" = { other = "تتوفر إصدارات أحدث لمكونات وأنماط عددها {n}" }
+"{n} conflicts to resolve" = { other = "عدد التعارضات المطلوب حلها: {n}" }
+"{n} days ago" = { other = "منذ مدة بالأيام قدرها {n}" }
+"{n} files" = { other = "عدد الملفات: {n}" }
+"{n} hours ago" = { other = "منذ مدة بالساعات قدرها {n}" }
+"{n} instances" = { other = "عدد النسخ المثيلة: {n}" }
+"{n} layers" = { other = "عدد الطبقات: {n}" }
+"{n} layers as one file" = { other = "طبقات في ملف واحد، العدد: {n}" }
+"{n} layers selected" = { other = "عدد الطبقات المحددة: {n}" }
+"{n} layers · {parts}" = { other = "عدد الطبقات: {n} · {parts}" }
+"{n} matches" = { other = "عدد التطابقات: {n}" }
+"{n} min" = "{n} د"
+"{n} minutes ago" = { other = "منذ مدة بالدقائق قدرها {n}" }
+"{n} months ago" = { other = "منذ مدة بالأشهر قدرها {n}" }
+"{n} more notes omitted from this report" = { other = "عدد الملاحظات الإضافية المحذوفة من هذا التقرير: {n}" }
+"{n} of {total}" = "{n} من {total}"
+"{n} people joined" = { other = "عدد المنضمين: {n}" }
+"{n} preferred components" = { other = "عدد المكونات المفضلة: {n}" }
+"{n} ready" = { other = "عدد العناصر الجاهزة: {n}" }
+"{n} removed" = { other = "عدد العناصر المحذوفة: {n}" }
+"{n} replies" = { other = "عدد الردود: {n}" }
+"{n} rows" = { other = "عدد الصفوف: {n}" }
+"{n} styles" = { other = "عدد الأنماط: {n}" }
+"{n} text layers" = { other = "عدد طبقات النص: {n}" }
+"{n} variables" = { other = "عدد المتغيرات: {n}" }
+"{n} votes" = { other = "عدد الأصوات: {n}" }
+"{n} votes left" = { other = "عدد الأصوات المتبقية: {n}" }
+"{n} weeks ago" = { other = "منذ مدة بالأسابيع قدرها {n}" }
+"{n} years ago" = { other = "منذ مدة بالسنوات قدرها {n}" }
+"{n}d" = "{n} ي"
+"{n}h" = "{n} س"
+"{n}m" = "{n} د"
+"{n}w" = "{n} أ"
+"{page}, nothing selected" = "{page}، لا يوجد تحديد"
+"{percent}%" = "{percent}%"
+"{profile} (current)" = "{profile} (الحالي)"
+"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} هي قيمتها؛ ويختار {property ? \"a\" : \"b\"} نصًا. تبقى {…} الأخرى كما كُتبت."
+"{server} can't say what became of your files (it is older than this app)." = "لا يستطيع {server} تحديد ما حدث لملفاتك (إصداره أقدم من هذا التطبيق)."
+"{server} · No longer on the server" = "{server} · لم يعد موجودًا على الخادم"
+"{server} · {shared} · Opened {date}" = "{server} · {shared} · فُتح {date}"
+"{tool} hold {key}" = "{tool} اضغط مطولًا على {key}"
+"{value} isn't a colour" = "{value} ليس لونًا"
+"{when} · edited" = "{when} · عُدّل"
+"← ↑ → ↓" = "← ↑ → ↓"
+"← → ↑ ↓" = "← → ↑ ↓"
+"←" = "←"
+"→, Space or N" = "→، Space أو N"
+"↵" = "↵"
+"⇥ / ⇧⇥" = "⇥ / ⇧⇥"
+"⌘ arrows" = "⌘ الأسهم"
+"⌘ click" = "⌘ نقر"
+"⌘ scroll" = "⌘ تمرير"
+"⌥ drag" = "⌥ سحب"
+
+[commands]
+"align.bottom" = "محاذاة لأسفل"
+"align.hcenter" = "محاذاة المراكز الأفقية"
+"align.left" = "محاذاة لليسار"
+"align.right" = "محاذاة لليمين"
+"align.top" = "محاذاة لأعلى"
+"align.vcenter" = "محاذاة المراكز الرأسية"
+"annotation.add" = "إضافة تعليق توضيحي"
+"annotation.addCategory" = "إضافة فئة تعليقات توضيحية"
+"annotation.categories" = "فئات التعليقات التوضيحية"
+"annotation.edit" = "تحرير التعليق التوضيحي"
+"annotation.editCategory" = "تحرير فئة التعليقات التوضيحية"
+"annotation.list" = "سرد التعليقات التوضيحية"
+"annotation.remove" = "إزالة التعليق التوضيحي"
+"annotation.removeCategory" = "حذف فئة التعليقات التوضيحية"
+"app.actions" = "الإجراءات"
+"app.preferences" = "التفضيلات"
+"arrange.back" = "إرسال إلى الخلف"
+"arrange.backward" = "إرجاع مستوى واحد"
+"arrange.forward" = "تقديم مستوى واحد"
+"arrange.front" = "إحضار إلى المقدمة"
+"arrange.packH" = "رص أفقي"
+"arrange.packV" = "رص رأسي"
+"arrange.reorder" = "إعادة ترتيب التحديد"
+"arrange.roundToPixels" = "تقريب إلى البكسلات"
+"arrange.spacing" = "التباعد"
+"arrange.tidy" = "ترتيب منظم"
+"board.connector" = "موصل"
+"board.ink" = "ضربة قلم التحديد"
+"board.label" = "تسمية الموصل"
+"board.nextSticky" = "الملاحظة اللاصقة التالية"
+"board.reattach" = "إعادة إرفاق الموصل"
+"board.setColor" = "لون اللوحة"
+"board.setStyle" = "نمط اللوحة"
+"board.shape" = "شكل بنص"
+"board.stamp" = "ختم"
+"board.sticky" = "ملاحظة لاصقة"
+"board.table" = "جدول"
+"board.tableEdit" = "تحرير الجدول"
+"board.template" = "قالب لوحة"
+"board.templates" = "سرد قوالب اللوحات"
+"board.timer" = "المؤقت"
+"board.timerState" = "حالة المؤقت"
+"board.vote" = "جلسة تصويت"
+"board.voteResults" = "نتائج التصويت"
+"bool.exclude" = "استبعاد التحديد"
+"bool.flatten" = "تسطيح المجموعة المنطقية"
+"bool.intersect" = "تقاطع التحديد"
+"bool.subtract" = "طرح التحديد"
+"bool.union" = "اتحاد التحديد"
+"branch.archive" = "أرشفة الفرع"
+"branch.create" = "إنشاء فرع"
+"branch.list" = "سرد الفروع"
+"branch.merge" = "دمج الفرع"
+"branch.open" = "فتح الفرع"
+"branch.review" = "مراجعة الفرع"
+"branch.updateFromMain" = "تحديث من الملف الرئيسي"
+"codeConnect.export" = "تصدير روابط الكود"
+"codeConnect.import" = "استيراد روابط الكود"
+"codeConnect.remove" = "فصل الربط بالكود"
+"codeConnect.set" = "ربط بالكود"
+"collab.cursor" = "إظهار المؤشر عند"
+"collab.cursorChat" = "دردشة المؤشر"
+"collab.diff" = "إظهار تغييرات المزامنة"
+"collab.files" = "ملفاتك على الإنترنت"
+"collab.follow" = "متابعة"
+"collab.forgetFile" = "نسيان ملف الإنترنت"
+"collab.host" = "بدء جلسة مباشرة"
+"collab.join" = "الانضمام إلى جلسة مباشرة"
+"collab.leave" = "مغادرة الجلسة المباشرة"
+"collab.manageFile" = "إدارة الملف المنشور"
+"collab.openFile" = "فتح ملف إنترنت"
+"collab.peers" = "الأشخاص في هذا الملف"
+"collab.publish" = "النشر إلى الخادم"
+"collab.recoverFile" = "استعادة ملف الإنترنت"
+"collab.registerLinks" = "فتح روابط الدعوة في FigCraft"
+"collab.removePerson" = "إزالة من الجلسة"
+"collab.report" = "الإبلاغ عن الملف لمشغّل الخادم"
+"collab.serverInfo" = "معلومات الخادم"
+"collab.share" = "المشاركة برمز انضمام"
+"collab.spotlight" = "توجيه الأنظار إليّ"
+"collab.stop" = "إنهاء الجلسة المباشرة"
+"command.batch" = "تشغيل عدة أوامر"
+"comment.add" = "إضافة تعليق"
+"comment.delete" = "حذف التعليق"
+"comment.edit" = "تحرير التعليق"
+"comment.link" = "نسخ رابط التعليق"
+"comment.list" = "سرد التعليقات"
+"comment.markRead" = "تحديد التعليق كمقروء"
+"comment.move" = "نقل التعليق"
+"comment.next" = "التعليق التالي"
+"comment.open" = "فتح التعليق"
+"comment.people" = "الأشخاص في التعليقات"
+"comment.react" = "التفاعل التعبيري مع التعليق"
+"comment.reply" = "الرد على التعليق"
+"comment.resolve" = "تحديد التعليق كمحلول"
+"comment.zoom" = "تكبير إلى التعليقات"
+"component.addProperty" = "إضافة خاصية مكوّن"
+"component.addVariant" = "إضافة تنويعة"
+"component.bindProperty" = "ربط الطبقة بخاصية"
+"component.combineVariants" = "جمع كتنويعات"
+"component.create" = "إنشاء مكوّن"
+"component.createMultiple" = "إنشاء مكونات متعددة"
+"component.deleteProperty" = "حذف خاصية المكوّن"
+"component.detach" = "فصل النسخة المثيلة"
+"component.editProperty" = "تحرير خاصية المكوّن"
+"component.exposeInstance" = "إتاحة نسخة مثيلة متداخلة"
+"component.goToMain" = "الانتقال إلى المكوّن الرئيسي"
+"component.hideWhenPublishing" = "إخفاء عند النشر"
+"component.list" = "سرد المكونات"
+"component.makeSlot" = "إنشاء خانة محتوى"
+"component.playground" = "ساحة تجربة المكونات"
+"component.properties" = "خصائص المكوّن"
+"component.pushChanges" = "دفع التغييرات إلى المكوّن الرئيسي"
+"component.removeSlot" = "إزالة خانة المحتوى"
+"component.resetInstance" = "إعادة تعيين جميع التغييرات"
+"component.resetOverride" = "إعادة تعيين التجاوز"
+"component.restoreMain" = "استعادة المكوّن الرئيسي"
+"data.fill" = "ملء النسخ بالبيانات"
+"dev.compare" = "مقارنة التغييرات"
+"dev.focus" = "عرض التركيز"
+"dev.resolveChange" = "حل التغيير"
+"dev.unfocus" = "الخروج من عرض التركيز"
+"devMode.addMeasurement" = "إضافة قياس"
+"devMode.measurements" = "سرد القياسات"
+"devMode.removeMeasurement" = "إزالة القياس"
+"devResource.add" = "إضافة مورد للمطور"
+"devResource.edit" = "تحرير مورد المطور"
+"devResource.list" = "سرد موارد المطورين"
+"devResource.remove" = "إزالة مورد المطور"
+"distribute.horizontal" = "توزيع المسافات الأفقية"
+"distribute.vertical" = "توزيع المسافات الرأسية"
+"doc.home" = "الرئيسية"
+"doc.list" = "سرد المستندات"
+"doc.nextTab" = "علامة التبويب التالية"
+"doc.previousTab" = "علامة التبويب السابقة"
+"doc.switch" = "تبديل المستند"
+"edit.copy" = "نسخ"
+"edit.copyAsCss" = "نسخ بصيغة CSS"
+"edit.copyAsPng" = "نسخ بصيغة PNG"
+"edit.copyAsSvg" = "نسخ بصيغة SVG"
+"edit.copyAsText" = "نسخ كنص"
+"edit.copyLink" = "نسخ رابط التحديد"
+"edit.copyProperties" = "نسخ الخصائص"
+"edit.cut" = "قص"
+"edit.delete" = "حذف"
+"edit.duplicate" = "تكرار"
+"edit.findReplace" = "بحث واستبدال"
+"edit.paste" = "لصق"
+"edit.pasteDesignLayers" = "لصق طبقات التصميم"
+"edit.pasteOver" = "لصق فوق التحديد"
+"edit.pasteProperties" = "لصق الخصائص"
+"edit.pasteReplace" = "لصق للاستبدال"
+"edit.pickColor" = "التقاط لون"
+"edit.redo" = "إعادة"
+"edit.repeat" = "تكرار آخر أمر"
+"edit.setDefaultProperties" = "تعيين الخصائص الافتراضية"
+"edit.undo" = "تراجع"
+"effect.add" = "إضافة تأثير"
+"effect.copy" = "نسخ التأثير"
+"effect.move" = "نقل التأثير"
+"effect.paste" = "لصق التأثير"
+"effect.remove" = "إزالة التأثير"
+"effect.set" = "تعيين التأثير"
+"export.add" = "إضافة إعداد تصدير"
+"export.remove" = "إزالة إعداد التصدير"
+"export.update" = "تحرير إعداد التصدير"
+"file.close" = "إغلاق علامة التبويب"
+"file.colorProfile" = "ملف تعريف اللون"
+"file.duplicate" = "تكرار الملف"
+"file.export" = "تصدير"
+"file.exportFramesPdf" = "تصدير الإطارات إلى PDF"
+"file.exportLayers" = "تصدير الطبقات"
+"file.exportSvg" = "تصدير التحديد بصيغة SVG"
+"file.import" = "استيراد"
+"file.markSaved" = "تحديد كمحفوظ"
+"file.moveTab" = "نقل علامة التبويب"
+"file.new" = "ملف تصميم جديد"
+"file.newBoard" = "لوحة جديدة"
+"file.newFromTemplate" = "جديد من قالب"
+"file.open" = "فتح"
+"file.openRecent" = "فتح الأخيرة"
+"file.placeImage" = "وضع صورة"
+"file.recover" = "استعادة الملف"
+"file.rename" = "إعادة تسمية الملف"
+"file.revert" = "العودة إلى المحفوظ"
+"file.save" = "حفظ"
+"file.saveAs" = "حفظ باسم"
+"file.saveVersion" = "حفظ في سجل الإصدارات"
+"file.versionHistory" = "إظهار سجل الإصدارات"
+"fill.add" = "إضافة تعبئة"
+"fill.copy" = "نسخ التعبئة"
+"fill.move" = "نقل التعبئة"
+"fill.paste" = "لصق التعبئة"
+"fill.pattern" = "تعبئة بنمط متكرر"
+"fill.remove" = "إزالة التعبئة"
+"fill.set" = "تعيين التعبئة"
+"frame.create" = "إنشاء إطار"
+"frame.presets" = "سرد إعدادات الإطارات المسبقة"
+"frame.swapOrientation" = "تبديل الاتجاه"
+"grid.add" = "إضافة شبكة تخطيط"
+"grid.move" = "نقل شبكة التخطيط"
+"grid.remove" = "إزالة شبكة التخطيط"
+"guide.add" = "إضافة دليل"
+"guide.clear" = "إزالة جميع الأدلة"
+"guide.move" = "نقل الدليل"
+"guide.remove" = "إزالة الدليل"
+"guide.select" = "تحديد دليل"
+"help.about" = "حول FigCraft"
+"help.gettingStarted" = "بدء الاستخدام"
+"help.page" = "صفحة المساعدة"
+"help.shortcuts" = "اختصارات لوحة المفاتيح"
+"image.adjust" = "ضبط الصورة"
+"image.deletePreset" = "حذف إعداد الصورة المسبق"
+"image.place" = "وضع صورة"
+"image.presets" = "إعدادات الصور المسبقة"
+"image.savePreset" = "حفظ إعداد الصورة المسبق"
+"inspect.assets" = "فحص الأصول"
+"inspect.code" = "كود الطبقة"
+"inspect.componentCode" = "كود المكوّن"
+"inspect.properties" = "فحص الخصائص"
+"inspect.readyForDev" = "جاهز للتطوير"
+"instance.create" = "إنشاء نسخة مثيلة"
+"instance.exposed" = "النسخ المثيلة المتداخلة المتاحة"
+"instance.place" = "وضع نسخة مثيلة"
+"instance.resetSlot" = "إعادة تعيين خانة المحتوى"
+"instance.setProperties" = "تعيين خصائص النسخة المثيلة"
+"instance.slotInsert" = "إدراج في خانة المحتوى"
+"instance.swap" = "استبدال النسخة المثيلة"
+"instance.swapAt" = "استبدال النسخة المثيلة عند نقطة"
+"layers.collapseAll" = "طي الطبقات"
+"layout.add" = "إضافة تخطيط تلقائي"
+"layout.remove" = "إزالة التخطيط التلقائي"
+"layout.setTrack" = "تعيين حجم مسار الشبكة"
+"layout.suggest" = "اقتراح تخطيط تلقائي"
+"libraries.open" = "المكتبات"
+"libraries.publish" = "نشر المكتبة…"
+"library.acceptUpdates" = "قبول تحديثات المكتبة"
+"library.add" = "إضافة مكتبة"
+"library.aliasVariable" = "إنشاء اسم مستعار لمتغير مكتبة"
+"library.applyStyle" = "تطبيق نمط مكتبة"
+"library.bindVariable" = "ربط متغير مكتبة"
+"library.extendCollection" = "توسيع مجموعة مكتبة"
+"library.import" = "استيراد مكوّن مكتبة"
+"library.importVariable" = "استيراد متغير مكتبة"
+"library.list" = "سرد المكتبات"
+"library.place" = "وضع مكوّن مكتبة"
+"library.publish" = "نشر المكتبة"
+"library.publishToServer" = "نشر المكتبة إلى الخادم"
+"library.remove" = "إزالة المكتبة"
+"library.setVariableMode" = "تعيين وضع متغير المكتبة"
+"library.swap" = "استبدال المكتبة"
+"library.swapAt" = "استبدال النسخة المثيلة بمكوّن مكتبة"
+"library.updates" = "تحديثات المكتبة"
+"library.usage" = "استخدام المكتبة"
+"measure.distances" = "قياس المسافات"
+"measure.padding" = "قياس حشو التخطيط التلقائي"
+"node.create" = "إنشاء طبقة"
+"node.get" = "جلب طبقة"
+"node.reorder" = "إعادة ترتيب الطبقة"
+"node.reparent" = "نقل الطبقة إلى داخل"
+"node.set" = "تعيين الخصائص"
+"object.batchRename" = "إعادة تسمية جماعية"
+"object.convertToFrame" = "تحويل إلى إطار"
+"object.convertToGroup" = "تحويل إلى مجموعة"
+"object.convertToSection" = "تحويل إلى قسم"
+"object.flipH" = "قلب أفقي"
+"object.flipV" = "قلب رأسي"
+"object.frameSelection" = "إحاطة التحديد بإطار"
+"object.group" = "تجميع التحديد"
+"object.hideOthers" = "إخفاء الطبقات الأخرى"
+"object.mask" = "استخدام كقناع"
+"object.moveToPage" = "نقل إلى الصفحة"
+"object.opacityKeys" = "العتامة بمفاتيح الأرقام"
+"object.rasterize" = "تحويل التحديد إلى صورة نقطية"
+"object.releaseMask" = "تحرير القناع"
+"object.removeFill" = "إزالة التعبئة"
+"object.removeFrame" = "إزالة الإطار"
+"object.removeStroke" = "إزالة الحد الخارجي"
+"object.rename" = "إعادة تسمية"
+"object.resizeToFit" = "تغيير الحجم للملاءمة"
+"object.rotate180" = "تدوير 180°"
+"object.rotateLeft" = "تدوير 90° لليسار"
+"object.rotateRight" = "تدوير 90° لليمين"
+"object.setThumbnail" = "تعيين كصورة مصغرة"
+"object.swapFillStroke" = "تبديل التعبئة والحد الخارجي"
+"object.toggleLock" = "قفل/إلغاء قفل التحديد"
+"object.toggleVisible" = "إظهار/إخفاء التحديد"
+"object.ungroup" = "فك تجميع التحديد"
+"object.wrapInSection" = "إحاطة بقسم جديد"
+"page.add" = "إضافة صفحة"
+"page.delete" = "حذف الصفحة"
+"page.duplicate" = "تكرار الصفحة"
+"page.list" = "سرد الصفحات"
+"page.move" = "نقل الصفحة"
+"page.rename" = "إعادة تسمية الصفحة"
+"page.select" = "الانتقال إلى الصفحة"
+"page.setBackground" = "خلفية الصفحة"
+"paint.documentColors" = "ألوان المستند"
+"plugin.close" = "إغلاق لوحة الإضافة"
+"plugin.directory.install" = "تثبيت إضافة من الدليل"
+"plugin.directory.list" = "استعراض دليل الإضافات"
+"plugin.directory.publish" = "نشر الإضافة في الدليل"
+"plugin.directory.unpublish" = "إلغاء نشر الإضافة من الدليل"
+"plugin.event" = "استخدام عنصر تحكم في لوحة الإضافة"
+"plugin.import" = "استيراد إضافة من ملف التعريف…"
+"plugin.list" = "سرد الإضافات"
+"plugin.remove" = "إزالة الإضافة"
+"plugin.run" = "تشغيل الإضافة"
+"plugin.ui" = "لوحة الإضافة"
+"plugins.manage" = "إدارة الإضافات…"
+"plugins.runLast" = "تشغيل آخر إضافة"
+"pref.autoSave" = "حفظ الملفات تلقائيًا"
+"pref.highlightHover" = "تمييز الطبقات عند التحويم"
+"pref.keepTool" = "إبقاء الأداة محددة بعد الاستخدام"
+"pref.language" = "لغة الواجهة"
+"pref.nudge" = "مقدار الإزاحة الطفيفة"
+"pref.numberKeysOpacity" = "استخدام مفاتيح الأرقام للعتامة"
+"pref.publishServer" = "خادم المشاركة عبر الإنترنت"
+"pref.reduceMotion" = "تقليل الحركة"
+"pref.renameDuplicates" = "إعادة تسمية الطبقات المكررة"
+"pref.showDimensions" = "إظهار الأبعاد على الكائنات"
+"pref.startOnHome" = "البدء من الشاشة الرئيسية"
+"pref.uiScale" = "حجم الواجهة"
+"pref.userName" = "اسمك في التعليقات"
+"proto.action.add" = "إضافة إجراء للنموذج التفاعلي"
+"proto.action.move" = "إعادة ترتيب إجراء النموذج التفاعلي"
+"proto.action.remove" = "إزالة إجراء النموذج التفاعلي"
+"proto.action.set" = "تحرير إجراء النموذج التفاعلي"
+"proto.closePreview" = "إغلاق معاينة النموذج التفاعلي"
+"proto.conditional.addBlock" = "إضافة «وإلا إذا» / «وإلا»"
+"proto.conditional.removeBlock" = "إزالة «إذا» / «وإلا إذا» / «وإلا»"
+"proto.conditional.setCondition" = "تحرير الشرط"
+"proto.evaluate" = "تقييم تعبير النموذج التفاعلي"
+"proto.flow.add" = "إضافة نقطة بداية تدفق"
+"proto.flow.remove" = "إزالة التدفق"
+"proto.flow.rename" = "إعادة تسمية التدفق"
+"proto.interaction.add" = "إضافة تفاعل"
+"proto.interaction.clear" = "إزالة جميع التفاعلات"
+"proto.interaction.remove" = "إزالة التفاعل"
+"proto.interaction.set" = "تحرير التفاعل"
+"proto.linkNotice" = "تنبيه قبل الانتقال إلى رابط خارجي"
+"proto.present" = "عرض"
+"proto.presentOptions" = "خيارات العرض التقديمي"
+"proto.preview" = "فتح معاينة النموذج التفاعلي"
+"proto.settings" = "إعدادات النموذج التفاعلي"
+"section.toggleContents" = "إخفاء/إظهار محتويات القسم"
+"select.add" = "إضافة إلى التحديد"
+"select.all" = "تحديد الكل"
+"select.area" = "تحديد منطقة"
+"select.byName" = "تحديد الطبقات حسب الاسم"
+"select.children" = "تحديد العناصر التابعة"
+"select.inverse" = "عكس التحديد"
+"select.layersAt" = "الطبقات عند نقطة"
+"select.matching" = "تحديد الطبقات المطابقة"
+"select.next" = "تحديد العنصر الشقيق التالي"
+"select.none" = "إلغاء التحديد"
+"select.parent" = "تحديد العنصر الأب"
+"select.previous" = "تحديد العنصر الشقيق السابق"
+"select.remove" = "إزالة من التحديد"
+"select.sameEffect" = "تحديد نفس التأثير"
+"select.sameFill" = "تحديد نفس التعبئة"
+"select.sameFont" = "تحديد نفس الخط"
+"select.sameInstance" = "تحديد نفس النسخة المثيلة"
+"select.sameProperties" = "تحديد نفس الخصائص"
+"select.sameStroke" = "تحديد نفس الحد الخارجي"
+"select.sameText" = "تحديد نفس خصائص النص"
+"select.set" = "تحديد"
+"selection.applyColorStyle" = "تطبيق نمط لون التحديد"
+"selection.bindColorVariable" = "تطبيق متغير لون التحديد"
+"selection.colors" = "ألوان التحديد"
+"selection.detachColor" = "فصل لون التحديد"
+"selection.selectColor" = "تحديد الطبقات باللون"
+"selection.setPaint" = "تغيير لون التحديد"
+"shape.arrow" = "سهم"
+"shape.ellipse" = "شكل بيضاوي"
+"shape.line" = "خط"
+"shape.polygon" = "مضلع"
+"shape.rectangle" = "مستطيل"
+"shape.star" = "نجمة"
+"shapeBuilder.merge" = "دمج المناطق"
+"shapeBuilder.mode" = "وضع باني الأشكال"
+"shapeBuilder.regions" = "مناطق باني الأشكال"
+"shapeBuilder.remove" = "إزالة المناطق"
+"stroke.add" = "إضافة حد خارجي"
+"stroke.brush" = "فرشاة"
+"stroke.copy" = "نسخ الحد الخارجي"
+"stroke.move" = "نقل الحد الخارجي"
+"stroke.paste" = "لصق الحد الخارجي"
+"stroke.remove" = "إزالة الحد الخارجي"
+"stroke.set" = "تعيين الحد الخارجي"
+"stroke.widthProfile" = "منحنى العرض"
+"style.apply" = "تطبيق نمط"
+"style.bind" = "ربط متغير في نمط"
+"style.create" = "إنشاء نمط"
+"style.delete" = "حذف النمط"
+"style.detach" = "فصل النمط"
+"style.duplicate" = "تكرار النمط"
+"style.list" = "سرد الأنماط"
+"style.move" = "نقل الأنماط"
+"style.renameFolder" = "إعادة تسمية مجلد الأنماط"
+"style.update" = "تحرير النمط"
+"template.list" = "سرد القوالب"
+"text.alignCenter" = "محاذاة للوسط أفقيًا"
+"text.alignJustify" = "ضبط المحاذاة"
+"text.alignLeft" = "محاذاة لليسار"
+"text.alignRight" = "محاذاة لليمين"
+"text.bold" = "غامق"
+"text.bulletList" = "قائمة نقطية"
+"text.case" = "حالة الأحرف"
+"text.caseLower" = "أحرف صغيرة"
+"text.caseOriginal" = "حالة الأحرف الأصلية"
+"text.caseSmallCaps" = "أحرف كبيرة مصغّرة"
+"text.caseTitle" = "حالة أحرف العنوان"
+"text.caseUpper" = "أحرف كبيرة"
+"text.create" = "النص"
+"text.decoration" = "زخرفة النص"
+"text.edit.begin" = "تحرير النص"
+"text.edit.caret" = "تعيين مؤشر الكتابة"
+"text.edit.end" = "إنهاء تحرير النص"
+"text.edit.insert" = "كتابة نص"
+"text.edit.key" = "مفتاح نصي"
+"text.edit.preedit" = "إدخال نص مركّب (IME)"
+"text.edit.selectLine" = "تحديد سطر"
+"text.edit.selectWord" = "تحديد كلمة"
+"text.edit.selection" = "تحديد النص"
+"text.endPreview" = "إنهاء معاينة النص"
+"text.fit" = "ملاءمة النص مع مربعه"
+"text.fonts" = "سرد الخطوط"
+"text.italic" = "مائل"
+"text.lineHeightDown" = "تقليل ارتفاع السطر"
+"text.lineHeightUp" = "زيادة ارتفاع السطر"
+"text.link" = "إنشاء رابط"
+"text.listIndent" = "زيادة المسافة البادئة للقائمة"
+"text.listOutdent" = "تقليل المسافة البادئة للقائمة"
+"text.listStyle" = "نمط القائمة"
+"text.loadFont" = "تحميل ملف خط"
+"text.maxLines" = "أقصى عدد للأسطر"
+"text.missingFonts" = "خطوط مفقودة"
+"text.numberList" = "قائمة مرقمة"
+"text.onPath" = "نص على مسار"
+"text.paragraphIndent" = "المسافة البادئة للفقرة"
+"text.paragraphSpacing" = "تباعد الفقرات"
+"text.pathOptions" = "خيارات النص على المسار"
+"text.preview" = "معاينة تغيير النص"
+"text.refit" = "ملاءمة النص مع الخطوط"
+"text.removeFromPath" = "إزالة النص من المسار"
+"text.rescanFonts" = "إعادة فحص الخطوط المثبتة"
+"text.set" = "تعيين النص"
+"text.setFeatures" = "مزايا OpenType"
+"text.setVariations" = "محاور الخط المتغير"
+"text.sizeDown" = "تقليل حجم الخط"
+"text.sizeUp" = "زيادة حجم الخط"
+"text.strikethrough" = "شطب"
+"text.style" = "تنسيق نطاق نصي"
+"text.trackingDown" = "تقليل تباعد الحروف"
+"text.trackingUp" = "زيادة تباعد الحروف"
+"text.underline" = "تسطير"
+"text.weightDown" = "تقليل وزن الخط"
+"text.weightUp" = "زيادة وزن الخط"
+"tool.arrow" = "سهم"
+"tool.comment" = "تعليق"
+"tool.connector" = "موصل"
+"tool.ellipse" = "شكل بيضاوي"
+"tool.eraser" = "ممحاة"
+"tool.frame" = "إطار"
+"tool.hand" = "اليد"
+"tool.highlighter" = "قلم تمييز"
+"tool.line" = "خط"
+"tool.marker" = "قلم تحديد"
+"tool.move" = "نقل"
+"tool.pen" = "قلم"
+"tool.pencil" = "قلم رصاص"
+"tool.pencilBrush" = "فرشاة قلم الرصاص"
+"tool.place" = "وضع باستخدام الأداة"
+"tool.polygon" = "مضلع"
+"tool.rectangle" = "مستطيل"
+"tool.scale" = "المقياس"
+"tool.scaleAnchor" = "مرساة التحجيم"
+"tool.section" = "قسم"
+"tool.set" = "اختيار أداة"
+"tool.shape" = "الأشكال"
+"tool.shapeBuilder" = "باني الأشكال"
+"tool.slice" = "شريحة"
+"tool.stamp" = "ختم"
+"tool.star" = "نجمة"
+"tool.sticky" = "ملاحظة لاصقة"
+"tool.text" = "النص"
+"tool.textOnPath" = "نص على مسار"
+"transform.move" = "نقل"
+"transform.moveTo" = "نقل إلى"
+"transform.nudge" = "إزاحة طفيفة"
+"transform.nudgeSize" = "تغيير الحجم بإزاحة طفيفة"
+"transform.resize" = "تغيير الحجم"
+"transform.rotate" = "تدوير"
+"transform.rotationOrigin" = "نقل مركز الدوران"
+"transform.scale" = "المقياس"
+"transform.setRotationOrigin" = "تعيين مركز الدوران"
+"variable.addMode" = "إضافة وضع"
+"variable.bind" = "ربط متغير"
+"variable.create" = "إنشاء متغير"
+"variable.createCollection" = "إنشاء مجموعة متغيرات"
+"variable.delete" = "حذف المتغير"
+"variable.deleteCollection" = "حذف المجموعة"
+"variable.duplicate" = "تكرار المتغير"
+"variable.export" = "تصدير المتغيرات"
+"variable.import" = "استيراد المتغيرات"
+"variable.list" = "سرد المتغيرات"
+"variable.moveMode" = "نقل الوضع"
+"variable.removeMode" = "حذف الوضع"
+"variable.renameCollection" = "إعادة تسمية المجموعة"
+"variable.renameMode" = "إعادة تسمية الوضع"
+"variable.resetOverride" = "إعادة تعيين تجاوز المتغير"
+"variable.set" = "تعيين قيمة المتغير"
+"variable.setMode" = "تعيين وضع المتغير"
+"variable.update" = "تحرير المتغير"
+"vector.create" = "متجه"
+"vector.deleteHeal" = "حذف التحديد ووصل المسار"
+"vector.edit.addVertex" = "إضافة نقطة"
+"vector.edit.begin" = "تحرير المتجه"
+"vector.edit.bend" = "ثني المقطع"
+"vector.edit.bounds" = "حدود النقاط المحددة"
+"vector.edit.connect" = "وصل النقاط"
+"vector.edit.cornerRadius" = "نصف قطر زاوية النقطة"
+"vector.edit.cut" = "قطع المسار"
+"vector.edit.delete" = "حذف النقاط"
+"vector.edit.end" = "إنهاء التحرير"
+"vector.edit.erase" = "محو"
+"vector.edit.lasso" = "تحديد حر"
+"vector.edit.mirroring" = "تماثل المقابض"
+"vector.edit.moveHandles" = "نقل المقابض"
+"vector.edit.moveVertices" = "نقل النقاط"
+"vector.edit.nudge" = "إزاحة النقاط قليلًا"
+"vector.edit.paint" = "دلو الطلاء"
+"vector.edit.pen" = "نقطة قلم"
+"vector.edit.penEnd" = "إنهاء مسار القلم"
+"vector.edit.position" = "موضع النقطة"
+"vector.edit.resize" = "تغيير حجم النقاط"
+"vector.edit.rotate" = "تدوير النقاط"
+"vector.edit.select" = "تحديد نقاط"
+"vector.edit.selectAll" = "تحديد جميع النقاط"
+"vector.edit.setHandle" = "تعيين المقبض"
+"vector.edit.smooth" = "تنعيم النقاط أو جعلها حادة"
+"vector.edit.state" = "حالة تحرير المتجه"
+"vector.edit.strokeCap" = "نهاية الحد عند النقطة"
+"vector.edit.strokeJoin" = "وصلة الحد عند النقطة"
+"vector.edit.tool" = "أداة تحرير المتجه"
+"vector.edit.transform" = "تحويل النقاط"
+"vector.edit.widthPoint" = "نقطة عرض"
+"vector.fillRule" = "تعيين قاعدة التعبئة"
+"vector.flatten" = "تسطيح"
+"vector.join" = "وصل التحديد"
+"vector.offset" = "إزاحة المسار"
+"vector.outlineStroke" = "تحويل الحد الخارجي إلى شكل"
+"vector.reverse" = "عكس اتجاه المسار"
+"vector.reverseLoop" = "عكس الحلقة"
+"vector.simplify" = "تبسيط المتجه"
+"vector.smoothJoin" = "وصل التحديد بسلاسة"
+"version.autosave" = "حفظ إصدار تلقائيًا"
+"version.delete" = "حذف الإصدار"
+"version.duplicate" = "تكرار الإصدار"
+"version.exitPreview" = "العودة إلى الإصدار الحالي"
+"version.list" = "سرد الإصدارات"
+"version.preview" = "عرض الإصدار"
+"version.rename" = "إعادة تسمية الإصدار"
+"version.restore" = "استعادة الإصدار"
+"view.assets" = "لوحة الأصول"
+"view.comments" = "التعليقات"
+"view.contextMenu" = "إظهار قائمة السياق"
+"view.design" = "لوحة التصميم"
+"view.designPrototype" = "التبديل بين التصميم والنموذج التفاعلي"
+"view.devMode" = "وضع المطور"
+"view.frameOutlines" = "مخططات الإطارات الخارجية"
+"view.fullscreen" = "ملء الشاشة"
+"view.get" = "جلب العرض"
+"view.layers" = "لوحة الطبقات"
+"view.layoutGrids" = "شبكات التخطيط"
+"view.leftSidebar" = "إظهار/إخفاء الشريط الجانبي الأيسر"
+"view.maskOutlines" = "مخططات الأقنعة الخارجية"
+"view.multiplayerCursors" = "مؤشرات المشاركين"
+"view.nextFrame" = "تكبير إلى الإطار التالي"
+"view.nextPage" = "الصفحة التالية"
+"view.nextRegion" = "التركيز على المنطقة التالية"
+"view.outlines" = "المخططات الخارجية"
+"view.outlinesHiddenLayers" = "تضمين الطبقات المخفية في المخططات الخارجية"
+"view.outlinesObjectBounds" = "تضمين حدود الكائن في المخططات الخارجية"
+"view.outlinesSelection" = "المخططات الخارجية للتحديد"
+"view.pan" = "تحريك العرض"
+"view.panelWidths" = "إعادة تعيين عروض اللوحات"
+"view.pixelGrid" = "شبكة البكسلات"
+"view.pixelPreview" = "معاينة البكسلات"
+"view.prevFrame" = "تكبير إلى الإطار السابق"
+"view.prevPage" = "الصفحة السابقة"
+"view.previousRegion" = "التركيز على المنطقة السابقة"
+"view.proofSrgb" = "محاكاة ألوان sRGB"
+"view.prototype" = "لوحة النموذج التفاعلي"
+"view.rulers" = "المساطر"
+"view.set" = "تعيين العرض"
+"view.slices" = "الشرائح"
+"view.snapGeometry" = "محاذاة تلقائية إلى الهندسة"
+"view.snapObjects" = "محاذاة تلقائية إلى الكائنات"
+"view.snapPixel" = "محاذاة تلقائية إلى شبكة البكسلات"
+"view.themeDark" = "السمة الداكنة"
+"view.themeLight" = "السمة الفاتحة"
+"view.themeSystem" = "اتباع سمة النظام"
+"view.toggleUi" = "إظهار/إخفاء الواجهة"
+"view.zoom100" = "تكبير إلى 100%"
+"view.zoom200" = "تكبير إلى 200%"
+"view.zoom50" = "تكبير إلى 50%"
+"view.zoomAt" = "أداة التكبير"
+"view.zoomFit" = "تكبير للملاءمة"
+"view.zoomIn" = "تكبير"
+"view.zoomOut" = "تصغير"
+"view.zoomSelection" = "تكبير إلى التحديد"
+"widget.click" = "نقر عنصر واجهة"
+"widget.insert" = "إدراج عنصر واجهة"
+"widget.list" = "سرد عناصر الواجهة"
+"widget.menu" = "قائمة عنصر الواجهة"
+"widget.setText" = "تحرير حقل عنصر الواجهة"
+"window.focus" = "إحضار النافذة إلى المقدمة"
+"window.new" = "نافذة جديدة"
+
+[menu]
+"bool.exclude" = "استبعاد"
+"bool.flatten" = "تسطيح"
+"bool.intersect" = "تقاطع"
+"bool.subtract" = "طرح"
+"bool.union" = "اتحاد"
+"file.duplicate" = "تكرار"
+"file.import" = "استيراد من ملف…"
+"file.rename" = "إعادة تسمية"
+"libraries.open" = "فتح المكتبات"
+"select.sameEffect" = "تأثير"
+"select.sameFill" = "تعبئة"
+"select.sameFont" = "الخط"
+"select.sameInstance" = "نسخة مثيلة"
+"select.sameProperties" = "الخصائص"
+"select.sameStroke" = "الحد الخارجي"
+"select.sameText" = "خصائص النص"
+"text.alignCenter" = "محاذاة النص للوسط"
+"text.alignJustify" = "ضبط محاذاة النص"
+"text.alignLeft" = "محاذاة النص لليسار"
+"text.alignRight" = "محاذاة النص لليمين"
+"view.assets" = "الأصول"
+"view.design" = "التصميم"
+"view.layers" = "الطبقات"
+"view.outlines" = "إظهار المخططات الخارجية"
+"view.outlinesHiddenLayers" = "تضمين الطبقات المخفية"
+"view.outlinesObjectBounds" = "تضمين حدود الكائن"
+"view.outlinesSelection" = "إظهار المخططات الخارجية للتحديد"
+"view.prototype" = "النموذج التفاعلي"
+"view.themeDark" = "داكن"
+"view.themeLight" = "فاتح"
+"view.themeSystem" = "اتباع النظام"
diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs
index fab93990..ccd54975 100644
--- a/crates/ui-egui/src/cjk.rs
+++ b/crates/ui-egui/src/cjk.rs
@@ -101,6 +101,11 @@ fn shows(code: &str, web: bool) -> bool {
if code.split('-').next() == Some("ka") {
return figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some();
}
+ if code.split('-').next() == Some("ar") {
+ // Web cannot borrow the desktop's Arabic faces. Its current font budget excludes
+ // Arabic; keep the catalog out until an embedded Arabic fallback is supplied.
+ return figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some();
+ }
let need = language(code);
need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some())
}
@@ -186,6 +191,14 @@ mod tests {
assert!(shows("en", true) && shows("tr", true));
}
+ #[test]
+ fn the_web_offers_arabic_only_with_its_face() {
+ assert!(shows("ar", false));
+ let face = figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some();
+ assert_eq!(shows("ar", true), face);
+ assert_eq!(shows("ar-SA", true), face);
+ }
+
/// The UI language's script leads the fallback order; the others follow.
#[test]
fn the_ui_language_script_comes_first() {
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
index d1fedef8..8ba884f0 100644
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -492,6 +492,50 @@ fn georgian_is_picked_for_georgian_locales() {
}
}
+#[test]
+fn arabic_tags_and_count_neutral_forms() {
+ let ar = LANGS.iter().position(|l| l.code == "ar").unwrap();
+ assert_eq!(LANGS[ar].name, "العربية");
+ for tag in ["ar", "ar-SA", "ar_EG.UTF-8", "ar_AE@arabic"] {
+ assert_eq!(resolve("system", Some(tag)), ar, "{tag}");
+ assert_eq!(resolve(tag, None), ar, "{tag}");
+ }
+ let counts = [0, 1, 2, 3, 10, 11, 99, 100, 102, 103, 111, 200, 1000];
+ for (key, text) in LANGS[ar].ui {
+ if let Text::Plural { one, few, many, other } = text {
+ assert!(one.is_none() && few.is_none() && many.is_none(), "{key}: count-neutral wording uses only other");
+ for n in counts {
+ assert_eq!(counted(ar, key, n), *other, "{key}, {n}");
+ assert!(fill(other, &[("n", &n)]).contains(&n.to_string()));
+ }
+ }
+ }
+}
+
+#[test]
+fn the_built_in_arabic_face_draws_the_arabic_catalog() {
+ if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular") {
+ eprintln!("built without craft-fonts' Noto Sans Arabic; skipping");
+ return;
+ }
+ let Some(face) = figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]) else {
+ panic!("the supplied Noto Sans Arabic face must draw Arabic");
+ };
+ let ar = &LANGS[LANGS.iter().position(|l| l.code == "ar").unwrap()];
+ let letters: BTreeSet = ar
+ .ui
+ .iter()
+ .chain(ar.commands)
+ .chain(ar.menu)
+ .flat_map(|(_, t)| forms(t))
+ .flat_map(str::chars)
+ .filter(|c| matches!(u32::from(*c), 0x0600..=0x06FF))
+ .collect();
+ assert!(letters.len() > 28);
+ let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect();
+ assert!(missing.is_empty(), "Arabic catalog glyphs missing from the embedded face: {missing:?}");
+}
+
/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces),
/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses.
#[test]
diff --git a/docs/gaps.md b/docs/gaps.md
index edc18dc3..7dc45dfb 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) |
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
-| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) |
+| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and panel mirroring pending | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 6 | Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) |
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
@@ -519,6 +519,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) |
| 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged |
| 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged |
| 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index f7fb2dde..d06ebd42 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -91,7 +91,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
-| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
+| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) |
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
@@ -123,6 +123,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 14d734ba..2ab450dc 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -18,7 +18,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja,
ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
the entries added since each catalog was written, then the other key languages (Hindi,
- Vietnamese, Arabic): 3–7 h per language plus native review.
+ Vietnamese): 3–7 h per language plus native review. Arabic now has a complete draft; native review and panel mirroring remain.
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@@ -58,7 +58,7 @@ blockers.
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
-| M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
+| M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Arabic draft: 2,508 entries, native review and panel mirroring pending | native reviews (including the Arabic draft), Hindi, Vietnamese, Arabic panel mirroring, engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 |
| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 |
| M13 AI features (new, owner decision) | not started | local models | 30–60 |
@@ -75,6 +75,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) |
| 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged |
| 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 9c96d657..af74182e 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -132,7 +132,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) |
-| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) |
+| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English; Arabic draft: 2,508 entries, native review and panel mirroring pending | [localization-parity](localization-parity.md) |
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
| Ecosystem/plugins | 3 | 40% | 6–22 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), text input and a property menu (API 4), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
@@ -645,6 +645,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) |
| 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged |
| 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged |
| 2026-10-24 | minor | Boards (M12): templates (five built in, `board.template`) and tables (`board.table`, `board.tableEdit`); area 22 ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h (outside the weighted sum) |
diff --git a/docs/translating.md b/docs/translating.md
index 996f7854..cae54888 100644
--- a/docs/translating.md
+++ b/docs/translating.md
@@ -81,6 +81,24 @@ it (`crates/text/build.rs`); the commit releases pin doesn't yet (`docs/releasin
picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in
`cjk::language`, or a built-in craft-fonts face.
+## Arabic draft
+
+`ar.toml` adds العربية as a draft: 1,857 UI entries, 620 command labels and 31 menu
+wordings (2,508 entries, measured 2026-10-28). Contributed by @hussain-hz with OpenAI Codex;
+not yet reviewed by a native speaker. Existing language catalogs are unchanged.
+
+The catalog uses Modern Standard Arabic, logical Unicode order, Western digits, and Arabic
+punctuation. Product names, command ids, keyboard glyphs and `{placeholders}` are preserved.
+English plural entries give only `other`, with count-neutral wording such as `عدد الطبقات: {n}`.
+This is deliberate: the current `Text::Plural` has no zero/two fields. It is not an implementation
+of Arabic's six CLDR categories; wording remains valid for 0, 1, 2 and larger integer counts.
+
+Arabic text uses the existing bidi/shaping and Arabic system-font fallback on desktop. Web builds
+require an embedded Noto Sans Arabic face to offer Arabic. The current web font budget filters
+that face out, so Arabic is not offered on the web yet. No font is added to
+this repository. The interface's panels are not mirrored; this catalog does not claim a complete
+right-to-left interface. Native review and representative in-app checks remain required.
+
## Adding a language
1. Copy `crates/ui-egui/locales/es.toml` to `.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and
From f24a8312f717589ed27d3cbcb0db3a9cf392f193 Mon Sep 17 00:00:00 2001
From: samhyun
Date: Wed, 28 Oct 2026 10:31:02 +0900
Subject: [PATCH 14/60] M8: the browser refines a zoom only once it has paused
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The browser renders on the UI thread. A zoom showed its coarse preview and then, in the very
next frame, ran the full render, so every wheel step of a continuous zoom had a blocking full
render between it and the next: on a heavy page each one took seconds, and the zoom stuttered
for as long as it went on.
The browser's worker (`Inline`) now refines a preview only once no newer request has come for
`SETTLE_MS` (120 ms) after the preview was shown: the zoom has paused. Each wheel step still
shows its preview at once, and the worker asks for a repaint when the wait is over. Pans and
edits have no preview and are refined at once, as before. The desktop has a render thread of
its own and is unchanged. A zoom passing through a scale also no longer spends a full render
there.
The wait counts from the preview being handed over, not from the request: rendering the
preview can itself take longer than the wait (the page is blocked meanwhile, so no newer
request could have come), and counted from the request the full render would follow in the
very next frame, as before.
Measured in Chrome on a synthetic page of 20 images, 60 shadowed rectangles and two
background blurs (a full render takes about 3.7 s, a preview about 250 ms), 20 wheel steps in
and 20 out, 50 ms apart, three runs each: the frame interval's p95 went from 3.7 s to 240 ms,
the frames over 100 ms from 79–80 (one full render per step) to 39–41 (one preview per step),
and the zoom took 10–12 s instead of 141 s. The one full render after the pause still takes as
long as before.
Co-Authored-By: Claude Fable 5.1
---
crates/ui-egui/src/canvas/render_worker.rs | 35 ++++++++++--
.../ui-egui/src/canvas/zoom_preview_tests.rs | 53 +++++++++++++++++--
docs/gaps.md | 5 ++
3 files changed, 86 insertions(+), 7 deletions(-)
diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs
index 873e762a..0cd3e040 100644
--- a/crates/ui-egui/src/canvas/render_worker.rs
+++ b/crates/ui-egui/src/canvas/render_worker.rs
@@ -12,6 +12,8 @@
//! renders) nothing is spent up front: on the desktop a watchdog thread renders the preview once
//! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the
//! other cores), and drops it if the full image is there first.
+//! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview
+//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused).
use std::sync::Arc;
@@ -25,6 +27,10 @@ use web_time::Instant;
pub const PREVIEW_FACTOR: f64 = 4.0;
/// Full renders quicker than this (about a frame) show without a preview.
pub const PREVIEW_AFTER_MS: f64 = 12.0;
+/// The browser refines a zoom's preview only once no newer request came for this long: a full
+/// render there blocks the page, and one between every two wheel steps of a zoom held it up for
+/// seconds.
+pub const SETTLE_MS: u64 = 120;
#[derive(Clone)]
pub struct Job {
@@ -334,14 +340,21 @@ impl Worker {
}
/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the
-/// refined image in the next frame unless a newer request replaced the job. Built everywhere so
-/// tests run it.
+/// refined image once no newer request came for [`SETTLE_MS`] (a zoom has paused), right away
+/// when no preview came first (a pan, an edit). Built everywhere so tests run it.
pub struct Inline {
ctx: egui::Context,
pending: Option,
/// Requests submitted so far.
submitted: u64,
previewed: bool,
+ /// Whether the pending job's preview was shown (its refinement then waits to settle).
+ preview_shown: bool,
+ /// When that preview was handed over: the wait counts from there, not from the request, since
+ /// rendering the preview itself can take longer than the wait (the page is blocked meanwhile,
+ /// so no newer request could have come).
+ since: Instant,
+ settle: std::time::Duration,
renders: Renders,
}
@@ -350,12 +363,17 @@ pub type Worker = Inline;
impl Inline {
pub fn new(ctx: egui::Context) -> Self {
- Self { ctx, pending: None, submitted: 0, previewed: false, renders: Renders::default() }
+ Self::with_settle(ctx, std::time::Duration::from_millis(SETTLE_MS))
+ }
+ /// Refining a preview after `settle` without newer requests (tests: zero, the next frame).
+ pub fn with_settle(ctx: egui::Context, settle: std::time::Duration) -> Self {
+ Self { ctx, pending: None, submitted: 0, previewed: false, preview_shown: false, since: Instant::now(), settle, renders: Renders::default() }
}
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
self.submitted += 1;
self.previewed = false;
+ self.preview_shown = false;
}
pub fn in_flight(&self) -> bool {
self.pending.is_some()
@@ -364,10 +382,19 @@ impl Inline {
if !self.previewed {
self.previewed = true;
if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) {
- self.ctx.request_repaint();
+ self.preview_shown = true;
+ self.since = Instant::now();
+ self.ctx.request_repaint_after(self.settle);
return Some(answering(d, self.submitted));
}
}
+ if self.pending.is_some() && self.preview_shown {
+ let waited = self.since.elapsed();
+ if waited < self.settle {
+ self.ctx.request_repaint_after(self.settle - waited);
+ return None;
+ }
+ }
let job = self.pending.take()?;
self.renders.refine(&job).map(|d| answering(d, self.submitted))
}
diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
index ab0b54da..6261625e 100644
--- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs
+++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
@@ -17,7 +17,9 @@ use serde_json::json;
use super::{CanvasState, shows};
use figcraft_render::Retained;
-use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, Worker, better, coarse_view, preview_with, render_now};
+use crate::render_worker::{
+ Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
+};
/// A page of ellipses with a rectangle on top; the session and the rectangle.
fn document() -> (Session, NodeId) {
@@ -392,8 +394,8 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra
let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
- // The production threshold, as the browser has it.
- let mut w = Inline::new(egui::Context::default());
+ // The production threshold, as the browser has it; refined the next frame (settling: below).
+ let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO);
w.submit(at(2.0));
let first = w.poll().expect("the first image");
assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by");
@@ -414,3 +416,48 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25));
assert!(w.poll().is_none() && !w.in_flight());
}
+
+#[test]
+fn the_browser_worker_refines_a_zoom_only_once_it_settles() {
+ let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
+ let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
+ let settle = std::time::Duration::from_millis(SETTLE_MS);
+ let mut w = Inline::with_settle(egui::Context::default(), settle);
+ w.submit(at(2.0));
+ assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
+ // Zooming: each step shows its preview, never a full render in between.
+ for scale in [1.5, 1.0] {
+ w.submit(at(scale));
+ assert!(w.poll().is_some_and(|d| d.coarse));
+ assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on");
+ }
+ std::thread::sleep(settle);
+ let d = w.poll().expect("refined once it settled");
+ assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
+ // A pan at the same zoom has no preview and is refined at once.
+ w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200)));
+ assert!(w.poll().is_some_and(|d| !d.coarse));
+}
+
+/// The wait counts from the preview being shown, not from the request: rendering the preview can
+/// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and
+/// counted from the request the refinement would follow in the very next frame.
+#[test]
+fn the_wait_counts_from_the_preview_not_from_the_request() {
+ let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
+ let s = slow_scene(&[view(2.0), view(1.0)], 20.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
+ let preview_ms = preview_with(&at(1.0), &mut Retained::default()).unwrap().ms;
+ let settle = std::time::Duration::from_secs_f64(preview_ms / 2000.0).max(std::time::Duration::from_millis(1));
+ let mut w = Inline::with_settle(egui::Context::default(), settle);
+ w.submit(at(2.0));
+ assert!(w.poll().is_some_and(|d| !d.coarse));
+ w.submit(at(1.0));
+ assert!(w.poll().is_some_and(|d| d.coarse), "the preview, which takes {preview_ms} ms");
+ assert!(w.poll().is_none() && w.in_flight(), "the wait ({settle:?}) starts only now");
+ std::thread::sleep(settle);
+ assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0));
+}
diff --git a/docs/gaps.md b/docs/gaps.md
index edc18dc3..8067c2df 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -247,6 +247,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
sixteenth of the pixels first, then refined. With no measure to go by (the first render, another
page, a zoom after quick renders), a watchdog thread previews any such render still running
after 12 ms (desktop only; the browser renders inline).
+- **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has
+ come for 120 ms (`SETTLE_MS`): its full renders run on the UI thread, and one between every two
+ wheel steps held the page up for seconds. Pans and edits (no preview) are refined at once; the
+ desktop, with its render thread, is unchanged.
- **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before
the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels;
this reduces blank strips without changing the CPU renderer.
@@ -519,6 +523,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged |
| 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged |
| 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged |
| 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged |
From 8c951810ab16d3e12ce7d2e51290646a288a1c2e Mon Sep 17 00:00:00 2001
From: samhyun
Date: Wed, 28 Oct 2026 22:29:55 +0900
Subject: [PATCH 15/60] M8: a test for same-zoom requests before a zoom
refines; the gaps status line
From the review of #1468: a pan or an edit at the new zoom before its full render has run
renders from scratch too, so it shows a preview and restarts the wait instead of running a
blocking full render between it and the next step; once refined, requests at that zoom build
on the kept image and are refined at once. The new test pins both down. The status line of
`docs/gaps.md` now carries the date of the gap 7 entry.
Co-Authored-By: Claude Fable 5.1
---
.../ui-egui/src/canvas/zoom_preview_tests.rs | 30 +++++++++++++++++++
docs/gaps.md | 2 +-
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
index 6261625e..47a6a026 100644
--- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs
+++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
@@ -461,3 +461,33 @@ fn the_wait_counts_from_the_preview_not_from_the_request() {
std::thread::sleep(settle);
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0));
}
+
+/// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit
+/// at the new zoom renders from scratch too: it shows a preview and restarts the wait rather than
+/// running a full render between it and the next step. Once refined, requests at that zoom build
+/// on the kept image: no preview, no wait.
+#[test]
+fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_after() {
+ let view = |c, scale| view_at(c, scale, 1200, 1200);
+ let s = slow_scene(&[view((300.0, 200.0), 2.0), view((300.0, 200.0), 1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |c, scale| job(&ds.doc, ds.page, ds.uid, view(c, scale));
+ let settle = std::time::Duration::from_millis(SETTLE_MS);
+ let mut w = Inline::with_settle(egui::Context::default(), settle);
+ w.submit(at((300.0, 200.0), 2.0));
+ assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait");
+ // A zoom step, then a pan at the new zoom before the pause: a preview each, no full render.
+ w.submit(at((300.0, 200.0), 1.0));
+ assert!(w.poll().is_some_and(|d| d.coarse));
+ assert!(w.poll().is_none() && w.in_flight());
+ w.submit(at((340.0, 200.0), 1.0));
+ assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom");
+ assert!(w.poll().is_none() && w.in_flight(), "the wait starts over");
+ std::thread::sleep(settle);
+ let d = w.poll().expect("refined once it settled");
+ assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
+ // From then on a pan at this zoom builds on the kept image: no preview, refined at once.
+ w.submit(at((380.0, 200.0), 1.0));
+ let d = w.poll().expect("the refined image");
+ assert!(!d.coarse && !w.in_flight());
+}
diff --git a/docs/gaps.md b/docs/gaps.md
index 8067c2df..67cc979d 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
From 3773a608019ea950462c6c91a6c81d4c6103d901 Mon Sep 17 00:00:00 2001
From: samhyun
Date: Wed, 28 Oct 2026 22:53:07 +0900
Subject: [PATCH 16/60] M8: the browser's wait after a preview never exceeds
the render it may save
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With a fixed 120 ms wait, a page whose full renders take 15–100 ms was held coarse for 120 ms
after a key zoom or a page switch, where before it was refined in the next frame. The wait is
now the last full render's time when that is shorter than `SETTLE_MS`: a wait never costs more
than the render it may save, and on a heavy page (seconds a render) it is 120 ms as before.
Also from review: the test that the wait counts from the preview no longer times a preview
render (a margin of a few milliseconds under load); a sleep between the request and the poll
stands in for the render, with the whole settle as margin. `Inline::in_flight` says that a
preview's wait counts.
Co-Authored-By: Claude Fable 5.1
---
crates/ui-egui/src/canvas/render_worker.rs | 35 ++++++++++++++----
.../ui-egui/src/canvas/zoom_preview_tests.rs | 37 ++++++++++++++++---
docs/gaps.md | 7 ++--
3 files changed, 62 insertions(+), 17 deletions(-)
diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs
index 0cd3e040..94b2d3d8 100644
--- a/crates/ui-egui/src/canvas/render_worker.rs
+++ b/crates/ui-egui/src/canvas/render_worker.rs
@@ -13,7 +13,9 @@
//! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the
//! other cores), and drops it if the full image is there first.
//! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview
-//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused).
+//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for
+//! as long as the last full render took when that is shorter (a wait never costs more than the
+//! render it may save).
use std::sync::Arc;
@@ -27,9 +29,9 @@ use web_time::Instant;
pub const PREVIEW_FACTOR: f64 = 4.0;
/// Full renders quicker than this (about a frame) show without a preview.
pub const PREVIEW_AFTER_MS: f64 = 12.0;
-/// The browser refines a zoom's preview only once no newer request came for this long: a full
-/// render there blocks the page, and one between every two wheel steps of a zoom held it up for
-/// seconds.
+/// The browser refines a zoom's preview only once no newer request came for this long (or for as
+/// long as the last full render took, when shorter): a full render there blocks the page, and one
+/// between every two wheel steps of a zoom held it up for seconds.
pub const SETTLE_MS: u64 = 120;
#[derive(Clone)]
@@ -354,7 +356,11 @@ pub struct Inline {
/// rendering the preview itself can take longer than the wait (the page is blocked meanwhile,
/// so no newer request could have come).
since: Instant,
+ /// How long to wait after a preview, at most: `wait` is this capped by the last full render's
+ /// time, so a page whose renders are quick is not held coarse for longer than one would take.
settle: std::time::Duration,
+ /// The wait for the pending job's preview.
+ wait: std::time::Duration,
renders: Renders,
}
@@ -367,7 +373,17 @@ impl Inline {
}
/// Refining a preview after `settle` without newer requests (tests: zero, the next frame).
pub fn with_settle(ctx: egui::Context, settle: std::time::Duration) -> Self {
- Self { ctx, pending: None, submitted: 0, previewed: false, preview_shown: false, since: Instant::now(), settle, renders: Renders::default() }
+ Self {
+ ctx,
+ pending: None,
+ submitted: 0,
+ previewed: false,
+ preview_shown: false,
+ since: Instant::now(),
+ settle,
+ wait: settle,
+ renders: Renders::default(),
+ }
}
pub fn submit(&mut self, job: Job) {
self.pending = Some(job);
@@ -375,6 +391,7 @@ impl Inline {
self.previewed = false;
self.preview_shown = false;
}
+ /// Whether a request is still to be refined (a preview's wait counts).
pub fn in_flight(&self) -> bool {
self.pending.is_some()
}
@@ -384,14 +401,16 @@ impl Inline {
if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) {
self.preview_shown = true;
self.since = Instant::now();
- self.ctx.request_repaint_after(self.settle);
+ let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0);
+ self.wait = full.map_or(self.settle, |full| full.min(self.settle));
+ self.ctx.request_repaint_after(self.wait);
return Some(answering(d, self.submitted));
}
}
if self.pending.is_some() && self.preview_shown {
let waited = self.since.elapsed();
- if waited < self.settle {
- self.ctx.request_repaint_after(self.settle - waited);
+ if waited < self.wait {
+ self.ctx.request_repaint_after(self.wait - waited);
return None;
}
}
diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
index 47a6a026..68b11870 100644
--- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs
+++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs
@@ -443,21 +443,23 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() {
/// The wait counts from the preview being shown, not from the request: rendering the preview can
/// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and
-/// counted from the request the refinement would follow in the very next frame.
+/// counted from the request the refinement would follow in the very next frame. The time the
+/// preview's render would take is stood in for by a sleep between the request and the poll.
#[test]
fn the_wait_counts_from_the_preview_not_from_the_request() {
let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
- let s = slow_scene(&[view(2.0), view(1.0)], 20.0 * PREVIEW_AFTER_MS);
+ // Renders from scratch of at least the settle, so the wait is the whole settle (see the cap).
+ let s = slow_scene(&[view(2.0), view(1.0)], SETTLE_MS as f64);
let ds = s.ds().unwrap();
let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
- let preview_ms = preview_with(&at(1.0), &mut Retained::default()).unwrap().ms;
- let settle = std::time::Duration::from_secs_f64(preview_ms / 2000.0).max(std::time::Duration::from_millis(1));
+ let settle = std::time::Duration::from_millis(SETTLE_MS);
let mut w = Inline::with_settle(egui::Context::default(), settle);
w.submit(at(2.0));
assert!(w.poll().is_some_and(|d| !d.coarse));
w.submit(at(1.0));
- assert!(w.poll().is_some_and(|d| d.coarse), "the preview, which takes {preview_ms} ms");
- assert!(w.poll().is_none() && w.in_flight(), "the wait ({settle:?}) starts only now");
+ std::thread::sleep(settle + std::time::Duration::from_millis(30));
+ assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait");
+ assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now");
std::thread::sleep(settle);
assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0));
}
@@ -491,3 +493,26 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af
let d = w.poll().expect("the refined image");
assert!(!d.coarse && !w.in_flight());
}
+
+/// The wait never costs more than the full render it may save: a preview is refined once the
+/// last full render's time has passed when that is shorter than `SETTLE_MS`, so a key zoom or a
+/// page switch on a page whose renders are quick is not held coarse for longer than before.
+#[test]
+fn the_wait_is_capped_by_the_time_of_the_last_full_render() {
+ let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200);
+ let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS);
+ let ds = s.ds().unwrap();
+ let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale));
+ // A settle far longer than this page's renders: the wait is their time instead.
+ let settle = std::time::Duration::from_secs(10);
+ let mut w = Inline::with_settle(egui::Context::default(), settle);
+ w.submit(at(2.0));
+ let first = w.poll().expect("the first image");
+ assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms);
+ w.submit(at(1.0));
+ assert!(w.poll().is_some_and(|d| d.coarse));
+ assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame");
+ std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0));
+ let d = w.poll().expect("refined once the last full render's time has passed, not the settle");
+ assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight());
+}
diff --git a/docs/gaps.md b/docs/gaps.md
index 67cc979d..9e079036 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -248,9 +248,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
page, a zoom after quick renders), a watchdog thread previews any such render still running
after 12 ms (desktop only; the browser renders inline).
- **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has
- come for 120 ms (`SETTLE_MS`): its full renders run on the UI thread, and one between every two
- wheel steps held the page up for seconds. Pans and edits (no preview) are refined at once; the
- desktop, with its render thread, is unchanged.
+ come for 120 ms (`SETTLE_MS`), or for as long as its last full render took when that is shorter:
+ its full renders run on the UI thread, and one between every two wheel steps held the page up
+ for seconds. Pans and edits (no preview) are refined at once; the desktop, with its render
+ thread, is unchanged.
- **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before
the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels;
this reduces blank strips without changing the CPU renderer.
From df6062d7fbe09df7f2778cf62a764094207f96d9 Mon Sep 17 00:00:00 2001
From: watchakorn-18k
Date: Wed, 28 Oct 2026 22:14:06 +0700
Subject: [PATCH 17/60] fix(ui): right-click on a frame's name label picks that
frame
The context menu looked only at what is under the pointer on the page, so a name label drawn over
its section selected the section. Copy link to selection then gave every frame in that section the
section's link. Labels now win, as they do on a press.
Co-Authored-By: Claude Sonnet 5.5
Claude-Session: https://claude.ai/code/session_015wxxFg3gana4zaUKnyirTC
---
crates/ui-egui/src/canvas/input.rs | 13 ++++---
crates/ui-egui/src/context_menu_tests.rs | 43 ++++++++++++++++++++++++
2 files changed, 51 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs
index fc518d2f..e7f96152 100644
--- a/crates/ui-egui/src/canvas/input.rs
+++ b/crates/ui-egui/src/canvas/input.rs
@@ -248,11 +248,14 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
let p = app.to_page(pos);
if let Ok(ds) = app.session.ds() {
let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6));
- if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) {
- let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep);
- if !sel.contains(&target) {
- app.session.set_selection(vec![target]);
- }
+ // A frame's name label is the frame, as on a press, even where it sits over a section.
+ let label = figcraft_engine::tools::label_at(&doc, page, &app.session.tool.labels, p);
+ let hit = app.session.hit(&doc, page, p, 2.0 / zoom);
+ let target = label.or_else(|| hit.map(|leaf| figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep)));
+ if let Some(target) = target
+ && !sel.contains(&target)
+ {
+ app.session.set_selection(vec![target]);
}
}
app.ui.context_menu = Some((pos, app.session.selection()));
diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs
index a447347d..9e2c0d68 100644
--- a/crates/ui-egui/src/context_menu_tests.rs
+++ b/crates/ui-egui/src/context_menu_tests.rs
@@ -84,3 +84,46 @@ fn copying_a_link_with_nothing_selected_copies_nothing() {
assert!(copied.lock().unwrap().is_empty(), "no link to node 0");
assert!(u.app.ui.toast.is_none());
}
+
+#[test]
+fn copying_a_link_on_a_frame_inside_a_selected_section_links_that_frame() {
+ // The section holds both frames; right-clicking each one must link that frame, not the section
+ // that was selected before.
+ let mut s = Session::new();
+ let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 400, "name": "Flow"})).unwrap());
+ let a = id(&s.execute("frame.create", &json!({"x": 20, "y": 60, "width": 300, "height": 200, "name": "main", "parent": sec})).unwrap());
+ let b = id(&s.execute("frame.create", &json!({"x": 400, "y": 60, "width": 300, "height": 200, "name": "dropdown", "parent": sec})).unwrap());
+ s.set_selection(vec![sec]);
+ let copied = Arc::new(Mutex::new(Vec::new()));
+ let mut u = Ui::new(s);
+ u.app.services = Box::new(Copied(copied.clone()));
+ u.frame(vec![]);
+ for (frame, x) in [(a, 170.0), (b, 550.0)] {
+ let at = u.app.to_screen(x, 160.0);
+ crate::canvas::open_context_menu(&mut u.app, at, false);
+ crate::menus::invoke(&mut u.app, "edit.copyLink");
+ let last = copied.lock().unwrap().last().cloned();
+ assert_eq!(last, Some(format!("figcraft://node/{}", frame.0)), "the frame at x={x}");
+ }
+}
+
+#[test]
+fn copying_a_link_from_a_frames_name_label_links_that_frame() {
+ // A frame inside a section has its label above it, inside the section: a right-click there
+ // must pick the frame whose label it is, not the section behind the label.
+ let mut s = Session::new();
+ let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 400, "name": "Flow"})).unwrap());
+ let a = id(&s.execute("frame.create", &json!({"x": 20, "y": 80, "width": 300, "height": 200, "name": "main", "parent": sec})).unwrap());
+ s.set_selection(vec![sec]);
+ let copied = Arc::new(Mutex::new(Vec::new()));
+ let mut u = Ui::new(s);
+ u.app.services = Box::new(Copied(copied.clone()));
+ u.frame(vec![]);
+ let quad = u.app.session.tool.labels.iter().find(|(i, _)| *i == a).map(|(_, q)| *q).expect("the frame has a label");
+ let (cx, cy) = (quad.iter().map(|p| p.x).sum::() / 4.0, quad.iter().map(|p| p.y).sum::() / 4.0);
+ let at = u.app.to_screen(cx, cy);
+ crate::canvas::open_context_menu(&mut u.app, at, false);
+ assert_eq!(u.app.session.selection(), vec![a], "the label's frame is selected");
+ crate::menus::invoke(&mut u.app, "edit.copyLink");
+ assert_eq!(copied.lock().unwrap().last().cloned(), Some(format!("figcraft://node/{}", a.0)));
+}
From 38ab0eaa6bbde560dc2babc331527b454bb68ce5 Mon Sep 17 00:00:00 2001
From: watchakorn-18k
Date: Wed, 28 Oct 2026 22:27:29 +0700
Subject: [PATCH 18/60] docs(ui): document the right-click label pick and its
tests
Co-Authored-By: Claude Sonnet 5.5
Claude-Session: https://claude.ai/code/session_015wxxFg3gana4zaUKnyirTC
---
crates/ui-egui/src/canvas/input.rs | 4 +++-
crates/ui-egui/src/context_menu_tests.rs | 8 ++++----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs
index e7f96152..75aa7c4e 100644
--- a/crates/ui-egui/src/canvas/input.rs
+++ b/crates/ui-egui/src/canvas/input.rs
@@ -243,7 +243,9 @@ fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) {
}
/// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then
-/// open the context menu there.
+/// open the context menu there. A frame's name label counts as the frame, as on a press, so a
+/// label drawn over its section picks the frame, not the section behind it. A layer that is
+/// already selected keeps the whole selection.
pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
let p = app.to_page(pos);
if let Ok(ds) = app.session.ds() {
diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs
index 9e2c0d68..41c2e0be 100644
--- a/crates/ui-egui/src/context_menu_tests.rs
+++ b/crates/ui-egui/src/context_menu_tests.rs
@@ -85,10 +85,10 @@ fn copying_a_link_with_nothing_selected_copies_nothing() {
assert!(u.app.ui.toast.is_none());
}
+/// With a section selected, a right-click on either frame inside it links that frame, not the
+/// section that was selected before.
#[test]
fn copying_a_link_on_a_frame_inside_a_selected_section_links_that_frame() {
- // The section holds both frames; right-clicking each one must link that frame, not the section
- // that was selected before.
let mut s = Session::new();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 400, "name": "Flow"})).unwrap());
let a = id(&s.execute("frame.create", &json!({"x": 20, "y": 60, "width": 300, "height": 200, "name": "main", "parent": sec})).unwrap());
@@ -107,10 +107,10 @@ fn copying_a_link_on_a_frame_inside_a_selected_section_links_that_frame() {
}
}
+/// A frame inside a section has its label above it, inside the section: a right-click there picks
+/// the frame whose label it is, not the section behind the label.
#[test]
fn copying_a_link_from_a_frames_name_label_links_that_frame() {
- // A frame inside a section has its label above it, inside the section: a right-click there
- // must pick the frame whose label it is, not the section behind the label.
let mut s = Session::new();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 400, "name": "Flow"})).unwrap());
let a = id(&s.execute("frame.create", &json!({"x": 20, "y": 80, "width": 300, "height": 200, "name": "main", "parent": sec})).unwrap());
From c420613efaa31e073a6f546e5cc71a2ba7e50ce6 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Mon, 26 Oct 2026 20:09:30 -0400
Subject: [PATCH 19/60] chore(clippy): enforce strict workspace lint policy
---
Cargo.toml | 3 +++
clippy.toml | 1 +
2 files changed, 4 insertions(+)
diff --git a/Cargo.toml b/Cargo.toml
index fd2fbb81..84ed164b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -102,6 +102,9 @@ webbrowser = "1"
unsafe_code = "forbid"
[workspace.lints.clippy]
+cognitive_complexity = "deny"
+too_many_arguments = "deny"
+wildcard_imports = "deny"
dbg_macro = "warn"
# Style lints from newer toolchains that fight index-heavy pixel code.
needless_range_loop = "allow"
diff --git a/clippy.toml b/clippy.toml
index 36cc87a4..305f94bd 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
+cognitive-complexity-threshold = 30
From e1a223bb1f9e5176ad3384c101cc370904f21a20 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Tue, 27 Oct 2026 11:33:22 +0000
Subject: [PATCH 20/60] refactor(layout): split flow layout phases
---
crates/layout/src/lib.rs | 273 ++++++++++++++++++++++-----------------
1 file changed, 157 insertions(+), 116 deletions(-)
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 02e7bdaf..468a5536 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -333,6 +333,154 @@ struct Item {
base_off: f64,
}
+/// Split flow items into lines, preserving a single empty line when wrapping is off.
+fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec> {
+ if !wraps {
+ return vec![(0..items.len()).collect()];
+ }
+ let mut lines = Vec::new();
+ let mut current = Vec::new();
+ let mut used = 0.0;
+ for (i, item) in items.iter().enumerate() {
+ let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main };
+ let added = if current.is_empty() { size } else { gap.max(0.0) + size };
+ if !current.is_empty() && used + added > inner_main + 1e-6 {
+ lines.push(std::mem::take(&mut current));
+ used = size;
+ } else {
+ used += added;
+ }
+ current.push(i);
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ lines
+}
+
+/// Resolve each line's main extent and baseline-adjusted cross extent.
+fn flow_line_extents(items: &mut [Item], lines: &[Vec], gap: f64) -> Vec<(f64, f64)> {
+ let mut extents = Vec::with_capacity(lines.len());
+ for line in lines {
+ let gaps = gap * line.len().saturating_sub(1) as f64;
+ let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max);
+ for i in line {
+ if let Some(item) = items.get_mut(*i) {
+ item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0);
+ }
+ }
+ let in_line = || line.iter().filter_map(|i| items.get(*i));
+ let main_used = in_line().map(|item| item.main).sum::() + gaps;
+ let all_fill = in_line().all(|item| item.fill_cross);
+ let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max);
+ extents.push((main_used, cross));
+ }
+ extents
+}
+
+/// Resolve cross-axis line spacing and alignment before positioning the flow items.
+fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) {
+ if extents.len() <= 1 {
+ return (0.0, cross_gap);
+ }
+ let lines_cross: f64 = extents.iter().map(|line| line.1).sum();
+ let between = (extents.len() - 1) as f64;
+ let spread = (inner_cross - lines_cross) / between;
+ if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
+ return (0.0, spread);
+ }
+ let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap };
+ let free = inner_cross - lines_cross - cross_gap * between;
+ if free > 0.0 && items.iter().all(|item| item.fill_cross) {
+ let extra = free / extents.len() as f64;
+ extents.iter_mut().for_each(|line| line.1 += extra);
+ return (0.0, cross_gap);
+ }
+ let offset = match al.cross_align {
+ CrossAlign::Min | CrossAlign::Baseline => 0.0,
+ CrossAlign::Center => free / 2.0,
+ CrossAlign::Max => free,
+ };
+ (offset, cross_gap)
+}
+
+/// Collect the size and sizing constraints of the children in flow order.
+fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec {
+ let mut items = Vec::with_capacity(kids.len());
+ for child in kids {
+ let Some(node) = doc.node(*child) else { continue };
+ let (w, h) = outer_size(doc, *child);
+ let cl = &node.child_layout;
+ let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
+ let (min_main, max_main, min_cross, max_cross) =
+ if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
+ items.push(Item {
+ id: *child,
+ main: if horizontal { w } else { h },
+ cross: if horizontal { h } else { w },
+ // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
+ fill_main: sm == Sizing::Fill && !hug_main,
+ fill_cross: sc == Sizing::Fill && !hug_cross,
+ min_main,
+ max_main,
+ min_cross,
+ max_cross,
+ baseline: None,
+ base_off: 0.0,
+ });
+ }
+ items
+}
+
+/// Position one resolved flow line, resizing wrapped fill children across the line.
+fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) {
+ let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry;
+ let total = used - gap * line.len().saturating_sub(1) as f64;
+ let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
+ pos += main_start;
+ for i in line {
+ let Some(it) = items.get(*i) else { continue };
+ let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
+ let off = match al.cross_align {
+ CrossAlign::Min => 0.0,
+ // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
+ CrossAlign::Baseline => it.base_off,
+ CrossAlign::Center => (line_cross - cross_size) / 2.0,
+ CrossAlign::Max => line_cross - cross_size,
+ };
+ if it.fill_cross && wraps {
+ let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
+ resize_child(doc, it.id, w, h, m);
+ }
+ let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
+ place(doc, it.id, x, y);
+ pos += it.main + step_gap;
+ }
+}
+
+struct FlowLineGeometry {
+ horizontal: bool,
+ wraps: bool,
+ main_start: f64,
+ inner_main: f64,
+ cross_pos: f64,
+ line_cross: f64,
+ used: f64,
+ gap: f64,
+}
+
+/// Distribute a line's remaining main-axis space among its fill children.
+fn distribute_flow_lines(items: &mut [Item], lines: &[Vec], inner_main: f64, gap: f64, auto_gap: bool) {
+ for line in lines {
+ let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.main).sum();
+ let gaps = gap * line.len().saturating_sub(1) as f64;
+ let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|item| item.fill_main)).collect();
+ if !fills.is_empty() {
+ distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps });
+ }
+ }
+}
+
/// Lay out one auto layout frame's children and size the frame if it hugs.
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let Some(_nested) = Nested::enter() else { return };
@@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let hug_cross = if horizontal { hug_h } else { hug_w };
let baseline = horizontal && al.cross_align == CrossAlign::Baseline;
- let mut items: Vec = Vec::with_capacity(kids.len());
- for c in &kids {
- let Some(n) = doc.node(*c) else { continue };
- let (w, h) = outer_size(doc, *c);
- let cl = &n.child_layout;
- let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
- let (min_main, max_main, min_cross, max_cross) =
- if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
- items.push(Item {
- id: *c,
- main: if horizontal { w } else { h },
- cross: if horizontal { h } else { w },
- // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
- fill_main: sm == Sizing::Fill && !hug_main,
- fill_cross: sc == Sizing::Fill && !hug_cross,
- min_main,
- max_main,
- min_cross,
- max_cross,
- baseline: None,
- base_off: 0.0,
- });
- }
+ let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross);
let gap = finite_or_zero(al.gap);
let auto_gap = al.main_align.is_auto_gap();
@@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
// Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one).
- let mut lines: Vec> = Vec::new();
- if wraps {
- let mut cur: Vec = Vec::new();
- let mut used = 0.0;
- for (i, it) in items.iter().enumerate() {
- let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };
- let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };
- if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {
- lines.push(std::mem::take(&mut cur));
- used = sz;
- } else {
- used += add;
- }
- cur.push(i);
- }
- if !cur.is_empty() {
- lines.push(cur);
- }
- } else {
- lines.push((0..items.len()).collect());
- }
+ let lines = flow_lines(&items, wraps, gap, inner_main_fixed);
// Share each line's free space among its fill children.
- for line in &lines {
- let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();
- let gaps = gap * line.len().saturating_sub(1) as f64;
- let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
- if !fills.is_empty() {
- distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps });
- }
- }
+ distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap);
// Fill children take their main size now, so their cross size (wrapped text, a hugging frame)
// is final before the lines and the frame are sized.
for it in items.iter_mut().filter(|it| it.fill_main) {
@@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
// Line extents: (main used, cross size).
- let mut line_extents: Vec<(f64, f64)> = Vec::new();
- for line in &lines {
- let gaps = gap * line.len().saturating_sub(1) as f64;
- // Baseline alignment: the line's baseline sits at the deepest first baseline.
- let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max);
- for i in line {
- if let Some(it) = items.get_mut(*i) {
- it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0);
- }
- }
- let in_line = || line.iter().filter_map(|i| items.get(*i));
- let main_used: f64 = in_line().map(|it| it.main).sum::() + gaps;
- // A line of nothing but fill-across children keeps their own size.
- let all_fill = in_line().all(|it| it.fill_cross);
- let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);
- line_extents.push((main_used, cross));
- }
+ let mut line_extents = flow_line_extents(&mut items, &lines, gap);
// Container size (on a hugging axis fill children can't fill: they count with their own size).
let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);
@@ -500,57 +583,15 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
// Position. Several lines share the free space across the flow: packed lines sit where the
// cross alignment says (lines of nothing but fill children stretch over it instead), or the
// space goes between them.
- let mut cross_pos = if horizontal { pt } else { pl };
+ let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap);
+ let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset;
let single = lines.len() == 1;
- let mut cross_gap = cross_gap;
- if lines.len() > 1 {
- let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum();
- let between = (lines.len() - 1) as f64;
- let spread = (inner_cross - lines_cross) / between;
- if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
- cross_gap = spread;
- } else {
- if al.content_align == ContentAlign::SpaceBetween {
- cross_gap = 0.0;
- }
- let free = inner_cross - lines_cross - cross_gap * between;
- if free > 0.0 && items.iter().all(|it| it.fill_cross) {
- let extra = free / lines.len() as f64;
- line_extents.iter_mut().for_each(|l| l.1 += extra);
- } else {
- cross_pos += match al.cross_align {
- CrossAlign::Min | CrossAlign::Baseline => 0.0,
- CrossAlign::Center => free / 2.0,
- CrossAlign::Max => free,
- };
- }
- }
- }
for (li, line) in lines.iter().enumerate() {
let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));
let line_cross = if single { inner_cross } else { line_cross };
- let total = used - gap * line.len().saturating_sub(1) as f64;
- let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
- pos += if horizontal { pl } else { pt };
- for i in line {
- let Some(it) = items.get(*i) else { continue };
- let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
- let off = match al.cross_align {
- CrossAlign::Min => 0.0,
- // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
- CrossAlign::Baseline => it.base_off,
- CrossAlign::Center => (line_cross - cross_size) / 2.0,
- CrossAlign::Max => line_cross - cross_size,
- };
- // Fill across a wrapped line: its size is known only now.
- if it.fill_cross && wraps {
- let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
- resize_child(doc, it.id, w, h, m);
- }
- let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
- place(doc, it.id, x, y);
- pos += it.main + step_gap;
- }
+ let geometry =
+ FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap };
+ place_flow_line(doc, &items, line, &al, m, geometry);
cross_pos += line_cross + cross_gap;
}
}
From 10bb163626ba9a9cd3bd120aebcd4a11ac4a51bf Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Tue, 27 Oct 2026 11:33:22 +0000
Subject: [PATCH 21/60] fix(xtask): apply workspace lint policy
---
xtask/Cargo.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index 59bfb3c9..1dbc3fc2 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
publish = false
+[lints]
+workspace = true
+
[dependencies]
serde_json = { workspace = true }
figcraft-engine = { workspace = true }
From 8591d44909e1f54e7d6d78549995fb8b49fc6140 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Tue, 27 Oct 2026 12:07:26 +0000
Subject: [PATCH 22/60] refactor(doc): declare node property dependencies
---
crates/doc/src/node.rs | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index 2a284b05..3146047a 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -1,7 +1,10 @@
//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
use crate::NodeId;
-use crate::props::*;
+use crate::props::{
+ AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,
+ StrokeProps,
+};
use crate::proto::{Interaction, Overflow, ScrollPosition};
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
use crate::text::TextData;
From 99afdd99abee629136062d0f0a45f6b4056984fe Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Tue, 27 Oct 2026 12:59:22 +0000
Subject: [PATCH 23/60] docs: document workspace lint inheritance
---
CLAUDE.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CLAUDE.md b/CLAUDE.md
index b2384311..fe6134a6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,6 +62,7 @@ code.
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
+- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`; production crate roots must begin with the required crate-level lint attribute.
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
safety nets, not substitutes for `Result`.
From c618e402fa89a6af3552ed1fec33f03fafc46a66 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Tue, 27 Oct 2026 14:00:36 +0000
Subject: [PATCH 24/60] refactor(format): replace wildcard imports
---
crates/format/src/fig/export/assets.rs | 5 ++++-
crates/format/src/fig/export/common.rs | 6 +++++-
crates/format/src/fig/export/component.rs | 2 +-
crates/format/src/fig/export/layer.rs | 6 +++++-
crates/format/src/fig/export/paint.rs | 5 ++++-
crates/format/src/fig/export/prototype.rs | 5 ++++-
crates/format/src/fig/export/shape.rs | 6 +++++-
crates/format/src/fig/kiwi.rs | 2 +-
crates/format/src/fig/map/component.rs | 2 +-
crates/format/src/fig/map/connector.rs | 2 +-
crates/format/src/fig/map/document.rs | 2 +-
crates/format/src/fig/map/layout.rs | 2 +-
crates/format/src/fig/map/paint.rs | 2 +-
crates/format/src/fig/map/proto.rs | 2 +-
crates/format/src/fig/map/text.rs | 2 +-
crates/format/src/fig/map/variables.rs | 2 +-
16 files changed, 37 insertions(+), 16 deletions(-)
diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs
index 00c84639..bd269556 100644
--- a/crates/format/src/fig/export/assets.rs
+++ b/crates/format/src/fig/export/assets.rs
@@ -1,5 +1,8 @@
use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};
-use super::*;
+use super::{
+ AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,
+ TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// GUIDs for every layer (pages first, then depth-first), component property definitions,
diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs
index 2853dd72..4a97eecb 100644
--- a/crates/format/src/fig/export/common.rs
+++ b/crates/format/src/fig/export/common.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,
+ PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,
+ s, xy,
+};
/// Longest kept description of such a layer (bytes of JSON).
const MAX_STASH: usize = 1 << 20;
diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs
index 7d88dcc8..2342386b 100644
--- a/crates/format/src/fig/export/component.rs
+++ b/crates/format/src/fig/export/component.rs
@@ -1,7 +1,7 @@
use super::common::{
bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
};
-use super::*;
+use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
impl<'d> Exporter<'d> {
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs
index 9925042f..55fd6dd9 100644
--- a/crates/format/src/fig/export/layer.rs
+++ b/crates/format/src/fig/export/layer.rs
@@ -1,5 +1,9 @@
use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};
-use super::*;
+use super::{
+ AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,
+ Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,
+ guid, int, matrix, o, position, root_fields, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.
diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs
index f0f40c18..26718e44 100644
--- a/crates/format/src/fig/export/paint.rs
+++ b/crates/format/src/fig/export/paint.rs
@@ -1,5 +1,8 @@
use super::common::one_sided;
-use super::*;
+use super::{
+ Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,
+ StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,
+};
impl<'d> Exporter<'d> {
/// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.
diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs
index 866b03c4..01c20db0 100644
--- a/crates/format/src/fig/export/prototype.rs
+++ b/crates/format/src/fig/export/prototype.rs
@@ -1,5 +1,8 @@
use super::common::key_codes;
-use super::*;
+use super::{
+ Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,
+ b, color, e, fl, int, o, position, s,
+};
impl<'d> Exporter<'d> {
/// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs
index 7ef79a78..ba420527 100644
--- a/crates/format/src/fig/export/shape.rs
+++ b/crates/format/src/fig/export/shape.rs
@@ -1,5 +1,9 @@
use super::common::{style_fields, unsupported_decoration_look};
-use super::*;
+use super::{
+ Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
+ StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
+ join_name, network, o, path_blob, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's
diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs
index 0f431082..82bdc6cd 100644
--- a/crates/format/src/fig/kiwi.rs
+++ b/crates/format/src/fig/kiwi.rs
@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> {
/// An encoder for the same format: the `.fig` export writes its schema and message with it (and
/// test fixtures are built with it). Values are checked against the schema; it never panics.
pub mod encode {
- use super::*;
+ use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err};
#[derive(Default)]
pub struct Writer(pub Vec);
diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs
index abc2225e..227c18aa 100644
--- a/crates/format/src/fig/map/component.rs
+++ b/crates/format/src/fig/map/component.rs
@@ -1,6 +1,6 @@
use super::layer::own_text;
use super::text::char_styles;
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs
index 172616dc..cd17680f 100644
--- a/crates/format/src/fig/map/connector.rs
+++ b/crates/format/src/fig/map/connector.rs
@@ -1,5 +1,5 @@
use super::layer::{cap_name, own_text, svg_path, valid_size};
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};
impl<'a> Mapper<'a> {
/// A connector's end caps, and when it has no saved outline or network a straight line
diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs
index d31ef5c6..69f221c1 100644
--- a/crates/format/src/fig/map/document.rs
+++ b/crates/format/src/fig/map/document.rs
@@ -1,7 +1,7 @@
use super::component::{assigned, prop_guid};
use super::paint::color;
use super::variables::variable_value;
-use super::*;
+use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows};
impl<'a> Mapper<'a> {
/// The DOCUMENT with its pages.
diff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs
index f4d50891..7900c453 100644
--- a/crates/format/src/fig/map/layout.rs
+++ b/crates/format/src/fig/map/layout.rs
@@ -1,5 +1,5 @@
use super::paint::{bound, color};
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
impl<'a> Mapper<'a> {
pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
index fe8cfa7b..808e6fc8 100644
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -1,6 +1,6 @@
use super::layer::cap_name;
use super::variables::alias;
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
fn filter_number(value: &Kv) -> Option {
let number = match value {
diff --git a/crates/format/src/fig/map/proto.rs b/crates/format/src/fig/map/proto.rs
index d3f5c022..9238cafb 100644
--- a/crates/format/src/fig/map/proto.rs
+++ b/crates/format/src/fig/map/proto.rs
@@ -1,6 +1,6 @@
use super::paint::color;
use super::variables::variable_data;
-use super::*;
+use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs
index 7431fbb5..6227c4d0 100644
--- a/crates/format/src/fig/map/text.rs
+++ b/crates/format/src/fig/map/text.rs
@@ -1,5 +1,5 @@
use super::paint::{paint, style_link};
-use super::*;
+use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
fn text_num(v: &View, key: &str) -> Option {
diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs
index d27c834b..8d891ded 100644
--- a/crates/format/src/fig/map/variables.rs
+++ b/crates/format/src/fig/map/variables.rs
@@ -1,5 +1,5 @@
use super::paint::color;
-use super::*;
+use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
From 6ebcf6b5112ee08681401e481bb1aa3af4058cdf Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Tue, 27 Oct 2026 14:00:38 +0000
Subject: [PATCH 25/60] refactor(text): split shaping and layout finishing
---
crates/text/src/layout.rs | 580 +++++++++++++++++++++-----------------
1 file changed, 319 insertions(+), 261 deletions(-)
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index ca5f0362..8bef740c 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -322,9 +322,16 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec {
order
}
-/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
-/// pass `None`). `box_height` is used for truncation only.
-pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout {
+struct ShapedText {
+ chars: Vec,
+ styles: Vec,
+ faces: Vec
\n", open("p")));
}
_ => {
diff --git a/crates/engine/src/dev/codegen/swift.rs b/crates/engine/src/dev/codegen/swift.rs
index a9f8eaaa..9d0dfcf8 100644
--- a/crates/engine/src/dev/codegen/swift.rs
+++ b/crates/engine/src/dev/codegen/swift.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
// ------------------------------------------------------------------ SwiftUI
diff --git a/crates/engine/src/dev/component_codegen.rs b/crates/engine/src/dev/component_codegen.rs
index c227fbdb..2e513cf6 100644
--- a/crates/engine/src/dev/component_codegen.rs
+++ b/crates/engine/src/dev/component_codegen.rs
@@ -7,13 +7,13 @@
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
//! real snippet instead.
-use figcraft_doc::*;
+use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
-use crate::params::*;
+use crate::params::{id_of, string, targets};
use crate::{bad, spec};
/// What a component property becomes in code.
diff --git a/crates/engine/src/dev/dev_resources.rs b/crates/engine/src/dev/dev_resources.rs
index 3da391a5..fc4e5b1a 100644
--- a/crates/engine/src/dev/dev_resources.rs
+++ b/crates/engine/src/dev/dev_resources.rs
@@ -7,12 +7,12 @@
//! capped) and never fetched or opened by the engine. A link added without a name is named after
//! its URL (no page title is looked up).
-use figcraft_doc::*;
+use figcraft_doc::{DevResource, Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::components::{inside_instance, variant_set};
-use crate::params::*;
+use crate::params::{get, req_index, targets};
use crate::{Result, Session, bad, spec};
/// Most dev resources on one layer.
diff --git a/crates/engine/src/dev/devcompare.rs b/crates/engine/src/dev/devcompare.rs
index ef40a2fb..6804f1aa 100644
--- a/crates/engine/src/dev/devcompare.rs
+++ b/crates/engine/src/dev/devcompare.rs
@@ -7,7 +7,7 @@
use std::collections::BTreeSet;
-use figcraft_doc::*;
+use figcraft_doc::{DevStatus, Document, MAX_SNAPSHOT_NODES, Node, NodeId, NodeKind};
use serde::Serialize;
use crate::devinspect::{Prop, Section, properties};
diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs
index 78aa5553..dbf3ac91 100644
--- a/crates/engine/src/dev/devinspect.rs
+++ b/crates/engine/src/dev/devinspect.rs
@@ -3,12 +3,15 @@
//! one. The Inspect tab shows these, and `inspect.properties` returns them.
use figcraft_color::{Color, GradientKind, Hsb, Hsl, PaintKind};
-use figcraft_doc::*;
+use figcraft_doc::{
+ AutoLayout, ContentAlign, CrossAlign, DevStatus, Document, EffectKind, ExportFormat, ExportSetting, LayoutMode, MainAlign, Node, NodeId,
+ NodeKind, NoiseMode, StrokeAlign, StyleId, TextAlignH, TextData, TextDecoration, Variable,
+};
use serde::Serialize;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
-use crate::params::*;
+use crate::params::{get, id, id_of, string, targets};
use crate::spec;
/// One group of properties ("Layout", "Typography"…).
From e98b97de2bda8af2d94e50fbb971385c82008896 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:01 +0000
Subject: [PATCH 31/60] refactor(engine): simplify session and test helpers
---
crates/engine/src/code_connect_tests.rs | 51 ++++++---
crates/engine/src/derived.rs | 2 +-
crates/engine/src/doc_state.rs | 2 +-
crates/engine/src/error.rs | 2 +-
crates/engine/src/keymap_tests.rs | 12 ++
crates/engine/src/library_variables_tests.rs | 108 ++++++++++--------
crates/engine/src/live_docs_tests.rs | 32 ++++--
crates/engine/src/live_share_tests.rs | 58 ++++++----
crates/engine/src/prefs.rs | 2 +-
crates/engine/src/proto_conditional_tests.rs | 50 ++++----
.../engine/src/selection/selection_colors.rs | 2 +-
crates/engine/src/session.rs | 5 +-
crates/engine/src/session/access.rs | 2 +-
crates/engine/src/session/exec.rs | 5 +-
crates/engine/src/session/new.rs | 2 +-
crates/engine/src/session/undo.rs | 5 +-
crates/engine/src/sharing/live/events.rs | 5 +-
crates/engine/src/sharing/live/host.rs | 5 +-
crates/engine/src/sharing/live/join.rs | 4 +-
crates/engine/src/sharing/live/sync.rs | 5 +-
crates/engine/src/sharing/live/types.rs | 5 +-
crates/engine/src/slot_tests.rs | 40 ++++---
.../src/vector_multi_behaviour_tests.rs | 91 +++++++++------
crates/engine/src/view_state.rs | 2 +-
24 files changed, 307 insertions(+), 190 deletions(-)
diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs
index 1ee785e6..24e03e4b 100644
--- a/crates/engine/src/code_connect_tests.rs
+++ b/crates/engine/src/code_connect_tests.rs
@@ -65,37 +65,47 @@ fn connected_instances_show_the_real_snippet() {
);
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
+ let screen = leptos_arkui_and_trees(&mut s, c, i);
+ rejects_bad_links(&mut s, c, screen);
+ disconnect_then_undo(&mut s, c, i);
+}
+
+fn leptos_arkui_and_trees(s: &mut Session, c: NodeId, i: NodeId) -> NodeId {
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
ex(
- &mut s,
+ s,
"codeConnect.set",
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}),
);
- assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n");
+ assert_eq!(code(s, i, "leptos"), "use crate::ui::Card;\n\n\n");
// ArkUI: an ArkTS import and an object-literal call.
ex(
- &mut s,
+ s,
"codeConnect.set",
json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}),
);
- assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
- let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"}));
+ assert_eq!(code(s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
+ let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "arkui"}));
assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";");
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
- ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
+ ex(s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
// inspect.componentCode: the snippet as the usage, and the link.
- let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
+ let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
assert_eq!(v["connected"]["import"], "import AcmeUI");
- assert_eq!(ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null);
+ assert_eq!(ex(s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null);
// Inside a generated layer tree, connected instances are their snippet.
- let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
- ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
- assert!(code(&mut s, screen, "react").contains("•\n"));
+ let screen = idv(&ex(s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
+ ex(s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
+ assert!(code(s, screen, "react").contains("•\n"));
// Literal braces stay; fields left out keep their value.
- ex(&mut s, "codeConnect.set", json!({"id": c, "template": ""}));
- assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\n");
+ ex(s, "codeConnect.set", json!({"id": c, "template": ""}));
+ assert_eq!(code(s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\n");
+ screen
+}
+
+fn rejects_bad_links(s: &mut Session, c: NodeId, screen: NodeId) {
// Validation.
for bad in [
json!({"id": c, "template": "{title ? \"a\"}"}),
@@ -109,13 +119,16 @@ fn connected_instances_show_the_real_snippet() {
] {
assert!(s.execute("codeConnect.set", &bad).is_err(), "{bad}");
}
+}
+
+fn disconnect_then_undo(s: &mut Session, c: NodeId, i: NodeId) {
// Disconnect one language; undo brings it back.
- ex(&mut s, "codeConnect.remove", json!({"id": i, "language": "react"}));
- assert!(code(&mut s, i, "react").contains("export function Card({"));
- assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI"));
- ex(&mut s, "edit.undo", json!({}));
- assert!(code(&mut s, i, "react").starts_with("import { Card }"));
- assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3);
+ ex(s, "codeConnect.remove", json!({"id": i, "language": "react"}));
+ assert!(code(s, i, "react").contains("export function Card({"));
+ assert!(code(s, i, "swiftui").starts_with("import AcmeUI"));
+ ex(s, "edit.undo", json!({}));
+ assert!(code(s, i, "react").starts_with("import { Card }"));
+ assert_eq!(ex(s, "codeConnect.remove", json!({"id": c}))["removed"], 3);
assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left");
}
diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs
index 765e3be5..b6a001b8 100644
--- a/crates/engine/src/derived.rs
+++ b/crates/engine/src/derived.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, BTreeSet, Document, Node, NodeId, board, cmd, components, measure, style_vars};
/// Nodes whose `Arc` differs from `before` (new or edited).
pub fn changed_nodes(doc: &Document, before: &Document) -> Vec {
diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs
index 4f9509a7..a9cad9c9 100644
--- a/crates/engine/src/doc_state.rs
+++ b/crates/engine/src/doc_state.rs
@@ -1,5 +1,5 @@
use super::view_state::ViewState;
-use super::*;
+use super::{Arc, BTreeSet, Deserialize, Document, NodeId, Serialize, cmd, components, file_access, guides, library, rotation_origin, vectoredit};
/// One undo step.
#[derive(Clone, Debug)]
diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs
index 7f2ab1d4..7f26258f 100644
--- a/crates/engine/src/error.rs
+++ b/crates/engine/src/error.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::NodeId;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs
index f624ecfb..bdf57c9d 100644
--- a/crates/engine/src/keymap_tests.rs
+++ b/crates/engine/src/keymap_tests.rs
@@ -95,6 +95,11 @@ fn key_names_normalise() {
#[test]
fn lookups_follow_the_platform_and_context() {
+ lookups_on_every_platform();
+ lookups_that_differ_by_platform();
+}
+
+fn lookups_on_every_platform() {
let none = Mods::default();
let shift = mods(true, false, false, false);
for p in PLATFORMS {
@@ -119,6 +124,9 @@ fn lookups_follow_the_platform_and_context() {
assert_eq!(id_for(p, Context::Text, "V", none), None);
assert_eq!(id_for(p, Context::Text, "A", mods(false, true, false, false)), None);
}
+}
+
+fn lookups_that_differ_by_platform() {
let mac = Platform::Mac;
let other = Platform::Other;
// Use as mask: ⌃⌘M on macOS, Ctrl+Alt+M elsewhere.
@@ -133,6 +141,10 @@ fn lookups_follow_the_platform_and_context() {
assert_eq!(id_for(mac, Context::Canvas, "T", mods(false, true, false, true)), Some("arrange.tidy"));
assert_eq!(id_for(other, Context::Canvas, "T", mods(true, true, true, true)), Some("arrange.tidy"));
assert_eq!(id_for(other, Context::Canvas, "T", mods(false, true, true, true)), Some("text.alignCenter"));
+ macos_tells_ctrl_from_cmd(mac, other);
+}
+
+fn macos_tells_ctrl_from_cmd(mac: Platform, other: Platform) {
// macOS tells ⌃ from ⌘: ⌃G is layout grids, ⌘G groups; ⌃C picks a colour, ⌘C copies.
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, false, true)), Some("view.layoutGrids"));
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, true, false)), Some("object.group"));
diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs
index b4a4171a..f61bab29 100644
--- a/crates/engine/src/library_variables_tests.rs
+++ b/crates/engine/src/library_variables_tests.rs
@@ -122,6 +122,65 @@ fn publishing_collections_hides_and_versions_variables() {
assert!(empty.execute("library.publish", &json!({})).is_err());
}
+/// `bind_alias_mode_read_only_and_updates`: every edit of a copy's variable or collection is refused.
+fn assert_copies_read_only(s: &mut Session, local: &str) {
+ let coll = copy_of(s, "primary").collection;
+ for (cmd, p) in [
+ ("variable.set", json!({"variable": local, "value": "#ff0000"})),
+ ("variable.update", json!({"variable": local, "name": "mine"})),
+ ("variable.delete", json!({"variable": local})),
+ ("variable.duplicate", json!({"variable": local})),
+ ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
+ ("variable.addMode", json!({"collection": coll, "name": "Mine"})),
+ ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
+ ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
+ ("variable.deleteCollection", json!({"collection": coll})),
+ ] {
+ let e = s.execute(cmd, &p).unwrap_err();
+ assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
+ }
+}
+
+/// `bind_alias_mode_read_only_and_updates`: saved and reopened, the file renders without the library.
+fn reopened_without_library(s: &mut Session, d: &std::path::Path, lib_path: &str, lid: &str, outer: NodeId) {
+ // Saved and reopened, the copies, records and bindings stay; the file renders without the library.
+ let user_path = path_str(&d.join("app.figcraft"));
+ ex(s, "file.saveAs", json!({"path": user_path}));
+ std::fs::remove_file(lib_path).unwrap();
+ let mut t = Session::empty();
+ ex(&mut t, "file.open", json!({"path": user_path}));
+ assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
+ assert_eq!(fill(&t, outer), "FF0000");
+ assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
+}
+
+/// `bind_alias_mode_read_only_and_updates`: the library changes its variables; the copies, bindings and alias follow.
+fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inner, outer]: [NodeId; 2]) {
+ let modes = Default::default();
+ // The library changes its variables and publishes again.
+ ex(lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
+ ex(lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
+ ex(lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
+ ex(lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
+ republish(lib);
+ let u = ex(s, "library.updates", json!({}));
+ assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
+ assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
+ let r = ex(s, "library.acceptUpdates", json!({}));
+ assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
+ // Same copies (same ids), new values: the bound layers and the alias follow.
+ assert_eq!(copy_of(s, "primary").id, local);
+ assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into()));
+ assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
+ assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
+ assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
+ // Undo takes the update back.
+ ex(s, "edit.undo", json!({}));
+ assert_eq!(fill(s, outer), "0000FF");
+ ex(s, "edit.redo", json!({}));
+ healthy(s);
+}
+
#[test]
fn bind_alias_mode_read_only_and_updates() {
let d = dir("flow");
@@ -180,57 +239,14 @@ fn bind_alias_mode_read_only_and_updates() {
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only");
// The copies are read-only.
- let coll = copy_of(&s, "primary").collection;
- for (cmd, p) in [
- ("variable.set", json!({"variable": local, "value": "#ff0000"})),
- ("variable.update", json!({"variable": local, "name": "mine"})),
- ("variable.delete", json!({"variable": local})),
- ("variable.duplicate", json!({"variable": local})),
- ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
- ("variable.addMode", json!({"collection": coll, "name": "Mine"})),
- ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
- ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
- ("variable.deleteCollection", json!({"collection": coll})),
- ] {
- let e = s.execute(cmd, &p).unwrap_err();
- assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
- }
+ assert_copies_read_only(&mut s, &local);
// Local edits still work.
ex(&mut s, "variable.set", json!({"variable": "gap", "value": 3}));
healthy(&s);
- // The library changes its variables and publishes again.
- ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
- ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
- ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
- ex(&mut lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
- republish(&mut lib);
- let u = ex(&mut s, "library.updates", json!({}));
- assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
- assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
- let r = ex(&mut s, "library.acceptUpdates", json!({}));
- assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
- // Same copies (same ids), new values: the bound layers and the alias follow.
- assert_eq!(copy_of(&s, "primary").id, local);
- assert_eq!((fill(&s, inner), fill(&s, outer)), ("222222".into(), "FF0000".into()));
- assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
- assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
- assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
- // Undo takes the update back.
- ex(&mut s, "edit.undo", json!({}));
- assert_eq!(fill(&s, outer), "0000FF");
- ex(&mut s, "edit.redo", json!({}));
- healthy(&s);
+ library_update_follows(&mut s, &mut lib, &local, [inner, outer]);
- // Saved and reopened, the copies, records and bindings stay; the file renders without the library.
- let user_path = path_str(&d.join("app.figcraft"));
- ex(&mut s, "file.saveAs", json!({"path": user_path}));
- std::fs::remove_file(&lib_path).unwrap();
- let mut t = Session::empty();
- ex(&mut t, "file.open", json!({"path": user_path}));
- assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
- assert_eq!(fill(&t, outer), "FF0000");
- assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
+ reopened_without_library(&mut s, &d, &lib_path, &lid, outer);
// Removing the library keeps the copies in use and drops the rest.
ex(&mut s, "variable.bind", json!({"ids": [outer], "field": "cornerRadius", "variable": null}));
diff --git a/crates/engine/src/live_docs_tests.rs b/crates/engine/src/live_docs_tests.rs
index 7380cc9b..df805266 100644
--- a/crates/engine/src/live_docs_tests.rs
+++ b/crates/engine/src/live_docs_tests.rs
@@ -62,7 +62,6 @@ fn each_document_has_its_own_session() {
let ua = uid(&s);
// A new document isn't in the first one's session.
run(&mut s, "file.new", json!({}));
- let ub = uid(&s);
let off = run(&mut s, "collab.peers", json!({}));
for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] {
assert_eq!(off[key], Value::Null, "{key}: {off}");
@@ -78,8 +77,12 @@ fn each_document_has_its_own_session() {
assert!(s.live_mut().is_none() && s.active_read_only().is_none());
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here");
assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped");
+ hosting_a_second_session(&mut s, &script, &a, ua);
+}
+
+fn hosting_a_second_session(s: &mut Session, script: &Arc>, a: &Value, ua: u64) {
// Hosting it too starts a second session; the first goes on.
- let b = run(&mut s, "collab.host", json!({}));
+ let b = run(s, "collab.host", json!({}));
assert_ne!(a["session"], b["session"]);
assert_ne!(a["link"], b["link"]);
assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2));
@@ -87,33 +90,38 @@ fn each_document_has_its_own_session() {
let sc = script.lock().unwrap();
assert_eq!((sc.hosted.len(), sc.left), (2, 0));
}
+ people_come_and_go(s, script, a, &b, ua);
+}
+
+fn people_come_and_go(s: &mut Session, script: &Arc>, a: &Value, b: &Value, ua: u64) {
+ let ub = uid(s);
// People come and go in their own session.
- let (a_id, b_id) = (id_of(&a), id_of(&b));
- feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
- feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
+ let (a_id, b_id) = (id_of(a), id_of(b));
+ feed(s, script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
+ feed(s, script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2));
let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::>();
let p = s.live_summary();
assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"]));
assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2)));
// The file in front decides what `collab.peers` and the commands are about.
- run(&mut s, "doc.switch", json!({"index": 0}));
- let p = run(&mut s, "collab.peers", json!({}));
+ run(s, "doc.switch", json!({"index": 0}));
+ let p = run(s, "collab.peers", json!({}));
assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"]));
- assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5);
+ assert_eq!(run(s, "collab.follow", json!({"client": 5}))["follow"], 5);
assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session");
- run(&mut s, "doc.switch", json!({"index": 1}));
+ run(s, "doc.switch", json!({"index": 1}));
assert!(s.execute("collab.follow", &json!({"client": 5})).is_err());
assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5)));
// Stopping ends this file's session only.
- assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true);
+ assert_eq!(run(s, "collab.stop", json!({}))["ended"], true);
assert_eq!(script.lock().unwrap().ended, [b_id]);
assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live));
// Someone ending or leaving the other one doesn't bring this one back.
- feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
+ feed(s, script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped");
// Closing a file ends its session.
- run(&mut s, "file.close", json!({"index": 0, "force": true}));
+ run(s, "file.close", json!({"index": 0, "force": true}));
s.live_sync();
assert_eq!(script.lock().unwrap().ended, [b_id, a_id]);
assert_eq!(s.live.iter().count(), 0);
diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs
index f486250f..452eb3e8 100644
--- a/crates/engine/src/live_share_tests.rs
+++ b/crates/engine/src/live_share_tests.rs
@@ -185,6 +185,38 @@ fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() {
assert_eq!(old.publish_server, DEFAULT_SERVER);
}
+/// The middle of the online-sharing test: re-asking switches codes, and an offline stop stops nothing.
+fn asking_again_and_offline_stop(s: &mut Session, script: &Arc>) {
+ // Asking again only switches which code is the one to give out.
+ let r = ex(s, "collab.share", json!({"access": "view"}));
+ assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
+ assert_eq!(ex(s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
+ assert_eq!(ex(s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
+ assert_eq!(script.lock().unwrap().published.len(), 1);
+ assert!(err(s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
+ assert!(err(s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
+ // Stopping while the server can't be reached stops nothing, and says so.
+ script.lock().unwrap().offline = true;
+ assert!(err(s, "collab.stop", json!({})).contains("couldn't stop sharing"));
+ assert_eq!(ex(s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
+ script.lock().unwrap().offline = false;
+}
+
+/// The sharing-again step of the online-sharing test: new links and codes, nothing published twice.
+fn sharing_again(s: &mut Session, script: &Arc>) {
+ // Sharing again: new links and codes for the same file, nothing published twice.
+ let r = ex(s, "collab.share", json!({}));
+ assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
+ assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
+ {
+ let sc = script.lock().unwrap();
+ assert_eq!(sc.published.len(), 1);
+ assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
+ }
+ assert_eq!(ex(s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
+ assert_eq!(script.lock().unwrap().asked.len(), 2);
+}
+
#[test]
fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
let (mut s, script) = session();
@@ -212,19 +244,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap());
assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}");
assert!(s.message.is_none(), "the popover shows the code: no toast about publishing");
- // Asking again only switches which code is the one to give out.
- let r = ex(&mut s, "collab.share", json!({"access": "view"}));
- assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
- assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
- assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
- assert_eq!(script.lock().unwrap().published.len(), 1);
- assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
- assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
- // Stopping while the server can't be reached stops nothing, and says so.
- script.lock().unwrap().offline = true;
- assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing"));
- assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
- script.lock().unwrap().offline = false;
+ asking_again_and_offline_stop(&mut s, &script);
// Stopping: the server is asked, as the file's owner, to revoke the others' links. The file
// stays there and its owner stays in it: nothing is left, nothing is removed.
assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
@@ -248,17 +268,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
);
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
- // Sharing again: new links and codes for the same file, nothing published twice.
- let r = ex(&mut s, "collab.share", json!({}));
- assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
- assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
- {
- let sc = script.lock().unwrap();
- assert_eq!(sc.published.len(), 1);
- assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
- }
- assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
- assert_eq!(script.lock().unwrap().asked.len(), 2);
+ sharing_again(&mut s, &script);
// Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves
// its session like any other (the file stays on the server).
ex(&mut s, "collab.stop", json!({}));
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index 6bfedcba..ced1793f 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, BTreeMap, Deserialize, Document, NodeId, Serialize, Value, cmd, live, online, plugins};
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
/// system's own size).
diff --git a/crates/engine/src/proto_conditional_tests.rs b/crates/engine/src/proto_conditional_tests.rs
index e40aea5d..b5441262 100644
--- a/crates/engine/src/proto_conditional_tests.rs
+++ b/crates/engine/src/proto_conditional_tests.rs
@@ -71,41 +71,51 @@ fn build_if_else_if_else_with_nested_actions() {
assert_eq!(shown(&s, bl[1].condition.as_ref().unwrap()), "open == false");
assert_eq!(shown(&s, bl[2].condition.as_ref().unwrap()), "label == \"Hi\"");
assert!(bl[3].condition.is_none(), "the else stays last");
+ actions_in_the_branches(&mut s, b, btn);
+ reorder_replace_and_remove(&mut s, b, btn);
+}
+
+fn actions_in_the_branches(s: &mut Session, b: NodeId, btn: NodeId) {
+ let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap();
// Actions inside the branches: a set variable with an expression in the if, a back in the else.
let r = ex(
- &mut s,
+ s,
"proto.action.add",
json!({"id": btn, "index": 0, "path": [1, 0, 0], "action": {"type": "setVariable", "variable": "count", "expression": "count + 1"}}),
);
assert_eq!(r["path"], json!([1, 0, 0]));
- ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}}));
- ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}}));
- let list = actions(&s, btn);
+ ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}}));
+ ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}}));
+ let list = actions(s, btn);
let Some(Action::SetVariable { variable, expr: Some(e), .. }) = action_at(&list, &[1, 0, 0]) else { panic!("{list:?}") };
- assert_eq!(variable, &var(&s, "count"));
- assert_eq!(shown(&s, e), "count + 1");
+ assert_eq!(variable, &var(s, "count"));
+ assert_eq!(shown(s, e), "count + 1");
assert_eq!(action_at(&list, &[1, 3, 0]), Some(&Action::Back));
+}
+
+fn reorder_replace_and_remove(s: &mut Session, b: NodeId, btn: NodeId) {
+ let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap();
// Reorder inside the else, replace one, change a condition, remove a block.
- assert_eq!(ex(&mut s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0]));
- assert!(matches!(action_at(&actions(&s, btn), &[1, 3, 0]), Some(Action::Navigate { .. })));
- ex(&mut s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}}));
- assert_eq!(action_at(&actions(&s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay));
- ex(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"}));
- assert_eq!(shown(&s, blocks(&actions(&s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)");
- ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2}));
- assert_eq!(blocks(&actions(&s, btn)[1]).len(), 3);
- ex(&mut s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]}));
- assert_eq!(blocks(&actions(&s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]);
+ assert_eq!(ex(s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0]));
+ assert!(matches!(action_at(&actions(s, btn), &[1, 3, 0]), Some(Action::Navigate { .. })));
+ ex(s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}}));
+ assert_eq!(action_at(&actions(s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay));
+ ex(s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"}));
+ assert_eq!(shown(s, blocks(&actions(s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)");
+ ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2}));
+ assert_eq!(blocks(&actions(s, btn)[1]).len(), 3);
+ ex(s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]}));
+ assert_eq!(blocks(&actions(s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]);
// Every edit is one undo step.
assert!(s.undo().unwrap());
- assert_eq!(blocks(&actions(&s, btn)[1])[2].actions.len(), 2);
+ assert_eq!(blocks(&actions(s, btn)[1])[2].actions.len(), 2);
// Removing the if leaves the else if as the if; removing that leaves only the else: the
// conditional goes.
- let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
+ let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
assert_eq!(r["removedAction"], false);
- let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
+ let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
assert_eq!(r["removedAction"], true);
- assert_eq!(actions(&s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]);
+ assert_eq!(actions(s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]);
}
#[test]
diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs
index 56bc4b35..00c7389f 100644
--- a/crates/engine/src/selection/selection_colors.rs
+++ b/crates/engine/src/selection/selection_colors.rs
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::cmd::styles::rebind_paint_alpha;
-use crate::params::*;
+use crate::params::{get, paint_of, parse, req_string, targets};
use crate::{CommandSpec, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs
index acafd229..a31114e2 100644
--- a/crates/engine/src/session.rs
+++ b/crates/engine/src/session.rs
@@ -2,7 +2,10 @@ use super::derived::normalize;
use super::doc_state::{DocState, ENDS_PREVIEW, HistoryEntry, Interaction, PREVIEW_READ_ONLY};
use super::error::{EngineError, Result, bad};
use super::prefs::{Clipboard, Prefs, UiRequest};
-use super::*;
+use super::{
+ Arc, BatchScope, BatchStep, CommandSpec, Document, Node, NodeId, Session, Value, background, board, branches, cmd, components, figcraft_collab,
+ find_command, guard, json, library, live, params, plugins, textpreview, tools,
+};
mod access;
mod exec;
diff --git a/crates/engine/src/session/access.rs b/crates/engine/src/session/access.rs
index af9fd7f5..05bc45d5 100644
--- a/crates/engine/src/session/access.rs
+++ b/crates/engine/src/session/access.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
impl Session {
pub fn ds(&self) -> Result<&DocState> {
diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs
index add12ac5..00798b2c 100644
--- a/crates/engine/src/session/exec.rs
+++ b/crates/engine/src/session/exec.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, CommandSpec, Document, ENDS_PREVIEW, EngineError, Node, NodeId, PREVIEW_READ_ONLY, Result, Session, Value, bad, board, branches, cmd,
+ components, find_command, guard, library, normalize, params, textpreview,
+};
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs
index 8790c83b..96bf0c2d 100644
--- a/crates/engine/src/session/new.rs
+++ b/crates/engine/src/session/new.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, Clipboard, DocState, Document, Prefs, Session, background, cmd, library, live, plugins, tools};
impl Session {
/// A session with one new document.
diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs
index a354ea27..5fd0ec80 100644
--- a/crates/engine/src/session/undo.rs
+++ b/crates/engine/src/session/undo.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest,
+ Value, cmd, figcraft_collab, normalize, textpreview,
+};
impl Session {
/// How many undo and redo steps the active document has (the user's own in a live session).
diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs
index 5b6008f8..9d851cab 100644
--- a/crates/engine/src/sharing/live/events.rs
+++ b/crates/engine/src/sharing/live/events.rs
@@ -1,6 +1,9 @@
use super::host::{clean_hosting, clean_shared};
use super::types::{Event, PageInfo, Role, SessionId, Status};
-use super::*;
+use super::{
+ Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
+ ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
+};
impl Session {
/// The active document (`None`: no document is open).
diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs
index 685ab5c0..06f6df95 100644
--- a/crates/engine/src/sharing/live/host.rs
+++ b/crates/engine/src/sharing/live/host.rs
@@ -1,5 +1,8 @@
use super::types::{Bind, HostOptions, NetReply, NetRequest, ONLINE_OFF, PublishOptions, Role, SessionId, Share, Shared, SharedLink, Status, Via};
-use super::*;
+use super::{
+ Access, Admin, AdminReply, ClientMsg, EngineError, HostInfo, IpAddr, Live, Result, Session, Value, bad, code, display_name, get, json,
+ req_string, string,
+};
/// What a server says about its hosting, made safe to show: no control characters, short, and a
/// terms address only when it is a web page (`http(s)://`).
diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs
index 972198ef..e08afcc6 100644
--- a/crates/engine/src/sharing/live/join.rs
+++ b/crates/engine/src/sharing/live/join.rs
@@ -1,6 +1,8 @@
use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable};
use super::types::{Published, Role, SessionId, Share, Status, join_target};
-use super::*;
+use super::{
+ Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string,
+};
fn join(s: &mut Session, p: &Value) -> Result {
const CMD: &str = "collab.join";
diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs
index f2fd3c54..3920bdca 100644
--- a/crates/engine/src/sharing/live/sync.rs
+++ b/crates/engine/src/sharing/live/sync.rs
@@ -1,6 +1,9 @@
use super::host::hosting_json;
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
-use super::*;
+use super::{
+ Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
+ editing_of, json,
+};
impl Session {
/// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for
diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs
index 16a237d5..efeecac9 100644
--- a/crates/engine/src/sharing/live/types.rs
+++ b/crates/engine/src/sharing/live/types.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Access, Admin, AdminReply, Arc, ClientMsg, Code, Document, HostInfo, IpAddr, LibraryReply, LibraryReq, NO_TEAM_LIBRARIES, Peer, PluginReply,
+ PluginReq, Presence, Resume, Serialize, ServerMsg, Signal, code,
+};
/// The server files are shared through online unless Preferences name another: the only place
/// its address is written.
diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs
index cbf824d9..01a5db08 100644
--- a/crates/engine/src/slot_tests.rs
+++ b/crates/engine/src/slot_tests.rs
@@ -99,6 +99,28 @@ fn slots_are_frames_of_a_main_with_a_slot_property() {
healthy(&s);
}
+/// `instances_fill_slots_with_their_own_content`: resetting a slot, or the whole instance, restores the default content.
+fn reset_slot_content(s: &mut Session, [a, aslot, ph, ichip, chip]: [NodeId; 5]) {
+ // Reset: back to the main's default content.
+ ex(s, "instance.resetSlot", json!({"id": aslot}));
+ healthy(s);
+ assert!(!slot_edited(s.doc().unwrap(), aslot));
+ assert_eq!(kids(s, aslot).len(), 2);
+ assert_eq!(fill(s, copy_of(s, a, ph)), "0000FF");
+ assert!(!s.doc().unwrap().contains(ichip));
+ s.undo().unwrap();
+ assert!(s.doc().unwrap().contains(ichip));
+ healthy(s);
+ // Reset all changes resets slots too; `replace` clears the default content first.
+ ex(s, "component.resetInstance", json!({"ids": [a]}));
+ assert!(!slot_edited(s.doc().unwrap(), aslot));
+ let only = idv(&ex(s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
+ assert_eq!(kids(s, aslot), vec![only]);
+ ex(s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
+ assert_eq!(kids(s, aslot).len(), 2);
+ healthy(s);
+}
+
#[test]
fn instances_fill_slots_with_their_own_content() {
let mut s = Session::new();
@@ -158,23 +180,7 @@ fn instances_fill_slots_with_their_own_content() {
// Layers of the instance can't move into its slot either.
let atitle = copy_of(&s, a, title);
refused(&mut s, "node.reparent", json!({"ids": [atitle], "parent": aslot}));
- // Reset: back to the main's default content.
- ex(&mut s, "instance.resetSlot", json!({"id": aslot}));
- healthy(&s);
- assert!(!slot_edited(s.doc().unwrap(), aslot));
- assert_eq!(kids(&s, aslot).len(), 2);
- assert_eq!(fill(&s, copy_of(&s, a, ph)), "0000FF");
- assert!(!s.doc().unwrap().contains(ichip));
- s.undo().unwrap();
- assert!(s.doc().unwrap().contains(ichip));
- healthy(&s);
- // Reset all changes resets slots too; `replace` clears the default content first.
- ex(&mut s, "component.resetInstance", json!({"ids": [a]}));
- assert!(!slot_edited(s.doc().unwrap(), aslot));
- let only = idv(&ex(&mut s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
- assert_eq!(kids(&s, aslot), vec![only]);
- ex(&mut s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
- assert_eq!(kids(&s, aslot).len(), 2);
+ reset_slot_content(&mut s, [a, aslot, ph, ichip, chip]);
healthy(&s);
}
diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs
index 2afaf9a2..ef337af8 100644
--- a/crates/engine/src/vector_multi_behaviour_tests.rs
+++ b/crates/engine/src/vector_multi_behaviour_tests.rs
@@ -95,6 +95,14 @@ fn selected_in(s: &Session, id: NodeId) -> Vec {
#[test]
fn editing_several_vectors_at_once() {
+ several_vectors_enter_edit_mode();
+ let (mut s, a, b, atl, btl) = points_across_both_layers();
+ arrows_and_drags_move_every_layer(&mut s, a, b, atl, btl);
+ corner_radius_mirroring_and_delete(&mut s, a, b, atl, btl);
+ bend_select_all_pen_and_clicks(&mut s, a, b);
+}
+
+fn several_vectors_enter_edit_mode() {
// (how edit mode is entered with two shapes selected, derivation)
let rows: Vec<(&str, &str)> = vec![
("command", "the edit command takes every selected shape"),
@@ -135,8 +143,9 @@ fn editing_several_vectors_at_once() {
crate::vectoredit::enter_targets(s.doc().unwrap(), &[a, f]).is_none(),
"silent: a frame in the selection leaves Enter to select its children (pinned)"
);
+}
- // Points across both layers.
+fn points_across_both_layers() -> (Session, NodeId, NodeId, usize, usize) {
let mut s = Session::new();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0));
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
@@ -161,64 +170,74 @@ fn editing_several_vectors_at_once() {
sb.sort_unstable();
assert_eq!((editing(&s).unwrap().id, selected_in(&s, a), selected_in(&s, b)), (active, sa, sb), "{why}");
}
+ (s, a, b, atl, btl)
+}
+
+fn arrows_and_drags_move_every_layer(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) {
// Arrows nudge every selected point, one undo step for all layers.
- ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
- ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
- assert_eq!((selected_in(&s, a), selected_in(&s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer");
+ ex(s, "vector.edit.select", json!({"vertices": [atl]}));
+ ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+ assert_eq!((selected_in(s, a), selected_in(s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer");
assert!(s.tool_key("Right", false, false, false).unwrap());
- assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
+ assert!(near(page_pos(s, a, atl), (1.0, 0.0)) && near(page_pos(s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
s.undo().unwrap();
- assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer");
+ assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "one undo step for every layer");
// Dragging one selected point drags them all (one on the transform box's edge: its corners
// scale, as with points of one layer).
- let atr = point_at(&s, a, (100.0, 0.0));
- ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
- drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE);
+ let atr = point_at(s, a, (100.0, 0.0));
+ ex(s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
+ drag(s, (100.0, 0.0), (110.0, 20.0), NONE);
assert!(
- near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)),
+ near(page_pos(s, a, atl), (10.0, 20.0)) && near(page_pos(s, a, atr), (110.0, 20.0)) && near(page_pos(s, b, btl), (210.0, 20.0)),
"dragging one selected point moves them all"
);
s.undo().unwrap();
- assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step");
- ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
- ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+ assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "the drag is one undo step");
+ ex(s, "vector.edit.select", json!({"vertices": [atl]}));
+ ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+}
+
+fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) {
// Corner radius, mirroring and deleting reach every layer's selected points.
- ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10}));
- assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
- ex(&mut s, "vector.edit.mirroring", json!({"mode": "angle"}));
+ ex(s, "vector.edit.cornerRadius", json!({"radius": 10}));
+ assert!(net(s, a).vertices[atl].radius == 10.0 && net(s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
+ ex(s, "vector.edit.mirroring", json!({"mode": "angle"}));
assert!(
- net(&s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle)
- && net(&s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle),
+ net(s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle)
+ && net(s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle),
"mirroring applies to every layer"
);
assert!(s.tool_key("Delete", false, false, false).unwrap());
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer");
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer");
s.undo().unwrap();
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (4, 4), "one undo step");
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (4, 4), "one undo step");
+}
+
+fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) {
// The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer.
- drag(&mut s, (250.0, 100.0), (250.0, 130.0), CMD);
- assert!(net(&s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
- assert!(net(&s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
+ drag(s, (250.0, 100.0), (250.0, 130.0), CMD);
+ assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
+ assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
// Select all takes every edited layer's points.
- let r = ex(&mut s, "vector.edit.selectAll", json!({}));
+ let r = ex(s, "vector.edit.selectAll", json!({}));
assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)");
// The pen draws in the active layer only.
- ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": []}));
- ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
- click(&mut s, (50.0, 300.0), NONE);
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
- ex(&mut s, "vector.edit.penEnd", json!({}));
- ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
+ ex(s, "vector.edit.select", json!({"id": a, "vertices": []}));
+ ex(s, "vector.edit.tool", json!({"tool": "pen"}));
+ click(s, (50.0, 300.0), NONE);
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
+ ex(s, "vector.edit.penEnd", json!({}));
+ ex(s, "vector.edit.tool", json!({"tool": "move"}));
// A click on empty canvas inside an edited layer's area keeps edit mode; so does one far
// away, which only clears the points (a double-click or Escape leaves).
- click(&mut s, (250.0, 50.0), NONE);
- assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays");
- let st = ex(&mut s, "vector.edit.state", json!({}));
+ click(s, (250.0, 50.0), NONE);
+ assert!(editing(s).is_some(), "a click inside an edited layer clears the points and stays");
+ let st = ex(s, "vector.edit.state", json!({}));
assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer");
- click(&mut s, (900.0, 900.0), NONE);
- assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode");
+ click(s, (900.0, 900.0), NONE);
+ assert!(editing(s).is_some(), "a click away from every edited layer keeps edit mode");
s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap();
- assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode");
+ assert!(editing(s).is_none(), "a double-click away from every edited layer leaves edit mode");
}
#[test]
diff --git a/crates/engine/src/view_state.rs b/crates/engine/src/view_state.rs
index e162f039..54102d6b 100644
--- a/crates/engine/src/view_state.rs
+++ b/crates/engine/src/view_state.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Deserialize, Serialize};
/// The canvas view of a document: what's at the centre of the viewport and the zoom.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
From 927ceb06383a15f4a9e38dc4dbc1bd3caec09569 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 32/60] refactor(ui): simplify panels and menus
---
crates/ui-egui/src/menus.rs | 10 +-
crates/ui-egui/src/menus/clipboard.rs | 2 +-
crates/ui-egui/src/menus/palette.rs | 5 +-
crates/ui-egui/src/menus/select_layer.rs | 24 +-
crates/ui-egui/src/menus/tree.rs | 26 +-
crates/ui-egui/src/panels/component_panel.rs | 2 +-
crates/ui-egui/src/panels/export_panel.rs | 2 +-
crates/ui-egui/src/panels/layers.rs | 485 ++++++++++--------
crates/ui-egui/src/panels/layout_panel.rs | 2 +-
crates/ui-egui/src/panels/proto_editor.rs | 68 ++-
crates/ui-egui/src/panels/proto_panel.rs | 75 ++-
crates/ui-egui/src/panels/right.rs | 6 +-
crates/ui-egui/src/panels/right/effects.rs | 6 +-
crates/ui-egui/src/panels/right/inspect.rs | 5 +-
crates/ui-egui/src/panels/right/menu.rs | 2 +-
crates/ui-egui/src/panels/right/paints.rs | 6 +-
crates/ui-egui/src/panels/right/stroke.rs | 5 +-
crates/ui-egui/src/panels/typography.rs | 5 +-
crates/ui-egui/src/panels/typography/axes.rs | 5 +-
.../ui-egui/src/panels/typography/fields.rs | 6 +-
.../ui-egui/src/panels/typography/glyphs.rs | 5 +-
.../ui-egui/src/panels/typography/picker.rs | 2 +-
.../ui-egui/src/panels/typography/spacing.rs | 5 +-
23 files changed, 463 insertions(+), 296 deletions(-)
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 549a55c0..b7785e08 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -27,7 +27,7 @@ pub use select_layer::open_select_layer;
use select_layer::{SELECT_LAYER, select_layer_ask_id, select_layer_menu};
#[cfg(test)]
pub(crate) use tree::{AIM_REST, HOVER_DELAY, heading_for, in_triangle};
-use tree::{At, menu_frame, plain_row_hover, set_open, submenu_at};
+use tree::{At, SubmenuOptions, menu_frame, plain_row_hover, set_open, submenu_at};
pub(crate) use tree::{forget_tree, item, separator, submenu};
/// Commands the UI layer implements itself (dialogs and platform actions).
@@ -403,8 +403,12 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
}
ui.add_space(6.0);
for top in TOP_MENUS {
- if submenu_at(ui, Id::unique(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(()))
- .is_some()
+ if submenu_at(
+ ui,
+ SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t },
+ |ui| menu_level(app, ui, top).then_some(()),
+ )
+ .is_some()
{
close = true;
}
diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs
index dd775297..3089ee3e 100644
--- a/crates/ui-egui/src/menus/clipboard.rs
+++ b/crates/ui-egui/src/menus/clipboard.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{FigApp, dev_mode_refuses, json, trn};
/// Run a menu item: some open dialogs or use platform services, the rest go to the engine.
/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied
diff --git a/crates/ui-egui/src/menus/palette.rs b/crates/ui-egui/src/menus/palette.rs
index 4890f8ad..d5f7e810 100644
--- a/crates/ui-egui/src/menus/palette.rs
+++ b/crates/ui-egui/src/menus/palette.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Align2, Color32, CornerRadius, FigApp, Id, MENU, Rect, SEP, Sense, Stroke, StrokeKind, Tokens, Ui, available, i18n, invoke, json, pos2,
+ pretty_shortcut, record_recent, regular, tr, vec2,
+};
/// The Actions palette's tab.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
diff --git a/crates/ui-egui/src/menus/select_layer.rs b/crates/ui-egui/src/menus/select_layer.rs
index 59389c41..73571f38 100644
--- a/crates/ui-egui/src/menus/select_layer.rs
+++ b/crates/ui-egui/src/menus/select_layer.rs
@@ -1,5 +1,5 @@
-use super::tree::{indented_item, separator, submenu_at};
-use super::*;
+use super::tree::{SubmenuOptions, indented_item, separator, submenu_at};
+use super::{FigApp, Id, Tokens, Ui, json, tr};
/// The context menu's Select layer submenu (its key in the context menu's tree).
pub(super) const SELECT_LAYER: &str = "Select layer";
@@ -35,15 +35,19 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
return false;
}
let sel = ds.selection.clone();
- let picked = submenu_at(ui, Id::unique("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| {
- let mut picked = None;
- for (id, depth, name) in &layers {
- if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
- picked = Some(*id);
+ let picked = submenu_at(
+ ui,
+ SubmenuOptions { area: Id::unique("ctx_select_layer"), key: SELECT_LAYER, label: tr("Select layer"), gap: 4.0, constrain: true, tokens: t },
+ |ui| {
+ let mut picked = None;
+ for (id, depth, name) in &layers {
+ if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
+ picked = Some(*id);
+ }
}
- }
- picked
- });
+ picked
+ },
+ );
separator(ui);
match picked {
Some(id) => {
diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs
index 4f181b3f..f0c64550 100644
--- a/crates/ui-egui/src/menus/tree.rs
+++ b/crates/ui-egui/src/menus/tree.rs
@@ -1,10 +1,10 @@
-use super::*;
+use super::{Color32, CornerRadius, Icon, Id, Rect, Sense, Stroke, Tokens, Ui, a11y, i18n, icons, pos2, pretty_shortcut, regular, vec2};
/// A submenu row `label` in the menu being drawn; while it's open, `add` draws its items beside
/// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add`
/// returned.
pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option {
- submenu_at(ui, Id::unique(("submenu", key)), key, label, 4.0, false, t, add)
+ submenu_at(ui, SubmenuOptions { area: Id::unique(("submenu", key)), key, label, gap: 4.0, constrain: false, tokens: t }, add)
}
// ---------------------------------------------------------------------------------------------
@@ -240,17 +240,17 @@ pub(super) fn plain_row_hover(ui: &Ui, resp: &egui::Response) -> bool {
}
/// A submenu row whose panel (area `area`, `gap` right of the row) shows while it's open.
-#[allow(clippy::too_many_arguments)]
-pub(super) fn submenu_at(
- ui: &mut Ui,
- area: Id,
- key: &str,
- label: &str,
- gap: f32,
- constrain: bool,
- t: &Tokens,
- add: impl FnOnce(&mut Ui) -> Option,
-) -> Option {
+pub(super) struct SubmenuOptions<'a> {
+ pub(super) area: Id,
+ pub(super) key: &'a str,
+ pub(super) label: &'a str,
+ pub(super) gap: f32,
+ pub(super) constrain: bool,
+ pub(super) tokens: &'a Tokens,
+}
+
+pub(super) fn submenu_at(ui: &mut Ui, options: SubmenuOptions<'_>, add: impl FnOnce(&mut Ui) -> Option) -> Option {
+ let SubmenuOptions { area, key, label, gap, constrain, tokens: t } = options;
let at = level_at(ui);
let aim = aiming(ui);
let w = ui.available_width();
diff --git a/crates/ui-egui/src/panels/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs
index 54bc0974..0ab3e5cc 100644
--- a/crates/ui-egui/src/panels/component_panel.rs
+++ b/crates/ui-egui/src/panels/component_panel.rs
@@ -11,7 +11,7 @@
//! (preferred ones first), select it, reset it to the main's default content.
use egui::{Id, Sense, Ui, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType};
use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
use figcraft_engine::components::{
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts,
diff --git a/crates/ui-egui/src/panels/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs
index 5a9c4ae2..cec8ebc0 100644
--- a/crates/ui-egui/src/panels/export_panel.rs
+++ b/crates/ui-egui/src/panels/export_panel.rs
@@ -6,7 +6,7 @@
use std::collections::BTreeSet;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{Document, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::cmd::file::export_jobs;
use serde_json::{Value, json};
diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs
index b5217678..1be4d28c 100644
--- a/crates/ui-egui/src/panels/layers.rs
+++ b/crates/ui-egui/src/panels/layers.rs
@@ -728,6 +728,259 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut
}
}
+/// Dragging near the list's top or bottom edge scrolls it, faster closer to the edge.
+fn drag_autoscroll(ui: &mut Ui, dragging: bool) {
+ if dragging && let Some(p) = ui.input(|i| i.pointer.hover_pos()) {
+ const EDGE: f32 = 28.0;
+ let view = ui.clip_rect();
+ let dy = if p.y < view.min.y + EDGE {
+ (view.min.y + EDGE - p.y).min(EDGE)
+ } else if p.y > view.max.y - EDGE {
+ -(p.y - (view.max.y - EDGE)).min(EDGE)
+ } else {
+ 0.0
+ };
+ if dy != 0.0 && view.x_range().contains(p.x) {
+ ui.scroll_with_delta(vec2(0.0, dy * 0.5));
+ ui.ctx().request_repaint();
+ }
+ }
+}
+
+/// A row's icon or name colour: the component tint (faded when hidden), else `normal` or `faded`.
+fn row_color(component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 {
+ if component {
+ COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
+ } else if shown {
+ normal
+ } else {
+ faded
+ }
+}
+
+/// The pointer `p` over row `rects` = [row, padded row]: the drop zone it shows (-1 above, 0 into,
+/// 1 below), whether holding there should open the collapsed row, and whether the button was released.
+fn drop_zone(ui: &mut Ui, p: egui::Pos2, rects: [Rect; 2], x0: f32, id: NodeId, collapsed: bool, n: &figcraft_doc::Node) -> (i8, bool, bool) {
+ let [r, rr] = rects;
+ let mut open = false;
+ let rel = (p.y - r.min.y) / r.height();
+ let into = n.kind.is_container() && !matches!(n.kind, NodeKind::Instance { .. }) && (0.25..0.75).contains(&rel);
+ let zone: i8 = if into {
+ 0
+ } else if rel < 0.5 {
+ -1
+ } else {
+ 1
+ };
+ let y = match zone {
+ -1 => r.min.y,
+ 1 => r.max.y,
+ _ => r.center().y,
+ };
+ if zone == 0 {
+ ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
+ // Holding over a collapsed group or frame opens it, to drop deeper inside.
+ if collapsed {
+ open = hold_opens(ui, id);
+ ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
+ }
+ } else {
+ crate::widgets::insertion_line(ui.painter(), x0, rr.max.x, y);
+ }
+ (zone, open, ui.input(|i| i.pointer.any_released()))
+}
+
+/// True once the pointer has stayed over row `id` for 0.6 s (the timer then restarts).
+fn hold_opens(ui: &mut Ui, id: NodeId) -> bool {
+ let hold_id = Id::unique("layer_drag_hold");
+ let now = ui.input(|i| i.time);
+ match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) {
+ Some((held, since)) if held == id => {
+ if now - since > 0.6 {
+ ui.data_mut(|d| d.remove::<(NodeId, f64)>(hold_id));
+ return true;
+ }
+ }
+ _ => {
+ ui.data_mut(|d| d.insert_temp(hold_id, (id, now)));
+ }
+ }
+ false
+}
+
+/// Applies a finished sweep across the eyes or padlocks, or keeps it going while the button is down.
+fn finish_sweep(app: &mut FigApp, ui: &mut Ui, sw: Sweep) {
+ let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down()));
+ if released {
+ let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") };
+ // A sweep started in the design can end after a switch to Dev Mode.
+ if !crate::menus::dev_mode_refuses(app, cmd) {
+ app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }}));
+ }
+ }
+ // A sweep whose release the panel never saw (it wasn't shown) is dropped.
+ if released || !down {
+ ui.data_mut(|d| d.remove::(sweep_id()));
+ } else {
+ ui.data_mut(|d| d.insert_temp(sweep_id(), sw));
+ }
+}
+
+/// A click on row `id`: plain selects it, Shift extends from the anchor, Cmd (or Shift without an
+/// anchor) toggles it.
+fn select_clicked(app: &mut FigApp, rows: &[Row], sel: &[NodeId], id: NodeId, m: egui::Modifiers) {
+ app.ui.layers_focus = true;
+ app.ui.pages_focus = None;
+ let order: Vec = rows.iter().map(|r| r.id).collect();
+ let pos = |x: NodeId| order.iter().position(|o| *o == x);
+ let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos);
+ if m.shift
+ && let (Some(ai), Some(bi)) = (anchor, pos(id))
+ {
+ // Shift: the visible rows from the anchor to here (added to the selection with Cmd).
+ let (lo, hi) = (ai.min(bi), ai.max(bi));
+ let mut s: Vec = if m.command { sel.to_vec() } else { vec![] };
+ for x in order.get(lo..=hi).unwrap_or_default() {
+ if !s.contains(x) {
+ s.push(*x);
+ }
+ }
+ app.run("select.set", json!({"ids": s}));
+ } else if m.shift || m.command {
+ let mut s = sel.to_vec();
+ if let Some(pos) = s.iter().position(|x| *x == id) {
+ s.remove(pos);
+ } else {
+ s.push(id);
+ }
+ app.ui.layer_anchor = Some(id);
+ app.run("select.set", json!({"ids": s}));
+ } else {
+ app.ui.layer_anchor = Some(id);
+ app.run("select.set", json!({"ids": [id]}));
+ }
+ // A click in the panel needs no reveal scroll.
+ app.ui.layers_seen_sel = app.session.selection();
+ if let Ok(ds) = app.session.ds_mut()
+ && ds.text_edit.is_some()
+ {
+ let _ = figcraft_engine::textedit::end(&mut app.session);
+ }
+}
+
+/// Drops `moving` on `target` (`zone`: 0 into it, -1 above, 1 below), unless Dev Mode refuses
+/// (a drag started in the design can end after a switch to it).
+fn drop_layers(app: &mut FigApp, doc: &Document, moving: &[NodeId], target: NodeId, zone: i8) {
+ if crate::menus::dev_mode_refuses(app, "node.reparent") {
+ return;
+ }
+ let ids = moving.to_vec();
+ match zone {
+ 0 => {
+ app.run("node.reparent", json!({"ids": ids, "parent": target}));
+ }
+ z => {
+ // Above in the list = higher z = after the target among siblings.
+ if let Some(parent) = doc.node(target).and_then(|n| n.parent) {
+ let idx = doc.index_in_parent(target).unwrap_or(0);
+ let gap = if z < 0 { idx + 1 } else { idx };
+ // `index` is the final position: the dragged layers below the gap leave it.
+ let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count());
+ app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below}));
+ }
+ }
+ }
+}
+
+/// The eye and padlock of a row: painted when hovered (`over`, false in Dev Mode) or set, and, outside
+/// Dev Mode, clickable; a drag over them starts a sweep. Returns the layers whose eye / padlock was clicked.
+fn eye_lock(
+ ui: &mut Ui,
+ t: &Tokens,
+ rects: [Rect; 2],
+ row: &Row,
+ n: &figcraft_doc::Node,
+ over: bool,
+ dev: bool,
+ sweep: &mut Option,
+) -> (Option, Option) {
+ let [r, rr] = rects;
+ let (mut vis, mut lock) = (None, None);
+ // A sweep across the eyes or padlocks takes in the rows the pointer passes.
+ if let Some(sw) = sweep.as_mut()
+ && !sw.ids.contains(&row.id)
+ && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y))
+ {
+ sw.ids.push(row.id);
+ }
+ let swept = |lock: bool| sweep.as_ref().filter(|sw| sw.lock == lock && sw.ids.contains(&row.id)).map(|sw| sw.on);
+ // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock.
+ let hidden = swept(false).unwrap_or(!n.visible);
+ let locked = swept(true).unwrap_or(n.locked);
+ // In Dev Mode they only show what is set.
+ let show_lock = over || locked || row.locked_above;
+ let show_eye = over || hidden;
+ let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
+ let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0));
+ if show_eye {
+ icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary);
+ }
+ if show_eye && !dev {
+ let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag());
+ a11y::toggle(&eresp, tr("Hidden"), hidden);
+ if eresp.clicked() {
+ vis = Some(row.id);
+ }
+ if eresp.drag_started() {
+ *sweep = Some(Sweep { lock: false, on: n.visible, ids: vec![row.id] });
+ }
+ }
+ if show_lock {
+ let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary };
+ icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col);
+ }
+ if show_lock && !dev {
+ let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag());
+ a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above);
+ if lresp.clicked() {
+ lock = Some(row.id);
+ }
+ if lresp.drag_started() {
+ *sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
+ }
+ }
+ (vis, lock)
+}
+
+/// Expands the ancestors of a selection not seen yet (once, so manual collapsing sticks); whether it was new.
+fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeId]) -> bool {
+ let reveal = sel != app.ui.layers_seen_sel;
+ if reveal {
+ for s in sel {
+ for a in doc.ancestors(*s) {
+ if a != page {
+ app.ui.expanded.insert(a);
+ }
+ }
+ }
+ app.ui.layers_seen_sel = sel.to_vec();
+ }
+ reveal
+}
+
+/// A row's background: selected, inside a selected layer, or outlined when hovered.
+/// `state` is [selected, in_selected, component].
+fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
+ let [selected, in_selected, is_comp] = state;
+ if selected {
+ ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
+ } else if in_selected {
+ ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
+ } else if hovered {
+ ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside);
+ }
+}
+
/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling,
/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions.
fn layers(app: &mut FigApp, ui: &mut Ui) {
@@ -761,17 +1014,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let hover = ds.hover;
// Reveal a new selection: expand its ancestors and scroll its row into view, once, so
// "Collapse layers" and manual collapsing stick.
- let reveal = sel != app.ui.layers_seen_sel;
- if reveal {
- for s in &sel {
- for a in doc.ancestors(*s) {
- if a != page {
- app.ui.expanded.insert(a);
- }
- }
- }
- app.ui.layers_seen_sel = sel.clone();
- }
+ let reveal = reveal_selection(app, &doc, page, &sel);
let filter = app.ui.layer_filter.trim().to_lowercase();
let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter);
let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None };
@@ -831,24 +1074,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
ui.add(egui::Label::new(egui::RichText::new(hint).font(regular(11.0)).color(t.text_secondary)).wrap());
});
}
- // Dragging near the list's top or bottom edge scrolls it, faster closer to the edge.
- if dragging.is_some()
- && let Some(p) = ui.input(|i| i.pointer.hover_pos())
- {
- const EDGE: f32 = 28.0;
- let view = ui.clip_rect();
- let dy = if p.y < view.min.y + EDGE {
- (view.min.y + EDGE - p.y).min(EDGE)
- } else if p.y > view.max.y - EDGE {
- -(p.y - (view.max.y - EDGE)).min(EDGE)
- } else {
- 0.0
- };
- if dy != 0.0 && view.x_range().contains(p.x) {
- ui.scroll_with_delta(vec2(0.0, dy * 0.5));
- ui.ctx().request_repaint();
- }
- }
+ drag_autoscroll(ui, dragging.is_some());
let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len());
let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len());
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
@@ -879,18 +1105,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
a11y::ring_around(ui, rr, &resp);
// Over the row, its eye and padlock included (they sit on top of it).
let over = resp.contains_pointer();
- if selected {
- ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
- } else if in_selected {
- ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
- } else if over || hover == Some(row.id) {
- ui.painter().rect_stroke(
- rr,
- CornerRadius::same(5),
- Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }),
- egui::StrokeKind::Inside,
- );
- }
+ paint_row_bg(ui, &t, rr, [selected, in_selected, is_comp], over || hover == Some(row.id));
if over {
hovered_row = Some(row.id);
}
@@ -912,13 +1127,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
let icon = node_icon(&doc, row.id);
let shown = n.visible && !row.dimmed;
- let icon_col = if is_comp || icon == Icon::Slot {
- COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
- } else if shown {
- t.icon
- } else {
- t.text_tertiary
- };
+ let icon_col = row_color(is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary);
icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col);
let name_x = x0 + 36.0;
if app.ui.rename.is_some_and(|rn| rn.id == row.id) {
@@ -926,68 +1135,18 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
rename_done = Some((row.id, new));
}
} else {
- let col = if is_comp {
- COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
- } else if shown {
- t.text
- } else {
- t.text_tertiary
- };
- let font = if doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page())
- && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
- {
- semibold(11.0)
- } else {
- regular(11.0)
- };
+ let col = row_color(is_comp, shown, t.text, t.text_tertiary);
+ let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page());
+ let font =
+ if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) };
let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() };
// Room left of the lock and eye buttons.
let max_w = (rr.max.x - 56.0 - name_x).max(20.0);
crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w);
}
- // A sweep across the eyes or padlocks takes in the rows the pointer passes.
- if let Some(sw) = sweep.as_mut()
- && !sw.ids.contains(&row.id)
- && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y))
- {
- sw.ids.push(row.id);
- }
- let swept = |lock: bool| sweep.as_ref().filter(|sw| sw.lock == lock && sw.ids.contains(&row.id)).map(|sw| sw.on);
- // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock.
- let hidden = swept(false).unwrap_or(!n.visible);
- let locked = swept(true).unwrap_or(n.locked);
- // In Dev Mode they only show what is set.
- let show_lock = (over && !dev) || locked || row.locked_above;
- let show_eye = (over && !dev) || hidden;
- let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
- let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0));
- if show_eye {
- icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary);
- }
- if show_eye && !dev {
- let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag());
- a11y::toggle(&eresp, tr("Hidden"), hidden);
- if eresp.clicked() {
- vis = Some(row.id);
- }
- if eresp.drag_started() {
- sweep = Some(Sweep { lock: false, on: n.visible, ids: vec![row.id] });
- }
- }
- if show_lock {
- let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary };
- icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col);
- }
- if show_lock && !dev {
- let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag());
- a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above);
- if lresp.clicked() {
- lock = Some(row.id);
- }
- if lresp.drag_started() {
- sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
- }
- }
+ let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep);
+ vis = v.or(vis);
+ lock = l.or(lock);
if resp.clicked() {
clicked = Some((row.id, ui.input(|i| i.modifiers)));
}
@@ -1016,43 +1175,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
&& r.contains(p)
{
- let rel = (p.y - r.min.y) / r.height();
- let into = n.kind.is_container() && !matches!(n.kind, NodeKind::Instance { .. }) && (0.25..0.75).contains(&rel);
- let zone: i8 = if into {
- 0
- } else if rel < 0.5 {
- -1
- } else {
- 1
- };
- let y = match zone {
- -1 => r.min.y,
- 1 => r.max.y,
- _ => r.center().y,
- };
- if zone == 0 {
- ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
- // Holding over a collapsed group or frame opens it, to drop deeper inside.
- if row.has_kids && !app.ui.expanded.contains(&row.id) {
- let hold_id = Id::unique("layer_drag_hold");
- let now = ui.input(|i| i.time);
- match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) {
- Some((id, since)) if id == row.id => {
- if now - since > 0.6 {
- toggle = Some(row.id);
- ui.data_mut(|d| d.remove::<(NodeId, f64)>(hold_id));
- }
- }
- _ => {
- ui.data_mut(|d| d.insert_temp(hold_id, (row.id, now)));
- }
- }
- ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
- }
- } else {
- crate::widgets::insertion_line(ui.painter(), x0, rr.max.x, y);
+ let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id);
+ let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n);
+ if open {
+ toggle = Some(row.id);
}
- if ui.input(|i| i.pointer.any_released()) {
+ if released {
drop = Some((row.id, zone));
}
}
@@ -1087,46 +1215,15 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
}
if let Some(sw) = sweep {
- let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down()));
- if released {
- let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") };
- // A sweep started in the design can end after a switch to Dev Mode.
- if !crate::menus::dev_mode_refuses(app, cmd) {
- app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }}));
- }
- }
- // A sweep whose release the panel never saw (it wasn't shown) is dropped.
- if released || !down {
- ui.data_mut(|d| d.remove::(sweep_id()));
- } else {
- ui.data_mut(|d| d.insert_temp(sweep_id(), sw));
- }
+ finish_sweep(app, ui, sw);
}
if ui.input(|i| i.pointer.any_released()) {
ui.data_mut(|d| {
d.remove::(drag_id);
d.remove::<(NodeId, f64)>(Id::unique("layer_drag_hold"));
});
- // A drag started in the design can end after a switch to Dev Mode.
- if let (Some(_), Some((target, zone))) = (dragging, drop)
- && !crate::menus::dev_mode_refuses(app, "node.reparent")
- {
- let ids = moving.clone();
- match zone {
- 0 => {
- app.run("node.reparent", json!({"ids": ids, "parent": target}));
- }
- z => {
- // Above in the list = higher z = after the target among siblings.
- if let Some(parent) = doc.node(target).and_then(|n| n.parent) {
- let idx = doc.index_in_parent(target).unwrap_or(0);
- let gap = if z < 0 { idx + 1 } else { idx };
- // `index` is the final position: the dragged layers below the gap leave it.
- let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count());
- app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below}));
- }
- }
- }
+ if let (Some(_), Some((target, zone))) = (dragging, drop) {
+ drop_layers(app, &doc, &moving, target, zone);
}
}
if let Ok(ds) = app.session.ds_mut() {
@@ -1159,43 +1256,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
a11y::refocus_row(&mut app.ui.nav);
}
if let Some((id, m)) = clicked {
- app.ui.layers_focus = true;
- app.ui.pages_focus = None;
- let order: Vec = rows.iter().map(|r| r.id).collect();
- let pos = |x: NodeId| order.iter().position(|o| *o == x);
- let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos);
- if m.shift
- && let (Some(ai), Some(bi)) = (anchor, pos(id))
- {
- // Shift: the visible rows from the anchor to here (added to the selection with Cmd).
- let (lo, hi) = (ai.min(bi), ai.max(bi));
- let mut s: Vec = if m.command { sel.clone() } else { vec![] };
- for x in order.get(lo..=hi).unwrap_or_default() {
- if !s.contains(x) {
- s.push(*x);
- }
- }
- app.run("select.set", json!({"ids": s}));
- } else if m.shift || m.command {
- let mut s = sel.clone();
- if let Some(pos) = s.iter().position(|x| *x == id) {
- s.remove(pos);
- } else {
- s.push(id);
- }
- app.ui.layer_anchor = Some(id);
- app.run("select.set", json!({"ids": s}));
- } else {
- app.ui.layer_anchor = Some(id);
- app.run("select.set", json!({"ids": [id]}));
- }
- // A click in the panel needs no reveal scroll.
- app.ui.layers_seen_sel = app.session.selection();
- if let Ok(ds) = app.session.ds_mut()
- && ds.text_edit.is_some()
- {
- let _ = figcraft_engine::textedit::end(&mut app.session);
- }
+ select_clicked(app, &rows, &sel, id, m);
}
let _ = Color32::TRANSPARENT;
}
diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs
index 0874da2f..c958007a 100644
--- a/crates/ui-egui/src/panels/layout_panel.rs
+++ b/crates/ui-egui/src/panels/layout_panel.rs
@@ -5,7 +5,7 @@
//! commands.
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing};
use serde_json::json;
use crate::FigApp;
diff --git a/crates/ui-egui/src/panels/proto_editor.rs b/crates/ui-egui/src/panels/proto_editor.rs
index 7163807e..32817e84 100644
--- a/crates/ui-egui/src/panels/proto_editor.rs
+++ b/crates/ui-egui/src/panels/proto_editor.rs
@@ -165,7 +165,7 @@ pub fn action_list(app: &mut FigApp, ui: &mut Ui, cx: &Cx, list: &[Action], pref
for (i, a) in list.iter().enumerate() {
let path = [prefix, &[i]].concat();
let is_open = single || open.starts_with(&path);
- let (header, extent) = action_item(app, ui, cx, a, path, list.len(), is_open, single);
+ let (header, extent) = action_item(app, ui, ActionItem { context: cx, action: a, path, count: list.len(), open: is_open, single });
if let Some(d) = &mut drag {
d.row(ui, i, header, extent, || action_summary(cx.doc, a));
}
@@ -222,8 +222,17 @@ fn add_action_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, path: Vec) {
/// One action: its kind (open) or a summary (closed) with a menu to move or remove it, and when
/// open its settings below. Returns its row and the area it covers with its settings.
-#[allow(clippy::too_many_arguments)]
-fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec, count: usize, open: bool, single: bool) -> (Rect, Rect) {
+struct ActionItem<'context, 'document> {
+ context: &'context Cx<'document>,
+ action: &'context Action,
+ path: Vec,
+ count: usize,
+ open: bool,
+ single: bool,
+}
+
+fn action_item(app: &mut FigApp, ui: &mut Ui, item: ActionItem<'_, '_>) -> (Rect, Rect) {
+ let ActionItem { context: cx, action: a, path, count, open, single } = item;
let t = Tokens::get(ui.ctx());
let i = path.last().copied().unwrap_or(0);
let removable = path.len() > 1 || count > 1;
@@ -451,7 +460,17 @@ fn condition_editor(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usiz
Some(s) => builder_row(app, ui, cx, slot, b, &s),
None => {
let id = slot.id("proto_cond_expr").with(b);
- if let Some(e) = expr_field(ui, id, vars, Some(c), tr("e.g. count >= 3 and not done"), |e| e.check_condition(vars), None::) {
+ if let Some(e) = expr_field(
+ ui,
+ ExprInput {
+ id,
+ variables: vars,
+ current: Some(c),
+ placeholder: tr("e.g. count >= 3 and not done"),
+ check: |e: &Expr| e.check_condition(vars),
+ trailing: None::,
+ },
+ ) {
set_condition(app, slot, b, &e);
}
}
@@ -575,14 +594,16 @@ fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, vari
Some(e) => {
let edited = expr_field(
ui,
- slot.id("proto_set_expr"),
- vars,
- Some(e),
- tr("e.g. count + 1"),
- |e| e.check_value(vars, var.kind),
- Some(|ui: &mut Ui| {
- toggled = toggle(ui);
- }),
+ ExprInput {
+ id: slot.id("proto_set_expr"),
+ variables: vars,
+ current: Some(e),
+ placeholder: tr("e.g. count + 1"),
+ check: |e: &Expr| e.check_value(vars, var.kind),
+ trailing: Some(|ui: &mut Ui| {
+ toggled = toggle(ui);
+ }),
+ },
);
if let Some(e) = edited {
set(app, value.clone(), Some(e));
@@ -616,16 +637,21 @@ fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, vari
/// An expression typed as text in a row (with a `trailing` button after the field): what `check`
/// accepts is returned when committed; otherwise the text stays and why shows under it.
-#[allow(clippy::too_many_arguments)]
-fn expr_field(
- ui: &mut Ui,
+struct ExprInput<'a, Check, Trailing> {
id: Id,
- vars: &Variables,
- cur: Option<&Expr>,
- placeholder: &str,
- check: impl Fn(&Expr) -> Result<(), String>,
- trailing: Option,
-) -> Option {
+ variables: &'a Variables,
+ current: Option<&'a Expr>,
+ placeholder: &'a str,
+ check: Check,
+ trailing: Option,
+}
+
+fn expr_field(ui: &mut Ui, input: ExprInput<'_, Check, Trailing>) -> Option
+where
+ Check: Fn(&Expr) -> Result<(), String>,
+ Trailing: FnOnce(&mut Ui),
+{
+ let ExprInput { id, variables: vars, current: cur, placeholder, check, trailing } = input;
let t = Tokens::get(ui.ctx());
let shown = cur.map(|e| e.display(vars)).unwrap_or_default();
let key = id.with("draft");
diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs
index 86dae3a8..e43815bd 100644
--- a/crates/ui-egui/src/panels/proto_panel.rs
+++ b/crates/ui-egui/src/panels/proto_panel.rs
@@ -8,7 +8,7 @@ use figcraft_doc::proto::{
Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition,
Transition, Trigger, is_gamepad_button,
};
-use figcraft_doc::*;
+use figcraft_doc::{Document, Node, NodeId, NodeKind, VariableValue, proto};
use serde_json::{Value, json};
use crate::FigApp;
@@ -640,15 +640,24 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
{
let panel_left = ui.clip_rect().min.x;
let y = anchor.map(|a| a.min.y).unwrap_or(ui.clip_rect().min.y + 80.0);
- editor_popover(app, ui.ctx(), doc, page, id, k, it, pos2(panel_left - 8.0, y));
+ editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, anchor: pos2(panel_left - 8.0, y) });
} else if open.is_some_and(|(n, _)| n == id) {
open_editor(ui.ctx(), None);
}
}
/// The interaction editor, floating left of the panel (`at` is its top-right corner).
-#[allow(clippy::too_many_arguments)]
-fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) {
+struct InteractionPopover<'a> {
+ doc: &'a Document,
+ page: NodeId,
+ node: NodeId,
+ index: usize,
+ interaction: &'a Interaction,
+ anchor: Pos2,
+}
+
+fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPopover<'_>) {
+ let InteractionPopover { doc, page, node, index: k, interaction: it, anchor: at } = popover;
let t = Tokens::get(ctx);
let width = 280.0;
let screen = ctx.content_rect();
@@ -832,17 +841,20 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
const PAD_MENU_W: f32 = 92.0;
/// A destination picker for the action at `slot`: `make` builds the action for the destination.
-#[allow(clippy::too_many_arguments)]
-fn dest_dropdown(
- app: &mut FigApp,
- ui: &mut Ui,
- doc: &Document,
- options: &[NodeId],
- cur: Option,
- label: &str,
- make: impl Fn(NodeId) -> Action,
- slot: &Slot,
-) {
+struct DestinationPicker<'a, Make> {
+ doc: &'a Document,
+ options: &'a [NodeId],
+ current: Option,
+ label: &'a str,
+ make: Make,
+ slot: &'a Slot,
+}
+
+fn dest_dropdown(app: &mut FigApp, ui: &mut Ui, picker: DestinationPicker<'_, Make>)
+where
+ Make: Fn(NodeId) -> Action,
+{
+ let DestinationPicker { doc, options, current: cur, label, make, slot } = picker;
widgets::sublabels(ui, label, None);
row(ui, 32.0, |ui| {
let text = cur.map(|d| name_of(doc, d)).unwrap_or_else(|| tr("None").into());
@@ -871,22 +883,45 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
Action::Navigate { dest, preserve_scroll } => {
let (dest, preserve_scroll) = (*dest, *preserve_scroll);
let mk = |d| Action::Navigate { dest: d, preserve_scroll };
- dest_dropdown(app, ui, doc, &nav_targets(doc, page), Some(dest), tr("Destination"), mk, slot);
+ dest_dropdown(
+ app,
+ ui,
+ DestinationPicker { doc, options: &nav_targets(doc, page), current: Some(dest), label: tr("Destination"), make: mk, slot },
+ );
row(ui, 28.0, |ui| {
if widgets::checkbox(ui, preserve_scroll, tr("Preserve scroll position")) {
set_action(app, slot, Action::Navigate { dest, preserve_scroll: !preserve_scroll });
}
});
}
- Action::SwapOverlay { dest } => dest_dropdown(app, ui, doc, &frames, Some(*dest), tr("Overlay"), |d| Action::SwapOverlay { dest: d }, slot),
+ Action::SwapOverlay { dest } => dest_dropdown(
+ app,
+ ui,
+ DestinationPicker { doc, options: &frames, current: Some(*dest), label: tr("Overlay"), make: |d| Action::SwapOverlay { dest: d }, slot },
+ ),
Action::ChangeTo { dest } => {
let vs = variants_for(doc, node);
- dest_dropdown(app, ui, doc, &vs, Some(*dest), tr("Variant"), |d| Action::ChangeTo { dest: d }, slot);
+ dest_dropdown(
+ app,
+ ui,
+ DestinationPicker { doc, options: &vs, current: Some(*dest), label: tr("Variant"), make: |d| Action::ChangeTo { dest: d }, slot },
+ );
}
Action::ScrollTo { dest, offset } => {
let (dest, offset) = (*dest, *offset);
let layers: Vec = doc.top_level_frame(node).map(|f| doc.subtree(f).into_iter().skip(1).take(400).collect()).unwrap_or_default();
- dest_dropdown(app, ui, doc, &layers, Some(dest), tr("Scroll to layer"), move |d| Action::ScrollTo { dest: d, offset }, slot);
+ dest_dropdown(
+ app,
+ ui,
+ DestinationPicker {
+ doc,
+ options: &layers,
+ current: Some(dest),
+ label: tr("Scroll to layer"),
+ make: move |d| Action::ScrollTo { dest: d, offset },
+ slot,
+ },
+ );
// Where the layer ends up from the top-left of what scrolls (room for a fixed header).
widgets::sublabels(ui, tr("Offset"), None);
row(ui, 32.0, |ui| {
@@ -920,7 +955,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor } => {
let (dest, position, close_on_outside, background, offset, anchor) = (*dest, *position, *close_on_outside, *background, *offset, *anchor);
let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background, offset, anchor };
- dest_dropdown(app, ui, doc, &frames, Some(dest), tr("Overlay"), mk, slot);
+ dest_dropdown(app, ui, DestinationPicker { doc, options: &frames, current: Some(dest), label: tr("Overlay"), make: mk, slot });
widgets::sublabels(ui, tr("Overlay position"), None);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, tr(position.label()), ui.available_width(), None);
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index f00a5821..b0c0e995 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -2,7 +2,11 @@
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
-use figcraft_doc::*;
+use figcraft_doc::{
+ Blur, BlurType, BoolOp, Brush, Document, Effect, EffectKind, ExportFormat, ExportSetting, Glass, GridAlign, GridKind, LayoutGrid,
+ MAX_BLUR_RADIUS, MaskType, Node, NodeId, NodeKind, Noise, NoiseMode, Shadow, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Texture,
+ VariableType, WidthPoint, WidthPreset, WidthProfile,
+};
use figcraft_engine::cmd::lists::{moved_index, removed_index};
use serde_json::{Value, json};
diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs
index ef62bbbc..f2d48eba 100644
--- a/crates/ui-egui/src/panels/right/effects.rs
+++ b/crates/ui-egui/src/panels/right/effects.rs
@@ -1,5 +1,9 @@
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
-use super::*;
+use super::{
+ Blur, BlurType, Document, Effect, EffectKind, FieldEdit, FigApp, Glass, GridAlign, GridKind, Icon, Id, LayoutGrid, Lead, ListDrag,
+ MAX_BLUR_RADIUS, NodeId, Noise, NoiseMode, PAD, Paint, Sense, Shadow, StyleSlot, Texture, Ui, VariableType, common, icons, json, move_flags,
+ moved_index, remove_flags, removed_index, style_picker, tr, trf, trn, vec2, widgets,
+};
/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an
/// open picker and settings flags aligned when rows move or are removed.
diff --git a/crates/ui-egui/src/panels/right/inspect.rs b/crates/ui-egui/src/panels/right/inspect.rs
index 658028f9..53403a5f 100644
--- a/crates/ui-egui/src/panels/right/inspect.rs
+++ b/crates/ui-egui/src/panels/right/inspect.rs
@@ -1,5 +1,8 @@
use super::menu::{menu_item, menu_popup, row};
-use super::*;
+use super::{
+ Align2, Color32, CornerRadius, Document, ExportFormat, ExportSetting, FigApp, Icon, Id, NodeId, PAD, Paint, Rect, Sense, Stroke, Tip, Tokens, Ui,
+ a11y, icons, pos2, regular, semibold, tr, trf, vec2, widgets,
+};
/// One group of inspected properties: label, value (with a swatch for colours) and the style or
/// variable it comes from. Clicking a row copies its value.
diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs
index fed4a960..fb51d857 100644
--- a/crates/ui-egui/src/panels/right/menu.rs
+++ b/crates/ui-egui/src/panels/right/menu.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{BlendMode, Color32, CornerRadius, FIELD_H, Id, PAD, Rect, Sense, Tokens, Ui, a11y, pos2, regular, tr, vec2};
/// The smallest width a popup menu is measured in (see [`menu_popup`]).
const MENU_MIN_W: f32 = 48.0;
diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs
index 66db55dd..b947e296 100644
--- a/crates/ui-egui/src/panels/right/paints.rs
+++ b/crates/ui-egui/src/panels/right/paints.rs
@@ -1,6 +1,10 @@
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::stroke::stroke_settings;
-use super::*;
+use super::{
+ Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, PAD, Paint,
+ PaintKind, Rect, Sense, Stroke, StrokeAlign, StyleSlot, Tip, Tokens, Ui, Value, VariableType, common, icons, json, moved_index, pos2, regular,
+ removed_index, style_picker, tr, trf, vec2, widgets,
+};
/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and
/// the blend-mode row.
diff --git a/crates/ui-egui/src/panels/right/stroke.rs b/crates/ui-egui/src/panels/right/stroke.rs
index c3bc8642..8576e642 100644
--- a/crates/ui-egui/src/panels/right/stroke.rs
+++ b/crates/ui-egui/src/panels/right/stroke.rs
@@ -1,5 +1,8 @@
use super::menu::{menu_item, menu_item_with, menu_popup, row};
-use super::*;
+use super::{
+ Brush, Color32, Document, FigApp, Icon, Id, Lead, NodeId, NodeKind, PAD, Pos2, Rect, Stroke, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps,
+ Tokens, Ui, Value, WidthPoint, WidthPreset, WidthProfile, a11y, common, icons, json, pos2, tr, widgets,
+};
/// A width profile drawn as a filled band across `rect` (thickest point = full height); `None`
/// draws a uniform stroke.
diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs
index 046428f7..a95af934 100644
--- a/crates/ui-egui/src/panels/typography.rs
+++ b/crates/ui-egui/src/panels/typography.rs
@@ -8,7 +8,10 @@
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{
+ DecorationLength, Document, LeadingTrim, LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAlignV, TextAutoResize,
+ TextCase, TextData, TextDecoration, TextStyle, VariableType, trim_num,
+};
use serde_json::{Value, json};
use crate::FigApp;
diff --git a/crates/ui-egui/src/panels/typography/axes.rs b/crates/ui-egui/src/panels/typography/axes.rs
index 6c042dec..953d6fa1 100644
--- a/crates/ui-egui/src/panels/typography/axes.rs
+++ b/crates/ui-egui/src/panels/typography/axes.rs
@@ -1,5 +1,8 @@
use super::fields::menu_item_hover;
-use super::*;
+use super::{
+ Color32, CornerRadius, FIELD_H, FieldEdit, FigApp, Id, Lead, MIXED, NodeId, Rect, Sense, Stroke, TextStyle, Tokens, Ui, Value, commit_text, json,
+ pos2, regular, text_style_params, tr, trf, trim_num, uniform, vec2, widgets,
+};
/// Type settings ▸ Variable: the font's named instances, then a slider and a field per visible
/// axis (values the style name implies show until changed; "Mixed" when the shown styles differ).
diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs
index 86fae14e..7104072f 100644
--- a/crates/ui-egui/src/panels/typography/fields.rs
+++ b/crates/ui-egui/src/panels/typography/fields.rs
@@ -2,7 +2,11 @@ use super::axes::variable_axes;
use super::glyphs::{LeadGlyph, caption, feature_row, text_seg};
use super::picker::{font_picker, remember_family};
use super::spacing::{SpacingField, commit_spacing, parse_letter_spacing, parse_line_height, spacing_field, spacing_input};
-use super::*;
+use super::{
+ Arc, DecorationLength, Document, FEATURES, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, LeadingTrim, ListStyle, MIXED, NodeId, PAD, SIZES, Sense,
+ TABS, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, Tokens, Ui, Value, VariableType, common, field_row,
+ icons, json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, widgets,
+};
/// The font rows: family, style and size, line height and letter spacing.
pub(super) fn font_rows(
diff --git a/crates/ui-egui/src/panels/typography/glyphs.rs b/crates/ui-egui/src/panels/typography/glyphs.rs
index 3b71b7c4..5b379f4f 100644
--- a/crates/ui-egui/src/panels/typography/glyphs.rs
+++ b/crates/ui-egui/src/panels/typography/glyphs.rs
@@ -4,7 +4,7 @@ use super::axes::round_axis;
use super::picker::{FontPick, font_picker};
#[cfg(test)]
use super::spacing::{parse_letter_spacing, parse_line_height};
-use super::*;
+use super::{Align2, Arc, Color32, CornerRadius, FIELD_H, Icon, Id, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, icons, pos2, regular, tr, vec2};
pub(super) fn caption(ui: &mut Ui, s: &str) {
let t = Tokens::get(ui.ctx());
@@ -147,6 +147,9 @@ pub(super) fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32)
#[cfg(test)]
mod tests {
use super::*;
+ use crate::panels::typography::{target_styles, uniform};
+ use figcraft_doc::{Document, LetterSpacing, LineHeight, Node, NodeKind, TextData, TextStyle};
+ use serde_json::json;
#[test]
fn small_caps_sample_draws_only_letters_the_ui_font_has() {
diff --git a/crates/ui-egui/src/panels/typography/picker.rs b/crates/ui-egui/src/panels/typography/picker.rs
index c7d8bea2..1dc88456 100644
--- a/crates/ui-egui/src/panels/typography/picker.rs
+++ b/crates/ui-egui/src/panels/typography/picker.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Align2, CornerRadius, Icon, Id, Rect, Sense, Tokens, Ui, icons, pos2, regular, semibold, tr, vec2, widgets};
// ---- Font picker ----
diff --git a/crates/ui-egui/src/panels/typography/spacing.rs b/crates/ui-egui/src/panels/typography/spacing.rs
index 971a3c0f..c6bc7a51 100644
--- a/crates/ui-egui/src/panels/typography/spacing.rs
+++ b/crates/ui-egui/src/panels/typography/spacing.rs
@@ -1,5 +1,8 @@
use super::glyphs::{LeadGlyph, paint_lead};
-use super::*;
+use super::{
+ Align2, CornerRadius, FIELD_H, FieldEdit, FigApp, Id, LetterSpacing, LineHeight, MIXED, NodeId, Rect, Sense, Stroke, StrokeKind, Tokens, Ui,
+ Value, VariableType, commit_text, json, live, pos2, regular, tr, trim_num, vec2, widgets,
+};
/// Line height, letter spacing or paragraph spacing: property, field id, glyph and tooltip.
pub(super) struct SpacingField {
From 252d90942779b6d7458f542f1268addacb8c7a90 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 33/60] refactor(ui): simplify widgets and dialogs
---
crates/ui-egui/src/dialogs/batch_rename.rs | 2 +-
crates/ui-egui/src/dialogs/color_profile.rs | 2 +-
crates/ui-egui/src/dialogs/export.rs | 5 +-
crates/ui-egui/src/dialogs/menu_input.rs | 2 +-
crates/ui-egui/src/home/home_recents.rs | 18 +-
crates/ui-egui/src/widgets/colorpicker.rs | 529 +++++++++++---------
crates/ui-egui/src/widgets/style_picker.rs | 65 ++-
7 files changed, 355 insertions(+), 268 deletions(-)
diff --git a/crates/ui-egui/src/dialogs/batch_rename.rs b/crates/ui-egui/src/dialogs/batch_rename.rs
index b28055f2..e09c1151 100644
--- a/crates/ui-egui/src/dialogs/batch_rename.rs
+++ b/crates/ui-egui/src/dialogs/batch_rename.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Context, FigApp, NodeId, RichText, Tokens, json, tr, trn, window};
/// The Rename layers dialog's fields.
#[derive(Clone, Debug, Default, PartialEq)]
diff --git a/crates/ui-egui/src/dialogs/color_profile.rs b/crates/ui-egui/src/dialogs/color_profile.rs
index f818c50d..3cf941dc 100644
--- a/crates/ui-egui/src/dialogs/color_profile.rs
+++ b/crates/ui-egui/src/dialogs/color_profile.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{ColorProfile, Context, FigApp, RichText, Tokens, json, regular, tr, trf, window};
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the
/// values) or keep the values (reinterpret them in the new space).
diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs
index e3a96e59..2581b91a 100644
--- a/crates/ui-egui/src/dialogs/export.rs
+++ b/crates/ui-egui/src/dialogs/export.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Align2, Context, CornerRadius, ExportFormat, ExportSetting, ExportSize, FigApp, Path, RichText, Sense, Stroke, Tokens, Value, export_jobs,
+ export_layers, exportable_layers, json, regular, save_one, suggested_name, tr, trf, trn, vec2, window,
+};
/// A `.fig` Save as result is an export; only native saves report that the document was saved.
pub(crate) fn file_saved(app: &mut FigApp, result: &Value) {
diff --git a/crates/ui-egui/src/dialogs/menu_input.rs b/crates/ui-egui/src/dialogs/menu_input.rs
index e815c532..86463465 100644
--- a/crates/ui-egui/src/dialogs/menu_input.rs
+++ b/crates/ui-egui/src/dialogs/menu_input.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Context, FigApp, Tokens, i18n, json, tr, window};
/// Values collected by a menu or Actions command before dispatching it to the engine.
#[derive(Clone, Debug, PartialEq)]
diff --git a/crates/ui-egui/src/home/home_recents.rs b/crates/ui-egui/src/home/home_recents.rs
index 3525b31f..72ef4713 100644
--- a/crates/ui-egui/src/home/home_recents.rs
+++ b/crates/ui-egui/src/home/home_recents.rs
@@ -813,7 +813,7 @@ fn grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
};
let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a });
fading |= alpha < 1.0;
- card(ui, t, rect, picture_h, &resp, selected, file, stat, picture, alpha, now);
+ card(CardPaint { ui, tokens: t, rect, picture_h, response: &resp, selected, file, stat, picture, alpha, now });
}
ui.add_space(GAP);
}
@@ -837,20 +837,22 @@ fn skeleton(ui: &Ui, t: &Tokens, rect: Rect, picture_h: f32) {
p.rect_stroke(rect, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside);
}
-#[allow(clippy::too_many_arguments)]
-fn card(
- ui: &Ui,
- t: &Tokens,
+struct CardPaint<'a> {
+ ui: &'a Ui,
+ tokens: &'a Tokens,
rect: Rect,
picture_h: f32,
- resp: &Response,
+ response: &'a Response,
selected: bool,
- file: &Shown,
+ file: &'a Shown,
stat: Stat,
picture: Option<(Option, Option, f32)>,
alpha: f32,
now: u64,
-) {
+}
+
+fn card(paint: CardPaint<'_>) {
+ let CardPaint { ui, tokens: t, rect, picture_h, response: resp, selected, file, stat, picture, alpha, now } = paint;
let p = ui.painter();
p.rect_filled(rect, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel });
let area = Rect::from_min_size(rect.min, vec2(rect.width(), picture_h)).shrink(1.0);
diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs
index accb70fd..0a3740e1 100644
--- a/crates/ui-egui/src/widgets/colorpicker.rs
+++ b/crates/ui-egui/src/widgets/colorpicker.rs
@@ -3,7 +3,8 @@
use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{
- Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile,
+ Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
+ PatternTile,
};
use figcraft_doc::{NodeId, NodeKind};
use serde_json::json;
@@ -537,6 +538,277 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
slider_knob(ui, rect, color.a, Id::unique("cp_alpha"))
}
+/// The row of paint kinds (solid, gradients, image, pattern) with `paint`'s kind selected.
+fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut Paint) {
+ let kinds = [
+ (Icon::Fill, tr("Solid")),
+ (Icon::GradientLinear, tr("Linear")),
+ (Icon::GradientRadial, tr("Radial")),
+ (Icon::GradientAngular, tr("Angular")),
+ (Icon::GradientDiamond, tr("Diamond")),
+ (Icon::Image, tr("Image")),
+ (Icon::ViewGrid, tr("Pattern")),
+ ];
+ let active = match &paint.kind {
+ PaintKind::Solid { .. } => 0,
+ PaintKind::Gradient(g) => match g.kind {
+ GradientKind::Linear => 1,
+ GradientKind::Radial => 2,
+ GradientKind::Angular => 3,
+ GradientKind::Diamond => 4,
+ },
+ PaintKind::Image(_) => 5,
+ PaintKind::Pattern(_) => 6,
+ };
+ ui.horizontal(|ui| {
+ ui.add_space(12.0);
+ if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
+ let base = paint.preview_color();
+ let mut image_pending = false;
+ paint.kind = match i {
+ 0 => PaintKind::Solid { color: base.with_alpha(1.0) },
+ 5 => {
+ image_pending = true;
+ let target = target.clone();
+ app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
+ let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
+ let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
+ if let Some(mut paint) = current_paint(app, &target) {
+ paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
+ write(app, &target, &paint, false);
+ }
+ });
+ paint.kind.clone()
+ }
+ 6 => match pattern_sources(app, target).first() {
+ Some((id, _)) => Paint::pattern(id.0).kind,
+ None => {
+ app.toast(tr("Add a layer or component to tile first").to_string());
+ paint.kind.clone()
+ }
+ },
+ k => {
+ let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
+ match &paint.kind {
+ PaintKind::Gradient(g) => {
+ PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })
+ }
+ _ => PaintKind::Gradient(Gradient::default_for(kind, base)),
+ }
+ }
+ };
+ if !image_pending {
+ write(app, target, paint, false);
+ }
+ }
+ });
+}
+
+/// The paint's own blend mode dropdown.
+fn blend_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut Paint) {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
+ crate::right::menu_popup(&resp).width(200.0).show(|ui| {
+ if let Some(b) = crate::right::blend_menu(ui, false) {
+ paint.blend = b;
+ write(app, target, paint, false);
+ }
+ });
+ });
+}
+
+/// The image paint's scale mode, rotation, colour profile, presets and filter sliders.
+fn image_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &mut Paint, mut img: ImagePaint, t: &Tokens) {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);
+ crate::right::menu_popup(&resp).show(|ui| {
+ for (l, m) in [
+ (tr("Fill"), ImageScaleMode::Fill),
+ (tr("Fit"), ImageScaleMode::Fit),
+ (tr("Crop"), ImageScaleMode::Crop),
+ (tr("Tile"), ImageScaleMode::Tile),
+ ] {
+ if crate::right::menu_item(ui, l, "") {
+ img.scale_mode = m;
+ paint.kind = PaintKind::Image(img.clone());
+ write(app, target, paint, false);
+ ui.close();
+ }
+ }
+ });
+ if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {
+ img.rotation = (img.rotation + 1) % 4;
+ paint.kind = PaintKind::Image(img.clone());
+ write(app, target, paint, false);
+ }
+ });
+ // The image's own colour profile (decoding converts it into the file's).
+ let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
+ let source = match source.as_deref() {
+ None => tr("Unknown — treated as sRGB"),
+ Some("") => tr("Embedded profile"),
+ Some(name) => name,
+ };
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ let text = trf("Color profile: {name}", &[("name", &source)]);
+ ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
+ });
+ image_presets(app, ui, target, paint, img.filters);
+ for (label, val) in [
+ (tr_noop("Exposure"), img.filters.exposure),
+ (tr_noop("Contrast"), img.filters.contrast),
+ (tr_noop("Saturation"), img.filters.saturation),
+ (tr_noop("Temperature"), img.filters.temperature),
+ (tr_noop("Tint"), img.filters.tint),
+ (tr_noop("Highlights"), img.filters.highlights),
+ (tr_noop("Shadows"), img.filters.shadows),
+ ] {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ // Labels share a column so the sliders line up.
+ let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
+ ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);
+ let mut v = val;
+ let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
+ if r.changed() {
+ let f = &mut img.filters;
+ match label {
+ "Exposure" => f.exposure = v,
+ "Contrast" => f.contrast = v,
+ "Saturation" => f.saturation = v,
+ "Temperature" => f.temperature = v,
+ "Tint" => f.tint = v,
+ "Highlights" => f.highlights = v,
+ _ => f.shadows = v,
+ }
+ paint.kind = PaintKind::Image(img.clone());
+ write(app, target, paint, !r.drag_stopped());
+ }
+ if r.drag_stopped() {
+ finish(app);
+ }
+ });
+ }
+}
+
+/// The gradient bar with its stops, the stop actions and the selected stop's colour variable.
+/// The selected stop's colour (what the colour fields then edit).
+fn gradient_controls(
+ app: &mut FigApp,
+ ui: &mut Ui,
+ target: &PickerTarget,
+ paint: &mut Paint,
+ mut g: Gradient,
+ t: &Tokens,
+ sel_stop_id: Id,
+) -> Option {
+ let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
+ let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
+ let bar = bar.shrink2(vec2(16.0, 6.0));
+ widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);
+ let n = 24;
+ for i in 0..n {
+ let a = i as f32 / n as f32;
+ let b = (i + 1) as f32 / n as f32;
+ let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
+ ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
+ }
+ let mut changed = false;
+ let mut done = false;
+ // The bar first, so the stops on it take their own clicks.
+ let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
+ for (i, s) in g.stops.clone().iter().enumerate() {
+ let x = bar.min.x + s.position * bar.width();
+ let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
+ let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
+ ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
+ ui.painter().rect_stroke(
+ r,
+ CornerRadius::same(3),
+ Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),
+ StrokeKind::Outside,
+ );
+ if resp.clicked() || resp.drag_started() {
+ sel = i;
+ }
+ if resp.dragged()
+ && let Some(p) = resp.interact_pointer_pos()
+ && let Some(st) = g.stops.get_mut(i)
+ {
+ st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
+ changed = true;
+ }
+ if resp.drag_stopped() {
+ done = true;
+ }
+ }
+ // Click on the bar adds a stop.
+ if bresp.clicked()
+ && let Some(p) = bresp.interact_pointer_pos()
+ {
+ let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
+ g.stops.push(ColorStop { position: pos, color: g.sample(pos) });
+ sel = g.stops.len() - 1;
+ changed = true;
+ done = true;
+ }
+ ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));
+ if changed {
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, !done);
+ }
+ if done {
+ finish(app);
+ }
+ let edit_color = g.stops.get(sel).map(|s| s.color);
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {
+ g.stops.remove(sel);
+ ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, false);
+ }
+ if ui.small_button(tr("Flip")).clicked() {
+ for s in &mut g.stops {
+ s.position = 1.0 - s.position;
+ }
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, false);
+ }
+ if ui.small_button(tr("Rotate 90°")).clicked() {
+ let (fx, fy) = g.from;
+ let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));
+ g.to = rot(g.to);
+ g.width = rot(g.width);
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, false);
+ }
+ });
+ // The selected stop's colour can come from a colour variable.
+ if let PickerTarget::Paint { fills, index, ids } = target {
+ let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });
+ let stop = g.stops.get(sel).map(|s| s.color);
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {
+ let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
+ let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
+ if let Some(c) = stop {
+ widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));
+ let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);
+ ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);
+ }
+ });
+ });
+ }
+ edit_color
+}
+
/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by
/// `Id::unique(Some(title))`) so the style picker can tell a click inside the picker from one outside.
pub fn window_id() -> Id {
@@ -609,84 +881,14 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
target,
PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
) {
- let kinds = [
- (Icon::Fill, tr("Solid")),
- (Icon::GradientLinear, tr("Linear")),
- (Icon::GradientRadial, tr("Radial")),
- (Icon::GradientAngular, tr("Angular")),
- (Icon::GradientDiamond, tr("Diamond")),
- (Icon::Image, tr("Image")),
- (Icon::ViewGrid, tr("Pattern")),
- ];
- let active = match &paint.kind {
- PaintKind::Solid { .. } => 0,
- PaintKind::Gradient(g) => match g.kind {
- GradientKind::Linear => 1,
- GradientKind::Radial => 2,
- GradientKind::Angular => 3,
- GradientKind::Diamond => 4,
- },
- PaintKind::Image(_) => 5,
- PaintKind::Pattern(_) => 6,
- };
- ui.horizontal(|ui| {
- ui.add_space(12.0);
- if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
- let base = paint.preview_color();
- let mut image_pending = false;
- paint.kind = match i {
- 0 => PaintKind::Solid { color: base.with_alpha(1.0) },
- 5 => {
- image_pending = true;
- let target = target.clone();
- app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
- let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
- let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
- if let Some(mut paint) = current_paint(app, &target) {
- paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
- write(app, &target, &paint, false);
- }
- });
- paint.kind.clone()
- }
- 6 => match pattern_sources(app, &target).first() {
- Some((id, _)) => Paint::pattern(id.0).kind,
- None => {
- app.toast(tr("Add a layer or component to tile first").to_string());
- paint.kind.clone()
- }
- },
- k => {
- let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
- match &paint.kind {
- PaintKind::Gradient(g) => {
- PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })
- }
- _ => PaintKind::Gradient(Gradient::default_for(kind, base)),
- }
- }
- };
- if !image_pending {
- write(app, &target, &paint, false);
- }
- }
- });
+ paint_type_row(ui, app, &target, &mut paint);
}
// The paint's own blend mode, with the paints and layers under it.
if !matches!(
target,
PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
) {
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
- crate::right::menu_popup(&resp).width(200.0).show(|ui| {
- if let Some(b) = crate::right::blend_menu(ui, false) {
- paint.blend = b;
- write(app, &target, &paint, false);
- }
- });
- });
+ blend_row(ui, app, &target, &mut paint);
}
// Gradient stops.
let mut edit_color: Option = match &paint.kind {
@@ -694,184 +896,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
_ => None,
};
let sel_stop_id = stop_id();
- if let PaintKind::Gradient(g) = &paint.kind {
- let mut g = g.clone();
- let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
- let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
- let bar = bar.shrink2(vec2(16.0, 6.0));
- widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);
- let n = 24;
- for i in 0..n {
- let a = i as f32 / n as f32;
- let b = (i + 1) as f32 / n as f32;
- let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
- ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
- }
- let mut changed = false;
- let mut done = false;
- // The bar first, so the stops on it take their own clicks.
- let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
- for (i, s) in g.stops.clone().iter().enumerate() {
- let x = bar.min.x + s.position * bar.width();
- let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
- let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
- ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
- ui.painter().rect_stroke(
- r,
- CornerRadius::same(3),
- Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),
- StrokeKind::Outside,
- );
- if resp.clicked() || resp.drag_started() {
- sel = i;
- }
- if resp.dragged()
- && let Some(p) = resp.interact_pointer_pos()
- && let Some(st) = g.stops.get_mut(i)
- {
- st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
- changed = true;
- }
- if resp.drag_stopped() {
- done = true;
- }
- }
- // Click on the bar adds a stop.
- if bresp.clicked()
- && let Some(p) = bresp.interact_pointer_pos()
- {
- let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
- g.stops.push(ColorStop { position: pos, color: g.sample(pos) });
- sel = g.stops.len() - 1;
- changed = true;
- done = true;
- }
- ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));
- if changed {
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, !done);
- }
- if done {
- finish(app);
- }
- edit_color = g.stops.get(sel).map(|s| s.color);
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {
- g.stops.remove(sel);
- ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, false);
- }
- if ui.small_button(tr("Flip")).clicked() {
- for s in &mut g.stops {
- s.position = 1.0 - s.position;
- }
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, false);
- }
- if ui.small_button(tr("Rotate 90°")).clicked() {
- let (fx, fy) = g.from;
- let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));
- g.to = rot(g.to);
- g.width = rot(g.width);
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, false);
- }
- });
- // The selected stop's colour can come from a colour variable.
- if let PickerTarget::Paint { fills, index, ids } = &target {
- let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });
- let stop = g.stops.get(sel).map(|s| s.color);
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {
- let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());
- ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
- let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
- if let Some(c) = stop {
- widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));
- let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);
- ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);
- }
- });
- });
- }
+ if let PaintKind::Gradient(g) = paint.kind.clone() {
+ edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id);
}
- if let PaintKind::Image(img) = &paint.kind {
- let mut img = img.clone();
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);
- crate::right::menu_popup(&resp).show(|ui| {
- for (l, m) in [
- (tr("Fill"), ImageScaleMode::Fill),
- (tr("Fit"), ImageScaleMode::Fit),
- (tr("Crop"), ImageScaleMode::Crop),
- (tr("Tile"), ImageScaleMode::Tile),
- ] {
- if crate::right::menu_item(ui, l, "") {
- img.scale_mode = m;
- paint.kind = PaintKind::Image(img.clone());
- write(app, &target, &paint, false);
- ui.close();
- }
- }
- });
- if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {
- img.rotation = (img.rotation + 1) % 4;
- paint.kind = PaintKind::Image(img.clone());
- write(app, &target, &paint, false);
- }
- });
- // The image's own colour profile (decoding converts it into the file's).
- let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
- let source = match source.as_deref() {
- None => tr("Unknown — treated as sRGB"),
- Some("") => tr("Embedded profile"),
- Some(name) => name,
- };
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- let text = trf("Color profile: {name}", &[("name", &source)]);
- ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
- });
- image_presets(app, ui, &target, &mut paint, img.filters);
- for (label, val) in [
- (tr_noop("Exposure"), img.filters.exposure),
- (tr_noop("Contrast"), img.filters.contrast),
- (tr_noop("Saturation"), img.filters.saturation),
- (tr_noop("Temperature"), img.filters.temperature),
- (tr_noop("Tint"), img.filters.tint),
- (tr_noop("Highlights"), img.filters.highlights),
- (tr_noop("Shadows"), img.filters.shadows),
- ] {
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- // Labels share a column so the sliders line up.
- let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
- ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);
- let mut v = val;
- let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
- if r.changed() {
- let f = &mut img.filters;
- match label {
- "Exposure" => f.exposure = v,
- "Contrast" => f.contrast = v,
- "Saturation" => f.saturation = v,
- "Temperature" => f.temperature = v,
- "Tint" => f.tint = v,
- "Highlights" => f.highlights = v,
- _ => f.shadows = v,
- }
- paint.kind = PaintKind::Image(img.clone());
- write(app, &target, &paint, !r.drag_stopped());
- }
- if r.drag_stopped() {
- finish(app);
- }
- });
- }
+ if let PaintKind::Image(img) = paint.kind.clone() {
+ image_controls(app, ui, &target, &mut paint, img, &t);
}
if let PaintKind::Pattern(pt) = paint.kind.clone() {
pattern_controls(app, ui, &target, &mut paint, pt);
diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs
index 04e29397..aaf807ca 100644
--- a/crates/ui-egui/src/widgets/style_picker.rs
+++ b/crates/ui-egui/src/widgets/style_picker.rs
@@ -706,8 +706,17 @@ fn grid_label(g: &figcraft_doc::LayoutGrid) -> String {
/// The chip in `rect` of the variable `name` bound to `field` of text style `style`. A click on it
/// opens the variable picker to change it, and its detach button unbinds it.
-#[allow(clippy::too_many_arguments)]
-fn text_binding(ui: &mut Ui, style: &str, field: &str, kind: VariableType, rect: Rect, name: &str, lead: widgets::Lead, out: &mut Frame) {
+struct TextBinding<'a> {
+ style: &'a str,
+ field: &'a str,
+ kind: VariableType,
+ rect: Rect,
+ name: &'a str,
+ lead: widgets::Lead<'a>,
+}
+
+fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) {
+ let TextBinding { style, field, kind, rect, name, lead } = binding;
match widgets::bound_field(ui, Id::unique(("ts_bound", style, field)), lead, rect, name) {
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at),
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())),
@@ -747,7 +756,18 @@ fn font_family_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle
match bound_name(doc, style, "fontFamily") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
- text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "fontFamily",
+ kind: VariableType::String,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Font")),
+ },
+ out,
+ );
None
}
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
@@ -762,7 +782,18 @@ fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSty
match bound_name(doc, style, "fontStyle") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
- text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "fontStyle",
+ kind: VariableType::String,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Font style")),
+ },
+ out,
+ );
}
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
}
@@ -802,7 +833,18 @@ fn line_height_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle
match bound_name(doc, style, "lineHeight") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
- text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "lineHeight",
+ kind: VariableType::Number,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Line height")),
+ },
+ out,
+ );
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
@@ -823,7 +865,18 @@ fn letter_spacing_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSt
match bound_name(doc, style, "letterSpacing") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
- text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "letterSpacing",
+ kind: VariableType::Number,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Letter spacing")),
+ },
+ out,
+ );
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
From ffb4fc7e3edeb07b081f74d59fd4b52b05aabd0b Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 34/60] refactor(ui): simplify app and presentation handlers
---
crates/ui-egui/src/app.rs | 7 +-
crates/ui-egui/src/app/clipboard.rs | 2 +-
crates/ui-egui/src/app/commands.rs | 5 +-
crates/ui-egui/src/app/control.rs | 4 +-
crates/ui-egui/src/app/files.rs | 5 +-
crates/ui-egui/src/app/frame.rs | 5 +-
crates/ui-egui/src/app/ime.rs | 2 +-
crates/ui-egui/src/canvas/builder.rs | 2 +-
crates/ui-egui/src/canvas/input.rs | 54 +-
crates/ui-egui/src/canvas/outline.rs | 4 +-
crates/ui-egui/src/canvas/overlays.rs | 395 ++++++-----
crates/ui-egui/src/canvas/render.rs | 2 +-
crates/ui-egui/src/canvas/rulers.rs | 2 +-
crates/ui-egui/src/control.rs | 807 ++++++++++++-----------
crates/ui-egui/src/field_tests.rs | 35 +-
crates/ui-egui/src/icons/draw.rs | 160 +++--
crates/ui-egui/src/open.rs | 2 +-
crates/ui-egui/src/present/actions.rs | 23 +-
crates/ui-egui/src/present/chrome.rs | 5 +-
crates/ui-egui/src/present/device.rs | 2 +-
crates/ui-egui/src/present/draw.rs | 2 +-
crates/ui-egui/src/present/input.rs | 283 ++++----
crates/ui-egui/src/present/layout.rs | 5 +-
crates/ui-egui/src/present/navigate.rs | 2 +-
crates/ui-egui/src/present/player.rs | 9 +-
crates/ui-egui/src/present/scroll.rs | 5 +-
crates/ui-egui/src/present/transition.rs | 5 +-
crates/ui-egui/src/services.rs | 2 +-
crates/ui-egui/src/tabs.rs | 412 +++++++-----
29 files changed, 1271 insertions(+), 977 deletions(-)
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index c10b402c..8d3521c2 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -2,7 +2,12 @@ use super::clock::{AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, CJK_CHAN
use super::open::OPEN_EXTENSIONS;
use super::services::{DialogFuture, PickFuture, Picked, RepaintWaker, Services};
use super::state::{LEFT_PANEL, LeftTab, MOBILE_STRIP, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels};
-use super::*;
+use super::{
+ Arc, Done, FigApp, NodeId, OsStr, Path, PathBuf, PendingAck, PendingDialog, Poll, Session, TaskContext, UiState, Value, Waker, a11y, branch_ui,
+ canvas, cjk, comments, cursors, dev_compare, dialogs, frame_stats, home, home_recents, i18n, icons, import_report, json, layers, live, menus,
+ missing_fonts, online, plugins_ui, present, render_worker, right, selection_spacing, shortcuts, style_picker, tabs, theme, toolbar, ui_scale,
+ updates_ui, variable_ui, versions, widgets, windows,
+};
mod clipboard;
mod commands;
diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs
index 2280f9a1..c54e5f94 100644
--- a/crates/ui-egui/src/app/clipboard.rs
+++ b/crates/ui-egui/src/app/clipboard.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::FigApp;
impl FigApp {
/// Put `text` on the system clipboard. Input injected through the control channel never
diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
index 38adf660..4ccb8939 100644
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Done, FigApp, LeftTab, MobilePanel, NodeId, PANEL_DEFAULT, RightTab, Value, comments, dev_compare, dialogs, home, i18n, import_report, json,
+ layers, live, menus, missing_fonts, plugins_ui, present, tabs, updates_ui, versions,
+};
impl FigApp {
/// Dismiss the composer after a successful switch away from the Comment tool.
diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs
index bbbe9c61..7fd8f139 100644
--- a/crates/ui-egui/src/app/control.rs
+++ b/crates/ui-egui/src/app/control.rs
@@ -1,4 +1,6 @@
-use super::*;
+use super::{
+ FigApp, PendingAck, Value, cursors, dev_compare, home_recents, import_report, json, live, missing_fonts, online, present, tabs, windows,
+};
impl FigApp {
/// Queue raw input injected through the control channel for the window drawing next, tagged
diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs
index ae17b7bc..532cf8ab 100644
--- a/crates/ui-egui/src/app/files.rs
+++ b/crates/ui-egui/src/app/files.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, Arc, DialogFuture, FigApp, OPEN_EXTENSIONS, OsStr, Path, PathBuf, PendingDialog, PickFuture,
+ Picked, Poll, RepaintWaker, Services, TaskContext, Value, Waker, dialogs, i18n, json, repaint_in, windows,
+};
impl FigApp {
pub fn save_as_dialog(&mut self) {
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
index 96225f20..c44d457d 100644
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, CJK_CHANGES, FigApp, LEFT_PANEL, MobilePanel, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TOAST_SECS, ToastClock, Value, a11y, cjk, i18n, icons,
+ json, repaint_in, theme,
+};
impl FigApp {
/// Show what `command params` would do on the canvas as a live preview (`text.preview`) while
diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs
index df0a7fd2..803e6230 100644
--- a/crates/ui-egui/src/app/ime.rs
+++ b/crates/ui-egui/src/app/ime.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{FigApp, shortcuts};
impl FigApp {
/// The pass's one IME observation: the focused on-canvas editor, or an explicit idle.
diff --git a/crates/ui-egui/src/canvas/builder.rs b/crates/ui-egui/src/canvas/builder.rs
index bc2360da..779a580a 100644
--- a/crates/ui-egui/src/canvas/builder.rs
+++ b/crates/ui-egui/src/canvas/builder.rs
@@ -1,6 +1,6 @@
use super::outline::stroke;
use super::overlays::accent;
-use super::*;
+use super::{Color32, FigApp, Pos2, Shape, Tokens, pos2};
/// Width of the lines hatching the hovered and selected regions in vector edit mode (screen px).
const REGION_HATCH_WIDTH: f32 = 1.4;
diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs
index fc518d2f..a6bc55b5 100644
--- a/crates/ui-egui/src/canvas/input.rs
+++ b/crates/ui-egui/src/canvas/input.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{FigApp, Point, PointerKind, Pos2, Rect, Ui, json, pointer_event};
/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag
/// zooms to the dragged area. Takes the pointer from the other tools while it's active.
@@ -25,30 +25,7 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods:
true
}
-/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
-/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
-/// then doesn't show.
-pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
- if crate::proto_canvas::input(app, ui, resp) {
- // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
- app.ui.pages_focus = None;
- return true;
- }
- let ctx = ui.ctx().clone();
- // Two fingers pan as well as pinch (a touch screen has no scroll wheel).
- let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
- let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
- (i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
- });
- let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
- let typing_elsewhere = crate::a11y::field_focused(&ctx);
- app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
- if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
- return false;
- }
- // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
- // a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
- // nothing into the design, and says so once for the whole drop.
+fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
for f in dropped {
@@ -75,6 +52,33 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
}
+}
+
+/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
+/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
+/// then doesn't show.
+pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
+ if crate::proto_canvas::input(app, ui, resp) {
+ // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
+ app.ui.pages_focus = None;
+ return true;
+ }
+ let ctx = ui.ctx().clone();
+ // Two fingers pan as well as pinch (a touch screen has no scroll wheel).
+ let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
+ let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
+ (i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
+ });
+ let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
+ let typing_elsewhere = crate::a11y::field_focused(&ctx);
+ app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
+ if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
+ return false;
+ }
+ // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
+ // a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
+ // nothing into the design, and says so once for the whole drop.
+ import_dropped(app, &ctx, hover_pos);
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs
index 58f5fd20..e5a589d8 100644
--- a/crates/ui-egui/src/canvas/outline.rs
+++ b/crates/ui-egui/src/canvas/outline.rs
@@ -1,4 +1,6 @@
-use super::*;
+use super::{
+ Arc, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, pos2, regular, semibold, tr, vec2,
+};
pub(super) fn stroke(c: Color32, w: f32) -> Stroke {
Stroke::new(w, c)
diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs
index 675427c1..d88d6ef6 100644
--- a/crates/ui-egui/src/canvas/overlays.rs
+++ b/crates/ui-egui/src/canvas/overlays.rs
@@ -1,7 +1,10 @@
use super::builder::{builder_overlay, hatched, region_hatch};
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
use super::rulers::{boards, fmt, rulers};
-use super::*;
+use super::{
+ Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId,
+ doc_c32, pos2, selection_box, selection_frame, semibold, tr, vec2,
+};
/// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the
/// page) for the engine to hit-test.
@@ -15,22 +18,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
let view = ds.view.clone();
// Pixel grid at high zoom.
if view.pixel_grid_shown() {
- let r = app.canvas.rect;
- let p0 = app.to_page(r.min);
- let p1 = app.to_page(r.max);
- let col = Color32::from_black_alpha(18);
- let mut x = p0.x.floor();
- while x <= p1.x {
- let s = app.to_screen(x, 0.0).x;
- painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
- x += 1.0;
- }
- let mut y = p0.y.floor();
- while y <= p1.y {
- let s = app.to_screen(0.0, y).y;
- painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
- y += 1.0;
- }
+ pixel_grid(app, painter);
}
// Layout grids and frame labels for top-level frames (also those in sections); the focus view
// labels only its frame.
@@ -44,20 +32,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
continue;
}
if view.layout_grids && !n.grids.is_empty() && figcraft_layout::grids_shown(&doc, *c) {
- let w = doc.world_transform(*c);
- for g in n.grids.iter().filter(|g| g.visible) {
- let col = doc_c32(painter.ctx(), g.color);
- for r in figcraft_layout::grid_lines(g, n.width, n.height) {
- let a = w * Point::new(r.x0, r.y0);
- let b = w * Point::new(r.x1, r.y1);
- let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
- if g.kind == figcraft_doc::GridKind::Grid {
- painter.line_segment([sa, sb], stroke(col, 1.0));
- } else {
- painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
- }
- }
- }
+ layout_grids(app, painter, &doc, *c, n);
}
// The focus view's bar names its frame.
// Board objects (stickies, shapes with text, stamps) are frames without a name label.
@@ -85,19 +60,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
}
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
- for id in doc.subtree(page) {
- let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
- if view.mask_outlines && n.is_mask {
- draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0));
- } else if view.frame_outlines && n.kind.is_frame_like() {
- draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
- } else if view.show_slices
- && matches!(n.kind, NodeKind::Slice)
- && let Some(q) = node_quad(app, &doc, id)
- {
- painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
- }
- }
+ view_outlines(app, painter, t, ds, &doc);
}
// Drop target / insertion indicator.
let ov = &app.session.tool.overlay;
@@ -148,82 +111,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
// Selection.
let editing = ds.text_edit.clone();
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
- let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
- for id in &sel {
- draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
- }
- // The shape builder reshapes by regions: no transform box.
- if app.session.tool.tool != ToolId::ShapeBuilder
- && let Some(b) = selection_box(&doc, &sel)
- && let Some((_, w, h)) = selection_frame(&doc, &sel)
- {
- let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
- // A selected connector's handles are its two ends (drag one onto another object).
- let connector = figcraft_engine::board::selected_connector_ends(&doc, &sel).map(|(_, ends)| ends);
- let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
- let scale = app.session.tool.overlay.scale;
- if let Some(hint) = scale {
- label = format!("{label} · {}x", fmt(hint.factor.abs()));
- }
- // A layer smaller than its handles on screen shows just its outline (they still answer
- // just outside it).
- let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
- // Measuring to another layer (Alt held) shows just the outlines: no handles or size.
- let measuring = app.session.tool.overlay.measure_box.is_some();
- // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
- // Mode: no handles either.
- let handles =
- if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
- 0
- } else if is_line {
- 2
- } else if tiny {
- 0
- } else {
- 4
- };
- size_label = transform_box(
- app,
- painter,
- b.map(|p| app.to_screen(p.x, p.y)),
- col,
- sel.len() > 1,
- handles,
- (!measuring && connector.is_none()).then_some(label),
- );
- for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
- let c = app.to_screen(end.x, end.y);
- painter.circle_filled(c, 5.0, Color32::WHITE);
- painter.circle_stroke(c, 5.0, stroke(col, 1.5));
- }
- if !measuring {
- arc_overlay(app, painter, col);
- shape_overlay(app, painter, col);
- }
- if app.session.tool.tool == ToolId::Scale {
- // The point that stays put: the dragged pivot, else the anchor.
- let pivot = scale
- .map(|h| Point::new(h.pivot[0], h.pivot[1]))
- .or_else(|| figcraft_engine::tools::scale_anchor_point(&doc, &sel, app.session.tool.scale_anchor));
- if let Some(p) = pivot {
- let c = app.to_screen(p.x, p.y);
- painter.circle_filled(c, 4.0, Color32::WHITE);
- painter.circle_stroke(c, 4.0, stroke(col, 1.5));
- painter.circle_filled(c, 1.5, col);
- }
- }
- // The rotation origin while it shows (Alt+R): a ring with a cross through it.
- if let Some(o) = ds.origin_handle() {
- let c = app.to_screen(o.x, o.y);
- painter.circle_filled(c, 5.0, Color32::WHITE);
- painter.circle_stroke(c, 5.0, stroke(col, 1.5));
- for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
- painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
- painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
- }
- painter.circle_filled(c, 1.5, col);
- }
- }
+ size_label = selection_overlay(app, painter, ds, &doc, &sel, zoom);
}
if app.session.tool.tool == ToolId::ShapeBuilder
&& let Some(arr) = figcraft_engine::shapebuilder::current(&app.session)
@@ -231,43 +119,8 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
builder_overlay(app, painter, t, &arr);
}
// Text editing: caret and selection, or the IME composition underlined at the caret.
- let preedit = app.preedit_view();
- let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc);
- if let Some(te) = editing
- && let Some(text) = TextOnScreen::of(edoc, te.id)
- {
- let layout = &text.layout;
- if let Some(q) = node_quad(app, edoc, te.id) {
- quad(painter, q, stroke(accent(painter), 1.0));
- }
- let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
- if a != b && preedit.is_none() {
- for q in text.selection(app, a, b) {
- painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
- }
- }
- if let Some(p) = preedit {
- for r in layout.selection_rects(p.range.0, p.range.1) {
- // Underline just below the baseline, across the composed chars only.
- let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
- let x1 = layout
- .glyphs
- .get(line.glyph_start..line.glyph_end)
- .unwrap_or(&[])
- .iter()
- .filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
- .map(|g| g.x + g.advance)
- .fold(r.x0, f64::max);
- let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
- painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
- }
- }
- let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
- let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
- if blink || a != b {
- painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
- }
- painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
+ if let Some(te) = editing {
+ text_edit_overlay(app, painter, t, &doc, &te, zoom);
}
// Smart guides and measurements (their labels kept clear of the size label).
crate::measure_overlay::draw(app, painter, t, size_label);
@@ -278,6 +131,198 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside);
}
// Pen path.
+ pen_overlay(app, painter, zoom);
+ // Eraser brush (vector edit mode).
+ if let Some([x, y, r]) = ov.brush {
+ let c = app.to_screen(x, y);
+ let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
+ painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
+ painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
+ }
+ // Lasso (vector edit mode).
+ if ov.lasso.len() > 1 {
+ let mut pts: Vec = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
+ if let Some(first) = pts.first().copied() {
+ pts.push(first);
+ }
+ painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
+ }
+ // Guides (under the rulers).
+ crate::guides::draw(app, painter);
+ // Rulers.
+ if view.rulers {
+ rulers(app, painter, t, &doc, &sel);
+ }
+ labels
+}
+
+/// Pixel grid at high zoom.
+fn pixel_grid(app: &FigApp, painter: &egui::Painter) {
+ let r = app.canvas.rect;
+ let p0 = app.to_page(r.min);
+ let p1 = app.to_page(r.max);
+ let col = Color32::from_black_alpha(18);
+ let mut x = p0.x.floor();
+ while x <= p1.x {
+ let s = app.to_screen(x, 0.0).x;
+ painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
+ x += 1.0;
+ }
+ let mut y = p0.y.floor();
+ while y <= p1.y {
+ let s = app.to_screen(0.0, y).y;
+ painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
+ y += 1.0;
+ }
+}
+
+/// The layout grids of frame `c` (`n`).
+fn layout_grids(app: &FigApp, painter: &egui::Painter, doc: &Document, c: NodeId, n: &figcraft_doc::Node) {
+ let w = doc.world_transform(c);
+ for g in n.grids.iter().filter(|g| g.visible) {
+ let col = doc_c32(painter.ctx(), g.color);
+ for r in figcraft_layout::grid_lines(g, n.width, n.height) {
+ let a = w * Point::new(r.x0, r.y0);
+ let b = w * Point::new(r.x1, r.y1);
+ let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
+ if g.kind == figcraft_doc::GridKind::Grid {
+ painter.line_segment([sa, sb], stroke(col, 1.0));
+ } else {
+ painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
+ }
+ }
+ }
+}
+
+fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, te: &figcraft_engine::TextEdit, zoom: f64) {
+ let preedit = app.preedit_view();
+ let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(doc);
+ let Some(text) = TextOnScreen::of(edoc, te.id) else { return };
+ let layout = &text.layout;
+ if let Some(q) = node_quad(app, edoc, te.id) {
+ quad(painter, q, stroke(accent(painter), 1.0));
+ }
+ let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
+ if a != b && preedit.is_none() {
+ for q in text.selection(app, a, b) {
+ painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
+ }
+ }
+ if let Some(p) = preedit {
+ for r in layout.selection_rects(p.range.0, p.range.1) {
+ // Underline just below the baseline, across the composed chars only.
+ let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
+ let x1 = layout
+ .glyphs
+ .get(line.glyph_start..line.glyph_end)
+ .unwrap_or(&[])
+ .iter()
+ .filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
+ .map(|g| g.x + g.advance)
+ .fold(r.x0, f64::max);
+ let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
+ painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
+ }
+ }
+ let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
+ let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
+ if blink || a != b {
+ painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
+ }
+ painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
+}
+
+/// The selection's outlines, transform box and handles; returns the size label's box.
+fn selection_overlay(
+ app: &FigApp,
+ painter: &egui::Painter,
+ ds: &figcraft_engine::DocState,
+ doc: &Document,
+ sel: &[NodeId],
+ zoom: f64,
+) -> Option {
+ let mut size_label = None;
+ let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
+ for id in sel {
+ draw_outline(app, painter, doc, *id, stroke(col, 1.0));
+ }
+ // The shape builder reshapes by regions: no transform box.
+ if app.session.tool.tool != ToolId::ShapeBuilder
+ && let Some(b) = selection_box(doc, sel)
+ && let Some((_, w, h)) = selection_frame(doc, sel)
+ {
+ let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
+ // A selected connector's handles are its two ends (drag one onto another object).
+ let connector = figcraft_engine::board::selected_connector_ends(doc, sel).map(|(_, ends)| ends);
+ let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
+ let scale = app.session.tool.overlay.scale;
+ if let Some(hint) = scale {
+ label = format!("{label} · {}x", fmt(hint.factor.abs()));
+ }
+ // A layer smaller than its handles on screen shows just its outline (they still answer
+ // just outside it).
+ let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
+ // Measuring to another layer (Alt held) shows just the outlines: no handles or size.
+ let measuring = app.session.tool.overlay.measure_box.is_some();
+ // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
+ // Mode: no handles either.
+ let handles =
+ if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) {
+ 0
+ } else if is_line {
+ 2
+ } else if tiny {
+ 0
+ } else {
+ 4
+ };
+ size_label = transform_box(
+ app,
+ painter,
+ b.map(|p| app.to_screen(p.x, p.y)),
+ col,
+ sel.len() > 1,
+ handles,
+ (!measuring && connector.is_none()).then_some(label),
+ );
+ for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
+ let c = app.to_screen(end.x, end.y);
+ painter.circle_filled(c, 5.0, Color32::WHITE);
+ painter.circle_stroke(c, 5.0, stroke(col, 1.5));
+ }
+ if !measuring {
+ arc_overlay(app, painter, col);
+ shape_overlay(app, painter, col);
+ }
+ if app.session.tool.tool == ToolId::Scale {
+ // The point that stays put: the dragged pivot, else the anchor.
+ let pivot = scale
+ .map(|h| Point::new(h.pivot[0], h.pivot[1]))
+ .or_else(|| figcraft_engine::tools::scale_anchor_point(doc, sel, app.session.tool.scale_anchor));
+ if let Some(p) = pivot {
+ let c = app.to_screen(p.x, p.y);
+ painter.circle_filled(c, 4.0, Color32::WHITE);
+ painter.circle_stroke(c, 4.0, stroke(col, 1.5));
+ painter.circle_filled(c, 1.5, col);
+ }
+ }
+ // The rotation origin while it shows (Alt+R): a ring with a cross through it.
+ if let Some(o) = ds.origin_handle() {
+ let c = app.to_screen(o.x, o.y);
+ painter.circle_filled(c, 5.0, Color32::WHITE);
+ painter.circle_stroke(c, 5.0, stroke(col, 1.5));
+ for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
+ painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
+ painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
+ }
+ painter.circle_filled(c, 1.5, col);
+ }
+ }
+ size_label
+}
+
+fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
+ let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
let pts: Vec = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if app.session.tool.tool == ToolId::Pencil {
@@ -315,28 +360,24 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
// A connector being drawn, or one of its ends dragged: from where it starts to the pointer.
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5));
}
- // Eraser brush (vector edit mode).
- if let Some([x, y, r]) = ov.brush {
- let c = app.to_screen(x, y);
- let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
- painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
- painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
- }
- // Lasso (vector edit mode).
- if ov.lasso.len() > 1 {
- let mut pts: Vec = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
- if let Some(first) = pts.first().copied() {
- pts.push(first);
+}
+
+/// Mask outlines, frame outlines and slices, as the View menu shows.
+fn view_outlines(app: &FigApp, painter: &egui::Painter, t: &Tokens, ds: &figcraft_engine::DocState, doc: &Document) {
+ let (view, page) = (&ds.view, ds.page);
+ for id in doc.subtree(page) {
+ let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
+ if view.mask_outlines && n.is_mask {
+ draw_outline(app, painter, doc, id, stroke(accent(painter), 1.0));
+ } else if view.frame_outlines && n.kind.is_frame_like() {
+ draw_outline(app, painter, doc, id, stroke(t.text_secondary, 1.0));
+ } else if view.show_slices
+ && matches!(n.kind, NodeKind::Slice)
+ && let Some(q) = node_quad(app, doc, id)
+ {
+ painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
}
- painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
- // Guides (under the rulers).
- crate::guides::draw(app, painter);
- // Rulers.
- if view.rulers {
- rulers(app, painter, t, &doc, &sel);
- }
- labels
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs
index 915e8ea5..c6c4702f 100644
--- a/crates/ui-egui/src/canvas/render.rs
+++ b/crates/ui-egui/src/canvas/render.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, CanvasState, Done, FigApp, Job, NodeId, Worker};
#[derive(Clone, PartialEq)]
pub(super) struct JobKey {
diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs
index a394ca7c..b43bcc5b 100644
--- a/crates/ui-egui/src/canvas/rulers.rs
+++ b/crates/ui-egui/src/canvas/rulers.rs
@@ -1,6 +1,6 @@
use super::outline::stroke;
use super::overlays::accent;
-use super::*;
+use super::{Align2, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Pos2, Rect, Tokens, pos2, regular, semibold, tr, vec2};
/// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level
/// layers and, inside sections whose contents show, their visible children (sections nest). A
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index afef387d..23b7a63e 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -924,6 +924,414 @@ fn b(p: &Value, k: &str) -> bool {
p.get(k).and_then(Value::as_bool).unwrap_or(false)
}
+fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result {
+ let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
+ let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
+ let platform = crate::shortcuts::platform();
+ use figcraft_engine::keymap::{Context, lookup, normalize};
+ // Text editing keys go to the editor (after the shortcuts live while editing).
+ if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
+ if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
+ crate::menus::invoke(app, bd.id);
+ return Ok(json!({"command": bd.id}));
+ }
+ let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
+ figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
+ return Ok(json!({"ok": true}));
+ }
+ // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
+ if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
+ return Ok(json!({"ok": true}));
+ }
+ // The Comment tool's arrows step through the comment threads.
+ if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
+ let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
+ return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
+ }
+ if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
+ crate::shortcuts::run_binding(app, bd.id);
+ return Ok(json!({"command": bd.id}));
+ }
+ let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
+ match name {
+ "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
+ let (dx, dy) = match name {
+ "ArrowLeft" => (-1, 0),
+ "ArrowRight" => (1, 0),
+ "ArrowUp" => (0, -1),
+ _ => (0, 1),
+ };
+ let (cmd, params) = if m.cmd || m.ctrl {
+ ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
+ } else {
+ ("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
+ };
+ app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
+ }
+ _ => {
+ // Number keys: opacity (two in quick succession make an exact percentage). A
+ // hidden window's logic tick has a stale `ctx.input(i.time)`.
+ let now = app.clock.now();
+ match egui::Key::from_name(name) {
+ Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
+ _ => Err(format!("no shortcut `{key}`")),
+ }
+ }
+ }
+}
+
+fn ui_input(app: &mut FigApp, ctx: &Context, p: &Value) -> Result {
+ let list = p
+ .get("events")
+ .and_then(Value::as_array)
+ .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
+ let pos = |v: &Value| -> Option {
+ let a = v.as_array()?;
+ let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
+ p.is_finite().then_some(p)
+ };
+ let mut events = Vec::new();
+ let mut pads = Vec::new();
+ for e in list.iter().take(256) {
+ let button =
+ if e.get("button").and_then(Value::as_str) == Some("secondary") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
+ let command = b(e, "cmd") || b(e, "ctrl");
+ let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
+ if let Some(p) = e.get("move").and_then(pos) {
+ events.push(egui::Event::PointerMoved(p));
+ } else if let Some((p, pressed)) = e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false))) {
+ events.push(egui::Event::PointerMoved(p));
+ events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
+ } else if let Some(k) = e.get("key").and_then(Value::as_str) {
+ let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
+ for pressed in [true, false] {
+ events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
+ }
+ } else if let Some(t) = e.get("text").and_then(Value::as_str) {
+ events.push(egui::Event::Text(t.to_string()));
+ } else if let Some(d) = e.get("scroll").and_then(pos) {
+ // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
+ events.push(egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ source: egui::MouseWheelSource::Wheel,
+ delta: d.to_vec2(),
+ phase: egui::TouchPhase::Move,
+ modifiers: mods,
+ });
+ } else if let Some(g) = e.get("gamepad") {
+ let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
+ pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
+ } else if b(e, "leave") {
+ events.push(egui::Event::PointerGone);
+ } else if let Some(h) = e.get("hold") {
+ let held = egui::Modifiers {
+ alt: b(h, "alt"),
+ ctrl: b(h, "cmd") || b(h, "ctrl"),
+ shift: b(h, "shift"),
+ mac_cmd: false,
+ command: b(h, "cmd") || b(h, "ctrl"),
+ };
+ events.push(egui::Event::ModifiersChanged(held));
+ } else {
+ return Err(format!("unknown input event {e}"));
+ }
+ }
+ // Controller buttons go straight to the prototype playing (its next frame takes them).
+ if !pads.is_empty() {
+ match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
+ (Some(p), _) => p.pads.extend(pads),
+ (None, Some(pane)) => pane.player.pads.extend(pads),
+ (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
+ }
+ }
+ app.control_input_queued = app.push_control_input(events);
+ ctx.request_repaint();
+ Ok(json!({"queued": list.len().min(256)}))
+}
+
+fn ui_set_view(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
+ app.ui.right_tab = match t {
+ "prototype" => crate::RightTab::Prototype,
+ "inspect" => crate::RightTab::Inspect,
+ _ => crate::RightTab::Design,
+ };
+ }
+ if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
+ app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
+ }
+ if let Some(t) = p.get("theme").and_then(Value::as_str) {
+ app.session.prefs.theme = t.to_string();
+ }
+ if let Some(h) = p.get("home").and_then(Value::as_bool) {
+ app.ui.home = h;
+ }
+ if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
+ app.ui.home_view.section = section;
+ }
+ if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
+ app.ui.home_view.file_query = query.chars().take(256).collect();
+ }
+ if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
+ view.store(&mut app.session.prefs);
+ }
+ if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
+ crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
+ }
+ if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
+ let sort = crate::home_recents::Sort::of(&app.session.prefs);
+ crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
+ }
+ if let Some(h) = p.get("palette").and_then(Value::as_bool) {
+ crate::menus::set_palette(&mut app.ui, h);
+ }
+ // true / false, or a top-level menu to open it at ("Plugins").
+ match p.get("mainMenu") {
+ Some(Value::Bool(h)) => app.ui.main_menu = *h,
+ Some(Value::String(top)) => {
+ app.ui.main_menu = true;
+ crate::menus::open_main_menu(ctx, top);
+ }
+ _ => {}
+ }
+ Ok(())
+}
+
+fn ui_set_plugins(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
+ app.ui.plugins_window = h;
+ }
+ // The Plugins window's tab ("installed", "browse"), and the plugin of the listed
+ // directory whose install asks for confirmation (null: none).
+ if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) {
+ app.ui.plugins_manage.tab = if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed };
+ }
+ match p.get("pluginConfirm") {
+ Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()),
+ Some(Value::Null) => app.ui.plugins_manage.confirm = None,
+ _ => {}
+ }
+ // A plugin command's parameters form: {id, command?}; null closes it.
+ match p.get("pluginForm") {
+ Some(Value::Null) => app.ui.plugin_form = None,
+ Some(v @ Value::Object(_)) => {
+ let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
+ let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
+ crate::plugins_ui::start(app, id, command);
+ }
+ _ => {}
+ }
+ // Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
+ if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
+ app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
+ }
+ // A right-click on the canvas at screen [sx, sy] (null closes the menu);
+ // `contextSelectLayer` opens its Select layer submenu.
+ match p.get("contextMenu") {
+ Some(Value::Array(at)) => {
+ let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
+ crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
+ }
+ Some(Value::Null) => app.ui.context_menu = None,
+ _ => {}
+ }
+ if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
+ crate::menus::open_select_layer(ctx, v);
+ }
+ if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
+ app.ui.icon_gallery = h;
+ }
+ Ok(())
+}
+
+fn ui_set_comments(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ // Comment thread popover: an id opens it, null closes it.
+ if let Some(v) = p.get("commentOpen") {
+ match v.as_u64() {
+ Some(id) => {
+ // Pins are drawn for the current page only: reveal the thread's page first.
+ let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
+ if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
+ app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
+ }
+ app.ui.comments.show(id);
+ }
+ None => app.ui.comments.close(),
+ }
+ }
+ if let Some(v) = p.get("commentDraft") {
+ match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
+ (Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
+ _ => app.ui.comments.draft = None,
+ }
+ }
+ if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
+ app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
+ }
+ if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
+ app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
+ }
+ if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
+ app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
+ }
+ if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
+ app.ui.comments.search = v.to_string();
+ }
+ // The open thread's reply field, with the cursor at its end (an `@` there offers names).
+ if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
+ app.ui.comments.reply = v.to_string();
+ if let Some(id) = app.ui.comments.open {
+ crate::comments::focus_reply(ctx, id, v.chars().count());
+ }
+ }
+ // Edit a message of the open thread: its index (0: the first message); null stops.
+ if let Some(v) = p.get("commentEdit") {
+ app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
+ (Some(id), Some(i)) => {
+ let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
+ Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
+ }
+ _ => None,
+ };
+ }
+ if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
+ app.ui.present_view.comments = v;
+ if let Some(pres) = app.ui.present.as_mut() {
+ pres.view.comments = v;
+ }
+ }
+ Ok(())
+}
+
+fn ui_set_panels(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ // Colour picker on the selection's paint `{fills?, index?}`; null closes it.
+ if let Some(v) = p.get("colorPicker") {
+ app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
+ fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
+ index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
+ ids: app.session.selection(),
+ });
+ }
+ // The Variables editor: true / a collection (id or name) / {library, collection?} (an
+ // enabled library's, by id or name) opens it, false closes it.
+ match p.get("variables") {
+ Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
+ Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
+ Some(v @ Value::Object(_)) => {
+ let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
+ app.ui.variables = Some(match text("library") {
+ Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
+ None => crate::variables_panel::Editor::on(text("collection")),
+ });
+ }
+ _ => {}
+ }
+ // Layers panel search: a string opens it with that filter, null closes it.
+ if let Some(v) = p.get("layerFilter") {
+ app.ui.layer_search = v.is_string();
+ app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
+ }
+ if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
+ app.ui.expanded.clear();
+ }
+ // The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
+ match p.get("protoEditor") {
+ Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
+ Some(Value::Array(a)) => {
+ let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
+ let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
+ crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
+ }
+ _ => {}
+ }
+ // The prototype preview pane: true / a frame id opens it, false or null closes it.
+ match p.get("preview") {
+ Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
+ Some(v @ (Value::Bool(true) | Value::Number(_))) => {
+ let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
+ app.session.execute("proto.preview", ¶ms).map_err(|e| e.to_string())?;
+ app.handle_ui_requests();
+ }
+ _ => {}
+ }
+ // Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
+ if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
+ let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
+ let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
+ (Some(p), _) => Some(p),
+ (None, Some(pane)) => Some(&mut pane.player),
+ _ => None,
+ };
+ player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
+ }
+ Ok(())
+}
+
+fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> {
+ // The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
+ // closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
+ // is `list` or `grid`.
+ if let Some(v) = p.get("stylePicker") {
+ match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
+ Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
+ None => app.ui.style_picker = None,
+ }
+ }
+ if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
+ app.ui.style_grid = v == "grid";
+ }
+ // The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
+ if let Some(v) = p.get("colorProfileDialog") {
+ app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
+ }
+ if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
+ app.ui.export_thumb = v;
+ }
+ // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion`
+ // the saved version it compares with (null: when it was marked ready for dev),
+ // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
+ if let Some(v) = p.get("devCompare") {
+ app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None));
+ }
+ if let Some(c) = app.ui.dev.compare.as_mut() {
+ if let Some(v) = p.get("devCompareVersion") {
+ c.version = v.as_str().map(str::to_string);
+ }
+ match p.get("devCompareMode").and_then(Value::as_str) {
+ Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
+ Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
+ Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
+ None => {}
+ }
+ if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
+ c.split = x.clamp(0.0, 1.0) as f32;
+ }
+ }
+ // The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
+ // chooses sides ({: "branch" | "main"}).
+ if let Some(v) = p.get("branchReview") {
+ match v.as_str() {
+ Some(id) => crate::branch_ui::open(app, Some(id)),
+ None => app.ui.branch_review = None,
+ }
+ }
+ if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
+ for (k, v) in picks {
+ let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
+ r.picks.insert(k.clone(), side);
+ }
+ }
+ // Which annotations the canvas shows: all, none (uncategorised) or a category.
+ if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
+ let doc = app.session.doc().map_err(|e| e.to_string())?;
+ app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
+ }
+ if b(p, "resetFrameStats") {
+ app.frame_stats.reset();
+ }
+ Ok(())
+}
+
/// Handle one control method (also used by tests and the headless snapshot tool).
pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result {
match method {
@@ -972,134 +1380,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(v)
}
"ui.pointer" => pointer(app, p),
- "ui.key" => {
- let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
- let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
- let platform = crate::shortcuts::platform();
- use figcraft_engine::keymap::{Context, lookup, normalize};
- // Text editing keys go to the editor (after the shortcuts live while editing).
- if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
- if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
- crate::menus::invoke(app, bd.id);
- return Ok(json!({"command": bd.id}));
- }
- let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
- figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
- return Ok(json!({"ok": true}));
- }
- // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
- if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
- return Ok(json!({"ok": true}));
- }
- // The Comment tool's arrows step through the comment threads.
- if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
- let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
- return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
- }
- if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
- crate::shortcuts::run_binding(app, bd.id);
- return Ok(json!({"command": bd.id}));
- }
- let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
- match name {
- "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
- let (dx, dy) = match name {
- "ArrowLeft" => (-1, 0),
- "ArrowRight" => (1, 0),
- "ArrowUp" => (0, -1),
- _ => (0, 1),
- };
- let (cmd, params) = if m.cmd || m.ctrl {
- ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
- } else {
- ("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
- };
- app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
- }
- _ => {
- // Number keys: opacity (two in quick succession make an exact percentage). A
- // hidden window's logic tick has a stale `ctx.input(i.time)`.
- let now = app.clock.now();
- match egui::Key::from_name(name) {
- Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
- _ => Err(format!("no shortcut `{key}`")),
- }
- }
- }
- }
- "ui.input" => {
- let list = p
- .get("events")
- .and_then(Value::as_array)
- .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
- let pos = |v: &Value| -> Option {
- let a = v.as_array()?;
- let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
- p.is_finite().then_some(p)
- };
- let mut events = Vec::new();
- let mut pads = Vec::new();
- for e in list.iter().take(256) {
- let button = if e.get("button").and_then(Value::as_str) == Some("secondary") {
- egui::PointerButton::Secondary
- } else {
- egui::PointerButton::Primary
- };
- let command = b(e, "cmd") || b(e, "ctrl");
- let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
- if let Some(p) = e.get("move").and_then(pos) {
- events.push(egui::Event::PointerMoved(p));
- } else if let Some((p, pressed)) =
- e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false)))
- {
- events.push(egui::Event::PointerMoved(p));
- events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
- } else if let Some(k) = e.get("key").and_then(Value::as_str) {
- let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
- for pressed in [true, false] {
- events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
- }
- } else if let Some(t) = e.get("text").and_then(Value::as_str) {
- events.push(egui::Event::Text(t.to_string()));
- } else if let Some(d) = e.get("scroll").and_then(pos) {
- // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
- events.push(egui::Event::MouseWheel {
- unit: egui::MouseWheelUnit::Point,
- source: egui::MouseWheelSource::Wheel,
- delta: d.to_vec2(),
- phase: egui::TouchPhase::Move,
- modifiers: mods,
- });
- } else if let Some(g) = e.get("gamepad") {
- let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
- pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
- } else if b(e, "leave") {
- events.push(egui::Event::PointerGone);
- } else if let Some(h) = e.get("hold") {
- let held = egui::Modifiers {
- alt: b(h, "alt"),
- ctrl: b(h, "cmd") || b(h, "ctrl"),
- shift: b(h, "shift"),
- mac_cmd: false,
- command: b(h, "cmd") || b(h, "ctrl"),
- };
- events.push(egui::Event::ModifiersChanged(held));
- } else {
- return Err(format!("unknown input event {e}"));
- }
- }
- // Controller buttons go straight to the prototype playing (its next frame takes them).
- if !pads.is_empty() {
- match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
- (Some(p), _) => p.pads.extend(pads),
- (None, Some(pane)) => pane.player.pads.extend(pads),
- (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
- }
- }
- app.control_input_queued = app.push_control_input(events);
- ctx.request_repaint();
- Ok(json!({"queued": list.len().min(256)}))
- }
+ "ui.key" => ui_key(app, ctx, p),
+ "ui.input" => ui_input(app, ctx, p),
"ui.type" => {
let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?;
figcraft_engine::textedit::insert(&mut app.session, text).map_err(|e| e.to_string())?;
@@ -1111,270 +1393,11 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(json!({"ok": true, "lastError": app.ui.last_error}))
}
"ui.set" => {
- if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
- app.ui.right_tab = match t {
- "prototype" => crate::RightTab::Prototype,
- "inspect" => crate::RightTab::Inspect,
- _ => crate::RightTab::Design,
- };
- }
- if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
- app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
- }
- if let Some(t) = p.get("theme").and_then(Value::as_str) {
- app.session.prefs.theme = t.to_string();
- }
- if let Some(h) = p.get("home").and_then(Value::as_bool) {
- app.ui.home = h;
- }
- if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
- app.ui.home_view.section = section;
- }
- if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
- app.ui.home_view.file_query = query.chars().take(256).collect();
- }
- if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
- view.store(&mut app.session.prefs);
- }
- if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
- crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
- }
- if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
- let sort = crate::home_recents::Sort::of(&app.session.prefs);
- crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
- }
- if let Some(h) = p.get("palette").and_then(Value::as_bool) {
- crate::menus::set_palette(&mut app.ui, h);
- }
- // true / false, or a top-level menu to open it at ("Plugins").
- match p.get("mainMenu") {
- Some(Value::Bool(h)) => app.ui.main_menu = *h,
- Some(Value::String(top)) => {
- app.ui.main_menu = true;
- crate::menus::open_main_menu(ctx, top);
- }
- _ => {}
- }
- if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
- app.ui.plugins_window = h;
- }
- // The Plugins window's tab ("installed", "browse"), and the plugin of the listed
- // directory whose install asks for confirmation (null: none).
- if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) {
- app.ui.plugins_manage.tab =
- if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed };
- }
- match p.get("pluginConfirm") {
- Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()),
- Some(Value::Null) => app.ui.plugins_manage.confirm = None,
- _ => {}
- }
- // A plugin command's parameters form: {id, command?}; null closes it.
- match p.get("pluginForm") {
- Some(Value::Null) => app.ui.plugin_form = None,
- Some(v @ Value::Object(_)) => {
- let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
- let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
- crate::plugins_ui::start(app, id, command);
- }
- _ => {}
- }
- // Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
- if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
- app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
- }
- // A right-click on the canvas at screen [sx, sy] (null closes the menu);
- // `contextSelectLayer` opens its Select layer submenu.
- match p.get("contextMenu") {
- Some(Value::Array(at)) => {
- let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
- crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
- }
- Some(Value::Null) => app.ui.context_menu = None,
- _ => {}
- }
- if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
- crate::menus::open_select_layer(ctx, v);
- }
- if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
- app.ui.icon_gallery = h;
- }
- // Comment thread popover: an id opens it, null closes it.
- if let Some(v) = p.get("commentOpen") {
- match v.as_u64() {
- Some(id) => {
- // Pins are drawn for the current page only: reveal the thread's page first.
- let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
- if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
- app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
- }
- app.ui.comments.show(id);
- }
- None => app.ui.comments.close(),
- }
- }
- if let Some(v) = p.get("commentDraft") {
- match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
- (Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
- _ => app.ui.comments.draft = None,
- }
- }
- if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
- app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
- }
- if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
- app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
- }
- if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
- app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
- }
- if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
- app.ui.comments.search = v.to_string();
- }
- // The open thread's reply field, with the cursor at its end (an `@` there offers names).
- if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
- app.ui.comments.reply = v.to_string();
- if let Some(id) = app.ui.comments.open {
- crate::comments::focus_reply(ctx, id, v.chars().count());
- }
- }
- // Edit a message of the open thread: its index (0: the first message); null stops.
- if let Some(v) = p.get("commentEdit") {
- app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
- (Some(id), Some(i)) => {
- let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
- Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
- }
- _ => None,
- };
- }
- if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
- app.ui.present_view.comments = v;
- if let Some(pres) = app.ui.present.as_mut() {
- pres.view.comments = v;
- }
- }
- // Colour picker on the selection's paint `{fills?, index?}`; null closes it.
- if let Some(v) = p.get("colorPicker") {
- app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
- fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
- index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
- ids: app.session.selection(),
- });
- }
- // The Variables editor: true / a collection (id or name) / {library, collection?} (an
- // enabled library's, by id or name) opens it, false closes it.
- match p.get("variables") {
- Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
- Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
- Some(v @ Value::Object(_)) => {
- let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
- app.ui.variables = Some(match text("library") {
- Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
- None => crate::variables_panel::Editor::on(text("collection")),
- });
- }
- _ => {}
- }
- // Layers panel search: a string opens it with that filter, null closes it.
- if let Some(v) = p.get("layerFilter") {
- app.ui.layer_search = v.is_string();
- app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
- }
- if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
- app.ui.expanded.clear();
- }
- // The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
- match p.get("protoEditor") {
- Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
- Some(Value::Array(a)) => {
- let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
- let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
- crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
- }
- _ => {}
- }
- // The prototype preview pane: true / a frame id opens it, false or null closes it.
- match p.get("preview") {
- Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
- Some(v @ (Value::Bool(true) | Value::Number(_))) => {
- let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
- app.session.execute("proto.preview", ¶ms).map_err(|e| e.to_string())?;
- app.handle_ui_requests();
- }
- _ => {}
- }
- // Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
- if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
- let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
- let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
- (Some(p), _) => Some(p),
- (None, Some(pane)) => Some(&mut pane.player),
- _ => None,
- };
- player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
- }
- // The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
- // closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
- // is `list` or `grid`.
- if let Some(v) = p.get("stylePicker") {
- match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
- Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
- None => app.ui.style_picker = None,
- }
- }
- if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
- app.ui.style_grid = v == "grid";
- }
- // The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
- if let Some(v) = p.get("colorProfileDialog") {
- app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
- }
- if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
- app.ui.export_thumb = v;
- }
- // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion`
- // the saved version it compares with (null: when it was marked ready for dev),
- // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
- if let Some(v) = p.get("devCompare") {
- app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None));
- }
- if let Some(c) = app.ui.dev.compare.as_mut() {
- if let Some(v) = p.get("devCompareVersion") {
- c.version = v.as_str().map(str::to_string);
- }
- match p.get("devCompareMode").and_then(Value::as_str) {
- Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
- Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
- Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
- None => {}
- }
- if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
- c.split = x.clamp(0.0, 1.0) as f32;
- }
- }
- // The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
- // chooses sides ({: "branch" | "main"}).
- if let Some(v) = p.get("branchReview") {
- match v.as_str() {
- Some(id) => crate::branch_ui::open(app, Some(id)),
- None => app.ui.branch_review = None,
- }
- }
- if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
- for (k, v) in picks {
- let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
- r.picks.insert(k.clone(), side);
- }
- }
- // Which annotations the canvas shows: all, none (uncategorised) or a category.
- if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
- let doc = app.session.doc().map_err(|e| e.to_string())?;
- app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
- }
- if b(p, "resetFrameStats") {
- app.frame_stats.reset();
- }
+ ui_set_view(app, ctx, p)?;
+ ui_set_plugins(app, ctx, p)?;
+ ui_set_comments(app, ctx, p)?;
+ ui_set_panels(app, ctx, p)?;
+ ui_set_styles(app, p)?;
Ok(app.inspect())
}
"ui.render" => {
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index 012dd923..fd7b529c 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -968,6 +968,25 @@ fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() {
}
}
+/// The save shortcut's key events (and, routing by tab, a click on the tab at `tab_at`), with the modifiers held.
+fn save_events(route: &str, save_as: bool, tab_at: egui::Pos2) -> (Vec, Modifiers) {
+ let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
+ if save_as {
+ modifiers = modifiers.plus(Modifiers::SHIFT);
+ }
+ let mut events = vec![
+ Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
+ Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
+ ];
+ if route == "tab" {
+ events.push(Event::PointerMoved(tab_at));
+ for pressed in [true, false] {
+ events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
+ }
+ }
+ (events, modifiers)
+}
+
/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A
/// must not commit A's cached X editor into B when routing presents B before the fields draw,
/// even though both panels use the same widget ID.
@@ -1031,21 +1050,9 @@ fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard:
} else {
None
};
- let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
- if save_as {
- modifiers = modifiers.plus(Modifiers::SHIFT);
- }
- let mut events = vec![
- Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
- Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
- ];
- if route == "tab" {
- events.push(Event::PointerMoved(tab_at));
- for pressed in [true, false] {
- events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
- }
- }
+ let (events, modifiers) = save_events(route, save_as, tab_at);
if discard {
+ let mut events = events;
events.insert(0, Event::ModifiersChanged(modifiers));
u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() });
} else {
diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs
index 1d088c91..a96314cf 100644
--- a/crates/ui-egui/src/icons/draw.rs
+++ b/crates/ui-egui/src/icons/draw.rs
@@ -1,4 +1,6 @@
-use super::*;
+use super::{
+ Color32, FRAC_PI_2, FRAC_PI_4, Icon, Mesh, PI, Painter, Pos2, Rect, Shape, Stroke, TAU, Vec2, arc_pts, capsule, pos2, triangulate, vec2,
+};
struct P<'a> {
painter: &'a Painter,
@@ -190,6 +192,89 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
draw(&p, icon);
}
+/// An open hand: four fingers (the middle one tallest), then the palm and the thumb.
+fn hand(p: &P) {
+ for (x, top) in [(4.5, 5.0), (6.5, 3.5), (8.5, 4.0), (10.5, 5.5)] {
+ let mut f = vec![(x, 8.0)];
+ f.extend(arc_pts(x + 1.0, top, 1.0, PI, TAU));
+ f.push((x + 2.0, 8.0));
+ p.path(&f, false);
+ }
+ let mut h = vec![(12.5, 8.0), (12.5, 10.0)];
+ h.extend(arc_pts(9.5, 10.0, 3.0, 0.0, FRAC_PI_2));
+ h.extend([(8.0, 13.0), (3.0, 9.4)]);
+ h.extend(arc_pts(3.6, 8.6, 1.0, 2.2, 5.3));
+ h.push((4.5, 8.5));
+ p.path(&h, false);
+}
+
+/// A curve with a smooth point and its handle line.
+fn bend(p: &P) {
+ let pts: Vec<(f32, f32)> = (0..=16)
+ .map(|i| {
+ let t = i as f32 / 16.0;
+ let u = 1.0 - t;
+ (u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.5 + 2.0 * u * t * 1.5 + t * t * 13.5)
+ })
+ .collect();
+ p.path(&pts, false);
+ p.line(3.0, 7.5, 13.0, 7.5);
+ p.dot(3.0, 7.5, 1.3);
+ p.dot(13.0, 7.5, 1.3);
+ p.fill_rect(7.0, 6.0, 9.0, 9.0, 0.0);
+}
+
+/// A five-pointed star, filled.
+fn star(p: &P) {
+ let pts: Vec<(f32, f32)> = (0..10)
+ .map(|i| {
+ let a = -FRAC_PI_2 + PI * i as f32 / 5.0;
+ let r = if i % 2 == 0 { 6.0 } else { 2.7 };
+ (8.0 + r * a.cos(), 8.6 + r * a.sin())
+ })
+ .collect();
+ p.path(&pts, true);
+}
+
+/// The Component icon.
+fn draw_component(p: &P) {
+ for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] {
+ p.fill(&[(cx, cy - 2.5), (cx + 2.5, cy), (cx, cy + 2.5), (cx - 2.5, cy)]);
+ }
+}
+
+/// The ComponentSet icon.
+fn draw_componentset(p: &P) {
+ for (cx, cy) in [(8.0, 4.3), (11.7, 8.0), (8.0, 11.7), (4.3, 8.0)] {
+ p.path(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true);
+ }
+}
+
+/// The Group icon.
+fn draw_group(p: &P) {
+ for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
+ p.poly(&[(x + 3.5 * dx, y), (x, y), (x, y + 3.5 * dy)], false);
+ }
+}
+
+/// The Grid/LayoutGrid icons.
+fn draw_grid_layoutgrid(p: &P) {
+ for i in [2.5, 6.5, 10.5] {
+ for j in [2.5, 6.5, 10.5] {
+ p.rect(i, j, i + 3.0, j + 3.0, 0.0);
+ }
+ }
+}
+
+/// The FlipH icon.
+fn draw_fliph(p: &P) {
+ for y in [1.0, 5.0, 9.0, 13.0] {
+ p.line(7.5, y, 7.5, y + 2.0);
+ }
+ p.poly(&[(5.5, 3.5), (5.5, 12.5), (1.5, 12.5)], true);
+ p.fill(&[(9.0, 3.0), (14.0, 13.0), (9.0, 13.0)]);
+}
+
/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box.
fn draw(p: &P, icon: Icon) {
use Icon::*;
@@ -201,19 +286,7 @@ fn draw(p: &P, icon: Icon) {
p.poly(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)], false);
}
Hand => {
- // An open hand: four fingers (the middle one tallest), then the palm and the thumb.
- for (x, top) in [(4.5, 5.0), (6.5, 3.5), (8.5, 4.0), (10.5, 5.5)] {
- let mut f = vec![(x, 8.0)];
- f.extend(arc_pts(x + 1.0, top, 1.0, PI, TAU));
- f.push((x + 2.0, 8.0));
- p.path(&f, false);
- }
- let mut h = vec![(12.5, 8.0), (12.5, 10.0)];
- h.extend(arc_pts(9.5, 10.0, 3.0, 0.0, FRAC_PI_2));
- h.extend([(8.0, 13.0), (3.0, 9.4)]);
- h.extend(arc_pts(3.6, 8.6, 1.0, 2.2, 5.3));
- h.push((4.5, 8.5));
- p.path(&h, false);
+ hand(p);
}
Frame => {
// Inset a unit from the box: four long hinted strokes read heavier than the
@@ -235,19 +308,7 @@ fn draw(p: &P, icon: Icon) {
p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3));
}
Bend => {
- // A curve with a smooth point and its handle line.
- let pts: Vec<(f32, f32)> = (0..=16)
- .map(|i| {
- let t = i as f32 / 16.0;
- let u = 1.0 - t;
- (u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.5 + 2.0 * u * t * 1.5 + t * t * 13.5)
- })
- .collect();
- p.path(&pts, false);
- p.line(3.0, 7.5, 13.0, 7.5);
- p.dot(3.0, 7.5, 1.3);
- p.dot(13.0, 7.5, 1.3);
- p.fill_rect(7.0, 6.0, 9.0, 9.0, 0.0);
+ bend(p);
}
PaintBucket => {
p.poly(&[(2.5, 8.0), (7.5, 3.0), (12.5, 8.0), (7.5, 13.0)], true);
@@ -273,16 +334,7 @@ fn draw(p: &P, icon: Icon) {
}
Ellipse => p.circle(8.0, 8.0, 5.5),
Polygon => p.poly(&[(8.0, 2.5), (13.5, 12.5), (2.5, 12.5)], true),
- Star => {
- let pts: Vec<(f32, f32)> = (0..10)
- .map(|i| {
- let a = -FRAC_PI_2 + PI * i as f32 / 5.0;
- let r = if i % 2 == 0 { 6.0 } else { 2.7 };
- (8.0 + r * a.cos(), 8.6 + r * a.sin())
- })
- .collect();
- p.path(&pts, true);
- }
+ Star => star(p),
Image => {
p.rect(2.5, 3.5, 13.5, 12.5, 1.0);
p.poly(&[(2.5, 11.5), (6.0, 8.0), (9.0, 11.0), (10.5, 9.5), (13.5, 12.5)], false);
@@ -352,22 +404,10 @@ fn draw(p: &P, icon: Icon) {
p.arc(13.0, 4.5, 2.5, PI, TAU);
}
}
- Component => {
- for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] {
- p.fill(&[(cx, cy - 2.5), (cx + 2.5, cy), (cx, cy + 2.5), (cx - 2.5, cy)]);
- }
- }
- ComponentSet => {
- for (cx, cy) in [(8.0, 4.3), (11.7, 8.0), (8.0, 11.7), (4.3, 8.0)] {
- p.path(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true);
- }
- }
+ Component => draw_component(p),
+ ComponentSet => draw_componentset(p),
Instance => p.path(&[(8.0, 2.0), (14.0, 8.0), (8.0, 14.0), (2.0, 8.0)], true),
- Group => {
- for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
- p.poly(&[(x + 3.5 * dx, y), (x, y), (x, y + 3.5 * dy)], false);
- }
- }
+ Group => draw_group(p),
Vector => {
p.path(&[(3.0, 12.5), (6.0, 4.0), (10.0, 12.0), (13.0, 3.5)], false);
p.fill_rect(1.5, 11.0, 4.5, 14.0, 0.0);
@@ -413,13 +453,7 @@ fn draw(p: &P, icon: Icon) {
p.circle(7.0, 7.0, 4.5);
p.line(10.5, 10.5, 14.0, 14.0);
}
- Grid | LayoutGrid => {
- for i in [2.5, 6.5, 10.5] {
- for j in [2.5, 6.5, 10.5] {
- p.rect(i, j, i + 3.0, j + 3.0, 0.0);
- }
- }
- }
+ Grid | LayoutGrid => draw_grid_layoutgrid(p),
AlignLeft => {
p.line(2.5, 2.0, 2.5, 14.0);
p.fill_rect(4.0, 4.0, 13.0, 7.0, 0.0);
@@ -462,13 +496,7 @@ fn draw(p: &P, icon: Icon) {
}
Rotate => p.arc_arrow(8.0, 8.5, 5.0, -PI * 0.55, PI * 1.25),
Reset => p.arc_arrow(8.0, 8.5, 5.0, -PI * 0.45, -PI * 2.25),
- FlipH => {
- for y in [1.0, 5.0, 9.0, 13.0] {
- p.line(7.5, y, 7.5, y + 2.0);
- }
- p.poly(&[(5.5, 3.5), (5.5, 12.5), (1.5, 12.5)], true);
- p.fill(&[(9.0, 3.0), (14.0, 13.0), (9.0, 13.0)]);
- }
+ FlipH => draw_fliph(p),
FlipV => {
for x in [1.0, 5.0, 9.0, 13.0] {
p.line(x, 7.5, x + 2.0, 7.5);
diff --git a/crates/ui-egui/src/open.rs b/crates/ui-egui/src/open.rs
index 4fe8b9bd..47b5d575 100644
--- a/crates/ui-egui/src/open.rs
+++ b/crates/ui-egui/src/open.rs
@@ -1,7 +1,7 @@
#[cfg(target_arch = "wasm32")]
use super::services::browser_can_read;
use super::services::{ReadFuture, unreadable};
-use super::*;
+use super::{OsStr, Path, theme};
/// The file types File ▸ Open and a drop on the home screen open.
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs
index 97f94eeb..ad2c646e 100644
--- a/crates/ui-egui/src/present/actions.rs
+++ b/crates/ui-egui/src/present/actions.rs
@@ -1,4 +1,17 @@
-use super::*;
+use super::{
+ Action, Document, Easing, FrameTex, NodeId, NodeKind, OverlayAnchor, OverlayPosition, OverlayState, Presenter, Rect, ScrollAnim, Temp,
+ Transition, Trigger, Waiting,
+};
+
+#[derive(Clone, Copy)]
+struct ActionTiming {
+ transition: Transition,
+ duration: f64,
+ easing: Easing,
+ rect: Option,
+ now: f64,
+ depth: usize,
+}
impl Presenter {
/// A mouse enter, leave, down or up on `node` (shown on `root`) met its interaction `k`: its
@@ -68,12 +81,12 @@ impl Presenter {
}
pub(crate) fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64) -> bool {
- self.perform_at(a, node, tr, dur, easing, rect, now, 0)
+ self.perform_at(a, node, ActionTiming { transition: tr, duration: dur, easing, rect, now, depth: 0 })
}
/// One action; `depth` counts the conditional actions it is inside.
- #[allow(clippy::too_many_arguments)]
- fn perform_at(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64, depth: usize) -> bool {
+ fn perform_at(&mut self, a: &Action, node: NodeId, timing: ActionTiming) -> bool {
+ let ActionTiming { transition: tr, duration: dur, easing, rect, now, depth } = timing;
let Some(doc) = self.doc.clone() else { return false };
match a {
Action::Navigate { dest, preserve_scroll } => {
@@ -213,7 +226,7 @@ impl Presenter {
// Evaluated now: earlier actions of the interaction may have changed variables.
let mut did = false;
for inner in figcraft_engine::proto_run::branch(&doc, node, blocks) {
- did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1);
+ did |= self.perform_at(&inner, node, ActionTiming { depth: depth + 1, ..timing });
}
did
}
diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs
index 80386eb7..eeaba1ce 100644
--- a/crates/ui-egui/src/present/chrome.rs
+++ b/crates/ui-egui/src/present/chrome.rs
@@ -1,7 +1,10 @@
use super::layout::{Chrome, FULL, Place};
use super::player::player;
use super::scroll::page_settings;
-use super::*;
+use super::{
+ Align2, Color32, CornerRadius, Document, Easing, FigApp, HashSet, Icon, Id, NodeId, Pos2, PresentScale, Presenter, PrototypeSettings, Rect,
+ Sense, Stroke, Tokens, Transition, Ui, icons, json, pos2, regular, tr, vec2, widgets,
+};
/// What the player's top bar asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
diff --git a/crates/ui-egui/src/present/device.rs b/crates/ui-egui/src/present/device.rs
index 327fdd66..9827c225 100644
--- a/crates/ui-egui/src/present/device.rs
+++ b/crates/ui-egui/src/present/device.rs
@@ -1,5 +1,5 @@
use super::layout::{bezel, upright_w};
-use super::*;
+use super::{Color32, CornerRadius, DeviceColor, Rect, Stroke, StrokeKind, pos2, vec2};
/// The black glass around a device's screen.
const GLASS: Color32 = Color32::from_rgb(0x16, 0x16, 0x18);
diff --git a/crates/ui-egui/src/present/draw.rs b/crates/ui-egui/src/present/draw.rs
index 271f8af7..b4d67914 100644
--- a/crates/ui-egui/src/present/draw.rs
+++ b/crates/ui-egui/src/present/draw.rs
@@ -1,7 +1,7 @@
use super::actions::texture_scale;
use super::layout::{Layout, Place, hotspots};
use super::transition::{smart_interpolate, transition_frames};
-use super::*;
+use super::{ACCENT, Color32, CornerRadius, FLASH_SECS, NodeId, OverlayState, Presenter, Rect, Stroke, StrokeKind, Transition, pos2, vec2};
pub(super) fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, now: f64) {
let ppp = f64::from(ctx.pixels_per_point());
diff --git a/crates/ui-egui/src/present/input.rs b/crates/ui-egui/src/present/input.rs
index 42df2286..bfebd4b1 100644
--- a/crates/ui-egui/src/present/input.rs
+++ b/crates/ui-egui/src/present/input.rs
@@ -1,20 +1,174 @@
use super::actions::find_trigger;
use super::chrome::layer_at;
-use super::layout::{Layout, hotspots};
+use super::layout::{Layout, Place, hotspots};
use super::navigate::next_delay;
use super::scroll::{COAST_MIN, DECAY_DRAG, DECAY_WHEEL, RUBBER_PTS, clamp_scroll, page_settings, rubber_band, scrollers_at, units_per_point};
use super::transition::{direction_vec, lerp};
-use super::*;
+use super::{Document, Easing, FLASH_SECS, Fling, KeyMods, NodeId, Pos2, Presenter, Rect, Scroller, Transition, Trigger, Vec2, vec2};
/// Keep a key the prototype used from reaching the editor's shortcuts.
fn consume_key(ctx: &egui::Context, key: egui::Key) {
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, .. } if *k == key)));
}
+/// A transition a drag started follows the pointer while it stays down, then plays on from there.
+fn follow_drag(p: &mut Presenter, pos: Option, down: bool, now: f64) {
+ if let Some(a) = p.anim.as_mut()
+ && let Some((from, dir, len)) = a.follow
+ {
+ match pos.filter(|_| down) {
+ Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
+ None => a.follow = None,
+ }
+ }
+}
+
+/// A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
+/// past the ends).
+fn release_drag_scroll(p: &mut Presenter, l: &Layout, down: bool, velocity: Vec2, now: f64) {
+ if !down && let Some((t, _)) = p.drag_scroll.take() {
+ let u = units_per_point(t, l.scale);
+ let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
+ let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
+ p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
+ }
+}
+
+/// Scrolls the first of `targets` the wheel can still move, and smooths the wheel's speed over
+/// ~50 ms whatever the frame rate.
+fn wheel_scroll(p: &mut Presenter, l: &Layout, base: &Document, targets: Vec, wheel: Vec2, dt: f64) {
+ let mut moved = None;
+ for t in targets {
+ let u = units_per_point(t, l.scale);
+ let cur = p.offset(t);
+ let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(base, t));
+ if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
+ p.set_offset(t, next);
+ p.scroll_anim = None;
+ p.fling = None;
+ moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
+ break;
+ }
+ }
+ // The wheel's speed, smoothed over ~50 ms whatever the frame rate.
+ p.wheel = moved.map(|(t, inst)| {
+ let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
+ let a = 1.0 - (-dt / 0.05).exp();
+ (t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
+ });
+}
+
+/// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
+/// screen. The device body stays in place even when the drag begins outside its screen.
+/// Panning the whole view remains available only without a mobile device.
+fn begin_drag_scroll(p: &mut Presenter, doc: &Document, root: NodeId, place: &Place, start: Pos2, d: Vec2) {
+ if let Some(base) = p.doc.clone() {
+ let horizontal = d.x.abs() >= d.y.abs();
+ let scrolls = scrollers_at(p, &base, doc, root, place, start);
+ let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
+ let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
+ let target = scrolls
+ .iter()
+ .copied()
+ .find(|f| on_axis(p.range(&base, *f)))
+ .or_else(|| scrolls.first().copied())
+ .map(Scroller::Frame)
+ .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
+ if let Some(t) = target {
+ p.fling = None;
+ p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
+ }
+ }
+}
+
+/// Dragging scrolls with the pointer; past the ends it stretches.
+fn drag_scrolls(p: &mut Presenter, ctx: &egui::Context, l: &Layout, down: bool, pos: Option) {
+ if down
+ && let (Some((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
+ && let Some(base) = p.doc.as_ref()
+ {
+ let (d, u) = (q - start, units_per_point(t, l.scale));
+ let r = p.limits(base, t);
+ let limit = RUBBER_PTS * u;
+ let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
+ p.set_offset(t, to);
+ p.scroll_anim = None;
+ ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
+ }
+}
+
+/// The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
+/// full-window presenter's size options. True when `key` was used.
+fn player_key(p: &mut Presenter, ctx: &egui::Context, l: &Layout, key: &egui::Key, mods: &egui::Modifiers, pane: bool, now: f64) -> bool {
+ if mods.is_none() {
+ match key {
+ egui::Key::R => {
+ p.restart(p.start, now);
+ consume_key(ctx, *key);
+ return true;
+ }
+ // (A responsive frame filling the window has no size options.)
+ egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
+ p.set_scale(p.view.scale.next(l.device.is_some()));
+ consume_key(ctx, *key);
+ return true;
+ }
+ _ => {}
+ }
+ }
+ false
+}
+
+/// The arrows (and Space or N forward) step through top-level frames when nothing else uses
+/// them. True when `key` stepped.
+fn step_frames(p: &mut Presenter, ctx: &egui::Context, key: &egui::Key, mods: &egui::Modifiers, now: f64) -> bool {
+ let forward = match key {
+ egui::Key::ArrowRight => Some(true),
+ egui::Key::Space | egui::Key::N if mods.is_none() => Some(true),
+ egui::Key::ArrowLeft => Some(false),
+ _ => None,
+ };
+ if let Some(forward) = forward
+ && p.overlays.is_empty()
+ && let Some(base) = p.doc.as_ref()
+ {
+ let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
+ if let Some(i) = frames.iter().position(|f| *f == p.current) {
+ let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
+ if let Some(n) = next.copied() {
+ p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
+ consume_key(ctx, *key);
+ return true;
+ }
+ }
+ }
+ false
+}
+
+/// Controller buttons: pressed alone, anywhere on the screen, like keys. True when one fired.
+fn fire_pads(p: &mut Presenter, doc: &Document, root: NodeId, pads: &[String], now: f64) -> bool {
+ for name in pads {
+ let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
+ if let Some((id, k)) = found
+ && p.fire(id, k, None, now)
+ {
+ return true;
+ }
+ }
+ false
+}
+
/// Pointer, wheel, keys and timers. `over_area`: the pointer is over the player; `keys_on`: the
/// player takes the keyboard; `pane`: it plays in the preview pane (which always fits).
-#[allow(clippy::too_many_arguments)]
-pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, keys_on: bool, pane: bool, now: f64) {
+pub(super) struct InputMode {
+ pub over_area: bool,
+ pub keys_on: bool,
+ pub pane: bool,
+ pub now: f64,
+}
+
+pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, mode: InputMode) {
+ let InputMode { over_area, keys_on, pane, now } = mode;
let (pos, pressed, released, down, wheel, velocity, dt, keys) = ctx.input(|i| {
let keys: Vec<(egui::Key, egui::Modifiers)> = i
.events
@@ -39,24 +193,9 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
// Controller buttons pressed while a link notice shows are dropped, as keys are.
let pads = std::mem::take(&mut p.pads);
let pads = if p.link_notice.is_none() { pads } else { Vec::new() };
- // A transition a drag started follows the pointer while it stays down, then plays on from there.
- if let Some(a) = p.anim.as_mut()
- && let Some((from, dir, len)) = a.follow
- {
- match pos.filter(|_| down) {
- Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
- None => a.follow = None,
- }
- }
+ follow_drag(p, pos, down, now);
let pos = pos.filter(|_| over_area || p.press.is_some());
- // A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
- // past the ends).
- if !down && let Some((t, _)) = p.drag_scroll.take() {
- let u = units_per_point(t, l.scale);
- let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
- let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
- p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
- }
+ release_drag_scroll(p, l, down, velocity, now);
// The active layer: the top overlay, else the frame.
let (root, place, clip) = l.active(p.current);
let doc = &l.disp;
@@ -83,25 +222,7 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
if p.pan_range != (0.0, 0.0) {
targets.push(Scroller::View);
}
- let mut moved = None;
- for t in targets {
- let u = units_per_point(t, l.scale);
- let cur = p.offset(t);
- let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(&base, t));
- if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
- p.set_offset(t, next);
- p.scroll_anim = None;
- p.fling = None;
- moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
- break;
- }
- }
- // The wheel's speed, smoothed over ~50 ms whatever the frame rate.
- p.wheel = moved.map(|(t, inst)| {
- let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
- let a = 1.0 - (-dt / 0.05).exp();
- (t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
- });
+ wheel_scroll(p, l, &base, targets, wheel, dt);
} else if wheel == Vec2::ZERO
&& let Some((t, v)) = p.wheel.take()
&& v.0.hypot(v.1) > COAST_MIN * units_per_point(t, l.scale)
@@ -202,37 +323,10 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
// screen. The device body stays in place even when the drag begins outside its screen.
// Panning the whole view remains available only without a mobile device.
- if let Some(base) = p.doc.clone() {
- let horizontal = d.x.abs() >= d.y.abs();
- let scrolls = scrollers_at(p, &base, doc, root, &place, start);
- let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
- let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
- let target = scrolls
- .iter()
- .copied()
- .find(|f| on_axis(p.range(&base, *f)))
- .or_else(|| scrolls.first().copied())
- .map(Scroller::Frame)
- .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
- if let Some(t) = target {
- p.fling = None;
- p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
- }
- }
+ begin_drag_scroll(p, doc, root, &place, start, d);
}
// Dragging scrolls with the pointer; past the ends it stretches.
- if down
- && let (Some((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
- && let Some(base) = p.doc.as_ref()
- {
- let (d, u) = (q - start, units_per_point(t, l.scale));
- let r = p.limits(base, t);
- let limit = RUBBER_PTS * u;
- let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
- p.set_offset(t, to);
- p.scroll_anim = None;
- ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
- }
+ drag_scrolls(p, ctx, l, down, pos);
if released && p.press.is_some() {
let dragged = p.dragged;
p.press = None;
@@ -262,14 +356,8 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
}
}
}
- // Controller buttons: pressed alone, anywhere on the screen, like keys.
- for name in &pads {
- let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
- if let Some((id, k)) = found
- && p.fire(id, k, None, now)
- {
- return;
- }
+ if fire_pads(p, doc, root, &pads, now) {
+ return;
}
// Keys.
for (key, mods) in &keys {
@@ -282,45 +370,8 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
}
continue;
}
- // The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
- // full-window presenter's size options.
- if mods.is_none() {
- match key {
- egui::Key::R => {
- p.restart(p.start, now);
- consume_key(ctx, *key);
- return;
- }
- // (A responsive frame filling the window has no size options.)
- egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
- p.set_scale(p.view.scale.next(l.device.is_some()));
- consume_key(ctx, *key);
- return;
- }
- _ => {}
- }
- }
- // The arrows (and Space or N forward) step through top-level frames when nothing else
- // uses them.
- let forward = match key {
- egui::Key::ArrowRight => Some(true),
- egui::Key::Space | egui::Key::N if mods.is_none() => Some(true),
- egui::Key::ArrowLeft => Some(false),
- _ => None,
- };
- if let Some(forward) = forward
- && p.overlays.is_empty()
- && let Some(base) = p.doc.as_ref()
- {
- let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
- if let Some(i) = frames.iter().position(|f| *f == p.current) {
- let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
- if let Some(n) = next.copied() {
- p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
- consume_key(ctx, *key);
- return;
- }
- }
+ if player_key(p, ctx, l, key, mods, pane, now) || step_frames(p, ctx, key, mods, now) {
+ return;
}
}
// After delay (timers start when the frame appears; not while animating).
diff --git a/crates/ui-egui/src/present/layout.rs b/crates/ui-egui/src/present/layout.rs
index 9584fc81..1844c4f3 100644
--- a/crates/ui-egui/src/present/layout.rs
+++ b/crates/ui-egui/src/present/layout.rs
@@ -1,5 +1,8 @@
use super::scroll::{clamp_scroll, page_settings};
-use super::*;
+use super::{
+ Device, DeviceColor, Document, NodeId, OverlayState, Point, Pos2, PresentScale, PresentView, Presenter, Rect, Scroller, Vec2,
+ anchored_overlay_rect, pos2, vec2,
+};
/// Page → screen mapping for one presented frame.
#[derive(Clone, Copy, Debug)]
diff --git a/crates/ui-egui/src/present/navigate.rs b/crates/ui-egui/src/present/navigate.rs
index a320e80d..75969485 100644
--- a/crates/ui-egui/src/present/navigate.rs
+++ b/crates/ui-egui/src/present/navigate.rs
@@ -1,5 +1,5 @@
use super::scroll::{apply_scroll, clamp_scroll, fit_viewport, page_settings, raise_fixed, viewport};
-use super::*;
+use super::{Anim, Arc, Document, Easing, NodeId, Presenter, Scroller, Snapshot, Transition, Trigger, Value, Visit, json};
impl Presenter {
/// Close the top overlay; it animates out with its opening transition reversed.
diff --git a/crates/ui-egui/src/present/player.rs b/crates/ui-egui/src/present/player.rs
index dc47baa7..006d9521 100644
--- a/crates/ui-egui/src/present/player.rs
+++ b/crates/ui-egui/src/present/player.rs
@@ -1,12 +1,15 @@
use super::chrome::{BarAction, layer_at, paint_menu_chevron};
use super::device::{device_body, device_glass};
use super::draw::draw;
-use super::input::input;
+use super::input::{InputMode, input};
use super::layout::{Chrome, Layout, layout};
use super::navigate::next_delay;
use super::scroll::page_settings;
use super::transition::lerp;
-use super::*;
+use super::{
+ ACCENT, Align2, Arc, Color32, CornerRadius, Device, Icon, NodeKind, PresentScale, Presenter, Rect, Sense, Stroke, StrokeKind, Tip, Tokens, Ui,
+ icons, pos2, regular, semibold, tr, tr_noop, vec2,
+};
/// One frame of the player in `rect`: the top bar, then the prototype (on its device) below.
/// Returns what the bar asked for (restarting is done here).
@@ -55,7 +58,7 @@ pub(super) fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrom
// The pane only takes the keyboard while the pointer is over it. While a link notice shows
// the prototype takes neither keys nor the pointer (Escape there cancels the notice).
let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over);
- input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now);
+ input(p, &ctx, &l, InputMode { over_area: over && p.link_notice.is_none(), keys_on: keys, pane: chrome.pane, now });
} else {
p.pads.clear();
}
diff --git a/crates/ui-egui/src/present/scroll.rs b/crates/ui-egui/src/present/scroll.rs
index 12c682cc..ea3db153 100644
--- a/crates/ui-egui/src/present/scroll.rs
+++ b/crates/ui-egui/src/present/scroll.rs
@@ -1,5 +1,8 @@
use super::layout::Place;
-use super::*;
+use super::{
+ Affine, Document, HConstraint, HashMap, NodeId, NodeKind, Overflow, Pos2, Presenter, PrototypeSettings, ScrollMove, ScrollPosition, Scroller,
+ VConstraint,
+};
/// How quickly a fling slows down after a drag (1/s; it travels its speed / decay).
pub(super) const DECAY_DRAG: f64 = 2.5;
diff --git a/crates/ui-egui/src/present/transition.rs b/crates/ui-egui/src/present/transition.rs
index e31078d2..09403c1f 100644
--- a/crates/ui-egui/src/present/transition.rs
+++ b/crates/ui-egui/src/present/transition.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Affine, BTreeMap, Direction, Document, HashMap, HashSet, Node, NodeId, OverlayPosition, Paint, PaintKind, Point, Rect, Transition, Vec2,
+ VecDeque, pos2,
+};
// ---------------------------------------------------------------------------------------------
// Pure prototype logic (tested below).
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
index d7490de4..e5f915b2 100644
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, FigApp, Future, PathBuf, Pin, Session, Value, Wake, i18n, json};
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
pub trait Services {
diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs
index 6f17910a..1a4c9f9b 100644
--- a/crates/ui-egui/src/tabs.rs
+++ b/crates/ui-egui/src/tabs.rs
@@ -105,21 +105,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let rect = ui.max_rect();
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 };
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
- // Home.
- let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
- let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
- let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
- crate::a11y::tab(&hresp, tr("Home"), home_active);
- if home_active {
- ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
- } else if hresp.hovered() {
- ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover);
- }
- icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
- if hresp.clicked() {
- pause_present(app);
- app.ui.home = true;
- }
+ home_tab(app, ui, &t, x, rect);
x += 44.0;
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect();
@@ -133,15 +119,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
motion.begin_frame(current_uids, now, reduce_motion);
motion.opening.clone()
});
- let preferred: Vec = document_tabs
- .iter()
- .map(|&i| {
- let d = &app.session.docs[i];
- let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
- let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
- (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
- })
- .collect();
+ let preferred: Vec = document_tabs.iter().map(|&i| preferred_width(app, i)).collect();
// Leave the new-tab button, theme toggle, window controls and a draggable gap free.
let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0);
let present_pref: Vec = app.ui.presents.iter().map(present_tab_width).collect();
@@ -172,25 +150,17 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let name = &d.doc.name;
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
- let mark_w = if live.is_some() { 18.0 } else { 0.0 };
let dirty = d.is_dirty();
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.scope_id().with(("tab", d.uid)), Sense::click_and_drag());
let cresp = ui.interact(close_r, ui.scope_id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
- if (resp.dragged() || resp.drag_stopped())
- && let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
- {
- if resp.drag_stopped() {
- if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
- detach = Some((i, Some(p)));
- } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
- dropped = Some((i, p.x));
- }
- } else {
- dragging = Some((i, p));
- }
+ match tab_gesture(ui, &resp, rect, several) {
+ Some(TabGesture::Detach(p)) => detach = Some((i, Some(p))),
+ Some(TabGesture::Drop(x)) => dropped = Some((i, x)),
+ Some(TabGesture::Drag(p)) => dragging = Some((i, p)),
+ None => {}
}
if several {
resp.context_menu(|ui| {
@@ -201,51 +171,20 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
});
}
- let label = match live {
- Some(true) => trf("{name}, in a live session", &[("name", name)]),
- Some(false) => trf("{name}, online, open only to you", &[("name", name)]),
- None => name.clone(),
- };
+ let label = tab_label(name, live);
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = open_progress(&opening, d.uid, now);
- let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
- let painter = ui.painter().with_clip_rect(r);
- if active {
- painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
- } else if resp.hovered() || cresp.hovered() {
- painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
- }
- icons::paint(
- &painter,
- Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
- icons::file_mark(d.doc.kind),
- crate::theme::ACCENT.linear_multiply(progress),
- );
- // The unsaved-changes dot follows the name on screen, whichever way the name reads.
- let shown = crate::bidi::paint_name(
- &painter,
- pos2(paint_r.min.x + 32.0, paint_r.center().y),
+ let tab = TabPaint {
name,
- regular(12.0),
- t.text.linear_multiply(progress),
- (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
- );
- if let Some(shared) = live {
- let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
- icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
- }
- if dirty {
- painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
- }
- let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
- // The close target never overlaps the draggable body, including on inactive tabs.
- if resp.hovered() || cresp.hovered() || active {
- if cresp.hovered() {
- painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
- }
- icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
- }
+ kind: d.doc.kind,
+ rects: [r, close_r],
+ width: w,
+ state: [active, resp.hovered(), cresp.hovered(), dirty],
+ live,
+ progress,
+ };
+ paint_tab(ui, &t, &tab);
if cresp.clicked() {
close = Some(i);
}
@@ -267,26 +206,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to)));
if layout.overflow {
- let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
- let resp = ui.interact(r, ui.scope_id().with("tab_overflow"), Sense::click()).tip(tr("Open files"));
- crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp));
- if resp.hovered() {
- ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
- }
- icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
- crate::right::menu_popup(&resp).show(|ui| {
- egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
- for &i in &document_tabs {
- let Some(d) = app.session.docs.get(i) else { continue };
- let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
- let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
- if crate::right::menu_check(ui, on, &label, "") {
- switch = Some(i);
- ui.close();
- }
- }
- });
- });
+ let resp = overflow_button(ui, &t, pos2(x, rect.min.y), "tab_overflow", tr("Open files"));
+ switch = doc_overflow_menu(app, &resp, &document_tabs).or(switch);
x += OVERFLOW_WIDTH;
}
// Presentations live in their own tabs. Switching back to the design keeps the player
@@ -306,26 +227,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border));
}
if present_layout.overflow {
- let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
- let resp = ui.interact(r, ui.scope_id().with("present_overflow"), Sense::click()).tip(tr("Open presentations"));
- crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp));
- if resp.hovered() {
- ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
- }
- icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
- crate::right::menu_popup(&resp).show(|ui| {
- egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
- for tab in &app.ui.presents {
- let on = present_on == Some(tab.id);
- if crate::right::menu_check(ui, on, &present_tab_name(tab), "") {
- if !on {
- to_present = Some(tab.id);
- }
- ui.close();
- }
- }
- });
- });
+ let resp = overflow_button(ui, &t, pos2(x, rect.min.y), "present_overflow", tr("Open presentations"));
+ to_present = present_overflow_menu(app, &resp, present_on).or(to_present);
x += OVERFLOW_WIDTH;
}
// New tab.
@@ -340,28 +243,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.run("file.new", json!({}));
app.ui.home = false;
}
- // Right side: theme toggle (left of the window buttons).
- let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
- let tresp = ui.interact(theme_r, ui.scope_id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
- if tresp.hovered() {
- ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
- }
- icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
- if tresp.clicked() {
- let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
- app.run(cmd, json!({}));
- }
- // Only the free space drags the window. A full-bar drag target steals tab drags.
- if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x {
- let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y));
- let drag = ui.interact(free, ui.scope_id().with("titlebar_drag"), Sense::click_and_drag());
- if drag.drag_started() {
- ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
- }
- if drag.double_clicked() {
- let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
- ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
- }
+ let theme_r = theme_toggle(app, ui, &t, rect, buttons_w);
+ if app.ui.title_bar != TitleBar::Native {
+ drag_free_space(ui, rect, x + 34.0, theme_r.min.x);
}
if let Some(i) = switch {
pause_present(app);
@@ -374,11 +258,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
show_present(app, id);
}
if let Some((i, at)) = detach {
- // Where the pointer let go, on the screen.
- let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
- if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
- app.toast(e);
- }
+ detach_tab(app, ui, i, at);
}
if let Some((from, to)) = reorder
&& from != to
@@ -386,20 +266,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.run("file.moveTab", json!({"from": from, "to": to}));
}
if let Some(i) = close {
- // A branch tab's edits live on in its main file: closing it loses nothing.
- let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
- if dirty {
- app.ui.find = None;
- app.toast(trf(
- "Unsaved changes: save first, or close again with {key} to discard",
- &[("key", &crate::toolbar::pretty_shortcut("Alt"))],
- ));
- if ui.input(|inp| inp.modifiers.alt) {
- app.run("file.close", json!({"index": i, "force": true}));
- }
- } else {
- app.run("file.close", json!({"index": i}));
- }
+ close_tab(app, ui, i);
}
if !opening.is_empty() {
ui.ctx().request_repaint();
@@ -407,6 +274,233 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
});
}
+/// The chevron button at `at` that opens a menu of the tabs that did not fit.
+fn overflow_button(ui: &mut Ui, t: &Tokens, at: egui::Pos2, salt: &str, label: &'static str) -> egui::Response {
+ let r = Rect::from_min_size(at, vec2(OVERFLOW_WIDTH, HEIGHT));
+ let resp = ui.interact(r, ui.scope_id().with(salt), Sense::click()).tip(label);
+ crate::a11y::menu_button(&resp, label, crate::a11y::menu_open(&resp));
+ if resp.hovered() {
+ ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
+ }
+ icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
+ resp
+}
+
+/// Only the free space between `from_x` and `to_x` drags the window (a full-bar drag target would
+/// steal tab drags); double click maximizes.
+fn drag_free_space(ui: &mut Ui, rect: Rect, from_x: f32, to_x: f32) {
+ if from_x >= to_x {
+ return;
+ }
+ let free = Rect::from_min_max(pos2(from_x, rect.min.y), pos2(to_x, rect.max.y));
+ let drag = ui.interact(free, ui.scope_id().with("titlebar_drag"), Sense::click_and_drag());
+ if drag.drag_started() {
+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
+ }
+ if drag.double_clicked() {
+ let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
+ }
+}
+
+/// Closes tab `i`; unsaved changes need a second close with Alt held.
+fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) {
+ // A branch tab's edits live on in its main file: closing it loses nothing.
+ let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
+ if dirty {
+ app.ui.find = None;
+ app.toast(trf("Unsaved changes: save first, or close again with {key} to discard", &[("key", &crate::toolbar::pretty_shortcut("Alt"))]));
+ if ui.input(|inp| inp.modifiers.alt) {
+ app.run("file.close", json!({"index": i, "force": true}));
+ }
+ } else {
+ app.run("file.close", json!({"index": i}));
+ }
+}
+
+/// The Home tab at `x`.
+fn home_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens, x: f32, rect: Rect) {
+ let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
+ let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
+ let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
+ crate::a11y::tab(&hresp, tr("Home"), home_active);
+ if home_active {
+ ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
+ } else if hresp.hovered() {
+ ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover);
+ }
+ icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
+ if hresp.clicked() {
+ pause_present(app);
+ app.ui.home = true;
+ }
+}
+
+/// The theme toggle, left of the window buttons; returns its rect.
+fn theme_toggle(app: &mut FigApp, ui: &mut Ui, t: &Tokens, rect: Rect, buttons_w: f32) -> Rect {
+ let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
+ let tresp = ui.interact(theme_r, ui.scope_id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
+ if tresp.hovered() {
+ ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
+ }
+ icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
+ if tresp.clicked() {
+ let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
+ app.run(cmd, json!({}));
+ }
+ theme_r
+}
+
+/// What a drag on a document tab is doing.
+enum TabGesture {
+ /// Released far off the strip: the tab moves to a window of its own, at this point.
+ Detach(egui::Pos2),
+ /// Released on the strip, at this x.
+ Drop(f32),
+ /// Still dragging, at this point.
+ Drag(egui::Pos2),
+}
+
+/// The drag on a tab body `resp` in the tab bar `rect`, if any.
+fn tab_gesture(ui: &Ui, resp: &egui::Response, rect: Rect, several: bool) -> Option {
+ if !(resp.dragged() || resp.drag_stopped()) {
+ return None;
+ }
+ let p = ui.input(|inp| inp.pointer.latest_pos())?;
+ if !resp.drag_stopped() {
+ return Some(TabGesture::Drag(p));
+ }
+ if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
+ Some(TabGesture::Detach(p))
+ } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
+ Some(TabGesture::Drop(p.x))
+ } else {
+ None
+ }
+}
+
+/// The menu of document tabs that did not fit: the one picked.
+fn doc_overflow_menu(app: &FigApp, resp: &egui::Response, document_tabs: &[usize]) -> Option {
+ let mut switch = None;
+ crate::right::menu_popup(resp).show(|ui| {
+ egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
+ for &i in document_tabs {
+ let Some(d) = app.session.docs.get(i) else { continue };
+ let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
+ let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
+ if crate::right::menu_check(ui, on, &label, "") {
+ switch = Some(i);
+ ui.close();
+ }
+ }
+ });
+ });
+ switch
+}
+
+/// The menu of presentation tabs that did not fit: the one picked (not the one already shown).
+fn present_overflow_menu(app: &FigApp, resp: &egui::Response, present_on: Option) -> Option {
+ let mut to_present = None;
+ crate::right::menu_popup(resp).show(|ui| {
+ egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
+ for tab in &app.ui.presents {
+ let on = present_on == Some(tab.id);
+ if crate::right::menu_check(ui, on, &present_tab_name(tab), "") {
+ if !on {
+ to_present = Some(tab.id);
+ }
+ ui.close();
+ }
+ }
+ });
+ });
+ to_present
+}
+
+/// Moves tab `i` to a window of its own; `at` is where the pointer let go, in the tab bar.
+fn detach_tab(app: &mut FigApp, ui: &Ui, i: usize, at: Option) {
+ // Where the pointer let go, on the screen.
+ let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
+ if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
+ app.toast(e);
+ }
+}
+
+/// How wide document tab `i` wants to be: its name, the live mark and the unsaved dot.
+fn preferred_width(app: &FigApp, i: usize) -> f32 {
+ let d = &app.session.docs[i];
+ let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
+ let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
+ (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
+}
+
+/// A tab's accessible name: `name`, and in a live session whether others get in.
+fn tab_label(name: &str, live: Option) -> String {
+ match live {
+ Some(true) => trf("{name}, in a live session", &[("name", &name)]),
+ Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
+ None => name.to_string(),
+ }
+}
+
+/// What to draw for one document tab.
+struct TabPaint<'a> {
+ name: &'a str,
+ kind: figcraft_doc::FileKind,
+ /// The tab, and its close target.
+ rects: [Rect; 2],
+ width: f32,
+ /// [active, body hovered, close hovered, unsaved changes].
+ state: [bool; 4],
+ /// In a live session: whether others get in.
+ live: Option,
+ /// How far the opening animation is (0..=1).
+ progress: f32,
+}
+
+/// Draws a document tab: background, icon, name, live mark, unsaved dot and close button.
+fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
+ let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, progress } = *tab;
+ let mark_w = if live.is_some() { 18.0 } else { 0.0 };
+ let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
+ let painter = ui.painter().with_clip_rect(r);
+ if active {
+ painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
+ } else if hovered || close_hovered {
+ painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
+ }
+ icons::paint(
+ &painter,
+ Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
+ icons::file_mark(kind),
+ crate::theme::ACCENT.linear_multiply(progress),
+ );
+ // The unsaved-changes dot follows the name on screen, whichever way the name reads.
+ let shown = crate::bidi::paint_name(
+ &painter,
+ pos2(paint_r.min.x + 32.0, paint_r.center().y),
+ name,
+ regular(12.0),
+ t.text.linear_multiply(progress),
+ (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
+ );
+ if let Some(shared) = live {
+ let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
+ icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
+ }
+ if dirty {
+ painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
+ }
+ let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
+ // The close target never overlaps the draggable body, including on inactive tabs.
+ if hovered || close_hovered || active {
+ if close_hovered {
+ painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
+ }
+ icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
+ }
+}
+
/// One presentation tab in `r`: whether it was clicked and whether it was closed (its X or a
/// middle click).
fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, r: Rect, active: bool) -> (bool, bool) {
From 95a456f39194769e1d49843e49210e9adfb4903c Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 35/60] refactor(server): simplify storage and collaboration
helpers
---
apps/figcraft-server/src/codes_tests.rs | 17 ++++---
apps/figcraft-server/src/library_tests.rs | 58 ++++++++++++-----------
apps/figcraft-server/src/store/backend.rs | 5 +-
apps/figcraft-server/src/store/files.rs | 8 +++-
apps/figcraft-server/src/store/journal.rs | 5 +-
apps/figcraft-server/src/store/library.rs | 6 ++-
apps/figcraft-server/src/store/links.rs | 5 +-
crates/collab-net/src/hub/backend.rs | 2 +-
crates/collab-net/src/hub/conn.rs | 6 ++-
crates/collab-net/src/hub/hosting.rs | 5 +-
crates/collab-net/src/hub/limits.rs | 2 +-
crates/collab-net/src/hub/queue.rs | 4 +-
12 files changed, 79 insertions(+), 44 deletions(-)
diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs
index 1e440a32..1e58f7c2 100644
--- a/apps/figcraft-server/src/codes_tests.rs
+++ b/apps/figcraft-server/src/codes_tests.rs
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
}
+/// The links file keeps hashes only, never the codes or the token they wrap.
+fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
+ let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
+ for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
+ assert!(!links.contains(secret), "{links}");
+ }
+ assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
+ assert_eq!(links.matches("\"code\"").count(), 2);
+}
+
#[test]
fn a_file_shared_online_is_joined_by_its_codes() {
let dir = TempDir::new("codes-share");
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
- let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
- for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
- assert!(!links.contains(secret), "{links}");
- }
- assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
- assert_eq!(links.matches("\"code\"").count(), 2);
+ only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
let mut b = app("Bo", &url);
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs
index f4703f20..5dd45435 100644
--- a/apps/figcraft-server/src/library_tests.rs
+++ b/apps/figcraft-server/src/library_tests.rs
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
}
}
+/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
+fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
+ let mut raw = Raw::connect(addr);
+ raw.send(&open(edit));
+ assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
+ raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
+ let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
+ let mut rng = Rng::new(5);
+ for round in 0..12 {
+ let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
+ for _ in 0..1 + rng.below(8) {
+ let i = rng.below(junk.len());
+ junk[i] = rng.next_u64() as u8;
+ }
+ raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
+ assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
+ }
+ let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
+ raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
+ assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
+ raw.send(&ClientMsg::Library(LibraryReq::Usage));
+ let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
+ let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
+ assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
+ assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
+ let mut raw = Raw::connect(addr);
+ raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
+ assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
+}
+
#[test]
fn publish_enable_update_and_usage() {
let dir = TempDir::new("library");
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
- // Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
let edit = Invite::parse(&p.edit).unwrap();
- let mut raw = Raw::connect(addr);
- raw.send(&open(&edit));
- assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
- raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
- let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
- let mut rng = Rng::new(5);
- for round in 0..12 {
- let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
- for _ in 0..1 + rng.below(8) {
- let i = rng.below(junk.len());
- junk[i] = rng.next_u64() as u8;
- }
- raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
- assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
- }
- let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
- raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
- assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
- raw.send(&ClientMsg::Library(LibraryReq::Usage));
- let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
- let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
- assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
- assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
- let mut raw = Raw::connect(addr);
- raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
- assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
+ junk_is_refused(addr, &edit);
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
// Kept on disk: the library is there after the server stops.
diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs
index af9b8bbb..95fd8948 100644
--- a/apps/figcraft-server/src/store/backend.rs
+++ b/apps/figcraft-server/src/store/backend.rs
@@ -1,5 +1,8 @@
use super::journal::flush_log;
-use super::*;
+use super::{
+ Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
+ LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
+};
impl Backend for Store {
fn admit(&mut self, file: &str, token: &str) -> Result {
diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs
index 53ae9c87..565916ed 100644
--- a/apps/figcraft-server/src/store/files.rs
+++ b/apps/figcraft-server/src/store/files.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
+ MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
+ Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
+};
impl Store {
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
@@ -222,7 +226,7 @@ impl Store {
pub fn break_log(&mut self, file: &str) {
let path = self.dir(file).join(LOG);
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
- && let Ok(f) = File::open(path)
+ && let Ok(f) = std::fs::File::open(path)
{
log.file = f;
}
diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs
index e3983543..5f8904fc 100644
--- a/apps/figcraft-server/src/store/journal.rs
+++ b/apps/figcraft-server/src/store/journal.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
+ now, write_json,
+};
impl Store {
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs
index adb592db..55ca50ad 100644
--- a/apps/figcraft-server/src/store/library.rs
+++ b/apps/figcraft-server/src/store/library.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
+ MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
+ write_json,
+};
impl Store {
/// A file's team library for `ip`, unless its version is `have`; within what one address and
diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs
index ad30f458..6c279219 100644
--- a/apps/figcraft-server/src/store/links.rs
+++ b/apps/figcraft-server/src/store/links.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
+ Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
+};
impl Store {
/// The files kept.
diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs
index 04c5363d..e594b0f1 100644
--- a/crates/collab-net/src/hub/backend.rs
+++ b/crates/collab-net/src/hub/backend.rs
@@ -1,5 +1,5 @@
use super::limits::Admission;
-use super::*;
+use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply, LibraryReq, PluginReply, PluginReq, Server};
/// An admin request's reply, and what the hub must do about connections it affects.
#[derive(Clone, Debug, PartialEq)]
diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs
index 109dfed9..895218c4 100644
--- a/crates/collab-net/src/hub/conn.rs
+++ b/crates/collab-net/src/hub/conn.rs
@@ -1,7 +1,11 @@
use super::hosting::{Open, spawn};
use super::limits::{PerAddress, ip_key};
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
-use super::*;
+use super::{
+ Arc, AtomicBool, AtomicUsize, Bucket, Bytes, ClientMsg, CloseCode, CloseFrame, Duplex, Duration, ErrorResponse, Instant, IpAddr, Message, Meter,
+ NetLimits, Ordering, Read, Readers, Receiver, RecvTimeoutError, Request, Response, Role, Sender, ServerMsg, SocketAddr, TcpListener, TcpStream,
+ WebSocket, Write, config, io, message, shutdown, sync_channel,
+};
/// How long a closing connection waits for the peer to hang up.
const LINGER: Duration = Duration::from_secs(2);
diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs
index 8588e56a..df8fd975 100644
--- a/crates/collab-net/src/hub/hosting.rs
+++ b/crates/collab-net/src/hub/hosting.rs
@@ -2,7 +2,10 @@ use super::backend::Backend;
use super::conn::{Shared, accept};
use super::limits::{PerAddress, loopback};
use super::queue::{Blocks, HubIn, InFlight};
-use super::*;
+use super::{
+ Arc, AtomicBool, AtomicUsize, ClientMsg, Duration, HashMap, Hub, Inbox, Instant, JoinHandle, MAX_NAME, NetLimits, Ordering, Presence, Readers,
+ Room, Sender, Server, SocketAddr, TcpListener, TcpStream, channel, io,
+};
/// The person hosting an app session: in the file's session without a socket.
pub(crate) struct Local {
diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs
index 333ff165..66369790 100644
--- a/crates/collab-net/src/hub/limits.rs
+++ b/crates/collab-net/src/hub/limits.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr};
/// What a link lets a hello do.
#[derive(Clone, Debug, PartialEq, Eq)]
diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs
index b6bce39e..1c6a5dfc 100644
--- a/crates/collab-net/src/hub/queue.rs
+++ b/crates/collab-net/src/hub/queue.rs
@@ -1,5 +1,7 @@
use super::limits::ip_blocks;
-use super::*;
+use super::{
+ Arc, AtomicUsize, Bytes, ClientMsg, CloseCode, HashMap, IpAddr, Mutex, Ordering, PoisonError, Presence, SyncSender, TcpStream, TrySendError,
+};
/// A message for one connection's writer: bytes (with what they count against, released when
/// they are sent or dropped), or the close.
From 8df2e4f8dc38027a1d71535ee8d7135cf7287654 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 36/60] refactor(render): pass context through export helpers
---
crates/pdf/src/draw.rs | 42 ++++++++++++++++++++++-------------
crates/pdf/src/text.rs | 42 ++++++++++++++++-------------------
crates/render/src/backdrop.rs | 35 +++++++++++++++++++++++++----
crates/render/src/draw.rs | 2 +-
crates/render/src/glass.rs | 5 +++--
crates/render/src/retained.rs | 14 +++++++++---
6 files changed, 91 insertions(+), 49 deletions(-)
diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs
index 1ff48409..95305390 100644
--- a/crates/pdf/src/draw.rs
+++ b/crates/pdf/src/draw.rs
@@ -22,6 +22,18 @@ pub(crate) const MAX_DEPTH: usize = figcraft_render::MAX_EXPORT_DEPTH;
/// Largest side of any raster made for the PDF, in pixels.
pub(crate) const MAX_RASTER_SIDE: f64 = 4096.0;
+#[derive(Clone, Copy)]
+pub(crate) struct PaintSize {
+ pub width: f64,
+ pub height: f64,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct PaintFrame {
+ pub size: PaintSize,
+ pub transform: Affine,
+}
+
pub(crate) struct Exporter<'a> {
pub doc: &'a Document,
pub opts: &'a PdfOptions,
@@ -318,7 +330,7 @@ impl<'a> Exporter<'a> {
&& closed
{
for p in &n.fills {
- self.fill(s, path, rule(*r), p, w, h, xf);
+ self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
}
}
self.place_inner(s, inner);
@@ -345,7 +357,7 @@ impl<'a> Exporter<'a> {
if let Some(g) = geom {
let area = figcraft_render::draw::stroke_area(n, &g, closed);
for p in &n.strokes {
- self.fill(s, &area, stroke_rule(n, closed), p, w, h, xf);
+ self.fill(s, &area, stroke_rule(n, closed), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
}
}
}
@@ -411,8 +423,8 @@ impl<'a> Exporter<'a> {
}
/// Fill `path` (local space, mapped to the page by `xf`) with one paint on a w×h box.
- #[allow(clippy::too_many_arguments)]
- pub(crate) fn fill(&mut self, s: &mut Surface, path: &BezPath, r: FillRule, p: &Paint, w: f64, h: f64, xf: Affine) {
+ pub(crate) fn fill(&mut self, s: &mut Surface, path: &BezPath, r: FillRule, p: &Paint, frame: PaintFrame) {
+ let PaintFrame { size: PaintSize { width: w, height: h }, transform: xf } = frame;
if !p.visible || !above(p.opacity, 0.0) {
return;
}
@@ -423,15 +435,13 @@ impl<'a> Exporter<'a> {
}
s.push_transform(&to_xf(xf));
match &p.kind {
- PaintKind::Image(img) => self.image_fill(s, &kp, r, img, p.opacity.clamp(0.0, 1.0), w, h),
+ PaintKind::Image(img) => self.image_fill(s, &kp, r, img, p.opacity.clamp(0.0, 1.0), frame.size),
PaintKind::Pattern(pt) => {
- if !self.pattern_fill(s, &kp, r, pt, p.opacity.clamp(0.0, 1.0), w, h) {
- self.raster_paint(s, path, &kp, r, p, w, h, xf);
+ if !self.pattern_fill(s, &kp, r, pt, p.opacity.clamp(0.0, 1.0), frame.size) {
+ self.raster_paint(s, path, &kp, r, p, frame);
}
}
- PaintKind::Gradient(g) if !matches!(g.kind, GradientKind::Linear | GradientKind::Radial) => {
- self.raster_paint(s, path, &kp, r, p, w, h, xf)
- }
+ PaintKind::Gradient(g) if !matches!(g.kind, GradientKind::Linear | GradientKind::Radial) => self.raster_paint(s, path, &kp, r, p, frame),
_ => {
if let Some((paint, opacity)) = vector_paint(p, w, h, self.ink()) {
s.set_fill(Some(Fill { paint, opacity, rule: r }));
@@ -475,8 +485,8 @@ impl<'a> Exporter<'a> {
}
/// An image fill; the surface is in the node's local space.
- #[allow(clippy::too_many_arguments)]
- fn image_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, img: &ImagePaint, op: f32, w: f64, h: f64) {
+ fn image_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, img: &ImagePaint, op: f32, size: PaintSize) {
+ let PaintSize { width: w, height: h } = size;
let Some((image, iw, ih)) = self.image(img) else { return };
let t = figcraft_render::paint::image_transform(img, f64::from(iw), f64::from(ih), w, h);
let t = Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]);
@@ -512,8 +522,8 @@ impl<'a> Exporter<'a> {
/// A pattern fill as a PDF tiling pattern of the source's vectors; false (draw it as an image
/// instead) when the source has effects, which the PDF draws as page-placed images.
- #[allow(clippy::too_many_arguments)]
- fn pattern_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, pt: &PatternPaint, op: f32, w: f64, h: f64) -> bool {
+ fn pattern_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, pt: &PatternPaint, op: f32, size: PaintSize) -> bool {
+ let PaintSize { width: w, height: h } = size;
let doc = self.doc;
let Some(src) = doc.node(NodeId(pt.source)).filter(|n| !n.is_page()) else { return true };
if doc.subtree(src.id).iter().filter_map(|i| doc.node(*i)).any(|n| n.effects.iter().any(|e| e.visible)) {
@@ -550,8 +560,8 @@ impl<'a> Exporter<'a> {
/// Angular and diamond gradients, and patterns of sources with effects: an image of the paint
/// over the path's bounds, clipped to it. The surface is in the node's local space; `xf` (local → page) sets the resolution.
- #[allow(clippy::too_many_arguments)]
- fn raster_paint(&mut self, s: &mut Surface, path: &BezPath, kp: &Path, r: FillRule, p: &Paint, w: f64, h: f64, xf: Affine) {
+ fn raster_paint(&mut self, s: &mut Surface, path: &BezPath, kp: &Path, r: FillRule, p: &Paint, frame: PaintFrame) {
+ let PaintFrame { size: PaintSize { width: w, height: h }, transform: xf } = frame;
let bb = path.bounding_box();
if !(bb.width() > 0.0 && bb.height() > 0.0 && crate::rect_finite(bb)) {
return;
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index cb2ec7a2..abbb65b3 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -16,7 +16,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph};
use figcraft_color::{GradientKind, Paint, PaintKind};
-use crate::draw::{Exporter, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
+use crate::draw::{Exporter, PaintFrame, PaintSize, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
use crate::raster::Raster;
/// Glyphs of one face and size on one line, written as one text run.
@@ -33,6 +33,12 @@ struct Run {
last_chars: Option<(usize, usize)>,
}
+struct GlyphSelection<'a> {
+ layout: &'a TextLayout,
+ style: usize,
+ chars: &'a [char],
+}
+
/// Draw a text node; `xf` maps its local space to the page.
pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine, inner: &[Raster]) {
let NodeKind::Text(t) = &n.kind else { return };
@@ -45,6 +51,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
record_links(ex, &layout, gxf);
}
let (w, h) = (n.width, n.height);
+ let frame = PaintFrame { size: PaintSize { width: w, height: h }, transform: gxf };
let chars: Vec = t.characters.chars().collect();
let styles: BTreeSet = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
// Every run's fills, then the inner shadows over them, then the strokes on top.
@@ -72,11 +79,11 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
if embeddable && !real_text_written && !ex.opts.outline_text && layout.on_path.is_none() {
// The first fill writes the text itself; the rest paint its outlines on top.
real_text_written = true;
- let mut rest = glyph_runs(ex, s, &layout, k, &chars, p, w, h, gxf);
+ let mut rest = glyph_runs(ex, s, GlyphSelection { layout: &layout, style: k, chars: &chars }, p, frame);
rest.extend(decorations.iter());
- ex.fill(s, &rest, FillRule::NonZero, p, w, h, gxf);
+ ex.fill(s, &rest, FillRule::NonZero, p, frame);
} else {
- ex.fill(s, &outlines, FillRule::NonZero, p, w, h, gxf);
+ ex.fill(s, &outlines, FillRule::NonZero, p, frame);
}
}
if n.has_visible_stroke() {
@@ -88,7 +95,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
for outlines in &stroked {
let area = figcraft_render::draw::stroke_area(n, outlines, true);
for p in &n.strokes {
- ex.fill(s, &area, stroke_rule(n, true), p, w, h, gxf);
+ ex.fill(s, &area, stroke_rule(n, true), p, frame);
}
}
}
@@ -147,18 +154,8 @@ impl Exporter<'_> {
/// Write the glyphs of style `style` as real text filled with `p` (layout space, mapped to the
/// page by `gxf`). Returns the outlines of glyphs that can't be written as text.
-#[allow(clippy::too_many_arguments)]
-fn glyph_runs(
- ex: &mut Exporter,
- s: &mut Surface,
- layout: &TextLayout,
- style: usize,
- chars: &[char],
- p: &Paint,
- w: f64,
- h: f64,
- gxf: Affine,
-) -> BezPath {
+fn glyph_runs(ex: &mut Exporter, s: &mut Surface, selection: GlyphSelection<'_>, p: &Paint, frame: PaintFrame) -> BezPath {
+ let GlyphSelection { layout, style, chars } = selection;
let mut rest = BezPath::new();
for line in &layout.lines {
let mut run: Option = None;
@@ -175,7 +172,7 @@ fn glyph_runs(
let continues = run.as_ref().is_some_and(|r| r.face == g.face.id && (r.size - g.size).abs() < 1e-9);
if !continues {
if let Some(r) = run.take() {
- write_run(s, r, p, w, h, gxf, ex.ink());
+ write_run(s, r, p, frame, ex.ink());
}
run = Some(Run { face: g.face.id, font, size: g.size, x0: g.x, y0: g.y, glyphs: Vec::new(), text: String::new(), last_chars: None });
}
@@ -184,7 +181,7 @@ fn glyph_runs(
}
}
if let Some(r) = run.take() {
- write_run(s, r, p, w, h, gxf, ex.ink());
+ write_run(s, r, p, frame, ex.ink());
}
}
rest
@@ -205,12 +202,11 @@ fn push_glyph(r: &mut Run, g: &Glyph, chars: &[char]) {
r.glyphs.push((g.gid, g.x, g.y, g.advance, range));
}
-#[allow(clippy::too_many_arguments)]
-fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine, ink: crate::print::Ink) {
+fn write_run(s: &mut Surface, r: Run, p: &Paint, frame: PaintFrame, ink: crate::print::Ink) {
if r.glyphs.is_empty() {
return;
}
- let Some((paint, opacity)) = vector_paint(p, w, h, ink) else { return };
+ let Some((paint, opacity)) = vector_paint(p, frame.size.width, frame.size.height, ink) else { return };
let size = r.size;
let glyphs: Vec = r
.glyphs
@@ -226,7 +222,7 @@ fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine, in
if let Some(b) = pb {
s.push_blend_mode(b);
}
- s.push_transform(&to_xf(gxf));
+ s.push_transform(&to_xf(frame.transform));
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity, rule: FillRule::NonZero }));
s.draw_glyphs(Point::from_xy(r.x0 as f32, r.y0 as f32), &glyphs, r.font, &r.text, size as f32, false);
diff --git a/crates/render/src/backdrop.rs b/crates/render/src/backdrop.rs
index bdd8405c..ef6994c6 100644
--- a/crates/render/src/backdrop.rs
+++ b/crates/render/src/backdrop.rs
@@ -19,6 +19,23 @@ use crate::shadow::{Outline, spread_alpha};
const MAX_DEPTH: usize = 256;
use crate::{View, fx, glass};
+/// The layer and backdrop geometry shared by backdrop effects.
+#[derive(Clone, Copy)]
+pub struct EffectFrame<'a> {
+ /// The layer's shape in local coordinates.
+ pub shape: &'a Outline,
+ /// Pixels behind the layer, padded beyond its bounds.
+ pub back: &'a Pixmap,
+ /// Backdrop padding in pixels on each side.
+ pub pad: u32,
+ /// Layer dimensions in pixels.
+ pub size: (u32, u32),
+ /// Local-to-layer pixel transform.
+ pub xf: Affine,
+ /// Device pixels per document unit.
+ pub scale: f64,
+}
+
/// One isolated surface of a render, and how it will be composited onto the surface below.
#[derive(Clone, Copy)]
pub struct Under<'a> {
@@ -124,8 +141,8 @@ pub fn pad(n: &Node, scale: f64) -> u32 {
/// A node's backdrop effects (background blurs and glass, in list order) for its `w` × `h` layer,
/// from `back`: its backdrop around the layer, `pad` pixels more each way. `shape` maps through
/// `xf` (local → layer pixels) at `scale` device pixels per unit. `None` when nothing shows.
-#[allow(clippy::too_many_arguments)]
-pub fn effects(n: &Node, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option {
+pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option {
+ let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
let pad_i = i32::try_from(pad).ok()?;
let mut out: Option = None;
let mut cov: Option = None;
@@ -143,7 +160,7 @@ pub fn effects(n: &Node, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u3
fx::mask_by(&mut l, cov.as_ref()?, false);
l
}
- EffectKind::Glass(g) => glass::glass(g, shape, back, pad, w, h, xf, scale)?,
+ EffectKind::Glass(g) => glass::glass(g, frame)?,
_ => continue,
};
match out.as_mut() {
@@ -228,7 +245,17 @@ pub fn effects_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View
let (ox, oy) = around.origin;
keep_inside(&mut back, Rect::new((k.x0 - ox) * s, (k.y0 - oy) * s, (k.x1 - ox) * s, (k.y1 - oy) * s));
}
- effects(n, &shape, &back, pad, view.width, view.height, view.transform() * doc.world_transform(id), view.scale)
+ effects(
+ n,
+ EffectFrame {
+ shape: &shape,
+ back: &back,
+ pad,
+ size: (view.width, view.height),
+ xf: view.transform() * doc.world_transform(id),
+ scale: view.scale,
+ },
+ )
}
/// Clear the pixels of `p` outside `r` (pixel coordinates).
diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs
index 75efae60..fa0f98ba 100644
--- a/crates/render/src/draw.rs
+++ b/crates/render/src/draw.rs
@@ -659,7 +659,7 @@ fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (
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)
+ backdrop::effects(n, backdrop::EffectFrame { shape: sh, back: &back, pad, size: (lw, lh), xf: lxf, scale: ctx.scale })
}
/// Node `n`'s layer blur, noise and texture on its `layer`, in list order.
diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs
index 3555ff44..23301b01 100644
--- a/crates/render/src/glass.rs
+++ b/crates/render/src/glass.rs
@@ -22,6 +22,7 @@ use figcraft_geom::Affine;
use figcraft_geom::kurbo::Shape as _;
use tiny_skia::Pixmap;
+use crate::backdrop::EffectFrame;
use crate::fx;
use crate::shadow::{Outline, spread_alpha};
@@ -406,8 +407,8 @@ fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc Option {
+pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option {
+ let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs
index 61f11003..620b8f56 100644
--- a/crates/render/src/retained.rs
+++ b/crates/render/src/retained.rs
@@ -344,7 +344,7 @@ fn patch(last: Frame, doc: &Arc, page: NodeId, view: &View, px: (i64,
let mut pix = last.pix;
shift(&mut pix, dx, dy)?;
for d in &damage {
- render_into(doc, page, view, px, d, margin, opts, &mut pix)?;
+ render_into(FrameRender { doc, page, view, px, opts }, d, margin, &mut pix)?;
}
Some((pix, pixels))
}
@@ -399,9 +399,17 @@ fn merge(mut rects: Vec) -> Vec {
rects
}
+struct FrameRender<'a> {
+ doc: &'a Document,
+ page: NodeId,
+ view: &'a View,
+ px: (i64, i64),
+ opts: &'a Options,
+}
+
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`.
-#[allow(clippy::too_many_arguments)]
-fn render_into(doc: &Document, page: NodeId, view: &View, px: (i64, i64), d: &Px, margin: i64, opts: &Options, pix: &mut Pixmap) -> Option<()> {
+fn render_into(frame: FrameRender<'_>, d: &Px, margin: i64, pix: &mut Pixmap) -> Option<()> {
+ let FrameRender { doc, page, view, px, opts } = frame;
let s = view.scale;
let (ox, oy) = (d.x0 - margin, d.y0 - margin);
let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?);
From 763c3a8dc4586486e513e8041209f3e609e15591 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 37/60] refactor(svg): split node rendering and text setup
---
crates/svg/src/import/text.rs | 32 +++---
crates/svg/src/lib.rs | 191 +++++++++++++++++++++-------------
2 files changed, 135 insertions(+), 88 deletions(-)
diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs
index 32d37bf1..d2a6c46e 100644
--- a/crates/svg/src/import/text.rs
+++ b/crates/svg/src/import/text.rs
@@ -271,7 +271,10 @@ fn on_path(cx: &mut Ctx, t: &usvg::Text, world: Affine, parent: NodeId) -> bool
local.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
td.path = Some(Box::new(figcraft_doc::TextPath { start: f64::from(tp.start_offset()) / len, ..figcraft_doc::TextPath::new(&local) }));
let transform = world * Affine::translate((bb.x0, bb.y0));
- add_layer(cx, t, &[line], td, transform, (bb.width().max(1.0), bb.height().max(1.0)), world, 1.0, parent);
+ add_layer(
+ cx,
+ TextLayer { source: t, block: &[line], data: td, transform, size: (bb.width().max(1.0), bb.height().max(1.0)), world, scale: 1.0, parent },
+ );
true
}
@@ -293,23 +296,24 @@ fn layer(cx: &mut Ctx, t: &usvg::Text, block: &[Line], m: Affine, s: f64, world:
let (w, h) = (lay.width.ceil().max(1.0), lay.height.max(1.0));
let baseline = lay.lines.first().map_or(0.0, |l| l.baseline);
let transform = m * Affine::translate((s * first.x - anchor_k(first.anchor) * w, s * first.y - baseline));
- add_layer(cx, t, block, td, transform, (w, h), world, s, parent);
+ add_layer(cx, TextLayer { source: t, block, data: td, transform, size: (w, h), world, scale: s, parent });
+}
+
+struct TextLayer<'text, 'block, 'span> {
+ source: &'text usvg::Text,
+ block: &'block [Line<'span>],
+ data: TextData,
+ transform: Affine,
+ size: (f64, f64),
+ world: Affine,
+ scale: f64,
+ parent: NodeId,
}
/// Add text layer `td` of `block` at `transform` with box `(w, h)`, with the spans' fills and the
/// first span's stroke (`world`: the SVG text's space; `s`: the scale folded into font sizes).
-#[allow(clippy::too_many_arguments)]
-fn add_layer(
- cx: &mut Ctx,
- t: &usvg::Text,
- block: &[Line],
- mut td: TextData,
- transform: Affine,
- (w, h): (f64, f64),
- world: Affine,
- s: f64,
- parent: NodeId,
-) {
+fn add_layer(cx: &mut Ctx, layer: TextLayer<'_, '_, '_>) {
+ let TextLayer { source: t, block, data: mut td, transform, size: (w, h), world, scale: s, parent } = layer;
let Some(first) = block.first() else { return };
let spans: Vec<&usvg::TextSpan> = block.iter().flat_map(|l| l.spans.iter().map(|sp| sp.2)).collect();
if spans.iter().all(|sp| !sp.is_visible()) {
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 233da218..25175d28 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -61,6 +61,18 @@ impl Default for SvgOptions {
}
}
+struct NodePaint<'a> {
+ node: &'a Node,
+ xf: Affine,
+ depth: usize,
+ paint_clip: String,
+ kid_clip: Option,
+ inherited: Option,
+ outline: Option<(BezPath, figcraft_render::FillRule)>,
+ grains: String,
+ size: (f64, f64),
+}
+
struct W<'a> {
doc: &'a Document,
/// The exported layers, in z-order (what backdrops see).
@@ -458,6 +470,12 @@ impl W<'_> {
NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_render::node_outline(self.doc, n),
};
+ self.render_node(NodePaint { node: n, xf, depth, paint_clip, kid_clip, inherited, outline, grains, size: (w, h) });
+ self.body.push_str("");
+ }
+
+ fn render_node(&mut self, paint: NodePaint<'_>) {
+ let NodePaint { node: n, xf, depth, paint_clip, kid_clip, inherited, outline, grains, size: (w, h) } = paint;
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 };
@@ -486,92 +504,117 @@ impl W<'_> {
}
// Inner shadows paint over the fills, under the children and strokes.
if let Some(o) = &outline {
- for e in n.effects.iter().filter(|e| e.visible) {
- if let EffectKind::InnerShadow(s) = &e.kind {
- self.inner_shadow(o, s, xf);
- }
- }
+ self.inner_shadows(n, o, xf);
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
- let mut kid_clip = kid_clip;
- if n.clip
- && n.kind.is_frame_like()
- && let Some((p, _)) = &outline
- {
- // Its own clip, within the clips around it.
- let cid = self.id("clip");
- let mut cp = p.clone();
- cp.apply_affine(xf);
- let _ = write!(self.defs, "", clip_attr(&kid_clip), path_d(&cp));
- kid_clip = Some(cid);
- }
- self.clip = kid_clip;
- let kids = n.children.clone();
- for run in self.doc.paint_runs(n) {
- let Some(&c) = kids.get(run.start) else { continue };
- if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
- let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
- if kind == figcraft_doc::MaskType::Vector {
- // A vector mask is a clip path: its outline.
- let cid = self.id("clip");
- let outline =
- self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
- let (shape, cr) = match &outline {
- Some((mxf, (mp, rule))) => (
- format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
- if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
- ),
- None => (" d=\"\"".to_string(), ""),
- };
- let _ = write!(self.defs, "");
- let _ = write!(self.body, "");
- } else {
- // The layer as drawn: its alpha or luminance masks.
- let mid = self.id("mask");
- let saved = std::mem::take(&mut self.body);
- let outer = self.mask.replace(c);
- let clip = self.clip.take();
- self.node(c, xf, depth + 1);
- self.clip = clip;
- self.mask = outer;
- let mask_body = std::mem::replace(&mut self.body, saved);
- let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
- let _ = write!(self.defs, "{mask_body}");
- let _ = write!(self.body, "");
- }
- for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) {
- self.node(*k, xf, depth + 1);
- }
- self.body.push_str("");
- } else {
- self.node(c, xf, depth + 1);
- }
- }
- self.clip = inherited;
+ self.children(n, xf, depth, outline.as_ref(), kid_clip, inherited);
}
if n.has_visible_stroke()
&& let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten()
{
self.body.push_str(&clipped(&paint_clip, simple));
} else if n.has_visible_stroke() {
- let geom = match &n.kind {
- NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
- _ => figcraft_doc::geometry::stroke_geometry(n),
+ self.stroke_paths(n, xf, outline.as_ref(), closed, &paint_clip);
+ }
+ self.body.push_str(&grains);
+ }
+
+ /// Paints the children of `n`, each mask layer wrapping the layers it covers.
+ fn children(
+ &mut self,
+ n: &Node,
+ xf: Affine,
+ depth: usize,
+ outline: Option<&(BezPath, figcraft_render::FillRule)>,
+ mut kid_clip: Option,
+ inherited: Option,
+ ) {
+ if n.clip
+ && n.kind.is_frame_like()
+ && let Some((p, _)) = &outline
+ {
+ // Its own clip, within the clips around it.
+ let cid = self.id("clip");
+ let mut cp = p.clone();
+ cp.apply_affine(xf);
+ let _ = write!(self.defs, "", clip_attr(&kid_clip), path_d(&cp));
+ kid_clip = Some(cid);
+ }
+ self.clip = kid_clip;
+ let kids = n.children.clone();
+ for run in self.doc.paint_runs(n) {
+ let Some(&c) = kids.get(run.start) else { continue };
+ if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
+ let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
+ self.open_mask(c, kind, xf, depth);
+ for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) {
+ self.node(*k, xf, depth + 1);
+ }
+ self.body.push_str("");
+ } else {
+ self.node(c, xf, depth + 1);
+ }
+ }
+ self.clip = inherited;
+ }
+
+ /// The inner shadows of `n` over its `outline`.
+ fn inner_shadows(&mut self, n: &Node, outline: &Outline, xf: Affine) {
+ for e in n.effects.iter().filter(|e| e.visible) {
+ if let EffectKind::InnerShadow(s) = &e.kind {
+ self.inner_shadow(outline, s, xf);
+ }
+ }
+ }
+
+ /// Opens the `` that the mask layer `c` clips or masks the layers after it with.
+ fn open_mask(&mut self, c: NodeId, kind: figcraft_doc::MaskType, xf: Affine, depth: usize) {
+ if kind == figcraft_doc::MaskType::Vector {
+ // A vector mask is a clip path: its outline.
+ let cid = self.id("clip");
+ let outline = self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
+ let (shape, cr) = match &outline {
+ Some((mxf, (mp, rule))) => (
+ format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
+ if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
+ ),
+ None => (" d=\"\"".to_string(), ""),
};
- if let Some(g) = geom {
- let area = figcraft_render::draw::stroke_area(n, &g, closed);
- let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
- let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
- for p in &n.strokes {
- if let Some(a) = self.paint_attr(p, w, h) {
- let el = format!("", matrix(xf), path_d(&area));
- self.body.push_str(&clipped(&paint_clip, el));
- }
+ let _ = write!(self.defs, "");
+ let _ = write!(self.body, "");
+ } else {
+ // The layer as drawn: its alpha or luminance masks.
+ let mid = self.id("mask");
+ let saved = std::mem::take(&mut self.body);
+ let outer = self.mask.replace(c);
+ let clip = self.clip.take();
+ self.node(c, xf, depth + 1);
+ self.clip = clip;
+ self.mask = outer;
+ let mask_body = std::mem::replace(&mut self.body, saved);
+ let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
+ let _ = write!(self.defs, "{mask_body}");
+ let _ = write!(self.body, "");
+ }
+ }
+
+ /// The visible strokes of `n` as filled outline paths.
+ fn stroke_paths(&mut self, n: &Node, xf: Affine, outline: Option<&(BezPath, figcraft_render::FillRule)>, closed: bool, paint_clip: &str) {
+ let geom = match &n.kind {
+ NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.map(|(p, _)| p.clone()),
+ _ => figcraft_doc::geometry::stroke_geometry(n),
+ };
+ if let Some(g) = geom {
+ let area = figcraft_render::draw::stroke_area(n, &g, closed);
+ let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
+ let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
+ for p in &n.strokes {
+ if let Some(a) = self.paint_attr(p, n.width, n.height) {
+ let el = format!("", matrix(xf), path_d(&area));
+ self.body.push_str(&clipped(paint_clip, el));
}
}
}
- self.body.push_str(&grains);
- self.body.push_str("");
}
/// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…).
From 94cf3f3fd5bac21239a53c84c17838b4395a039d Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 16:00:05 +0000
Subject: [PATCH 38/60] test(text): derive wrap threshold from matching glyph
metrics
---
crates/text/src/tail_tests.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs
index 2d0ad22b..712ff6aa 100644
--- a/crates/text/src/tail_tests.rs
+++ b/crates/text/src/tail_tests.rs
@@ -182,8 +182,8 @@ fn hanging_punctuation_outside_the_aligned_edge() {
// quote stays on one line.
let mut t = text("\u{201C}ab cd").unwrap();
t.auto_resize = TextAutoResize::Height;
- let w = layout(&t, None, None).width;
- let room = w - plain.glyphs[0].advance + 0.01;
+ let unwrapped = layout(&t, None, None);
+ let room = unwrapped.width - unwrapped.glyphs[0].advance + 0.01;
assert_eq!(layout(&t, Some(room), None).lines.len(), 2, "without hanging it wraps");
t.hanging_punctuation = true;
assert_eq!(layout(&t, Some(room), None).lines.len(), 1, "with hanging it fits");
From 08204fd7c2d6ac3e9a347f7093d2eb506e306643 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 28 Oct 2026 12:18:40 -0400
Subject: [PATCH 39/60] Release 0.12.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 44 ++++++++++++++++++++++----------------------
Cargo.toml | 2 +-
2 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 555ed9af..1fc83bc9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1593,7 +1593,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"arboard",
"craft-update",
@@ -1621,7 +1621,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1635,7 +1635,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1651,7 +1651,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1674,7 +1674,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"kurbo",
"serde",
@@ -1683,7 +1683,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1695,7 +1695,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1727,7 +1727,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1743,7 +1743,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"kurbo",
"serde",
@@ -1751,7 +1751,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1759,7 +1759,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1775,7 +1775,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1784,7 +1784,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1801,7 +1801,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1814,7 +1814,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1832,7 +1832,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1856,7 +1856,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1872,7 +1872,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"core-text",
"figcraft-doc",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1916,7 +1916,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1930,7 +1930,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"base64 0.22.1",
"eframe",
@@ -6422,7 +6422,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.11.2"
+version = "0.12.0"
dependencies = [
"figcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index fd2fbb81..61bc7cf1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.2"
+version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From 32e8fd0b298e9efabfdcff4c3319691ce43ba6c5 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 13:20:45 -0400
Subject: [PATCH 40/60] Grant network access so update checks can reach GitHub
(#1471)
---
packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml | 6 +++---
packaging/linux/flatpak/ai.storyteller.figcraft.yml | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml
index 4b0bd0fe..579a5d64 100644
--- a/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml
+++ b/packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml
@@ -30,9 +30,9 @@ finish-args:
# line, Open Recent, "Save" next to the original) work in the usual places.
- --filesystem=xdg-pictures
- --filesystem=xdg-documents
- # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
- # the sandbox; `flatpak override --user --share=network ai.storyteller.figcraft` exposes it to
- # host agents) and home/host filesystem access.
+ # Network: update checks reach GitHub. The optional --control server still listens on
+ # 127.0.0.1 in the sandbox. Home/host filesystem access is not granted.
+ - --share=network
modules:
- name: figcraft
buildsystem: simple
diff --git a/packaging/linux/flatpak/ai.storyteller.figcraft.yml b/packaging/linux/flatpak/ai.storyteller.figcraft.yml
index 542f1ab7..461176c9 100644
--- a/packaging/linux/flatpak/ai.storyteller.figcraft.yml
+++ b/packaging/linux/flatpak/ai.storyteller.figcraft.yml
@@ -35,9 +35,9 @@ finish-args:
# line, Open Recent, "Save" next to the original) work in the usual places.
- --filesystem=xdg-pictures
- --filesystem=xdg-documents
- # Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
- # the sandbox; `flatpak override --user --share=network ai.storyteller.figcraft` exposes it to
- # host agents) and home/host filesystem access.
+ # Network: update checks reach GitHub. The optional --control server still listens on
+ # 127.0.0.1 in the sandbox. Home/host filesystem access is not granted.
+ - --share=network
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
modules:
From cb227c9715f7de42c3acb50717f6d6de4691ed7c Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 28 Oct 2026 13:36:52 -0400
Subject: [PATCH 41/60] Web: raise the wasm size gate to 25 MiB - 512 KiB
The 0.12.0 release's Web job failed at 25,319,327 bytes against the 24 MiB (25,165,824-byte) gate. opt-level z, fat LTO and wasm-opt -Oz are already in place and extra wasm-opt passes save only ~20 KB, so the gate moves to 25,690,112 bytes, still under Cloudflare's 25 MiB per-file cap. The wasm grew ~2 MB in three days (egui 0.37, new features); bringing it back down is follow-up work.
Co-Authored-By: Claude Opus 5.5
---
Cargo.toml | 2 +-
docs/releasing.md | 2 +-
packaging/web/README.md | 2 +-
packaging/web/package.sh | 4 +++-
4 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 8c4142e6..0f435818 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -133,7 +133,7 @@ lto = "thin"
codegen-units = 4
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
-# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
+# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
diff --git a/docs/releasing.md b/docs/releasing.md
index 8ad7e614..4769c518 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -195,7 +195,7 @@ tar -xzf figcraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
### Web
`packaging/web/package.sh` runs `trunk build --release` in `apps/figcraft-web` (`Trunk.toml`:
-`public_url = "./"`, output `dist/web`), fails if a `.wasm` exceeds 24 MiB, and zips the site
+`public_url = "./"`, output `dist/web`), fails if a `.wasm` exceeds 25 MiB minus 512 KiB, and zips the site
with sample `_headers`/`.htaccess` and the hosting guide. The app uses WebGPU with a WebGL2
fallback; `?webgl` forces WebGL2. 0.1.0: wasm 17.7 MiB raw, 6.7 MiB gzip, 4.4 MiB Brotli.
diff --git a/packaging/web/README.md b/packaging/web/README.md
index e17be23e..24131b0a 100644
--- a/packaging/web/README.md
+++ b/packaging/web/README.md
@@ -37,7 +37,7 @@ Measured on the 0.1.0 build (`trunk build --release`; gzip `-9`, Brotli quality
Per-file upload limits: Cloudflare Pages and Workers static assets reject any file over
**25 MiB** (26,214,400 bytes). `packaging/web/package.sh` fails the build if the wasm ever grows
-past 24 MiB, so a release can't ship a file a common host refuses.
+past 25 MiB minus 512 KiB (25,690,112 bytes), so a release can't ship a file a common host refuses.
## Any path works
diff --git a/packaging/web/package.sh b/packaging/web/package.sh
index f9ee34a7..a29bad31 100755
--- a/packaging/web/package.sh
+++ b/packaging/web/package.sh
@@ -27,7 +27,9 @@ fi
# Size gate: Cloudflare Pages/Workers reject any single file over 25 MiB, and other
# hosts and CDNs have similar caps. Fail well before that so a regression shows up here, not at
# upload time. Override with FIGCRAFT_WASM_MAX_BYTES only to investigate.
-MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-25165824}" # 24 MiB
+# 25 MiB - 512 KiB: raised from 24 MiB for 0.12.1, when egui 0.37 and new features took the wasm to
+# 25.3 MB with opt-level z and wasm-opt -Oz already applied. Cutting the wasm back down is open work.
+MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-25690112}" # 25 MiB - 512 KiB
WASM_COUNT=0
for wasm in "$SITE"/*.wasm; do
[[ -f "$wasm" ]] || continue
From ef9721c5b9d0f1cc19a74449cbe175fc06bf8efa Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 28 Oct 2026 13:37:05 -0400
Subject: [PATCH 42/60] Release 0.12.1
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 44 ++++++++++++++++++++++----------------------
Cargo.toml | 2 +-
2 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 1fc83bc9..a0f71692 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1593,7 +1593,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"arboard",
"craft-update",
@@ -1621,7 +1621,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-engine",
@@ -1635,7 +1635,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1651,7 +1651,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1674,7 +1674,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"kurbo",
"serde",
@@ -1683,7 +1683,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1695,7 +1695,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1727,7 +1727,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-pathops",
@@ -1743,7 +1743,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"kurbo",
"serde",
@@ -1751,7 +1751,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1759,7 +1759,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1775,7 +1775,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1784,7 +1784,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1801,7 +1801,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"serde",
@@ -1814,7 +1814,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -1832,7 +1832,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"crc32fast",
"ctrlc",
@@ -1856,7 +1856,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -1872,7 +1872,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"core-text",
"figcraft-doc",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1916,7 +1916,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1930,7 +1930,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"base64 0.22.1",
"eframe",
@@ -6422,7 +6422,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.12.0"
+version = "0.12.1"
dependencies = [
"figcraft-engine",
"serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 0f435818..b4977989 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
-version = "0.12.0"
+version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From 2af5e232d5dd86321e4671655de60f9ae031c53a Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 28 Oct 2026 15:48:52 -0400
Subject: [PATCH 43/60] Honor CARGO_BUILD_TARGET in FreeBSD packaging for
aarch64 release builds (#1475)
Resolve host-tuple to the host triple, pass --target to cargo build when
CARGO_BUILD_TARGET is set, and skip the --version smoke run for cross
builds (they cannot run on the x86_64 VM); cross builds get an ELF check
instead. Archive names carry the arch from the target triple.
Co-authored-by: Claude
---
packaging/freebsd/package.sh | 36 +++++++++++++++++++++---------------
1 file changed, 21 insertions(+), 15 deletions(-)
diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh
index 9ee0394b..3b693d0b 100755
--- a/packaging/freebsd/package.sh
+++ b/packaging/freebsd/package.sh
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
# Build and package FigCraft for FreeBSD:
#
-# $DIST/figcraft--freebsd-x86_64.tar.gz a /usr/local-style tree:
-# figcraft--freebsd-x86_64/{bin, share/applications, share/icons, share/mime,
+# $DIST/figcraft--freebsd-.tar.gz (arch: x86_64 or aarch64) a /usr/local-style tree:
+# figcraft--freebsd-/{bin, share/applications, share/icons, share/mime,
# share/metainfo, share/doc/figcraft}
# bin/ holds figcraft, plus figcraft-cli once apps/figcraft-cli exists.
#
# Install by copying the tree's contents into /usr/local:
-# tar -xzf figcraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
+# tar -xzf figcraft--freebsd-.tar.gz --strip-components 1 -C /usr/local
#
# Usage: packaging/freebsd/package.sh [--skip-build] [--dry-run]
# --dry-run stage the tree from stub binaries and list it, without building (works on any OS)
@@ -42,6 +42,10 @@ release_arch() {
}
# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to
# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target.
+# host-tuple means the build host; resolve it so the target dir path is a real triple.
+if [[ "${CARGO_BUILD_TARGET:-}" == host-tuple ]]; then
+ CARGO_BUILD_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
+fi
case "${CARGO_BUILD_TARGET:-}" in
"") ARCH="$(release_arch "$(uname -m)")" ;;
aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;;
@@ -64,7 +68,7 @@ echo "==> FigCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
if [[ "$SKIP_BUILD" = 0 ]]; then
- (cd "$ROOT" && cargo build --release --locked "${CARGO_PKGS[@]}")
+ (cd "$ROOT" && cargo build --release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"} "${CARGO_PKGS[@]}")
fi
WORK="$CARGO_TARGET_DIR/freebsd-package"
STAGE="$WORK/$BASENAME"
@@ -116,19 +120,21 @@ mkdir -p "$OUT_DIR"
tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $OUT_DIR/$BASENAME.tar.gz"
-if [[ "$DRY_RUN" = 1 ]] || [[ "$ARCH" = "$HOST_ARCH" ]]; then
- "$STAGE/bin/$(smoke_bin)" --version
-else
+if [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then
# They cannot run here, so check what they are instead: executables, not libraries or objects.
# An older file(1) calls a PIE a shared object; its interpreter still marks it as a program.
- want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
- for b in "$STAGE"/bin/*; do
- case "$(file -b "$b")" in
- ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
- *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;;
- esac
- done
- echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine"
+ if [[ "$DRY_RUN" = 0 ]]; then
+ want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
+ for b in "$STAGE"/bin/*; do
+ case "$(file -b "$b")" in
+ ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
+ *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;;
+ esac
+ done
+ fi
+ echo "skipping --version: $CARGO_BUILD_TARGET binaries do not run on this $HOST_ARCH machine"
+elif [[ "$DRY_RUN" = 1 ]] || [[ "$ARCH" = "$HOST_ARCH" ]]; then
+ "$STAGE/bin/$(smoke_bin)" --version
fi
if [[ "$DRY_RUN" = 1 ]]; then
echo "==> dry run: tarball contents"
From d5ce10f0bed8abceff386956ccc1075aaacc65d4 Mon Sep 17 00:00:00 2001
From: Filip Strajnar
Date: Wed, 28 Oct 2026 21:42:17 +0100
Subject: [PATCH 44/60] M8: repair and library manifests check membership by
set lookups (#1078)
Repair re-listed unlisted pages with a scan of the page list per page, and
reading a published library kept manifest styles, collections and variables
with a scan of the snapshot's lists per entry: a file with many pages or a
long manifest made both quadratic. Both now look ids up in a set built once.
validate() already indexed its parent/child edges and page list.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
---
crates/doc/src/handoff_tests.rs | 20 ++++++++++++++++++++
crates/doc/src/lib.rs | 4 +++-
crates/format/src/library.rs | 10 +++++++---
3 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs
index 79b97194..8a18151d 100644
--- a/crates/doc/src/handoff_tests.rs
+++ b/crates/doc/src/handoff_tests.rs
@@ -85,3 +85,23 @@ fn validate_is_linear_in_a_wide_parent() {
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
+
+/// Repair lists every unlisted page once, by set lookups rather than a scan of the page list per
+/// page (#1078: a file with many pages made repair quadratic).
+#[test]
+fn repair_relists_many_unlisted_pages_once_each() {
+ let mut doc = Document::new("d");
+ for i in 0..5_000 {
+ doc.add_page(&format!("P{i}"));
+ }
+ let all: Vec<_> = doc.pages.clone();
+ // Half the pages fall off the list.
+ doc.pages.retain(|p| p.0 % 2 == 0);
+ doc.repair();
+ let mut listed = doc.pages.clone();
+ listed.sort_unstable();
+ let mut want = all;
+ want.sort_unstable();
+ assert_eq!(listed, want, "every page is listed again, once");
+ assert!(doc.validate().is_empty());
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index caf0c861..7d1f6928 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -1143,8 +1143,10 @@ impl Document {
Arc::make_mut(&mut self.libraries).store = store;
}
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
+ // Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
+ let mut listed: std::collections::BTreeSet = self.pages.iter().copied().collect();
for (k, n) in self.nodes.iter() {
- if n.is_page() && !self.pages.contains(k) && Some(*k) != store {
+ if n.is_page() && Some(*k) != store && listed.insert(*k) {
self.pages.push(*k);
}
}
diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs
index daec05e2..12d38e94 100644
--- a/crates/format/src/library.rs
+++ b/crates/format/src/library.rs
@@ -90,9 +90,13 @@ pub(crate) fn read(
}
// Keep what the snapshot really holds; clip what people see.
manifest.components.retain(|a| doc.node(a.id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })));
- manifest.styles.retain(|s| doc.styles.iter().any(|x| x.id == s.id));
- manifest.collections.retain(|c| doc.variables.collections.iter().any(|x| x.id == c.id));
- manifest.variables.retain(|v| doc.variables.variables.iter().any(|x| x.id == v.id && x.collection == v.collection));
+ // Membership by set lookups: a long manifest against a large snapshot stays linear (#1078).
+ let styles: BTreeSet<&str> = doc.styles.iter().map(|x| x.id.as_str()).collect();
+ let collections: BTreeSet<&str> = doc.variables.collections.iter().map(|x| x.id.as_str()).collect();
+ let variables: BTreeSet<(&str, &str)> = doc.variables.variables.iter().map(|x| (x.id.as_str(), x.collection.as_str())).collect();
+ manifest.styles.retain(|s| styles.contains(s.id.as_str()));
+ manifest.collections.retain(|c| collections.contains(c.id.as_str()));
+ manifest.variables.retain(|v| variables.contains(&(v.id.as_str(), v.collection.as_str())));
manifest.name = manifest.name.chars().take(MAX_NAME).collect();
manifest.description = manifest.description.chars().take(MAX_DESCRIPTION).collect();
Ok(Some(PublishedLibrary { manifest, doc: Arc::new(doc) }))
From 8d7c09422420809ee5c9edbbce47b1189e097656 Mon Sep 17 00:00:00 2001
From: Filip Strajnar
Date: Wed, 28 Oct 2026 21:42:25 +0100
Subject: [PATCH 45/60] M1: the stroke weight keeps room for its value beside
the variable button (#1452)
The stroke's position dropdown took half the row and the weight field what
was left after the per-side and Advanced buttons: about 40 pt at the
panel's default width, so the variable button sat on the weight's icon and
hid its value, and the row ran 3 pt past the panel's edge. The position
and the weight now share what the buttons leave: the panel's two columns
for layers without per-side strokes, and about 66 pt each with them.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
---
crates/ui-egui/src/lib.rs | 2 +
crates/ui-egui/src/panels/right/paints.rs | 13 ++--
crates/ui-egui/src/stroke_row_tests.rs | 82 +++++++++++++++++++++++
3 files changed, 92 insertions(+), 5 deletions(-)
create mode 100644 crates/ui-egui/src/stroke_row_tests.rs
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 4c1ed337..ef55faf8 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -187,6 +187,8 @@ mod shortcut_keys_tests;
#[cfg(test)]
mod shortcuts_tests;
#[cfg(test)]
+mod stroke_row_tests;
+#[cfg(test)]
mod style_folder_rename_tests;
#[cfg(test)]
mod text_enter_tests;
diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs
index b947e296..182116fe 100644
--- a/crates/ui-egui/src/panels/right/paints.rs
+++ b/crates/ui-egui/src/panels/right/paints.rs
@@ -348,7 +348,12 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
if !fills && !empty {
let weight = common(doc, sel, |n| n.stroke.weight);
let align = common(doc, sel, |n| n.stroke.align);
- let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
+ let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
+ // The position and the weight share what the buttons (Advanced, and per-side strokes for
+ // layers with sides) leave: halving the whole row left the weight too narrow for its value
+ // beside the variable button and ran the row past the panel's edge (#1452).
+ let buttons = if sided { 2.0 } else { 1.0 } * (24.0 + 8.0);
+ let field_w = ((ui.available_width() - PAD * 2.0 - 8.0 - buttons) / 2.0).max(40.0);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(
ui,
@@ -358,7 +363,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
Some(StrokeAlign::Outside) => tr("Outside"),
None => tr("Mixed"),
},
- half,
+ field_w,
None,
);
menu_popup(&resp).show(|ui| {
@@ -369,8 +374,6 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
}
}
});
- let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
- let weight_w = half - if sided { 60.0 } else { 32.0 };
let e = crate::variable_ui::number_field_mixed(
app,
ui,
@@ -380,7 +383,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
Lead::Icon(Icon::Menu, tr("Stroke weight")),
weight,
"",
- weight_w,
+ field_w,
);
app.field_of(&ids, "strokeWeight", e, |n| n.stroke.weight, |v| json!(v.max(0.0)));
if sided {
diff --git a/crates/ui-egui/src/stroke_row_tests.rs b/crates/ui-egui/src/stroke_row_tests.rs
new file mode 100644
index 00000000..18ed7a00
--- /dev/null
+++ b/crates/ui-egui/src/stroke_row_tests.rs
@@ -0,0 +1,82 @@
+//! The stroke's position / weight row in the Design panel, driven headless at the panel's default
+//! width: the weight field keeps room for its value beside the variable button, whose click opens
+//! the variable picker, and the row's buttons stay inside the panel (#1452: the weight field was
+//! so narrow that the variable button sat on its icon and hid the value).
+
+use egui::accesskit::Role;
+use egui::{Id, Pos2, Rect};
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::layers_drag_tests::Ui;
+
+/// A stroked rectangle (`rect`) or ellipse, selected.
+fn scene(kind: &str) -> Ui {
+ let mut s = Session::new();
+ let made = s.execute(&format!("shape.{kind}"), &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap();
+ let id = made["id"].as_u64().unwrap();
+ s.execute("stroke.add", &json!({"ids": [id]})).unwrap();
+ s.execute("select.set", &json!({"ids": [id]})).unwrap();
+ let mut u = Ui::new(s);
+ u.frame(vec![]);
+ u
+}
+
+/// The area of part `part` ("lead", "field" or "bind") of the stroke weight field.
+fn weight(u: &Ui, part: &str) -> Rect {
+ u.ctx.read_response(Id::unique("stroke_w").with(part)).unwrap_or_else(|| panic!("the weight field's {part} shows")).rect
+}
+
+/// The bounds of the button named `label`, found in the AccessKit tree.
+fn named_button(u: &mut Ui, label: &str) -> Rect {
+ u.ctx.enable_accesskit();
+ u.frame(vec![]);
+ let out = u.frame_output(vec![]);
+ let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
+ let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label);
+ let b = n.bounds().expect("bounds");
+ Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32))
+}
+
+/// The variable button clears the weight's icon and leaves the value room for a few digits, for
+/// layers with per-side strokes (one more button in the row) and without.
+#[test]
+fn weight_field_keeps_its_value_beside_the_variable_button() {
+ for kind in ["rectangle", "ellipse"] {
+ let u = scene(kind);
+ let (lead, field, bind) = (weight(&u, "lead"), weight(&u, "field"), weight(&u, "bind"));
+ assert!(bind.min.x >= lead.max.x + 4.0, "{kind}: the variable button {bind:?} sits on the icon {lead:?}");
+ assert!(bind.min.x - field.min.x >= 16.0, "{kind}: no room for the value left of the variable button in {field:?} ({bind:?})");
+ }
+}
+
+/// The row's last button (Advanced) stays inside the panel's padding, in line with the panel's
+/// icon column, also with the per-side button beside the weight.
+#[test]
+fn stroke_row_buttons_stay_inside_the_panel() {
+ for kind in ["rectangle", "ellipse"] {
+ let mut u = scene(kind);
+ let advanced = named_button(&mut u, "Advanced stroke");
+ let panel_right = u.ctx.content_rect().max.x - crate::widgets::PAD;
+ assert!(advanced.max.x <= panel_right + 0.5, "{kind}: Advanced at {advanced:?} runs past the panel's edge {panel_right}");
+ if kind == "rectangle" {
+ let sides = named_button(&mut u, "Individual strokes");
+ assert!(sides.min.x >= weight(&u, "field").max.x, "{kind}: the per-side button {sides:?} overlaps the weight");
+ assert!(sides.max.x <= advanced.min.x, "{kind}: the per-side button {sides:?} overlaps Advanced {advanced:?}");
+ }
+ }
+}
+
+/// A click on the variable button opens the variable picker for the weight.
+#[test]
+fn variable_button_opens_the_picker() {
+ let mut u = scene("rectangle");
+ let field = weight(&u, "field");
+ let at = weight(&u, "bind").center();
+ // The button shows while the pointer is over the field.
+ u.frame(vec![egui::Event::PointerMoved(field.center())]);
+ u.button(at, true);
+ u.button(at, false);
+ u.frame(vec![]);
+ assert!(crate::variable_ui::picker_open(&u.ctx), "the variable picker opened");
+}
From 62c69ba7ab1bed1e6cbf941a6aeeb7734d0e4465 Mon Sep 17 00:00:00 2001
From: Filip Strajnar
Date: Wed, 28 Oct 2026 21:59:47 +0100
Subject: [PATCH 46/60] M8: normalising after a command runs under the panic
guard (part of #1085)
Session::execute ran a command under guard::run but normalised what it
changed afterwards, outside it: a panic in normalisation escaped execute
instead of becoming EngineError::Internal with the edit rolled back. Inside
a batch it unwound through Session::grouped, which then never restored the
undo limit it had lifted or ended the group. Normalising is now the last
step of the guarded result chain, so a panic there fails the command like
one in the command itself (and a batch step fails like any other failed
step); live text previews normalise under the guard too.
The other half of #1085, restoring grouped()'s state when its closure
panics, is left alone: #1419 rewrites that function.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
---
crates/engine/src/canvas/textpreview.rs | 9 ++-
crates/engine/src/derived.rs | 19 ++++++
crates/engine/src/lib.rs | 2 +
crates/engine/src/normalize_guard_tests.rs | 72 ++++++++++++++++++++++
crates/engine/src/session/exec.rs | 31 +++++++---
5 files changed, 123 insertions(+), 10 deletions(-)
create mode 100644 crates/engine/src/normalize_guard_tests.rs
diff --git a/crates/engine/src/canvas/textpreview.rs b/crates/engine/src/canvas/textpreview.rs
index da101d5e..07a09a15 100644
--- a/crates/engine/src/canvas/textpreview.rs
+++ b/crates/engine/src/canvas/textpreview.rs
@@ -39,7 +39,14 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
ds.text_edit = text_edit;
match result {
Ok(_) if !Arc::ptr_eq(&ds.doc, &base) => {
- normalize(ds.doc_mut(), &base);
+ // A panic while normalising shows no preview (#1085).
+ if let Err(e) = guard::run(command, || {
+ normalize(ds.doc_mut(), &base);
+ Ok(())
+ }) {
+ ds.doc = base;
+ return Err(e);
+ }
ds.text_preview = Some(base);
ds.revision += 1;
Ok(())
diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs
index b6a001b8..c618269f 100644
--- a/crates/engine/src/derived.rs
+++ b/crates/engine/src/derived.rs
@@ -10,9 +10,28 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec {
.collect()
}
+#[cfg(test)]
+thread_local! {
+ /// Tests: normalising panics, standing for a bug in it (a command's edit is rolled back).
+ pub(crate) static PANIC: std::cell::Cell = const { std::cell::Cell::new(false) };
+}
+
+/// Tests: panic when [`PANIC`] is set, as a bug in normalising would.
+#[cfg(test)]
+fn test_panic() {
+ if PANIC.with(std::cell::Cell::get) {
+ panic!("normalize: test panic");
+ }
+}
+
+/// Shipped builds: nothing (the test hook compiles away).
+#[cfg(not(test))]
+fn test_panic() {}
+
/// Keep derived state consistent after edits: text boxes resize to their content, groups wrap
/// their children, instances follow their main components, auto layout re-flows.
pub fn normalize(doc: &mut Document, before: &Document) {
+ test_panic();
// Only what was touched since the last normalisation (edited, added or removed).
let mut touched: BTreeSet = doc.take_dirty();
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index ea135ca1..beaf989c 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -346,6 +346,8 @@ mod nested_override_tests;
#[cfg(test)]
mod node_set_refusals_tests;
#[cfg(test)]
+mod normalize_guard_tests;
+#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
diff --git a/crates/engine/src/normalize_guard_tests.rs b/crates/engine/src/normalize_guard_tests.rs
new file mode 100644
index 00000000..c4ad7c08
--- /dev/null
+++ b/crates/engine/src/normalize_guard_tests.rs
@@ -0,0 +1,72 @@
+//! A panic while normalising what a command changed is caught like one in the command itself
+//! (#1085): the command fails with `EngineError::Internal`, the document is as it was, no undo step
+//! is pushed, and the session keeps working.
+
+use serde_json::json;
+
+use crate::{EngineError, Session, derived};
+
+/// Run `f` with normalisation panicking.
+fn panicking(f: impl FnOnce() -> T) -> T {
+ derived::PANIC.with(|p| p.set(true));
+ let out = f();
+ derived::PANIC.with(|p| p.set(false));
+ out
+}
+
+/// The layer id a creating command returned.
+fn id(v: &serde_json::Value) -> u64 {
+ v["id"].as_u64().unwrap()
+}
+
+/// A command whose edit panics while normalising fails with `EngineError::Internal`, leaves the
+/// document and the undo history as they were, and the same command then runs as usual.
+#[test]
+fn a_panic_while_normalising_rolls_the_command_back() {
+ let mut s = Session::new();
+ let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
+ let before = s.doc().unwrap().clone();
+ let undo = s.undo_depth();
+ let result = panicking(|| s.execute("node.set", &json!({"ids": [r], "props": {"name": "Renamed"}})));
+ assert!(matches!(result, Err(EngineError::Internal { .. })), "{result:?}");
+ assert_eq!(s.doc().unwrap(), &before, "the edit is rolled back");
+ assert_eq!(s.undo_depth(), undo, "no undo step for the failed command");
+ // The next command runs as usual.
+ s.execute("node.set", &json!({"ids": [r], "props": {"name": "Renamed"}})).unwrap();
+ assert_eq!(s.doc().unwrap().node(figcraft_doc::NodeId(r)).unwrap().name, "Renamed");
+ assert_eq!(s.undo_depth().0, undo.0 + 1);
+}
+
+/// The step fails like any other failed step: the batch's group restores what it set aside for
+/// its steps (the history limit, the group depth), which a panic unwinding through it skipped.
+#[test]
+fn a_panic_while_normalising_a_batch_step_rolls_the_batch_back() {
+ let mut s = Session::new();
+ let before = s.doc().unwrap().clone();
+ let undo = s.undo_depth();
+ let limit = s.ds().unwrap().history.limit;
+ let result = panicking(|| {
+ s.execute("command.batch", &json!({"commands": [{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 10, "height": 10}}]}))
+ });
+ assert!(result.is_err(), "{result:?}");
+ assert_eq!(s.doc().unwrap(), &before);
+ assert_eq!(s.undo_depth(), undo);
+ assert_eq!(s.ds().unwrap().history.limit, limit, "the undo limit is back");
+ assert_eq!(s.group_depth, 0, "the group ended");
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+}
+
+/// A live text preview whose normalising panics fails and shows nothing: the document is as it
+/// was, and the same preview then shows.
+#[test]
+fn a_panic_while_normalising_a_text_preview_shows_none() {
+ let mut s = Session::new();
+ let t = id(&s.execute("text.create", &json!({"x": 0, "y": 0, "text": "Preview"})).unwrap());
+ let before = s.doc().unwrap().clone();
+ let result = panicking(|| s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontSize": 32}})));
+ assert!(matches!(result, Err(EngineError::Internal { .. })), "{result:?}");
+ assert_eq!(s.doc().unwrap(), &before);
+ assert!(!crate::textpreview::active(&s), "no preview shows");
+ s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontSize": 32}})).unwrap();
+ assert!(crate::textpreview::active(&s));
+}
diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs
index 00798b2c..f5743d5d 100644
--- a/crates/engine/src/session/exec.rs
+++ b/crates/engine/src/session/exec.rs
@@ -51,7 +51,14 @@ impl Session {
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
- .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
+ .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v))
+ // Normalising is part of the command: a panic in it rolls the edit back too (#1085).
+ .and_then(|v| {
+ guard::run(id, || {
+ self.normalize_edit(spec, &before);
+ Ok(v)
+ })
+ });
match &result {
Ok(_) => {
self.after_command(spec, before);
@@ -141,7 +148,19 @@ impl Session {
.map_err(|m| EngineError::Disabled(id.to_string(), m))
}
- /// Bookkeeping after a successful command: normalise what changed, push undo, bump revision.
+ /// Normalise what a command changed in the document it ran on (text boxes, groups, instances,
+ /// auto layout…).
+ fn normalize_edit(&mut self, spec: &CommandSpec, before: &Option<(Arc, Vec, NodeId, u64)>) {
+ let Some((bdoc, _, _, uid)) = before else { return };
+ let Some(ds) = self.docs.iter_mut().find(|d| d.uid == *uid) else { return };
+ // Saving may adopt the chosen filename as the document title. The encoded snapshot is
+ // already final; normalising it again would detach it from the saved baseline.
+ if !Arc::ptr_eq(&ds.doc, bdoc) && !matches!(spec.id, "file.save" | "file.saveAs") {
+ normalize(ds.doc_mut(), bdoc);
+ }
+ }
+
+ /// Bookkeeping after a successful command (normalised already): push undo, bump revision.
fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc, Vec, NodeId, u64)>) {
let Some((bdoc, bsel, bpage, uid)) = before else {
if let Ok(ds) = self.ds_mut() {
@@ -150,13 +169,7 @@ impl Session {
return;
};
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
- let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
- // Saving may adopt the chosen filename as the document title. The encoded snapshot is
- // already final; normalising it again would detach it from the saved baseline.
- if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
- normalize(ds.doc_mut(), &bdoc);
- }
- let edit = changed && ds.interaction.is_none();
+ let edit = !Arc::ptr_eq(&ds.doc, &bdoc) && ds.interaction.is_none();
ds.clean_selection();
ds.revision += 1;
if edit {
From 2d6638599d1003f658955454d579c4211e09b0b3 Mon Sep 17 00:00:00 2001
From: Filip Strajnar
Date: Wed, 28 Oct 2026 22:10:39 +0100
Subject: [PATCH 47/60] M3: scaled instances keep their scale when their main
is rebuilt (#922)
The Scale tool (and `transform.scale`) scales a layer's children whatever
their constraints. After the edit, overrides are recorded by diffing the
changed layers, and a size or place that changed while an ancestor resized
is read as what constraints give, so it isn't an override. For a scaled
instance that left its layers' scaled size and points unrecorded: the next
rebuild of the instance (a layer added to its main, say) put a 14 px
icon's triangle back at the main's 24 px size. Strokes already kept their
scaled weight, as a separate override group.
`scale_node` now marks `size` and `position` overrides on the layers it
scales below the scaled layer when they are layers of an instance and the
value changed. Undo takes them back with the rest of the edit.
The report's own sequence (clearing effects on the component holding the
icon) no longer reproduces on main; this covers the rebuild path that
still did. Regressions: a scaled instance through value edits and a
rebuild of its main; one scaled with the tool, with undo; a scaled icon
nested in a component and in an instance of that component, through edits
of both mains.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
---
ROADMAP.md | 4 +-
crates/engine/src/cmd/arrange.rs | 14 +++
crates/engine/src/lib.rs | 2 +
crates/engine/src/scaled_instance_tests.rs | 131 +++++++++++++++++++++
4 files changed, 150 insertions(+), 1 deletion(-)
create mode 100644 crates/engine/src/scaled_instance_tests.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index 74a617f3..a94fedb8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (scaled instances keep their scale when their main is rebuilt (#922); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs
index 4806afb3..19572c95 100644
--- a/crates/engine/src/cmd/arrange.rs
+++ b/crates/engine/src/cmd/arrange.rs
@@ -405,6 +405,11 @@ fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result
> {
pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) };
let sub = doc.subtree(id);
+ // The layers below `id` scale whatever their constraints, so where they are layers of an
+ // instance their new size and place are overrides. The edit's own diff takes geometry that
+ // changed with an ancestor's size for what constraints give, so the next rebuild of the
+ // instance would put them back at their main's size (#922).
+ let in_instance: Vec = sub.iter().enumerate().map(|(depth, i)| depth > 0 && crate::components::inside_instance(doc, *i)).collect();
let tiles = |paints: &mut Vec| {
for p in paints {
if let figcraft_color::PaintKind::Image(img) = &mut p.kind
@@ -416,6 +421,7 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
};
for (depth, i) in sub.iter().enumerate() {
let n = doc.get_mut(*i)?;
+ let (w0, h0, t0) = (n.width, n.height, n.transform);
tiles(&mut n.fills);
tiles(&mut n.strokes);
let cl = &mut n.child_layout;
@@ -511,6 +517,14 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
let c = n.transform.as_coeffs();
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]);
}
+ if in_instance.get(depth).copied().unwrap_or(false) {
+ if n.width != w0 || n.height != h0 {
+ n.overrides.insert("size".into());
+ }
+ if n.transform != t0 {
+ n.overrides.insert("position".into());
+ }
+ }
}
Ok(())
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index ea135ca1..0d4b044c 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -424,6 +424,8 @@ mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
#[cfg(test)]
+mod scaled_instance_tests;
+#[cfg(test)]
mod section_contents_tests;
#[cfg(test)]
mod section_tests;
diff --git a/crates/engine/src/scaled_instance_tests.rs b/crates/engine/src/scaled_instance_tests.rs
new file mode 100644
index 00000000..f79e9534
--- /dev/null
+++ b/crates/engine/src/scaled_instance_tests.rs
@@ -0,0 +1,131 @@
+//! Instances scaled with the Scale tool keep their scaled layers when a main around them or their
+//! own main changes (#922): a 24 px icon with a 1.5 px stroke shown at 14 px has a 0.875 px stroke,
+//! and its points and size are 14/24 of its main's.
+
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::components::tests::{copy_of, ex, healthy, idv, node};
+use figcraft_doc::{NodeId, NodeKind};
+
+/// The scale the icon is shown at: 24 px to 14 px.
+const K: f64 = 14.0 / 24.0;
+
+/// Whether two lengths are equal up to rounding.
+fn near(a: f64, b: f64) -> bool {
+ (a - b).abs() < 1e-6
+}
+
+/// The icon: a 24 px component holding a flattened triangle with a 1.5 px stroke; returns the
+/// component and the triangle.
+fn icon(s: &mut Session) -> (NodeId, NodeId) {
+ let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 24, "height": 24})));
+ let p = idv(&ex(s, "shape.polygon", json!({"x": 4, "y": 4, "width": 16, "height": 16, "parent": f})));
+ ex(s, "vector.flatten", json!({"ids": [p]}));
+ let v = node(s, f).children[0];
+ assert!(matches!(node(s, v).kind, NodeKind::Vector { .. }));
+ ex(s, "node.set", json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 1.5}}));
+ let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
+ (c, v)
+}
+
+/// `copy` shows main layer `main` at 14/24: stroke, size and points.
+fn assert_scaled(s: &Session, copy: NodeId, main: NodeId, why: &str) {
+ let (c, m) = (node(s, copy), node(s, main));
+ assert!(near(c.stroke.weight, m.stroke.weight * K), "{why}: stroke {} (main {})", c.stroke.weight, m.stroke.weight);
+ assert!(near(c.width, m.width * K) && near(c.height, m.height * K), "{why}: size {}×{} (main {}×{})", c.width, c.height, m.width, m.height);
+ let (NodeKind::Vector { network: a }, NodeKind::Vector { network: b }) = (&c.kind, &m.kind) else { panic!("{why}: not vectors") };
+ assert_eq!(a.vertices.len(), b.vertices.len(), "{why}");
+ for (va, vb) in a.vertices.iter().zip(&b.vertices) {
+ assert!(near(va.x, vb.x * K) && near(va.y, vb.y * K), "{why}: point ({}, {}) (main ({}, {}))", va.x, va.y, vb.x, vb.y);
+ }
+}
+
+/// Edits of a main: a value on its root, a value on a layer, a layer added (a rebuild).
+fn main_edits(main: NodeId, layer: NodeId) -> Vec<(&'static str, Value)> {
+ vec![
+ ("effects on the main", json!({"id": main, "props": {"effects": [{"type": "dropShadow"}]}})),
+ ("effects cleared on the main", json!({"id": main, "props": {"effects": []}})),
+ ("fill on the main", json!({"id": main, "props": {"fills": ["#ff0000"]}})),
+ ("fill on a main layer", json!({"id": layer, "props": {"fills": ["#00ff00"]}})),
+ ]
+}
+
+/// An instance scaled on the page keeps its scaled layers through value edits of its main and
+/// a rebuild (a layer added to the main).
+#[test]
+fn a_scaled_instance_keeps_its_scale_when_its_main_changes() {
+ let mut s = Session::new();
+ let (c, v) = icon(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0})));
+ ex(&mut s, "transform.scale", json!({"ids": [i], "factor": K}));
+ let iv = copy_of(&s, i, v);
+ assert_scaled(&s, iv, v, "scaled");
+ for (why, p) in main_edits(c, v) {
+ ex(&mut s, "node.set", p);
+ assert_scaled(&s, copy_of(&s, i, v), v, why);
+ }
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
+ assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main");
+ healthy(&s);
+}
+
+/// Dragging a Scale tool handle records the same overrides, and undoing the drag takes them back.
+#[test]
+fn an_instance_scaled_with_the_tool_keeps_its_scale_and_undo_takes_it_back() {
+ use crate::tools::{PointerEvent, PointerKind};
+ let mut s = Session::new();
+ let (c, v) = icon(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 100, "y": 0})));
+ ex(&mut s, "select.set", json!({"ids": [i]}));
+ ex(&mut s, "tool.scale", json!({}));
+ // The bottom right handle, from 24 px to 14 px about the top left corner.
+ for (kind, (x, y)) in
+ [(PointerKind::Down, (124.0, 24.0)), (PointerKind::Move, (119.0, 19.0)), (PointerKind::Move, (114.0, 14.0)), (PointerKind::Up, (114.0, 14.0))]
+ {
+ s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
+ }
+ assert!(near(node(&s, i).width, 14.0), "{}", node(&s, i).width);
+ assert_scaled(&s, copy_of(&s, i, v), v, "scaled with the tool");
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
+ assert_scaled(&s, copy_of(&s, i, v), v, "a layer added to the main");
+ s.undo().unwrap();
+ s.undo().unwrap();
+ let back = node(&s, copy_of(&s, i, v));
+ assert!(near(back.stroke.weight, 1.5) && back.overrides.is_empty(), "undo: {} {:?}", back.stroke.weight, back.overrides);
+ healthy(&s);
+}
+
+/// The report's case: an icon scaled inside a header that then becomes a component keeps its scale
+/// in the header and in the header's instances, through edits of both mains.
+#[test]
+fn a_scaled_nested_icon_keeps_its_scale_when_the_component_holding_it_changes() {
+ let mut s = Session::new();
+ let (c, v) = icon(&mut s);
+ // The header holds the icon at 14 px, scaled with the Scale tool, then becomes a component.
+ let h = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 200, "height": 48})));
+ let hi = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 117, "parent": h})));
+ ex(&mut s, "transform.scale", json!({"ids": [hi], "factor": K}));
+ let header = idv(&ex(&mut s, "component.create", json!({"ids": [h]})));
+ let screen = idv(&ex(&mut s, "instance.create", json!({"component": header, "x": 0, "y": 300})));
+ // The icon's triangle as the header and as the instance of the header show it.
+ let both = |s: &Session| {
+ let in_header = copy_of(s, hi, v);
+ [in_header, copy_of(s, copy_of(s, screen, hi), in_header)]
+ };
+ let check = |s: &Session, why: &str| {
+ for (copy, where_) in both(s).into_iter().zip(["the header's icon", "the icon in an instance of the header"]) {
+ assert_scaled(s, copy, v, &format!("{why}: {where_}"));
+ }
+ };
+ check(&s, "made a component");
+ for (why, p) in main_edits(header, hi).into_iter().chain(main_edits(c, v)) {
+ ex(&mut s, "node.set", p);
+ check(&s, why);
+ }
+ ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 110, "width": 20, "height": 20, "parent": header}));
+ check(&s, "a layer added to the header");
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 2, "height": 2, "parent": c}));
+ check(&s, "a layer added to the icon");
+ healthy(&s);
+}
From 3009d19fdea1a9a615cac25dc8fe78ba30d75a2d Mon Sep 17 00:00:00 2001
From: Filip Strajnar
Date: Wed, 28 Oct 2026 22:13:37 +0100
Subject: [PATCH 48/60] M6: .fig embedded images are copied once per blob, not
once per hash (#1059)
Images carried inside a `.fig` message (`image.dataBlob` indexing the
message's `blobs`) were copied out before the message is dropped, one copy
per image hash. Nothing stops a file from pointing many hashes at one
blob: 100 hashes on a 250 MB blob made 25 GB of copies, an out-of-memory
abort from a small file.
The walk now records each hash's blob index, and `embedded_copies` makes
one shared copy per blob for every hash that names it, so the copies are
bounded by the message's own size. Each resolved image is still copied
once on its way into the document, where images are kept by content hash.
Regression: fifty hashes on one blob share one copy; a hash naming a
missing blob is left out.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
---
ROADMAP.md | 4 +++-
crates/format/src/fig.rs | 43 +++++++++++++++++++++++++---------
crates/format/src/fig/tests.rs | 17 ++++++++++++++
3 files changed, 52 insertions(+), 12 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 74a617f3..e0e3ed01 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (`.fig` embedded images copied once per blob (#1059); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs
index d81a5856..c7f4a382 100644
--- a/crates/format/src/fig.rs
+++ b/crates/format/src/fig.rs
@@ -47,6 +47,7 @@ mod text_style_tests;
use std::collections::BTreeMap;
use std::io::Read;
+use std::sync::Arc;
use figcraft_doc::Document;
@@ -109,9 +110,9 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
// The message goes before the layers are built, so the images it carries are copied out first.
- let embedded: BTreeMap> = embedded_images(&msg).into_iter().map(|(hash, bytes)| (hash, bytes.to_vec())).collect();
+ let embedded = embedded_copies(&msg);
drop(msg);
- let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
+ let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
// Both stages stop at the same cap and say so in the same words: one note (#981).
for w in more.into_iter().filter_map(map::adapt_warning) {
@@ -151,17 +152,17 @@ fn corrupt(msg: impl Into) -> FormatError {
/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's
/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed
/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The
-/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`]. The
-/// bytes stay borrowed from the message.
-fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap {
+/// walk is iterative (no recursion on nested data) and skips blobs larger than [`MAX_IMAGE`].
+/// Returns the index of each hash's blob.
+fn embedded_images(msg: &Kv) -> BTreeMap {
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
let mut found = BTreeMap::new();
let mut pending = vec![msg];
while let Some(value) = pending.pop() {
match value {
Kv::Object(fields) => {
- if let Some((hash, bytes)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
- found.entry(hash).or_insert(bytes);
+ if let Some((hash, index)) = value.get("image").and_then(|image| embedded_image(image, blobs)) {
+ found.entry(hash).or_insert(index);
}
pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field));
}
@@ -172,15 +173,35 @@ fn embedded_images<'m>(msg: &'m Kv) -> BTreeMap {
found
}
-/// The hash and bytes of an `Image` value whose `dataBlob` points at a usable blob.
-fn embedded_image<'k>(image: &Kv, blobs: &'k [Kv]) -> Option<(String, &'k [u8])> {
+/// The images that travel inside the message ([`embedded_images`]), copied out of it once per
+/// blob: every hash naming the same blob shares its copy. A file can point any number of hashes
+/// at one large blob, and a copy for each would multiply it (#1059).
+fn embedded_copies(msg: &Kv) -> BTreeMap> {
+ let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
+ let mut copies: BTreeMap> = BTreeMap::new();
+ let mut out = BTreeMap::new();
+ for (hash, index) in embedded_images(msg) {
+ let Some(bytes) = blob(blobs, index) else { continue };
+ out.insert(hash, Arc::clone(copies.entry(index).or_insert_with(|| Arc::from(bytes))));
+ }
+ out
+}
+
+/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob.
+fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> {
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
- let bytes = blobs.get(usize::try_from(*index).ok()?)?.get("bytes").and_then(Kv::bytes)?;
+ let index = usize::try_from(*index).ok()?;
+ let bytes = blob(blobs, index)?;
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE {
return None;
}
- Some((hash.iter().map(|b| format!("{b:02x}")).collect(), bytes))
+ Some((hash.iter().map(|b| format!("{b:02x}")).collect(), index))
+}
+
+/// The bytes of blob `index`.
+fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
+ blobs.get(index)?.get("bytes").and_then(Kv::bytes)
}
/// The parts of a `.fig` archive the import reads.
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
index 4ebb117f..c1253048 100644
--- a/crates/format/src/fig/tests.rs
+++ b/crates/format/src/fig/tests.rs
@@ -777,6 +777,23 @@ fn a_data_blob_past_the_blobs_stays_a_placeholder() {
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
}
+/// A file can point any number of image hashes at one large blob; each used to get its own copy
+/// of it (#1059).
+#[test]
+fn hashes_naming_one_blob_share_one_copy_of_it() {
+ let image = |hash: u8, blob: u64| o(vec![("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]);
+ let msg = o(vec![
+ ("blobs", Kv::Array(vec![o(vec![("bytes", Kv::Bytes(png()))]), o(vec![("bytes", Kv::Bytes(vec![7; 16]))])])),
+ ("nodeChanges", Kv::Array((1..=50).map(|h| image(h, 0)).chain([image(99, 1), image(98, 2)]).collect())),
+ ]);
+ let copies = embedded_copies(&msg);
+ assert_eq!(copies.len(), 51, "the hash naming no blob is left out");
+ let first = &copies[&"01".repeat(20)];
+ assert_eq!(**first, *png());
+ assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50);
+ assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
+}
+
#[test]
fn other_files_are_not_fig() {
assert!(try_import(b"{\"document\": {}}", "x").is_none());
From 652bf35b196624c9881c797b986b5be4c90f9a90 Mon Sep 17 00:00:00 2001
From: Filip Strajnar
Date: Wed, 28 Oct 2026 21:58:16 +0100
Subject: [PATCH 49/60] M1: page ruler guides survive save and reopen (#1476)
A page kept its ruler guides in `NodeKind::Page`, and since frame guides
(#70) every layer also has its own `guides` field. Both are written as
`guides`, and on reading the layer's own field takes the key, so:
- a reopened page's guides landed in the layer field and were not drawn;
- saving that page wrote `guides` twice (the page kind's list, always
written, then the layer's), and the file no longer opened ("duplicate
field `guides`").
Every layer, pages included, now keeps its guides in the one layer field;
the key in the file is unchanged, so existing files open with their guides.
The field reads every `guides` list a layer has, so files already saved
with the key twice open again with both lists, in file order. This also
covers collab `Create` payloads from older peers.
Regressions: JSON round trips of page and frame guides, a page with two
lists, a bad list still refused (doc); save, reopen, save, reopen through
`.figcraft` and a document.json with the key twice (format). Checked in the
app over the control channel: guides drawn after two save/reopen cycles, and
a file with the key twice opens with all its guides.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
---
ROADMAP.md | 4 +-
crates/doc/src/client_id_tests.rs | 4 +-
crates/doc/src/guides_tests.rs | 61 +++++++++++++++++
crates/doc/src/lib.rs | 11 ++--
crates/doc/src/library.rs | 2 +-
crates/doc/src/node.rs | 91 ++++++++++++++++++++++----
crates/engine/src/cmd/page.rs | 8 ++-
crates/engine/src/tests.rs | 6 +-
crates/engine/src/theme_pages_tests.rs | 11 +++-
crates/format/src/fig/export/layer.rs | 4 +-
crates/format/src/guides_tests.rs | 45 +++++++++++++
crates/format/src/lib.rs | 2 +
12 files changed, 215 insertions(+), 34 deletions(-)
create mode 100644 crates/doc/src/guides_tests.rs
create mode 100644 crates/format/src/guides_tests.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index 74a617f3..662c2e3d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -348,6 +349,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
diff --git a/crates/doc/src/client_id_tests.rs b/crates/doc/src/client_id_tests.rs
index 520895fb..94b676c2 100644
--- a/crates/doc/src/client_id_tests.rs
+++ b/crates/doc/src/client_id_tests.rs
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
assert!(d.validate().is_empty());
}
+/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
+/// that isn't a page, and repairs into a file with a page.
#[test]
fn pages_can_be_inserted_onto_a_page_less_base() {
let mut d = Document::without_pages("snapshot");
assert!(d.pages.is_empty() && d.node_count() == 0);
let p = Node::new(
NodeId(C3 | 1),
- NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
+ NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
"P",
0.0,
0.0,
diff --git a/crates/doc/src/guides_tests.rs b/crates/doc/src/guides_tests.rs
new file mode 100644
index 00000000..82926e5d
--- /dev/null
+++ b/crates/doc/src/guides_tests.rs
@@ -0,0 +1,61 @@
+//! Ruler guides survive a save and reopen (#1476).
+
+use crate::{Document, Guide, Node, NodeKind};
+
+/// A guide: vertical (at an x offset) or horizontal (at a y offset).
+fn guide(vertical: bool, offset: f64) -> Guide {
+ Guide { vertical, offset }
+}
+
+/// A page's guides are written once and read back onto the page.
+#[test]
+fn page_guides_round_trip_through_json() {
+ let mut d = Document::new("a");
+ let page = d.first_page().unwrap();
+ d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0));
+ let json = serde_json::to_string(d.node(page).unwrap()).unwrap();
+ assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
+ let back: Node = serde_json::from_str(&json).unwrap();
+ assert_eq!(back.guides(), &[guide(true, 120.0)]);
+ assert_eq!(back, *d.node(page).unwrap());
+}
+
+/// A frame's guides round trip too, and a layer without guides writes no `guides` key.
+#[test]
+fn frame_guides_round_trip_and_none_are_left_out() {
+ let mut d = Document::new("a");
+ let page = d.first_page().unwrap();
+ let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
+ for id in [page, frame] {
+ let json = serde_json::to_value(d.node(id).unwrap()).unwrap();
+ assert!(json.get("guides").is_none(), "{json}");
+ }
+ d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0));
+ let json = serde_json::to_string(d.node(frame).unwrap()).unwrap();
+ assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
+ let back: Node = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, *d.node(frame).unwrap());
+}
+
+/// A page saved with two `guides` keys reads both lists, in file order, and writes them once.
+#[test]
+fn a_page_written_with_two_guides_lists_opens_with_both() {
+ // How a page was saved after its file had been reopened: the page kind's list (guides added
+ // since), then the layer's (the ones the file had).
+ let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}],
+ "width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#;
+ let n: Node = serde_json::from_str(json).unwrap();
+ assert!(matches!(n.kind, NodeKind::Page { .. }));
+ assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]);
+ let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
+ assert_eq!(again.guides(), n.guides());
+}
+
+/// A `guides` value that isn't a list of guides is still refused.
+#[test]
+fn a_bad_guides_list_is_still_an_error() {
+ for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] {
+ let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#);
+ assert!(serde_json::from_str::(&json).is_err(), "{bad}");
+ }
+}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index caf0c861..1214e3cf 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -407,13 +407,8 @@ impl Document {
/// Add a page at the end.
pub fn add_page(&mut self, name: &str) -> NodeId {
let id = self.alloc_id();
- let node = Node::new(
- id,
- NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
- name,
- 0.0,
- 0.0,
- );
+ let node =
+ Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0);
self.nodes.insert(id, Arc::new(node));
self.dirty.touch(id);
self.pages.push(id);
@@ -1258,6 +1253,8 @@ mod diff_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
+mod guides_tests;
+#[cfg(test)]
mod handoff_tests;
#[cfg(test)]
mod ids_tests;
diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs
index 35994bb0..fe53ec29 100644
--- a/crates/doc/src/library.rs
+++ b/crates/doc/src/library.rs
@@ -260,7 +260,7 @@ impl Document {
let id = self.alloc_id();
let node = Node::new(
id,
- NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
+ NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() },
STORE_NAME,
0.0,
0.0,
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index 3146047a..bcf8f09d 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -66,6 +66,9 @@ pub enum PropDef {
}
/// What a node is, with per-type data.
+// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
+// Page's guides moved to the node (#1476).
+#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum NodeKind {
@@ -76,8 +79,6 @@ pub enum NodeKind {
flows: Vec,
#[serde(default)]
prototype: crate::proto::PrototypeSettings,
- #[serde(default)]
- guides: Vec,
},
Frame,
Group,
@@ -472,8 +473,8 @@ pub struct Node {
pub mask_type: MaskType,
#[serde(default, skip_serializing_if = "is_default")]
pub grids: Vec,
- /// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
- #[serde(default, skip_serializing_if = "is_default")]
+ /// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]).
+ #[serde(flatten, with = "guides_key")]
pub guides: Vec,
#[serde(default, skip_serializing_if = "is_default")]
pub exports: Vec,
@@ -578,6 +579,77 @@ fn is_identity(v: &Affine) -> bool {
*v == identity()
}
+/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is
+/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the
+/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two
+/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order.
+mod guides_key {
+ use super::Guide;
+ use serde::de::{IgnoredAny, MapAccess, Visitor};
+ use serde::ser::SerializeMap;
+ use serde::{Deserialize, Deserializer, Serializer};
+ use std::fmt;
+
+ const KEY: &str = "guides";
+
+ /// Write `guides` as the layer's `guides` key, or nothing when there are none.
+ pub fn serialize(guides: &[Guide], s: S) -> Result {
+ let mut map = s.serialize_map(None)?;
+ if !guides.is_empty() {
+ map.serialize_entry(KEY, guides)?;
+ }
+ map.end()
+ }
+
+ /// Read every `guides` list among the layer's fields, joined in file order.
+ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> {
+ d.deserialize_map(Lists)
+ }
+
+ /// Reads the `guides` lists of a layer's fields and skips the other fields.
+ struct Lists;
+
+ impl<'de> Visitor<'de> for Lists {
+ type Value = Vec;
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("a layer's fields")
+ }
+ /// Every `guides` value joined, in order; a malformed one is an error.
+ fn visit_map>(self, mut map: A) -> Result, A::Error> {
+ let mut guides = Vec::new();
+ while let Some(key) = map.next_key::()? {
+ if key.0 {
+ guides.extend(map.next_value::>()?);
+ } else {
+ map.next_value::()?;
+ }
+ }
+ Ok(guides)
+ }
+ }
+
+ /// Whether a field name is `guides` (without copying the name).
+ struct Key(bool);
+
+ impl<'de> Deserialize<'de> for Key {
+ /// Compare a field name with `guides` as it is read.
+ fn deserialize>(d: D) -> Result {
+ struct Name;
+ impl Visitor<'_> for Name {
+ type Value = Key;
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("a field name")
+ }
+ /// `guides` or another field.
+ fn visit_str(self, s: &str) -> Result {
+ Ok(Key(s == KEY))
+ }
+ }
+ d.deserialize_identifier(Name)
+ }
+ }
+}
+
impl Node {
/// A node with Figma's defaults for its kind.
pub fn new(id: NodeId, kind: NodeKind, name: impl Into, width: f64, height: f64) -> Self {
@@ -712,17 +784,12 @@ impl Node {
/// The ruler guides on this page or frame, in its own coordinates.
pub fn guides(&self) -> &[Guide] {
- match &self.kind {
- NodeKind::Page { guides, .. } => guides,
- _ => &self.guides,
- }
+ &self.guides
}
+ /// The ruler guides on this page or frame, to edit.
pub fn guides_mut(&mut self) -> &mut Vec {
- match &mut self.kind {
- NodeKind::Page { guides, .. } => guides,
- _ => &mut self.guides,
- }
+ &mut self.guides
}
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs
index 44c7b367..fefd8c73 100644
--- a/crates/engine/src/cmd/page.rs
+++ b/crates/engine/src/cmd/page.rs
@@ -33,6 +33,7 @@ fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result {
Ok(target)
}
+/// The page and ruler guide commands.
pub fn specs() -> Vec {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
@@ -106,8 +107,10 @@ pub fn specs() -> Vec {
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
+ let guides = doc.get(src)?.guides.clone();
+ doc.get_mut(np)?.guides = guides;
+ if let NodeKind::Page { background, flows, prototype } = doc.get(src)?.kind.clone()
+ && let NodeKind::Page { background: b, flows: f, prototype: pr } = &mut doc.get_mut(np)?.kind
{
*b = background;
*f = flows
@@ -115,7 +118,6 @@ pub fn specs() -> Vec {
.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) {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 811724f2..b330b8e4 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -616,13 +616,11 @@ fn document_colors_are_counted_per_page() {
assert!(v["colors"].as_array().unwrap().is_empty());
}
+/// `guide.add`, `guide.move` and `guide.remove` edit the current page's guides by index.
#[test]
fn guides_add_move_remove() {
let mut s = sess();
- let guides = |s: &Session| match &s.doc().unwrap().node(s.page().unwrap()).unwrap().kind {
- NodeKind::Page { guides, .. } => guides.clone(),
- _ => vec![],
- };
+ let guides = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().guides.clone();
let r = s.execute("guide.add", &json!({"vertical": true, "offset": 120})).unwrap();
assert_eq!(r["index"], json!(0));
s.execute("guide.add", &json!({"vertical": false, "offset": 40.5})).unwrap();
diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs
index 5172bb4b..1ebd78b0 100644
--- a/crates/engine/src/theme_pages_tests.rs
+++ b/crates/engine/src/theme_pages_tests.rs
@@ -183,6 +183,8 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
}
+/// `page.duplicate` copies the page's flows (pointing at the copied frames), prototype settings
+/// and guides.
#[test]
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let mut s = Session::new();
@@ -195,9 +197,12 @@ fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
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 state = |s: &Session, id: NodeId| {
+ let n = s.doc().unwrap().node(id).unwrap();
+ match &n.kind {
+ NodeKind::Page { flows, prototype, .. } => (flows.clone(), prototype.clone(), n.guides.clone()),
+ _ => panic!("expected a page"),
+ }
};
let (flows, prototype, guides) = state(&s, copy);
let (src_flows, src_prototype, src_guides) = state(&s, source);
diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs
index 55fd6dd9..4a8604fc 100644
--- a/crates/format/src/fig/export/layer.rs
+++ b/crates/format/src/fig/export/layer.rs
@@ -19,11 +19,11 @@ impl<'d> Exporter<'d> {
("phase", e("CREATED")),
("parentIndex", o(vec![("guid", parent_guid), ("position", s(&position(index)))])),
];
- let children = if let NodeKind::Page { background, guides, prototype, .. } = &n.kind {
+ let children = if let NodeKind::Page { background, prototype, .. } = &n.kind {
f.extend([("type", e("CANVAS")), ("name", s(&n.name))]);
f.extend(root_fields());
f.extend([("backgroundColor", color(*background)), ("backgroundOpacity", fl(background.a.into())), ("backgroundEnabled", b(true))]);
- if !guides.is_empty() {
+ if !n.guides.is_empty() {
self.warn("ruler guides were left out");
}
if *prototype != Default::default() {
diff --git a/crates/format/src/guides_tests.rs b/crates/format/src/guides_tests.rs
new file mode 100644
index 00000000..7fcbc8f1
--- /dev/null
+++ b/crates/format/src/guides_tests.rs
@@ -0,0 +1,45 @@
+//! Ruler guides in `.figcraft` files: a reopened page keeps its guides, and files saved with a
+//! page's guides written twice open again (#1476).
+
+use figcraft_doc::{Document, Guide, NodeKind};
+
+use super::*;
+
+/// A guide: vertical (at an x offset) or horizontal (at a y offset).
+fn guide(vertical: bool, offset: f64) -> Guide {
+ Guide { vertical, offset }
+}
+
+/// Page and frame guides survive saving, reopening, saving the reopened file and opening it again.
+#[test]
+fn page_and_frame_guides_survive_save_and_reopen() {
+ let mut d = Document::new("ruler");
+ let page = d.first_page().unwrap();
+ let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
+ d.node_mut(page).unwrap().guides_mut().extend([guide(true, 120.0), guide(false, -30.5)]);
+ d.node_mut(frame).unwrap().guides_mut().push(guide(true, 12.0));
+ let (once, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
+ assert!(issues.is_empty(), "{issues:?}");
+ assert_eq!(once.node(page).unwrap().guides(), d.node(page).unwrap().guides());
+ assert_eq!(once.node(frame).unwrap().guides(), d.node(frame).unwrap().guides());
+ // Saving the reopened file and opening it again (where the file stopped opening).
+ let text = serde_json::to_string(&to_json(&once).unwrap()).unwrap();
+ assert_eq!(text.matches("\"guides\"").count(), 2, "one list on the page, one on the frame: {text}");
+ let (twice, _) = load_bytes(&save_bytes(&once, None).unwrap()).unwrap();
+ assert_eq!(twice.node(page).unwrap().guides(), d.node(page).unwrap().guides());
+}
+
+/// A `document.json` with a page's `guides` key twice (as saving a reopened page wrote it) opens
+/// with both lists.
+#[test]
+fn a_file_with_a_page_guides_list_written_twice_opens() {
+ let mut d = Document::new("twice");
+ let page = d.first_page().unwrap();
+ d.node_mut(page).unwrap().guides_mut().push(guide(false, 7.0));
+ let text = serde_json::to_string(&to_json(&d).unwrap()).unwrap();
+ // What saving a reopened page wrote: the guides added since, then the ones the file had.
+ let broken = text.replacen("\"guides\":[", "\"guides\":[{\"vertical\":true,\"offset\":5.0}],\"guides\":[", 1);
+ assert_eq!(broken.matches("\"guides\"").count(), 2);
+ let (back, _) = from_json(&broken).unwrap();
+ assert_eq!(back.node(page).unwrap().guides(), &[guide(true, 5.0), guide(false, 7.0)]);
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 30f6e548..634d0363 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -977,6 +977,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
+mod guides_tests;
+#[cfg(test)]
mod image_budget_tests;
#[cfg(test)]
mod image_tests;
From 53f47706ecee78284d7545c461edec587c848d9d Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 00:37:22 +0800
Subject: [PATCH 50/60] fix(present): prototype links open only behind the
notice or a viewer's click (#1096)
An Open link action carries a URL from the document and fires from whatever
trigger the prototype sets, so an unattended presentation could open web links
and mail messages one after another: a mail link bypassed the leaving-the-
prototype notice entirely, and a skipped notice let any trigger open any
number of links.
- finish() routes every openable URL, mail included, through the notice.
- Skipping the notice ("Don't show this again") opens at once only a link the
viewer's own click, press, drag or key fired, one per frame. A delay, the
pointer passing over a layer (enter, leave, hover) and a timed mouse
trigger that fires after the gesture is over still show the notice, which
then offers no checkbox: it couldn't skip that notice anyway.
- The player tags each queued link with whether the viewer fired it
(Presenter::fire_from); an OpenUrl outside an interaction defaults to not.
- The notice covers mail links (new locale string, mail wording).
- Engine prefs/command doc text follows the rule; behavior untouched there.
- present_links_tests: shared setup helpers for the link tests; new
tests for the skip rule (delay, hover, delayed mouse-up, key, one link per
click, checkbox only for clicked links); the scheme-filter test now clicks
with the notice skipped so the filter alone keeps those URLs out.
- docs/prototyping-parity.md row, status line and revision history updated.
---
crates/engine/src/cmd/proto.rs | 2 +-
crates/engine/src/prefs.rs | 6 +-
crates/ui-egui/locales/en.toml | 1 +
crates/ui-egui/src/present.rs | 12 +-
crates/ui-egui/src/present/actions.rs | 27 ++-
crates/ui-egui/src/present/chrome.rs | 39 ++--
crates/ui-egui/src/present_links_tests.rs | 211 +++++++++++++++++++---
docs/prototyping-parity.md | 5 +-
8 files changed, 248 insertions(+), 55 deletions(-)
diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs
index c451eeb2..d0822db9 100644
--- a/crates/engine/src/cmd/proto.rs
+++ b/crates/engine/src/cmd/proto.rs
@@ -161,7 +161,7 @@ pub fn specs() -> Vec {
spec!(
"proto.linkNotice",
"Notice before leaving for a link",
- "{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}",
+ "{show? (default true; false skips it)} whether a presented prototype's Open link the viewer clicked (or fired by a key) first says that it leaves the prototype, with Open link and Cancel (a link a delay or hover fired always does); kept with the preferences → {shown}",
false,
|s, p| {
let show = boolean(p, "show").unwrap_or(true);
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index ced1793f..35a26bc9 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -82,8 +82,10 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
- /// A prototype's Open link goes to a website without first telling the viewer that it leaves
- /// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again).
+ /// A prototype's Open link — to a website or to the mail app — that the viewer's own click,
+ /// press, drag or key fired opens without first telling them that it leaves the prototype
+ /// (the notice's "Don't show this again"; `proto.linkNotice` shows it again). A link a delay
+ /// or the pointer passing over a layer fired still shows the notice (#1096).
pub link_notice_skipped: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index b0afcdf7..ce98cf75 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -1487,6 +1487,7 @@ name = "English"
"This frame is new on the branch." = "This frame is new on the branch."
"This frame isn't on the branch." = "This frame isn't on the branch."
"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:"
+"This link sends a mail message through your mail app:" = "This link sends a mail message through your mail app:"
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs
index 64a3b70a..8e394a86 100644
--- a/crates/ui-egui/src/present.rs
+++ b/crates/ui-egui/src/present.rs
@@ -299,10 +299,14 @@ pub struct Presenter {
clock: f64,
/// The layer the Inspect panel shows (the last one under the pointer).
inspected: Option,
- urls: Vec,
- /// A link to a website waiting for the viewer to open it ([`link_notice`]), and whether "Don't
- /// show this again" is ticked.
- link_notice: Option<(String, bool)>,
+ /// The links the last frame's actions opened, and whether the viewer's own click, press, drag
+ /// or key fired each; the frame-end queues them behind the notice, but for one the viewer
+ /// fired while choosing to skip the notice (#1096).
+ urls: Vec<(String, bool)>,
+ /// A link waiting for the viewer to open it ([`link_notice`]), and whether "Don't show this
+ /// again" is ticked; `None` when the notice doesn't offer it (the viewer didn't fire the link,
+ /// so skipping the notice wouldn't cover it, or they skip it already).
+ link_notice: Option<(String, Option)>,
/// The editor's document as last copied (the player copies it again when it changes).
source: Option>,
/// Scrolling by dragging: what it scrolls and its offset when the drag began.
diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs
index ad2c646e..5cd4e03e 100644
--- a/crates/ui-egui/src/present/actions.rs
+++ b/crates/ui-egui/src/present/actions.rs
@@ -36,14 +36,23 @@ impl Presenter {
self.waiting.retain(|w| w.root == root);
let mut did = false;
for w in due.into_iter().filter(|w| w.root == root) {
- // An earlier one may have left the screen: the rest are dropped with it.
- did = did || self.fire(w.node, w.k, w.rect, now);
+ // An earlier one may have left the screen: the rest are dropped with it. The gesture
+ // is long over: what these open is not the viewer's doing (#1096).
+ did = did || self.fire_from(w.node, w.k, w.rect, now, false);
}
did
}
/// Run the actions of interaction `k` on `node`. `rect` is the layer's screen rect.
pub(crate) fn fire(&mut self, node: NodeId, k: usize, rect: Option, now: f64) -> bool {
+ self.fire_from(node, k, rect, now, true)
+ }
+
+ /// [`fire`](Self::fire); `live`: the viewer's input fired it just now (not a delay it waited
+ /// out). The links it opens are the viewer's own only when live from a click, press, drag or
+ /// key: a delay, the pointer passing over a layer and a timed mouse trigger fire without the
+ /// viewer asking for anything ([`Presenter::urls`], #1096).
+ fn fire_from(&mut self, node: NodeId, k: usize, rect: Option, now: f64, live: bool) -> bool {
let Some(it) = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).cloned() else { return false };
let dur = it.easing.seconds(it.duration_ms);
let temp = match it.trigger {
@@ -53,9 +62,18 @@ impl Presenter {
};
let snap = if temp.is_some() && self.temp.is_none() { self.snapshot() } else { None };
let mut did = false;
+ let first_link = self.urls.len();
for a in &it.actions {
did |= self.perform(a, node, it.transition, dur, it.easing, rect, now);
}
+ let asked = live
+ && matches!(
+ it.trigger,
+ Trigger::OnClick | Trigger::OnPress | Trigger::OnDrag | Trigger::MouseDown | Trigger::MouseUp | Trigger::KeyPress { .. }
+ );
+ for link in self.urls.iter_mut().skip(first_link) {
+ link.1 = asked;
+ }
if did && let (Some(press), Some(snap), Some(rect)) = (temp, snap, rect) {
self.temp = Some(Temp { rect, press, snap, transition: it.transition, dur, easing: it.easing });
}
@@ -178,9 +196,10 @@ impl Presenter {
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
// The URL comes from the document and this action fires from whatever trigger the
- // prototype sets, including a delay, so no click is implied: only a web link opens.
+ // prototype sets, including a delay, so no click is implied: only a web or mail
+ // link is kept, as not the viewer's doing until `fire_from` says otherwise.
if figcraft_engine::doc_urls::openable(url) {
- self.urls.push(url.clone());
+ self.urls.push((url.clone(), false));
}
true
}
diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs
index eeaba1ce..12399ced 100644
--- a/crates/ui-egui/src/present/chrome.rs
+++ b/crates/ui-egui/src/present/chrome.rs
@@ -239,15 +239,20 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) {
crate::repaint_in(ctx, PAD_POLL_SECS);
}
-/// After a player's frame: the links it opens and the widget clicks it took. A link to a website
-/// waits in [`link_notice`] unless the viewer chose to skip the notice; a second link to a website
-/// that fires while the notice shows is dropped.
+/// After a player's frame: the links it opens and the widget clicks it took. Every link waits in
+/// [`link_notice`] — to a website or to the mail app (#1096). Skipping the notice opens at once
+/// only a link the viewer's own click, press, drag or key fired, one per frame: a delay or the
+/// pointer passing over a layer never opens anything unasked. A further link that fires while the
+/// notice shows is dropped.
fn finish(app: &mut FigApp, p: &mut Presenter) {
- for u in p.urls.drain(..) {
- if app.session.prefs.link_notice_skipped || !goes_to_a_website(&u) {
+ let skipped = app.session.prefs.link_notice_skipped;
+ let mut opened = false;
+ for (u, asked) in p.urls.drain(..) {
+ if skipped && asked && !opened {
app.services.open_url(&u);
+ opened = true;
} else if p.link_notice.is_none() {
- p.link_notice = Some((u, false));
+ p.link_notice = Some((u, (asked && !skipped).then_some(false)));
}
}
if let Some(e) = p.click_widgets(&app.session) {
@@ -255,24 +260,32 @@ fn finish(app: &mut FigApp, p: &mut Presenter) {
}
}
-/// Whether `url` is an `http` or `https` link (a `mailto:` link opens the mail app instead).
+/// Whether `url` is an `http` or `https` link (of the openable URLs, the others are `mailto:`;
+/// [`figcraft_engine::doc_urls`]).
fn goes_to_a_website(url: &str) -> bool {
url.trim().split_once(':').is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
}
-/// The notice before a link leaves the prototype for a website: the link, "Don't show this
-/// again" (saved with the preferences when Open link is pressed), Open link and Cancel. Escape or
-/// the close button cancels.
+/// The notice before a link leaves the prototype — for a website, or for the mail app: the link,
+/// "Don't show this again" for a link the viewer fired (saved with the preferences when Open link
+/// is pressed), Open link and Cancel. Escape or the close button cancels.
fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) {
let Some((url, skip)) = p.link_notice.as_mut() else { return };
let t = Tokens::get(ctx);
let (mut open, mut go, mut cancel) = (true, false, false);
crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| {
ui.set_width(340.0);
- widgets::label(ui, tr("This link opens a website outside the prototype:"), t.text);
+ let what = if goes_to_a_website(url) {
+ tr("This link opens a website outside the prototype:")
+ } else {
+ tr("This link sends a mail message through your mail app:")
+ };
+ widgets::label(ui, what, t.text);
ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap());
ui.add_space(8.0);
- if widgets::checkbox(ui, *skip, tr("Don't show this again")) {
+ if let Some(skip) = skip.as_mut()
+ && widgets::checkbox(ui, *skip, tr("Don't show this again"))
+ {
*skip = !*skip;
}
ui.add_space(12.0);
@@ -284,7 +297,7 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id)
cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if go {
let Some((url, skip)) = p.link_notice.take() else { return };
- if skip {
+ if skip == Some(true) {
app.run("proto.linkNotice", json!({"show": false}));
app.services.save_prefs(&app.session.prefs);
}
diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs
index a2b571af..03c82552 100644
--- a/crates/ui-egui/src/present_links_tests.rs
+++ b/crates/ui-egui/src/present_links_tests.rs
@@ -1,9 +1,13 @@
//! A prototype's Open link action carries a URL from the document and fires from whatever trigger
//! the prototype sets, including a delay, so the player hands the opener only a web link
//! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web
-//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. A link to a
-//! website then waits behind a notice that it leaves the prototype, unless the viewer chose to skip
-//! the notice.
+//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. Every
+//! openable link waits behind a notice that it leaves the prototype — to a website or to the
+//! mail app (#1096). Skipping the notice opens at once only a link the viewer's own click, press,
+//! drag or key fired, one per frame; a delay or the pointer passing over a layer never opens
+//! anything unasked. Before, a mail link opened at once however it was fired, and with the notice
+//! skipped any trigger opened any number of links: an unattended presentation could open links and
+//! mail messages one after another.
use std::sync::{Arc, Mutex};
@@ -35,46 +39,76 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
-/// A presented frame whose layer opens `url` 100ms after the screen appears, with no click
-/// anywhere, and with the notice skipped or not. Returns the harness and what the player opened.
+/// Frame A presented by `cmd` (`proto.present` or `proto.preview`) with the notice skipped or
+/// not, its `interaction` (a trigger and maybe `delayMs`) opening each of `urls`. Returns the
+/// harness and what the player opened.
+fn playing(cmd: &str, skip_notice: bool, interaction: Value, urls: &[&str]) -> (Harness, Opened) {
+ let mut s = Session::new();
+ s.prefs.link_notice_skipped = skip_notice;
+ let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
+ let mut it = interaction;
+ it["id"] = json!(f);
+ it["actions"] = urls.iter().map(|u| json!({"type": "openUrl", "url": u})).collect();
+ it["transition"] = json!({"type": "instant"});
+ ex(&mut s, "proto.interaction.add", it);
+ present(cmd, s, f)
+}
+
+/// `s` presented by `cmd` from frame `start`, with the opener and the preferences recorded.
+fn present(cmd: &str, s: Session, start: figcraft_doc::NodeId) -> (Harness, Opened) {
+ let seen = Opened::default();
+ let mut u = Harness::new(s);
+ u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone()));
+ u.ctx.enable_accesskit();
+ u.run(cmd, json!({"start": start}));
+ u.frames(2, Vec::new);
+ (u, seen)
+}
+
+/// A presented frame that opens `url` 100ms after the screen appears, with no click anywhere,
+/// and with the notice skipped or not. Returns the harness and what the player opened.
fn presented(url: &str, skip_notice: bool) -> (Harness, Opened) {
played("proto.present", url, skip_notice)
}
/// [`presented`], played by `cmd` (`proto.present` or `proto.preview`).
fn played(cmd: &str, url: &str, skip_notice: bool) -> (Harness, Opened) {
- let mut s = Session::new();
- s.prefs.link_notice_skipped = skip_notice;
- let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
- let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
- ex(
- &mut s,
- "proto.interaction.add",
- json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}),
- );
- let seen = Opened::default();
- let mut u = Harness::new(s);
- u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone()));
- u.ctx.enable_accesskit();
- u.run(cmd, json!({"start": f}));
+ let (mut u, seen) = playing(cmd, skip_notice, json!({"trigger": {"type": "afterDelay", "ms": 100}}), &[url]);
// Well past the delay.
u.frames(40, Vec::new);
(u, seen)
}
+/// A click on the presented frame, and a few frames for what it fires.
+fn click_the_frame(u: &mut Harness) {
+ let at = egui::pos2(700.0, 290.0);
+ u.button(at, true);
+ u.button(at, false);
+ u.frames(3, Vec::new);
+}
+
+/// What a click on a frame whose click opens `url` opens, with the notice skipped.
+fn opens_on_a_click(url: &str) -> Vec {
+ let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "onClick"}}), &[url]);
+ click_the_frame(&mut u);
+ opened(&seen)
+}
+
fn opened(seen: &Opened) -> Vec {
seen.0.lock().map(|v| v.clone()).unwrap_or_default()
}
-fn opens_after_a_delay(url: &str) -> Vec {
- opened(&presented(url, true).1)
-}
-
/// The URL the presenter's notice waits to open.
fn notice(u: &Harness) -> Value {
u.app.inspect()["presenter"]["linkNotice"].clone()
}
+/// The labels of the controls drawn now.
+fn labels(u: &mut Harness) -> Vec {
+ let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
+ tree.into_iter().filter_map(|(_, n)| n.label().map(str::to_string)).collect()
+}
+
/// Clicks the control labeled `label`.
fn click(u: &mut Harness, label: &str) {
let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
@@ -87,10 +121,128 @@ fn click(u: &mut Harness, label: &str) {
#[test]
fn a_prototype_opens_web_links() {
- assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]);
- assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]);
- // A mail link opens the mail app without the notice.
- assert_eq!(opened(&presented("mailto:a@b.c", false).1), ["mailto:a@b.c"]);
+ // The viewer chose to skip the notice: their yes, kept, for the links they click.
+ assert_eq!(opens_on_a_click("https://example.com/a"), ["https://example.com/a"]);
+ assert_eq!(opens_on_a_click("mailto:a@b.c"), ["mailto:a@b.c"]);
+ // With the notice on a mail link waits behind it like a website: nothing reaches the mail
+ // app, or any opener, unasked (#1096).
+ let (mut u, seen) = presented("mailto:a@b.c", false);
+ assert!(opened(&seen).is_empty(), "a mail link fired without a click waits behind the notice");
+ assert_eq!(notice(&u), "mailto:a@b.c");
+ click(&mut u, "Open link");
+ assert_eq!(opened(&seen), ["mailto:a@b.c"]);
+ assert!(notice(&u).is_null());
+}
+
+/// Skipping the notice covers what the viewer does, not what the prototype does by itself: a link
+/// on a delay still waits behind the notice, which then offers no "Don't show this again", as
+/// that couldn't cover it (#1096).
+#[test]
+fn a_skipped_notice_still_shows_for_a_link_no_click_fired() {
+ for url in ["https://example.com/a", "mailto:a@b.c"] {
+ let (mut u, seen) = presented(url, true);
+ assert!(opened(&seen).is_empty(), "{url}: a delay opened it with the notice skipped");
+ assert_eq!(notice(&u), url);
+ assert!(!labels(&mut u).iter().any(|l| l == "Don't show this again"), "{url}: the checkbox can't skip this notice");
+ click(&mut u, "Open link");
+ assert_eq!(opened(&seen), [url]);
+ }
+}
+
+/// A clicked link the notice first shows for offers "Don't show this again" (#1096).
+#[test]
+fn the_notice_for_a_clicked_link_offers_to_skip_it() {
+ let (mut u, _) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
+ click_the_frame(&mut u);
+ assert_eq!(notice(&u), "https://example.com/a");
+ assert!(labels(&mut u).iter().any(|l| l == "Don't show this again"));
+}
+
+/// With the notice skipped one click opens one link, however many its interaction opens: the
+/// next waits behind the notice and the rest are dropped (#1096).
+#[test]
+fn a_click_opens_one_link_with_the_notice_skipped() {
+ let urls = ["https://example.com/1", "https://example.com/2", "mailto:a@b.c"];
+ let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "onClick"}}), &urls);
+ click_the_frame(&mut u);
+ assert_eq!(opened(&seen), ["https://example.com/1"]);
+ assert_eq!(notice(&u), "https://example.com/2");
+}
+
+/// The pointer passing over a layer is not the viewer asking for a link: with the notice skipped,
+/// a mouse enter's link still waits behind it (#1096).
+#[test]
+fn a_link_the_pointer_fires_by_passing_waits_with_the_notice_skipped() {
+ let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "mouseEnter"}}), &["https://example.com/hover"]);
+ u.frame(vec![egui::Event::PointerMoved(egui::pos2(700.0, 290.0))]);
+ u.frames(3, Vec::new);
+ assert!(opened(&seen).is_empty(), "passing over the layer opened it");
+ assert_eq!(notice(&u), "https://example.com/hover");
+}
+
+/// A presentation whose prototype queues several mail links on delays opens none of them by
+/// itself: the first waits behind the notice and the rest are dropped with it (#1096).
+#[test]
+fn delayed_opens_do_not_pile_up_unattended() {
+ let mut s = Session::new();
+ s.prefs.link_notice_skipped = true;
+ let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
+ for ms in [100u32, 200, 300] {
+ let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
+ ex(
+ &mut s,
+ "proto.interaction.add",
+ json!({"id": layer, "trigger": {"type": "afterDelay", "ms": ms}, "action": {"type": "openUrl", "url": format!("mailto:victim@example.com?subject=spam{ms}")}}),
+ );
+ }
+ let (mut u, seen) = present("proto.present", s, f);
+ u.frames(40, Vec::new); // no click, no key: the presentation alone
+ assert!(opened(&seen).is_empty(), "a presentation by itself opens nothing, the notice skipped or not");
+ assert_eq!(notice(&u), "mailto:victim@example.com?subject=spam100", "the first waits, the rest are dropped");
+ click(&mut u, "Open link");
+ assert_eq!(opened(&seen), ["mailto:victim@example.com?subject=spam100"]);
+}
+
+/// A mail link the viewer clicks is still leaving the prototype: it waits behind the notice like
+/// every other link (#1096). Before, a mail link opened straight away, trigger or no trigger.
+#[test]
+fn a_clicked_mail_link_waits_behind_the_notice() {
+ let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["mailto:a@b.c?subject=hello"]);
+ click_the_frame(&mut u);
+ assert!(opened(&seen).is_empty(), "the notice comes first, whatever fired the link");
+ assert_eq!(notice(&u), "mailto:a@b.c?subject=hello");
+ click(&mut u, "Open link");
+ assert_eq!(opened(&seen), ["mailto:a@b.c?subject=hello"]);
+}
+
+/// A mouse-up whose actions run on a delay fires once the click is long over; its link waits
+/// behind the notice like a timed one does, the notice skipped or not (#1096).
+#[test]
+fn a_link_a_click_queued_to_fire_later_waits_behind_the_notice() {
+ for skip in [false, true] {
+ let (mut u, seen) = playing("proto.present", skip, json!({"trigger": {"type": "mouseUp"}, "delayMs": 150}), &["https://example.com/late"]);
+ click_the_frame(&mut u);
+ u.frames(20, Vec::new); // well past the 150 ms the click queued
+ assert!(opened(&seen).is_empty(), "skip {skip}: the actions fire after the click is over: not a gesture");
+ assert_eq!(notice(&u), "https://example.com/late");
+ }
+}
+
+/// A link a key trigger fires waits behind the notice too, and opens at once with it skipped: a
+/// key is the viewer's own doing (#1096).
+#[test]
+fn a_link_fired_by_a_key_waits_behind_the_notice() {
+ for skip in [false, true] {
+ let (mut u, seen) = playing("proto.present", skip, json!({"trigger": {"type": "keyPress", "key": "K"}}), &["https://example.com/keyed"]);
+ u.key(egui::pos2(700.0, 450.0), egui::Key::K);
+ u.frames(3, Vec::new);
+ if skip {
+ assert_eq!(opened(&seen), ["https://example.com/keyed"]);
+ } else {
+ assert!(opened(&seen).is_empty(), "a key is not a click");
+ assert_eq!(notice(&u), "https://example.com/keyed");
+ }
+ }
}
#[test]
@@ -192,7 +344,8 @@ fn the_prototype_takes_no_controller_buttons_while_the_notice_shows() {
#[test]
fn dont_show_this_again_is_saved_with_the_preferences() {
- let (mut u, seen) = presented("https://example.com/a", false);
+ let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
+ click_the_frame(&mut u);
click(&mut u, "Don't show this again");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["https://example.com/a"]);
@@ -216,7 +369,7 @@ fn a_prototype_cannot_open_a_script_or_handler_url() {
"ms-msdt:/id PCWDiagnostic",
"figcraft://join?host=127.0.0.1:47900&file=f&token=t",
] {
- assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener");
+ assert!(opens_on_a_click(url).is_empty(), "{url:?} must never reach the opener");
}
}
diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md
index 6c7b2437..ec2daf04 100644
--- a/docs/prototyping-parity.md
+++ b/docs/prototyping-parity.md
@@ -1,6 +1,6 @@
# Prototyping parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** patch (Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
@@ -94,7 +94,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
-| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
+| Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Opening a link in place | missing | |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
@@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again" (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged |
| 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged |
From 73398cedf1c6afc32ef04f7214d64666305295f6 Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 03:11:59 +0800
Subject: [PATCH 51/60] =?UTF-8?q?fix(app):=20ask=20before=20=E2=8C=98Q=20q?=
=?UTF-8?q?uits=20with=20unsaved=20changes=20(#857)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
winit's default macOS app menu ends the process through AppKit's
`terminate:` at once, so ⌘Q and Quit from the menu bar never asked about
unsaved changes (the recovery copies were all that survived).
- Install craft-libs' app menu (craft-menu, same rev as the other
craft-libs pins; macOS only). Its Quit sends the main window a close
request, the same path as the window's close button, so
windows::on_close_request cancels the close and the unsaved-changes
question asks first. Failure to install keeps winit's menu and logs it.
- windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer pins
the contract the menu relies on: a close request with unsaved changes
sends CancelClose and asks; Don't Save then closes.
- Like the close button it closes the main window only: with several
windows open, each quit closes one (documented).
- docs/hardware-parity.md macOS row, status line and revision history.
- Cargo.lock: craft-menu, craft-quit, craft-keymap, muda and their deps;
cargo's own minimal resolution also moves a few windows-sys/getrandom
edges to versions already in the lock.
---
Cargo.lock | 88 ++++++++++++++++++++++++++---
Cargo.toml | 3 +
apps/figcraft/Cargo.toml | 4 ++
apps/figcraft/src/main.rs | 9 +++
crates/ui-egui/src/windows_tests.rs | 32 +++++++++--
docs/hardware-parity.md | 5 +-
6 files changed, 125 insertions(+), 16 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index a0f71692..d997c336 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -253,7 +253,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -972,6 +972,40 @@ dependencies = [
"writeable",
]
+[[package]]
+name = "craft-keymap"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "egui",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "craft-menu"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "craft-keymap",
+ "craft-quit",
+ "egui",
+ "muda",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
+[[package]]
+name = "craft-quit"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "dispatch2",
+ "egui",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -1034,6 +1068,15 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -1526,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1596,6 +1639,7 @@ name = "figcraft"
version = "0.12.1"
dependencies = [
"arboard",
+ "craft-menu",
"craft-update",
"craft-window",
"eframe",
@@ -2865,6 +2909,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keyboard-types"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -3077,6 +3131,24 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "muda"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "keyboard-types",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "png",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "naga"
version = "30.0.1"
@@ -3575,7 +3647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4249,7 +4321,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4306,7 +4378,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4869,10 +4941,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5992,7 +6064,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index b4977989..2bf5da74 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,9 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
+# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
+craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml
index bd8c09c8..2b5a3bd1 100644
--- a/apps/figcraft/Cargo.toml
+++ b/apps/figcraft/Cargo.toml
@@ -48,6 +48,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
+# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
+# same guarded path as the close button); winit's default Quit ends the process through
+# `terminate:`, losing unsaved changes without a word (issue #857).
+craft-menu = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index a3cd21bf..5371045d 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -271,6 +271,15 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
+ // winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
+ // app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
+ // sends the root window a close request instead, and the unsaved-changes question
+ // asks just as it does for the close button. Failure keeps winit's menu: the app
+ // runs, but ⌘Q ends it without asking.
+ #[cfg(target_os = "macos")]
+ if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
+ log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
+ }
Ok(Box::new(App {
queue,
ctx_slot,
diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs
index af42d363..5743574b 100644
--- a/crates/ui-egui/src/windows_tests.rs
+++ b/crates/ui-egui/src/windows_tests.rs
@@ -10,18 +10,19 @@ use serde_json::{Value, json};
use crate::control::{ControlReply, ControlRequest};
use crate::{FigApp, NoServices};
-/// One main-window frame, as the desktop app runs it; `close` asks the window to close.
-fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) {
+/// One main-window frame, as the desktop app runs it; `close` asks the window to close. Returns
+/// the commands the frame sent the main window.
+fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) -> Vec {
let mut input = RawInput::default();
if close {
input.viewports.insert(ViewportId::ROOT, ViewportInfo { events: vec![ViewportEvent::Close], ..Default::default() });
}
- ctx.run_ui(input, |ui| {
+ let mut out = ctx.run_ui(input, |ui| {
crate::windows::on_close_request(app, ui.ctx());
app.ui(ui);
- })
- .textures_delta
- .clear();
+ });
+ out.textures_delta.clear();
+ out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
}
/// A control request queued for the UI, and where its reply arrives.
@@ -501,3 +502,22 @@ fn enter_answers_save() {
assert!(app.ui.quit);
std::fs::remove_dir_all(&d).ok();
}
+
+/// macOS's Quit (⌘Q, the app menu's Quit item, the Dock) reaches the app as a close request on
+/// the main window (#857: the craft-menu app menu replaces winit's, whose Quit ended the process
+/// at once). With unsaved changes the window cancels the close, so the process stays, and asks;
+/// Don't Save then closes it.
+#[test]
+fn a_quit_with_unsaved_changes_waits_for_the_answer() {
+ let mut s = Session::new();
+ s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+ let ctx = egui::Context::default();
+ let mut app = FigApp::new(s, Box::new(NoServices));
+ frame(&ctx, &mut app, false);
+ let sent = frame(&ctx, &mut app, true);
+ assert!(sent.contains(&egui::ViewportCommand::CancelClose), "the quit is held: {sent:?}");
+ assert!(app.ui.confirm_close && !app.ui.quit, "and the question asks");
+ crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::DontSave);
+ let sent = frame(&ctx, &mut app, false);
+ assert!(app.ui.quit && sent.contains(&egui::ViewportCommand::Close), "Don't Save quits: {sent:?}");
+}
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
index 61282d53..f2b86bd0 100644
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -1,6 +1,6 @@
# Hardware and platform parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -39,7 +39,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Platform | Figma | FigCraft | Notes |
|---|---|---|---|
-| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); Quit from the menu bar, which asks nothing, keeps unsaved files' recovery copies for the next start |
+| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start |
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
| FreeBSD | none | tarball | |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. Numbers unchanged |
| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged |
| 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. |
| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |
From f1c2ee81e4a24383131e3f572c794f9ceccf2b47 Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 01:44:36 +0800
Subject: [PATCH 52/60] fix(collab): image entries must match their hash and
sane bounds (#1086)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Image entries applied bytes to any key with no checks (audit COL-9): a
collaborator could poison the shared content-addressed image store, claim
absurd dimensions, balloon memory, or name an image `../../x`, which a
saved file then wrote as the archive member `images/../../x`.
- Every key must be 1–128 ASCII letters, digits, `-` or `_`:
`figcraft_doc::ImageData::is_valid_key`, promoted from the format
crate's private `is_valid_hash` so the file reader and collab share one
rule. The reader now also takes `-` and `_`, so the REST importer's
`rest-image-missing` placeholder survives a save and load.
- The REST importer's side of the same attack: an unresolved `imageRef`
that can't legitimately be an archive member name (path text, an
over-long ref) shares the `rest-image-missing` placeholder instead of
being keyed by its raw name
(`image_tests::a_path_like_rest_image_ref_never_names_an_archive_member`).
- A key with the shape of a content hash (64 hex chars) must be exactly
the lowercase sha256 of the bytes, the form `image_hash` writes; an
uppercase look-alike is refused. Other keys (svg FNV-128, REST
placeholders) claim no content address and only meet the rules above.
- A side is at most 65 535 px; decoded bytes at most 32 MiB, with the
base64 text capped before decoding. 0×0 stays valid: the format crate
stores it for sizes it can't probe.
- Refusals are Error::Invalid and reject their batch, so every replica's
store stays clean; catch-up snapshots apply through the same check.
- Docs: collab-protocol.md, collaboration-parity.md and
file-format-parity.md (rows, status lines, histories).
- Tests: collab image_tests (8), format image_tests: round-trip and the
REST placeholder gate.
---
Cargo.lock | 1 +
crates/collab/Cargo.toml | 1 +
crates/collab/src/change.rs | 4 +
crates/collab/src/docprops.rs | 55 ++++++++++++-
crates/collab/src/image_tests.rs | 137 +++++++++++++++++++++++++++++++
crates/collab/src/lib.rs | 2 +
crates/doc/src/lib.rs | 10 +++
crates/format/src/image_tests.rs | 33 ++++++++
crates/format/src/lib.rs | 4 +-
crates/format/src/library.rs | 9 +-
crates/format/src/rest.rs | 11 ++-
docs/collab-protocol.md | 7 +-
docs/collaboration-parity.md | 5 +-
docs/file-format-parity.md | 5 +-
14 files changed, 265 insertions(+), 19 deletions(-)
create mode 100644 crates/collab/src/image_tests.rs
diff --git a/Cargo.lock b/Cargo.lock
index a0f71692..6ed19cd1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1646,6 +1646,7 @@ dependencies = [
"postcard",
"serde",
"serde_json",
+ "sha2",
"thiserror 2.0.21",
]
diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml
index ed85201d..189c1a02 100644
--- a/crates/collab/Cargo.toml
+++ b/crates/collab/Cargo.toml
@@ -17,6 +17,7 @@ chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
+sha2 = { workspace = true }
[dev-dependencies]
figcraft-color = { workspace = true }
diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs
index 7977f83a..4ae0015b 100644
--- a/crates/collab/src/change.rs
+++ b/crates/collab/src/change.rs
@@ -245,6 +245,10 @@ pub enum Entry {
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
+ /// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256
+ /// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain
+ /// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte
+ /// size are bounded as the entry applies (`image_of` in `docprops.rs`).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs
index 24a604ff..8e78deb0 100644
--- a/crates/collab/src/docprops.rs
+++ b/crates/collab/src/docprops.rs
@@ -8,6 +8,7 @@ use base64::Engine as _;
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
use crate::Error;
use crate::change::{Change, DocProp, Entry, Family, Json};
@@ -164,13 +165,63 @@ struct ImageValue {
bytes: String,
}
+/// Largest decoded image an entry may carry: 32 MiB, mirroring `MAX_IMAGE_FILE_BYTES` in
+/// `figcraft_format::thumbnail` (collab sits below the format crate and can't name it).
+pub(crate) const MAX_IMAGE_BYTES: usize = 32 << 20;
+
+/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
+/// image this app realistically opens reaches it — an entry claiming a bigger side is
+/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
+/// couldn't probe (`image_data`).
+pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
+
+/// Whether `key` has the shape of a content hash: 64 hex chars, what `image_hash` in
+/// `figcraft_format` (sha256) produces — which writes it lowercase, so a match is exact:
+/// an uppercase look-alike claims a content address it doesn't have, and storing bytes
+/// there would file a second copy under a name no honest lookup reaches. Other key
+/// shapes exist and stay valid (the svg importer's FNV-128 keys, the REST importer's
+/// placeholder under the imageRef itself); they claim no content address, so only the
+/// key-name rule and the size bounds apply to them. One untestable edge: a REST imageRef of exactly 64 hex
+/// chars would be hash-shaped — Figma's imageRefs are 40 hex, so it doesn't occur.
+fn is_content_hash(key: &str) -> bool {
+ key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
+}
+
+/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
+fn sha256_hex(bytes: &[u8]) -> String {
+ Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
+}
+
fn image_json(img: &ImageData) -> Result {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
-fn image_of(value: &Json) -> Result {
+/// The image a change carries, validated as a collaborator's claim (an invalid entry
+/// rejects its batch, leaving every replica's store clean): a key that can name an archive
+/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/`), dimensions within
+/// [`MAX_IMAGE_SIDE`], decoded bytes within [`MAX_IMAGE_BYTES`] (the encoded length is
+/// capped before decoding so hostile text can't force a large allocation), and a key with
+/// the shape of a content hash must be that hash of these bytes — a poisoned
+/// content-addressed store would show the wrong image everywhere at once (#1086).
+fn image_of(key: &str, value: &Json) -> Result {
+ if !ImageData::is_valid_key(key) {
+ return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
+ }
let v: ImageValue = value.read("image")?;
+ if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
+ return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
+ }
+ let max_encoded = (MAX_IMAGE_BYTES / 3) * 4 + 4;
+ if v.bytes.len() > max_encoded {
+ return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {MAX_IMAGE_BYTES} of bytes")));
+ }
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
+ if bytes.len() > MAX_IMAGE_BYTES {
+ return Err(Error::Invalid(format!("image: {} bytes pass the {MAX_IMAGE_BYTES} an image may be", bytes.len())));
+ }
+ if is_content_hash(key) && key != sha256_hex(&bytes) {
+ return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
+ }
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
}
@@ -286,7 +337,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
- let img = image_of(value)?;
+ let img = image_of(k, value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs
new file mode 100644
index 00000000..a57c6497
--- /dev/null
+++ b/crates/collab/src/image_tests.rs
@@ -0,0 +1,137 @@
+//! Image entries are checked as they apply: bytes carried under a content-hash key must be
+//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash`
+//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded
+//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared
+//! content-addressed image store or balloon memory (#1086).
+
+use base64::Engine as _;
+use figcraft_doc::Document;
+use serde_json::json;
+use sha2::{Digest as _, Sha256};
+
+use crate::change::{Change, Entry, Json};
+use crate::docprops::apply as apply_doc;
+use crate::docprops::{MAX_IMAGE_BYTES, MAX_IMAGE_SIDE};
+
+/// The side bound the implementation enforces.
+const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
+/// The decoded-bytes cap the implementation enforces.
+const MAX_BYTES: usize = MAX_IMAGE_BYTES;
+
+/// sha256 hex, computed here independently of the implementation under test.
+fn sha256_hex(bytes: &[u8]) -> String {
+ Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
+ Change::Put {
+ entry: Entry::Image(key.into()),
+ value: Json(json!({
+ "width": width,
+ "height": height,
+ "bytes": base64::engine::general_purpose::STANDARD.encode(bytes),
+ })),
+ }
+}
+
+/// Bytes a collaborator hashed into the key, and different bytes they send under it.
+fn forged() -> (String, &'static [u8]) {
+ (sha256_hex(b"the image the key actually names"), b"someone else's bytes")
+}
+
+#[test]
+fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() {
+ let (key, bytes) = forged();
+ let mut doc = Document::new("t");
+ let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) {
+ Ok(_) => panic!("bytes that don't hash to their key must be refused"),
+ Err(e) => e,
+ };
+ assert!(e.to_string().contains("image"), "not an image refusal: {e}");
+ assert!(doc.images.is_empty(), "the image store was poisoned under key {key}");
+}
+
+/// An uppercase key looks like a content hash but isn't one: producers store the lowercase
+/// form, so bytes matching under the uppercase form would file a second copy under a name
+/// no honest lookup reaches. Refused (#1086).
+#[test]
+fn an_uppercase_content_hash_shaped_key_is_refused() {
+ let bytes = b"honestly keyed, uppercase claim".as_slice();
+ let mut doc = Document::new("t");
+ let upper = sha256_hex(bytes).to_uppercase();
+ let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) {
+ Ok(_) => panic!("an uppercase key claims a content address it doesn't have"),
+ Err(e) => e,
+ };
+ assert!(e.to_string().contains("image"), "not an image refusal: {e}");
+ assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy");
+}
+
+#[test]
+fn an_image_that_hashes_to_its_key_applies() {
+ let bytes = b"a real image, honestly keyed".as_slice();
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged");
+ let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored");
+ assert_eq!((img.width, img.height), (2, 1));
+ assert_eq!(img.bytes.as_slice(), bytes);
+}
+
+#[test]
+fn absurd_dimensions_are_refused() {
+ let bytes = b"honest bytes, dishonest size".as_slice();
+ let key = sha256_hex(bytes);
+ for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] {
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&key, w, h, bytes))
+ .err()
+ .unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len()));
+ assert!(doc.images.is_empty());
+ }
+}
+
+#[test]
+fn oversized_image_bytes_are_refused() {
+ let bytes = vec![0u8; MAX_BYTES + 1];
+ let key = sha256_hex(&bytes);
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&key, 100, 100, &bytes)).err().unwrap_or_else(|| panic!("a {}-byte image must be refused, but it applied", bytes.len()));
+ assert!(doc.images.is_empty());
+}
+
+#[test]
+fn a_zero_sized_image_still_applies() {
+ // The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`),
+ // so a shared document can legitimately carry one; the bytes still have to be honest.
+ let bytes = b"undecodable bytes kept from a file load".as_slice();
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd");
+ assert!(doc.images.contains_key(&sha256_hex(bytes)));
+}
+
+#[test]
+fn a_key_that_is_not_a_content_hash_shape_still_applies() {
+ // Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex
+ // chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the
+ // imageRef itself. They are not content-addressed references, so no hash is claimed; the
+ // dimension and size bounds still hold.
+ for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] {
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}"));
+ assert!(doc.images.contains_key(key));
+ let mut big = Document::new("t");
+ assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions");
+ }
+}
+
+/// A key becomes an archive member name when the file is saved (`images/`), so a key that
+/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write
+/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086).
+#[test]
+fn a_key_that_cannot_name_an_archive_member_is_refused() {
+ for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] {
+ let mut doc = Document::new("t");
+ assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused");
+ assert!(doc.images.is_empty(), "key {key:?} reached the image store");
+ }
+}
diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs
index fa271f35..f256ef15 100644
--- a/crates/collab/src/lib.rs
+++ b/crates/collab/src/lib.rs
@@ -136,6 +136,8 @@ mod entry_ids_tests;
#[cfg(test)]
mod frac_tests;
#[cfg(test)]
+mod image_tests;
+#[cfg(test)]
mod journal_tests;
#[cfg(test)]
mod merge_list_tests;
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index caf0c861..1e94d08c 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -161,6 +161,16 @@ pub struct ImageData {
pub height: u32,
}
+impl ImageData {
+ /// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes,
+ /// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key
+ /// becomes an archive member name (`images/`) when the file is saved, so only these are
+ /// read back from a file or taken from a collaborator: never `/`, `.` or other path text.
+ pub fn is_valid_key(key: &str) -> bool {
+ (1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
+ }
+}
+
/// A whole design file.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
diff --git a/crates/format/src/image_tests.rs b/crates/format/src/image_tests.rs
index e58a4ab1..9c6e4b10 100644
--- a/crates/format/src/image_tests.rs
+++ b/crates/format/src/image_tests.rs
@@ -112,3 +112,36 @@ fn image_members_with_path_like_names_are_dropped_and_not_saved_back() {
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::>());
let _ = hash;
}
+
+/// An unresolved `imageRef` from a hostile REST file never becomes the image's own key: the
+/// save writer names archive members after keys, so `../../evil` would inject an
+/// `images/../../evil` member into the next save. Such refs store the shared placeholder
+/// under its own key instead.
+#[test]
+fn a_path_like_rest_image_ref_never_names_an_archive_member() {
+ let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
+ "fills": [{"type": "IMAGE", "imageRef": "../../evil", "scaleMode": "FIT"}]});
+ let file = json!({"name": "Hostile", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
+ "children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
+ let (doc, _warnings) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
+ assert!(!doc.images.contains_key("../../evil"), "the raw imageRef must not name an image: {:?}", doc.images.keys().collect::>());
+ assert!(doc.images.contains_key("rest-image-missing"), "the shared placeholder stores it instead: {:?}", doc.images.keys().collect::>());
+ let bytes = save_bytes(&doc, None).unwrap();
+ let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
+ assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::>());
+ assert!(
+ z.file_names().any(|n| n == "images/rest-image-missing"),
+ "the placeholder is saved under its own name: {:?}",
+ z.file_names().collect::>()
+ );
+}
+
+/// A key with `-` or `_` is a plain archive member name too: the REST importer's placeholder
+/// (`rest-image-missing`) comes back after a save and load instead of being dropped as damaged.
+#[test]
+fn an_image_keyed_with_dashes_comes_back_after_a_save() {
+ let mut d = Document::new("t");
+ d.images.insert("rest-image-missing".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(png()), width: 2, height: 2 });
+ let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
+ assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues);
+}
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 30f6e548..e60d8845 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -720,9 +720,9 @@ fn read_images(z: &mut zip::ZipArchive, own: Option<&BTreeSet
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
let mut images = BTreeMap::new();
let mut unread = Vec::new();
- // Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back.
+ // Member names that can't be real image keys (`../x`, `a/b`) are never read, so never written back.
hashes.retain(|h| {
- let ok = library::is_valid_hash(h);
+ let ok = ImageData::is_valid_key(h);
if !ok {
unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string())));
}
diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs
index daec05e2..cab34cfb 100644
--- a/crates/format/src/library.rs
+++ b/crates/format/src/library.rs
@@ -38,18 +38,13 @@ pub fn is_valid_library_id(id: &str) -> bool {
(1..=MAX_ID).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
-/// Image hashes become archive member names: only plain hex-like names are looked up.
-pub(crate) fn is_valid_hash(h: &str) -> bool {
- (1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
-}
-
/// Write the published library's entries. `written` are the images already in `images/`.
pub(crate) fn write(z: &mut zip::ZipWriter, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
put_json(z, SNAPSHOT, &lib.doc)?;
for (hash, img) in &lib.doc.images {
- if !written.contains(hash) && is_valid_hash(hash) {
+ if !written.contains(hash) && ImageData::is_valid_key(hash) {
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;
}
}
@@ -75,7 +70,7 @@ pub(crate) fn read(
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
let (mut doc, _) = from_json(&text)?;
for hash in used_images(&doc) {
- if !is_valid_hash(&hash) {
+ if !ImageData::is_valid_key(&hash) {
continue;
}
if let Some(img) = own.get(&hash) {
diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs
index 15fbce9d..8c83b0af 100644
--- a/crates/format/src/rest.rs
+++ b/crates/format/src/rest.rs
@@ -947,7 +947,8 @@ impl<'a> Ctx<'a> {
}
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
- /// grey placeholder stored under the `imageRef` itself.
+ /// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
+ /// archive member (`is_valid_key`); any other name shares the `rest-image-missing` placeholder.
pub(crate) fn image_key(&mut self, image_ref: &str) -> String {
if let Some(k) = self.image_keys.get(image_ref) {
return k.clone();
@@ -957,7 +958,11 @@ impl<'a> Ctx<'a> {
Some((hash, _, _)) => hash,
None => {
self.missing_images += 1;
- let key = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) }.to_string();
+ // An `imageRef` arrives from an untrusted file, and keys become archive member
+ // names on save: a name that can't legitimately be one must not reach the map.
+ let candidate = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) };
+ let key = if ImageData::is_valid_key(candidate) { candidate } else { MISSING_IMAGE };
+ let key = key.to_string();
if !self.doc.images.contains_key(&key)
&& let Some(png) = placeholder_png()
{
@@ -1018,7 +1023,7 @@ impl<'a> Ctx<'a> {
let n = self.missing_images;
let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
self.warn(format!(
- "{n} image{s} without image data {verb} a grey placeholder named by its imageRef (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
+ "{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
));
}
let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md
index 2dfbbb6f..a9d1b247 100644
--- a/docs/collab-protocol.md
+++ b/docs/collab-protocol.md
@@ -151,7 +151,12 @@ As built:
colour profile, image presets, annotation categories and library data (whole values); styles,
comment threads, variable collections and variables (one entry per id plus the order of ids;
the whole list when ids aren't unique); dev-mode snapshots (per layer); images (per content
- hash, the encoded bytes as base64); a board's timer and vote (whole values, `DocProp::Timer` and
+ hash, the encoded bytes as base64; as an entry applies the claim is checked: every key is
+ 1–128 ASCII letters, digits, `-` or `_` (a saved file names the image `images/`), a key
+ with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256 hex of
+ those bytes (as the app writes them); a side is at most 65 535 px and the decoded bytes at
+ most 32 MiB, and base64 that can't decode is refused); a
+ board's timer and vote (whole values, `DocProp::Timer` and
`DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting
at once write different entries). The timer travels as its end time (or, paused, the time left),
never as ticks: each client counts down on its own clock. The page order is the pages'
diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md
index c9af478f..4fe058ae 100644
--- a/docs/collaboration-parity.md
+++ b/docs/collaboration-parity.md
@@ -1,6 +1,6 @@
# Collaboration parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
-| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Left: server archives beyond Linux | 25–55 |
+| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 32 MiB, so no collaborator can poison the shared image store or balloon memory (#1086). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 32 MiB, mirroring the format crate's thumbnail cap — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged |
| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged |
| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 7a6356a3..01f41ff4 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -21,7 +21,7 @@ and prototype interactions are still unchecked there.
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
-| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
+| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -181,6 +181,7 @@ each.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) now shares that placeholder instead of being keyed by its raw name. Numbers unchanged |
| 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged |
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
From 7660877845327f0ab089b2cd87cb2e41da3dc3c9 Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 08:50:00 +0800
Subject: [PATCH 53/60] fix(present): nothing opens or releases behind an open
link notice (#1096)
- finish(): a viewer-fired link opens directly (notice skipped) only while
no notice shows; any other link that fires while one shows is dropped.
- finish(): raising a notice ends the press under way, so its release
doesn't fire the prototype's mouse-up actions (a link, a navigation)
behind the notice, which takes no input while it shows.
- The mail notice says the link opens the mail app to write a message
(opening a mailto: link doesn't send anything).
- Tests: nothing_opens_while_a_notice_shows_with_the_notice_skipped,
a_press_that_raised_a_notice_doesnt_release_behind_it.
- docs/prototyping-parity.md row and revision history.
---
crates/ui-egui/locales/en.toml | 2 +-
crates/ui-egui/src/present/chrome.rs | 13 +++--
crates/ui-egui/src/present_links_tests.rs | 60 +++++++++++++++++++++++
docs/prototyping-parity.md | 4 +-
4 files changed, 71 insertions(+), 8 deletions(-)
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index ce98cf75..f84cd45b 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -1487,7 +1487,7 @@ name = "English"
"This frame is new on the branch." = "This frame is new on the branch."
"This frame isn't on the branch." = "This frame isn't on the branch."
"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:"
-"This link sends a mail message through your mail app:" = "This link sends a mail message through your mail app:"
+"This link opens your mail app to write a message:" = "This link opens your mail app to write a message:"
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs
index 12399ced..bd74b5ac 100644
--- a/crates/ui-egui/src/present/chrome.rs
+++ b/crates/ui-egui/src/present/chrome.rs
@@ -241,18 +241,21 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) {
/// After a player's frame: the links it opens and the widget clicks it took. Every link waits in
/// [`link_notice`] — to a website or to the mail app (#1096). Skipping the notice opens at once
-/// only a link the viewer's own click, press, drag or key fired, one per frame: a delay or the
-/// pointer passing over a layer never opens anything unasked. A further link that fires while the
-/// notice shows is dropped.
+/// only a link the viewer's own click, press, drag or key fired, one per frame, and never while a
+/// notice shows: a delay or the pointer passing over a layer never opens anything unasked. A
+/// further link that fires while the notice shows is dropped, whatever fired it.
fn finish(app: &mut FigApp, p: &mut Presenter) {
let skipped = app.session.prefs.link_notice_skipped;
let mut opened = false;
for (u, asked) in p.urls.drain(..) {
- if skipped && asked && !opened {
+ if skipped && asked && !opened && p.link_notice.is_none() {
app.services.open_url(&u);
opened = true;
} else if p.link_notice.is_none() {
p.link_notice = Some((u, (asked && !skipped).then_some(false)));
+ // A press under way ends here: its release would otherwise reach the prototype
+ // behind the notice, which takes no input while it shows.
+ p.press = None;
}
}
if let Some(e) = p.click_widgets(&app.session) {
@@ -278,7 +281,7 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id)
let what = if goes_to_a_website(url) {
tr("This link opens a website outside the prototype:")
} else {
- tr("This link sends a mail message through your mail app:")
+ tr("This link opens your mail app to write a message:")
};
widgets::label(ui, what, t.text);
ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap());
diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs
index 03c82552..39137fca 100644
--- a/crates/ui-egui/src/present_links_tests.rs
+++ b/crates/ui-egui/src/present_links_tests.rs
@@ -404,3 +404,63 @@ fn the_prototype_takes_no_clicks_while_the_notice_shows() {
u.frames(5, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "with the notice gone the same click navigates");
}
+
+/// While a notice shows nothing else opens, even a link the viewer fired with the notice skipped:
+/// a press that opened one link and put the next behind the notice then releases over a
+/// mouse-up link, which is dropped with the notice up (#1096).
+#[test]
+fn nothing_opens_while_a_notice_shows_with_the_notice_skipped() {
+ let mut s = Session::new();
+ s.prefs.link_notice_skipped = true;
+ let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
+ let open = |u: &str| json!({"type": "openUrl", "url": u});
+ ex(
+ &mut s,
+ "proto.interaction.add",
+ json!({"id": f, "trigger": {"type": "mouseDown"}, "actions": [open("https://example.com/1"), open("https://example.com/2")], "transition": {"type": "instant"}}),
+ );
+ ex(
+ &mut s,
+ "proto.interaction.add",
+ json!({"id": f, "trigger": {"type": "mouseUp"}, "actions": [open("https://example.com/3")], "transition": {"type": "instant"}}),
+ );
+ let (mut u, seen) = present("proto.present", s, f);
+ let at = egui::pos2(700.0, 290.0);
+ u.button(at, true);
+ u.frames(2, Vec::new);
+ assert_eq!(opened(&seen), ["https://example.com/1"]);
+ assert_eq!(notice(&u), "https://example.com/2");
+ u.button(at, false);
+ u.frames(3, Vec::new);
+ assert_eq!(opened(&seen), ["https://example.com/1"], "the release opened a link with the notice up");
+ assert_eq!(notice(&u), "https://example.com/2");
+}
+
+/// The prototype takes no input while a notice shows, including the release of a press that
+/// began before it: a mouse-up that navigates doesn't run behind the notice (#1096).
+#[test]
+fn a_press_that_raised_a_notice_doesnt_release_behind_it() {
+ let mut s = Session::new();
+ s.prefs.link_notice_skipped = true;
+ let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
+ let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"})));
+ let open = |u: &str| json!({"type": "openUrl", "url": u});
+ ex(
+ &mut s,
+ "proto.interaction.add",
+ json!({"id": a, "trigger": {"type": "mouseDown"}, "actions": [open("https://example.com/1"), open("https://example.com/2")], "transition": {"type": "instant"}}),
+ );
+ ex(
+ &mut s,
+ "proto.interaction.add",
+ json!({"id": a, "trigger": {"type": "mouseUp"}, "action": {"type": "navigate", "dest": b}, "transition": {"type": "instant"}}),
+ );
+ let (mut u, _) = present("proto.present", s, a);
+ let at = egui::pos2(700.0, 290.0);
+ u.button(at, true);
+ u.frames(2, Vec::new);
+ assert_eq!(notice(&u), "https://example.com/2");
+ u.button(at, false);
+ u.frames(3, Vec::new);
+ assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the release navigated behind the notice");
+}
diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md
index ec2daf04..f8241097 100644
--- a/docs/prototyping-parity.md
+++ b/docs/prototyping-parity.md
@@ -94,7 +94,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
-| Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
+| Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame and never while a notice shows — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; a press under way when the notice appears ends there, so its release doesn't reach the prototype behind it; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Opening a link in place | missing | |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
@@ -105,7 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
-| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again" (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
+| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged |
| 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged |
From 0e508fd316436a48e5b7f84aa41715c99d5655d6 Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 08:50:00 +0800
Subject: [PATCH 54/60] fix(app): a second quit before the window draws counts
once (#857)
The app's logic runs while the main window is minimised, without the
frame that acts on a close. Two quits there queued the main window's close
twice: the first let it take over another window, and the second then
quit the app over that window's unsaved changes, as answered.
- close_current queues a window's close only once.
- Test: quitting_twice_before_the_window_draws_closes_it_once.
- docs/hardware-parity.md revision history.
---
crates/ui-egui/src/windows.rs | 7 +++++-
crates/ui-egui/src/windows_tests.rs | 34 +++++++++++++++++++++++++++++
docs/hardware-parity.md | 2 +-
3 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
index 95ec721d..a0875ee5 100644
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -396,7 +396,12 @@ pub(crate) fn request_close(app: &mut FigApp, ctx: &Context) {
}
fn close_current(app: &mut FigApp, ctx: &Context) {
- app.windows.closing.push(app.windows.current);
+ // Once only: a second close of the same window before [`begin`] runs (two quits while the
+ // main window is hidden, when only the app's logic runs) would act on the window that took its
+ // place, without asking about that window's files.
+ if !app.windows.closing.contains(&app.windows.current) {
+ app.windows.closing.push(app.windows.current);
+ }
ctx.request_repaint_of(ViewportId::ROOT);
}
diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs
index 5743574b..2019b411 100644
--- a/crates/ui-egui/src/windows_tests.rs
+++ b/crates/ui-egui/src/windows_tests.rs
@@ -521,3 +521,37 @@ fn a_quit_with_unsaved_changes_waits_for_the_answer() {
let sent = frame(&ctx, &mut app, false);
assert!(app.ui.quit && sent.contains(&egui::ViewportCommand::Close), "Don't Save quits: {sent:?}");
}
+
+/// A quit while the main window is hidden reaches only the app's logic, which runs without the
+/// frame that acts on it; a second quit before the window draws again must not count twice. The
+/// main window has no unsaved changes and another window has: one quit lets the main window take
+/// that window over (its files keep their changes), and a doubled one must not then quit the app
+/// as if those files had been answered for.
+#[test]
+fn quitting_twice_before_the_window_draws_closes_it_once() {
+ let mut s = Session::empty();
+ for n in ["A", "B"] {
+ s.execute("file.new", &json!({"name": n})).unwrap();
+ }
+ let ctx = egui::Context::default();
+ ctx.set_embed_viewports(false);
+ let mut app = FigApp::new(s, Box::new(NoServices));
+ frame(&ctx, &mut app, false);
+ crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap();
+ frame(&ctx, &mut app, false);
+ // A, in the other window, has unsaved changes; B, in the main window, has none.
+ let a = app.session.docs.iter().position(|d| d.doc.name == "A").unwrap();
+ app.session.execute("doc.switch", &json!({"index": a})).unwrap();
+ app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
+ frame(&ctx, &mut app, false);
+ // Two quits while the main window is hidden: only the logic runs.
+ for _ in 0..2 {
+ let mut input = RawInput::default();
+ input.viewports.insert(ViewportId::ROOT, ViewportInfo { events: vec![ViewportEvent::Close], occluded: Some(true), ..Default::default() });
+ let _ = ctx.run_logic(&input, |ctx| crate::windows::on_close_request(&mut app, ctx));
+ }
+ frame(&ctx, &mut app, false);
+ frame(&ctx, &mut app, false);
+ assert!(app.session.docs.iter().any(|d| d.doc.name == "A" && d.is_dirty()), "A keeps its changes: {:?}", names(&app));
+ assert!(!app.ui.quit, "the app didn't quit over A's unsaved changes");
+}
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
index f2b86bd0..20ceb2fb 100644
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -50,7 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
-| 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. Numbers unchanged |
+| 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. A second quit before the window draws again (two quits while it is minimised, when only the app's logic runs) counts once: before, it could quit the app over another window's unsaved changes (`windows_tests::quitting_twice_before_the_window_draws_closes_it_once`). Numbers unchanged |
| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged |
| 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. |
| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |
From 949d74f7ca37f9523b540440fc5bdc607ff4cab3 Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 08:50:00 +0800
Subject: [PATCH 55/60] fix(collab): image checks keep documents shareable and
history replayable (#1086)
- The entry size cap is figcraft_doc::ImageData::MAX_BYTES (256 MiB), the
cap the .fig, .sketch and REST importers already read images with (now
one constant), instead of 32 MiB: an imported image larger than 32 MiB
made the document unjoinable and its server snapshot unrestorable.
- History written before the checks (a restored or joined snapshot, a
replayed server log, a file published to a server) applies without the
image entries they refuse (docprops::without_refused_images), instead of
the file not opening or a restart dropping every edit after the image.
- A key that already holds an image can't be given other bytes: producers
key images by their bytes (the REST importer's placeholder keys always
hold the same picture), and merge treats a key's image as never
changing.
- REST import: an unresolved imageRef shaped like a content hash shares
the rest-image-missing placeholder (the placeholder's bytes don't hash to
it); ImageData::claims_content_hash is shared with collab.
- Tests: collab image_tests (4 new, size test uses a small cap), format
image_tests (1 new).
- Docs: collab-protocol.md, collaboration-parity.md, file-format-parity.md.
---
crates/collab/src/docprops.rs | 58 +++++++++++++-----------
crates/collab/src/image_tests.rs | 71 ++++++++++++++++++++++++++----
crates/collab/src/replica.rs | 4 +-
crates/collab/src/server.rs | 5 ++-
crates/doc/src/lib.rs | 10 +++++
crates/engine/src/cmd/file/load.rs | 2 +-
crates/format/src/fig.rs | 2 +-
crates/format/src/image_tests.rs | 15 +++++++
crates/format/src/rest.rs | 10 +++--
crates/format/src/sketch.rs | 2 +-
docs/collab-protocol.md | 5 ++-
docs/collaboration-parity.md | 4 +-
docs/file-format-parity.md | 2 +-
13 files changed, 144 insertions(+), 46 deletions(-)
diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs
index 8e78deb0..285b4f95 100644
--- a/crates/collab/src/docprops.rs
+++ b/crates/collab/src/docprops.rs
@@ -165,33 +165,25 @@ struct ImageValue {
bytes: String,
}
-/// Largest decoded image an entry may carry: 32 MiB, mirroring `MAX_IMAGE_FILE_BYTES` in
-/// `figcraft_format::thumbnail` (collab sits below the format crate and can't name it).
-pub(crate) const MAX_IMAGE_BYTES: usize = 32 << 20;
-
/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
/// image this app realistically opens reaches it — an entry claiming a bigger side is
/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
/// couldn't probe (`image_data`).
pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
-/// Whether `key` has the shape of a content hash: 64 hex chars, what `image_hash` in
-/// `figcraft_format` (sha256) produces — which writes it lowercase, so a match is exact:
-/// an uppercase look-alike claims a content address it doesn't have, and storing bytes
-/// there would file a second copy under a name no honest lookup reaches. Other key
-/// shapes exist and stay valid (the svg importer's FNV-128 keys, the REST importer's
-/// placeholder under the imageRef itself); they claim no content address, so only the
-/// key-name rule and the size bounds apply to them. One untestable edge: a REST imageRef of exactly 64 hex
-/// chars would be hash-shaped — Figma's imageRefs are 40 hex, so it doesn't occur.
-fn is_content_hash(key: &str) -> bool {
- key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
-}
-
/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
+/// `changes` without the image entries [`image_of`] refuses. For history written before those
+/// checks (a saved snapshot, a server's log): the rest of it still applies, where refusing the
+/// whole would leave a document that can't open or drop every edit after the image.
+pub(crate) fn without_refused_images(changes: &[Change]) -> Vec {
+ changes.iter().filter(|c| !matches!(c, Change::Put { entry: Entry::Image(k), value } if image_of(k, value).is_err())).cloned().collect()
+}
+
+/// The JSON value an image entry carries: its size and its encoded bytes in base64.
fn image_json(img: &ImageData) -> Result {
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
}
@@ -199,11 +191,19 @@ fn image_json(img: &ImageData) -> Result {
/// The image a change carries, validated as a collaborator's claim (an invalid entry
/// rejects its batch, leaving every replica's store clean): a key that can name an archive
/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/`), dimensions within
-/// [`MAX_IMAGE_SIDE`], decoded bytes within [`MAX_IMAGE_BYTES`] (the encoded length is
-/// capped before decoding so hostile text can't force a large allocation), and a key with
-/// the shape of a content hash must be that hash of these bytes — a poisoned
-/// content-addressed store would show the wrong image everywhere at once (#1086).
+/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read;
+/// the encoded length is capped before decoding so hostile text can't force a large
+/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must
+/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash`
+/// writes — a poisoned content-addressed store would show the wrong image everywhere at once
+/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders)
+/// claim no content address, so only the name and size rules apply to them.
fn image_of(key: &str, value: &Json) -> Result {
+ image_within(key, value, ImageData::MAX_BYTES)
+}
+
+/// [`image_of`] with `max_bytes` for the decoded size.
+pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result {
if !ImageData::is_valid_key(key) {
return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
}
@@ -211,15 +211,15 @@ fn image_of(key: &str, value: &Json) -> Result {
if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
}
- let max_encoded = (MAX_IMAGE_BYTES / 3) * 4 + 4;
- if v.bytes.len() > max_encoded {
- return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {MAX_IMAGE_BYTES} of bytes")));
+ let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4);
+ if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded {
+ return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes")));
}
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
- if bytes.len() > MAX_IMAGE_BYTES {
- return Err(Error::Invalid(format!("image: {} bytes pass the {MAX_IMAGE_BYTES} an image may be", bytes.len())));
+ if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
+ return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len())));
}
- if is_content_hash(key) && key != sha256_hex(&bytes) {
+ if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) {
return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
}
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
@@ -338,6 +338,12 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result
}
Entry::Image(k) => {
let img = image_of(k, value)?;
+ // A key always names the same bytes (producers key images by their bytes; the REST
+ // importer's placeholder keys always hold the same grey picture), so nobody can
+ // swap the picture under a key someone else added.
+ if doc.images.get(k).is_some_and(|old| old.bytes != img.bytes) {
+ return Err(Error::Invalid(format!("image: {k} already holds other bytes")));
+ }
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs
index a57c6497..e2c65d35 100644
--- a/crates/collab/src/image_tests.rs
+++ b/crates/collab/src/image_tests.rs
@@ -11,18 +11,17 @@ use sha2::{Digest as _, Sha256};
use crate::change::{Change, Entry, Json};
use crate::docprops::apply as apply_doc;
-use crate::docprops::{MAX_IMAGE_BYTES, MAX_IMAGE_SIDE};
+use crate::docprops::{MAX_IMAGE_SIDE, image_within};
/// The side bound the implementation enforces.
const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
-/// The decoded-bytes cap the implementation enforces.
-const MAX_BYTES: usize = MAX_IMAGE_BYTES;
/// sha256 hex, computed here independently of the implementation under test.
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
+/// An image entry putting `bytes` (`width` × `height`) under `key`.
fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
Change::Put {
entry: Entry::Image(key.into()),
@@ -90,13 +89,19 @@ fn absurd_dimensions_are_refused() {
}
}
+/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked
+/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps
+/// the test light.
#[test]
fn oversized_image_bytes_are_refused() {
- let bytes = vec![0u8; MAX_BYTES + 1];
- let key = sha256_hex(&bytes);
- let mut doc = Document::new("t");
- apply_doc(&mut doc, &put(&key, 100, 100, &bytes)).err().unwrap_or_else(|| panic!("a {}-byte image must be refused, but it applied", bytes.len()));
- assert!(doc.images.is_empty());
+ let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)});
+ let at = vec![0u8; 1024];
+ image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies");
+ let past = vec![0u8; 1025];
+ assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap");
+ let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)});
+ let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default();
+ assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}");
}
#[test]
@@ -135,3 +140,53 @@ fn a_key_that_cannot_name_an_archive_member_is_refused() {
assert!(doc.images.is_empty(), "key {key:?} reached the image store");
}
}
+
+/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a
+/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session.
+#[test]
+fn an_image_as_large_as_an_import_takes_still_applies() {
+ let bytes = vec![0u8; 40 << 20];
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read");
+ assert_eq!(doc.images.len(), 1);
+}
+
+/// An image key is content-derived by every producer, so its bytes never change: a collaborator
+/// can't swap the picture under a key another one added.
+#[test]
+fn an_image_cannot_be_replaced_under_its_key() {
+ let mut doc = Document::new("t");
+ apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
+ assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key");
+ assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
+ apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)");
+}
+
+/// A snapshot written before these checks may hold an image they refuse: the session still
+/// opens, without that image, instead of not opening at all.
+#[test]
+fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() {
+ let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
+ let (key, bytes) = forged();
+ snapshot.push(put(&key, 2, 1, bytes));
+ let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens");
+ assert!(!r.document().images.contains_key(&key));
+}
+
+/// A server's log written before these checks may hold an image they refuse: a restart replays
+/// the edits after it instead of dropping them with the rest of the log.
+#[test]
+fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
+ let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
+ let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
+ let (key, bytes) = forged();
+ let good = b"an honest image after it".as_slice();
+ let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
+ for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])]
+ {
+ r.replay(j).expect("the log replays");
+ }
+ assert_eq!(r.seq(), 2, "both batches count");
+ assert!(!r.document().images.contains_key(&key), "the refused image is left out");
+ assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
+}
diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs
index e14c5fec..7607ae56 100644
--- a/crates/collab/src/replica.rs
+++ b/crates/collab/src/replica.rs
@@ -73,7 +73,9 @@ impl Replica {
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
pub fn from_snapshot(changes: &[Change]) -> Result {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
- r.apply(changes, Mode::Strict)?;
+ // A snapshot saved before the image checks may hold an image they refuse: it opens
+ // without that image (`docprops::without_refused_images`).
+ r.apply(&docprops::without_refused_images(changes), Mode::Strict)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs
index 52f112ad..077858ee 100644
--- a/crates/collab/src/server.rs
+++ b/crates/collab/src/server.rs
@@ -198,10 +198,13 @@ impl Server {
Ok(())
}
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
- Journal::Accepted(acc) => {
+ Journal::Accepted(mut acc) => {
if acc.seq != self.seq + 1 {
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
}
+ // A log written before the image checks may hold an image they refuse: the batch
+ // replays without it, so the edits after it aren't dropped with the rest of the log.
+ acc.changes = docprops::without_refused_images(&acc.changes);
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 1e94d08c..bc2cf78d 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -162,6 +162,16 @@ pub struct ImageData {
}
impl ImageData {
+ /// Largest encoded image a document takes from outside: an image member of a `.fig` or
+ /// `.sketch` file, an image file a REST import reads, an image entry from a live session.
+ pub const MAX_BYTES: u64 = 256 * 1024 * 1024;
+
+ /// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex
+ /// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it.
+ pub fn claims_content_hash(key: &str) -> bool {
+ key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
+ }
+
/// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes,
/// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key
/// becomes an archive member name (`images/`) when the file is saved, so only these are
diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs
index a033475e..8c15783c 100644
--- a/crates/engine/src/cmd/file/load.rs
+++ b/crates/engine/src/cmd/file/load.rs
@@ -351,7 +351,7 @@ fn link_instances(doc: &mut Document) {
/// Largest image file read for a REST API file JSON import.
#[cfg(not(target_arch = "wasm32"))]
-const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024;
+const MAX_IMAGE_FILE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to
/// the file.
diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs
index d81a5856..042d0b3e 100644
--- a/crates/format/src/fig.rs
+++ b/crates/format/src/fig.rs
@@ -59,7 +59,7 @@ use kiwi::{Kv, Schema};
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest image member read.
-const MAX_IMAGE: u64 = 256 * 1024 * 1024;
+const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
diff --git a/crates/format/src/image_tests.rs b/crates/format/src/image_tests.rs
index 9c6e4b10..23bbad0d 100644
--- a/crates/format/src/image_tests.rs
+++ b/crates/format/src/image_tests.rs
@@ -145,3 +145,18 @@ fn an_image_keyed_with_dashes_comes_back_after_a_save() {
let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues);
}
+
+/// An unresolved `imageRef` shaped like a content hash would claim the placeholder's bytes hash
+/// to it, which they don't: a live session would refuse that image. It shares the placeholder
+/// instead.
+#[test]
+fn a_hash_shaped_rest_image_ref_never_names_its_placeholder() {
+ for image_ref in ["ab".repeat(32), "A".repeat(64)] {
+ let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
+ "fills": [{"type": "IMAGE", "imageRef": image_ref, "scaleMode": "FIT"}]});
+ let file = json!({"name": "F", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
+ "children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
+ let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
+ assert_eq!(doc.images.keys().collect::>(), ["rest-image-missing"], "{image_ref}");
+ }
+}
diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs
index 8c83b0af..f9b83691 100644
--- a/crates/format/src/rest.rs
+++ b/crates/format/src/rest.rs
@@ -948,7 +948,9 @@ impl<'a> Ctx<'a> {
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
/// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
- /// archive member (`is_valid_key`); any other name shares the `rest-image-missing` placeholder.
+ /// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the
+ /// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing`
+ /// placeholder.
pub(crate) fn image_key(&mut self, image_ref: &str) -> String {
if let Some(k) = self.image_keys.get(image_ref) {
return k.clone();
@@ -959,9 +961,11 @@ impl<'a> Ctx<'a> {
None => {
self.missing_images += 1;
// An `imageRef` arrives from an untrusted file, and keys become archive member
- // names on save: a name that can't legitimately be one must not reach the map.
+ // names on save: a name that can't legitimately be one must not reach the map,
+ // nor one claiming a content hash the placeholder's bytes don't have.
let candidate = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) };
- let key = if ImageData::is_valid_key(candidate) { candidate } else { MISSING_IMAGE };
+ let named = ImageData::is_valid_key(candidate) && !ImageData::claims_content_hash(candidate);
+ let key = if named { candidate } else { MISSING_IMAGE };
let key = key.to_string();
if !self.doc.images.contains_key(&key)
&& let Some(png) = placeholder_png()
diff --git a/crates/format/src/sketch.rs b/crates/format/src/sketch.rs
index 985541b1..8a35bb5e 100644
--- a/crates/format/src/sketch.rs
+++ b/crates/format/src/sketch.rs
@@ -66,7 +66,7 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024;
#[cfg(target_pointer_width = "32")]
const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024;
/// Largest image member read.
-const MAX_IMAGE: u64 = 256 * 1024 * 1024;
+const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Most pages read from one file.
const MAX_PAGES: usize = 1000;
diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md
index a9d1b247..8023823a 100644
--- a/docs/collab-protocol.md
+++ b/docs/collab-protocol.md
@@ -155,7 +155,10 @@ As built:
1–128 ASCII letters, digits, `-` or `_` (a saved file names the image `images/`), a key
with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256 hex of
those bytes (as the app writes them); a side is at most 65 535 px and the decoded bytes at
- most 32 MiB, and base64 that can't decode is refused); a
+ most 256 MiB (`ImageData::MAX_BYTES`, what the importers read), base64 that can't decode is
+ refused, and a key that already holds an image can't be given other bytes; a snapshot or a
+ server log written before these checks opens without the images they refuse, rather than
+ failing, and a file published to a server is kept without them); a
board's timer and vote (whole values, `DocProp::Timer` and
`DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting
at once write different entries). The timer travels as its end time (or, paused, the time left),
diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md
index 4fe058ae..66816b4b 100644
--- a/docs/collaboration-parity.md
+++ b/docs/collaboration-parity.md
@@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
-| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 32 MiB, so no collaborator can poison the shared image store or balloon memory (#1086). Left: server archives beyond Linux | 25–55 |
+| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), and a key's bytes can't be replaced, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
@@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
-| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 32 MiB, mirroring the format crate's thumbnail cap — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged |
+| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse, instead of the file not opening or a restart dropping every edit after the image. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged |
| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged |
| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 01f41ff4..54c05a5a 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -181,7 +181,7 @@ each.
| Date | Change | Summary |
|---|---|---|
-| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) now shares that placeholder instead of being keyed by its raw name. Numbers unchanged |
+| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged |
| 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged |
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
From 9aac391e045c3c5894c1112e537d8ea2da382521 Mon Sep 17 00:00:00 2001
From: samhyun
Date: Thu, 29 Oct 2026 10:29:56 +0900
Subject: [PATCH 56/60] M8: a progressive JPEG sampling a component finer than
its first is refused, not decoded
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler on a progressive frame
whose later component is sampled more finely than its first (etemesi254/zune-image#236; an 8 × 8
Y 1×1, Cb 2×2 file is enough). The panic reached everywhere such an image is decoded: the CLI died
writing a thumbnail, the desktop canvas's guard caught it on every render of the page, and in
the browser it aborted the tab. The fix in zune-jpeg (36007420) is in 0.5.16 pre-releases only,
which `image`'s requirement doesn't take.
Such a JPEG is now refused from its frame header before the decoder sees it, as an image past
the byte budget is. The decoder refuses the larger ones of this shape itself, so nothing that
decoded is lost; baseline frames of the same shape decode and are left to it. The check can go
once zune-jpeg 0.5.16 ships.
Co-Authored-By: Claude Fable 5.1
---
crates/render/src/images.rs | 48 +++++++++++
crates/render/src/jpeg_sampling_tests.rs | 104 +++++++++++++++++++++++
crates/render/src/lib.rs | 2 +
3 files changed, 154 insertions(+)
create mode 100644 crates/render/src/jpeg_sampling_tests.rs
diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs
index 586c2dac..380b6712 100644
--- a/crates/render/src/images.rs
+++ b/crates/render/src/images.rs
@@ -100,6 +100,9 @@ fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
/// tall.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
+ if jpeg_finer_than_first(bytes) {
+ return None;
+ }
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max_bytes);
@@ -364,6 +367,51 @@ pub fn apply_filters(p: &mut Pixmap, f: &ImageFilters) {
}
}
+/// Whether `bytes` are a progressive JPEG whose frame samples some component more finely (across
+/// or down) than its first. zune-jpeg 0.5.15, the decoder behind `image`, asserts in its upsampler
+/// on such a frame (etemesi254/zune-image#236; fixed there by 36007420, unreleased as of
+/// 0.5.16-rc3), and in the browser a panic aborts the page. Until the fix ships, such a JPEG is
+/// refused from its header: the decoder refuses the larger ones itself, so nothing that decoded is
+/// lost. Baseline frames of the same shape decode and are left to it.
+pub(crate) fn jpeg_finer_than_first(bytes: &[u8]) -> bool {
+ let Some((marker, frame)) = jpeg_frame(bytes) else { return false };
+ if !matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE) {
+ return false;
+ }
+ // Length (2), precision (1), height (2), width (2), components (1), then id, sampling and
+ // table for each component.
+ let n = usize::from(frame.get(7).copied().unwrap_or(0));
+ let sampling = |k: usize| frame.get(9 + 3 * k).map(|b| (b >> 4, b & 0x0F));
+ let Some((h0, v0)) = sampling(0) else { return false };
+ (1..n).any(|k| sampling(k).is_some_and(|(h, v)| h > h0 || v > v0))
+}
+
+/// A JPEG's start-of-frame marker and the segment after it (from its length bytes), found as the
+/// decoders find it: fill bytes are skipped and markers without a length stepped over. `None` for
+/// anything but a JPEG, or one whose scan, end or bytes come before a frame.
+fn jpeg_frame(bytes: &[u8]) -> Option<(u8, &[u8])> {
+ if !bytes.starts_with(&[0xFF, 0xD8]) {
+ return None;
+ }
+ let mut at = 2;
+ loop {
+ if *bytes.get(at)? != 0xFF {
+ return None;
+ }
+ let marker = *bytes.get(at + 1)?;
+ match marker {
+ 0xFF => at += 1,
+ 0x01 | 0xD0..=0xD8 => at += 2,
+ 0xD9 | 0xDA => return None,
+ 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => return Some((marker, bytes.get(at + 2..)?)),
+ _ => {
+ let len = usize::from(u16::from_be_bytes([*bytes.get(at + 2)?, *bytes.get(at + 3)?]));
+ at = at.checked_add(2)?.checked_add(len)?;
+ }
+ }
+ }
+}
+
/// Read an image's pixel size without keeping the decode.
pub fn dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
diff --git a/crates/render/src/jpeg_sampling_tests.rs b/crates/render/src/jpeg_sampling_tests.rs
new file mode 100644
index 00000000..11e21853
--- /dev/null
+++ b/crates/render/src/jpeg_sampling_tests.rs
@@ -0,0 +1,104 @@
+//! A progressive JPEG whose frame samples a component more finely than its first is refused from
+//! its header, not handed to the decoder: zune-jpeg 0.5.15, the decoder behind `image`, asserts
+//! in its upsampler on such a frame (etemesi254/zune-image#236), and in the browser a panic aborts
+//! the page. Baseline frames of the same shape are decoded (or refused) by the decoder itself.
+
+use std::io::Cursor;
+
+use super::*;
+
+/// An 8 × 8 progressive RGB JPEG with every component sampled 1 × 1, as jpeg-encoder 0.6 writes it
+/// at quality 50 (JFIF header, the standard tables, three scans). Its frame header (SOF2) starts at
+/// byte 20; the second component's sampling byte is [`SECOND_SAMPLING`], the third's follows 3 on.
+const TINY_PROGRESSIVE: &[u8] = &[
+ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xC2, 0x00, 0x11,
+ 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x00, 0x11, 0x00, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E,
+ 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33,
+ 0x3D, 0x3C, 0x39, 0x33, 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68,
+ 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F,
+ 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
+ 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
+ 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00,
+ 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
+ 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24,
+ 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A,
+ 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A,
+ 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99,
+ 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
+ 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1,
+ 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
+ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00,
+ 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31,
+ 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15,
+ 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39,
+ 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
+ 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97,
+ 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4,
+ 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA,
+ 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xFF, 0xDA, 0x00, 0x08,
+ 0x01, 0x01, 0x11, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00,
+ 0x00, 0x01, 0x14, 0x00, 0x4D, 0xB0, 0x97, 0x8E, 0x45, 0x01, 0x08, 0x03, 0x70, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x01, 0x14, 0x00,
+ 0xAD, 0x17, 0xA5, 0x4A, 0x6C, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x01, 0x14, 0x00, 0x8A, 0x75, 0xA1, 0xCB, 0x6D, 0x62, 0x7F, 0xFF, 0xDA,
+ 0x00, 0x08, 0x01, 0x00, 0x00, 0x15, 0x29, 0x00, 0xE0, 0x75, 0x1C, 0x8C, 0x74, 0xAF, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x15, 0x29, 0x00,
+ 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x11, 0x15, 0x29, 0x00, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x00, 0x00, 0x2A, 0x3F, 0x00, 0xC8, 0x3C,
+ 0x7E, 0x1F, 0x79, 0x07, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x11, 0x2A, 0x3F, 0x00, 0xFE, 0x96, 0x8E, 0x3F, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02,
+ 0x11, 0x2A, 0x3F, 0x00, 0xF9, 0x36, 0xFF, 0x00, 0x99, 0xFB, 0xDF, 0xFF, 0xD9,
+];
+const SECOND_SAMPLING: usize = 34;
+
+fn with_second_sampled(hv: u8) -> Vec {
+ let mut b = TINY_PROGRESSIVE.to_vec();
+ b[SECOND_SAMPLING] = hv;
+ b
+}
+
+#[test]
+fn the_jpeg_as_written_is_not_finer_than_its_first_and_decodes() {
+ assert_eq!(TINY_PROGRESSIVE[SECOND_SAMPLING], 0x11);
+ assert!(!images::jpeg_finer_than_first(TINY_PROGRESSIVE));
+ let p = images::decode(TINY_PROGRESSIVE).expect("decoded");
+ assert_eq!((p.width(), p.height()), (8, 8));
+ // Chroma sampled coarser than luma (the usual way round) isn't finer either.
+ assert!(!images::jpeg_finer_than_first(&{
+ let mut b = TINY_PROGRESSIVE.to_vec();
+ b[SECOND_SAMPLING - 3] = 0x22;
+ b
+ }));
+}
+
+/// Without the check this test panics in the decoder (an assertion in zune-jpeg's upsampler).
+#[test]
+fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_refused_from_its_header() {
+ for hv in [0x22, 0x21, 0x12] {
+ let b = with_second_sampled(hv);
+ assert!(images::jpeg_finer_than_first(&b), "second component {hv:#x}");
+ assert!(images::decode(&b).is_none(), "second component {hv:#x}");
+ }
+ let mut third = TINY_PROGRESSIVE.to_vec();
+ third[SECOND_SAMPLING + 3] = 0x22;
+ assert!(images::jpeg_finer_than_first(&third));
+ assert!(images::decode(&third).is_none());
+}
+
+#[test]
+fn baseline_frames_and_other_files_are_left_to_the_decoder() {
+ // A baseline JPEG (as `image` writes them) with its second component made finer: the decoder
+ // takes baseline frames of this shape, so it isn't refused from its header.
+ let mut out = Vec::new();
+ image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([x as u8 * 30, y as u8 * 30, 100]))
+ .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Jpeg)
+ .unwrap();
+ let sof = out.windows(2).position(|m| m == [0xFF, 0xC0]).expect("a baseline frame");
+ out[sof + 14] = 0x22;
+ assert!(!images::jpeg_finer_than_first(&out));
+ let _ = images::decode(&out);
+ // Not JPEGs, or JPEGs that end before their frame.
+ let mut png = Vec::new();
+ image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+ assert!(!images::jpeg_finer_than_first(&png));
+ assert!(!images::jpeg_finer_than_first(&TINY_PROGRESSIVE[..19]));
+ assert!(!images::jpeg_finer_than_first(&[0xFF, 0xD8]));
+ assert!(!images::jpeg_finer_than_first(&[]));
+ assert!(images::decode(&TINY_PROGRESSIVE[..19]).is_none());
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index 24a5e57a..858dc27a 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -492,6 +492,8 @@ mod image_budget_tests;
#[cfg(test)]
mod image_profile_tests;
#[cfg(test)]
+mod jpeg_sampling_tests;
+#[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod magnified_image_tests;
From 7cce115ad669020651369f24228e36f8ad583eb7 Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 09:37:33 +0800
Subject: [PATCH 57/60] fix(collab): check old images against the state they
apply to (#1086)
- without_refused_images judges each image entry against the document it
applies to and the batch's earlier entries (image_for, shared with
apply): a logged put of other bytes under a key the replay state holds
is left out, so a restart no longer stops there and drops the rest of
the log.
- Server::restore keeps its saved log only when the log puts no images:
a resuming client may already hold other bytes under a logged key, which
the restored state can't check, so clients catch up with the checked
snapshot instead.
- Tests: a_restart_replays_past_a_logged_image_replacement,
a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up,
a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up.
- Docs: collab-protocol.md, collaboration-parity.md.
---
crates/collab/src/docprops.rs | 51 +++++++++++++++++++++++-------
crates/collab/src/image_tests.rs | 29 +++++++++++++++--
crates/collab/src/journal_tests.rs | 46 +++++++++++++++++++++++++++
crates/collab/src/replica.rs | 3 +-
crates/collab/src/server.rs | 9 ++++--
docs/collab-protocol.md | 6 ++--
docs/collaboration-parity.md | 2 +-
7 files changed, 125 insertions(+), 21 deletions(-)
diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs
index 285b4f95..cff37300 100644
--- a/crates/collab/src/docprops.rs
+++ b/crates/collab/src/docprops.rs
@@ -176,11 +176,44 @@ fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
-/// `changes` without the image entries [`image_of`] refuses. For history written before those
-/// checks (a saved snapshot, a server's log): the rest of it still applies, where refusing the
-/// whole would leave a document that can't open or drop every edit after the image.
-pub(crate) fn without_refused_images(changes: &[Change]) -> Vec {
- changes.iter().filter(|c| !matches!(c, Change::Put { entry: Entry::Image(k), value } if image_of(k, value).is_err())).cloned().collect()
+/// `changes` without the image entries applying them to `doc` would refuse ([`image_for`]): an
+/// invalid image, or other bytes under a key `doc` or an earlier change of the batch already
+/// holds. For history written before those checks (a saved snapshot, a server's log): the rest of
+/// it still applies, where refusing the whole would leave a document that can't open or drop
+/// every edit after the image.
+pub(crate) fn without_refused_images(doc: &Document, changes: &[Change]) -> Vec {
+ // What each image key holds as the batch goes: the document's image, then the batch's own.
+ let mut held: BTreeMap<&str, Option> = BTreeMap::new();
+ let mut out = Vec::with_capacity(changes.len());
+ for c in changes {
+ match c {
+ Change::Put { entry: Entry::Image(k), value } => {
+ let now = held.get(k.as_str()).cloned().unwrap_or_else(|| doc.images.get(k).cloned());
+ if let Ok(img) = image_for(now.as_ref(), k, value) {
+ held.insert(k, Some(img));
+ out.push(c.clone());
+ }
+ }
+ Change::Remove { entry: Entry::Image(k) } => {
+ held.insert(k, None);
+ out.push(c.clone());
+ }
+ _ => out.push(c.clone()),
+ }
+ }
+ out
+}
+
+/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that
+/// already holds an image can't be given other bytes. A key always names the same bytes
+/// (producers key images by their bytes; the REST importer's placeholder keys always hold the
+/// same grey picture), so nobody can swap the picture under a key someone else added.
+fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result {
+ let img = image_of(key, value)?;
+ if held.is_some_and(|old| old.bytes != img.bytes) {
+ return Err(Error::Invalid(format!("image: {key} already holds other bytes")));
+ }
+ Ok(img)
}
/// The JSON value an image entry carries: its size and its encoded bytes in base64.
@@ -337,13 +370,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result
doc.dev_snapshots.insert(*k, Arc::new(snap));
}
Entry::Image(k) => {
- let img = image_of(k, value)?;
- // A key always names the same bytes (producers key images by their bytes; the REST
- // importer's placeholder keys always hold the same grey picture), so nobody can
- // swap the picture under a key someone else added.
- if doc.images.get(k).is_some_and(|old| old.bytes != img.bytes) {
- return Err(Error::Invalid(format!("image: {k} already holds other bytes")));
- }
+ let img = image_for(doc.images.get(k), k, value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs
index e2c65d35..f9b4d7c7 100644
--- a/crates/collab/src/image_tests.rs
+++ b/crates/collab/src/image_tests.rs
@@ -17,12 +17,12 @@ use crate::docprops::{MAX_IMAGE_SIDE, image_within};
const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
/// sha256 hex, computed here independently of the implementation under test.
-fn sha256_hex(bytes: &[u8]) -> String {
+pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// An image entry putting `bytes` (`width` × `height`) under `key`.
-fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
+pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
Change::Put {
entry: Entry::Image(key.into()),
value: Json(json!({
@@ -34,7 +34,7 @@ fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
}
/// Bytes a collaborator hashed into the key, and different bytes they send under it.
-fn forged() -> (String, &'static [u8]) {
+pub(crate) fn forged() -> (String, &'static [u8]) {
(sha256_hex(b"the image the key actually names"), b"someone else's bytes")
}
@@ -190,3 +190,26 @@ fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
assert!(!r.document().images.contains_key(&key), "the refused image is left out");
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
}
+
+/// A server's log written before these checks may give an image other bytes under its key (the
+/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and
+/// every edit after it, instead of stopping there and dropping the rest of the log.
+#[test]
+fn a_restart_replays_past_a_logged_image_replacement() {
+ let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
+ let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
+ let good = b"an honest image after it".as_slice();
+ let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
+ let journal = [
+ crate::Journal::Issued { client: 1 },
+ accepted(1, vec![put("img1", 2, 1, b"the first bytes")]),
+ accepted(2, vec![put("img1", 2, 1, b"other bytes")]),
+ accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]),
+ ];
+ for j in journal {
+ r.replay(j).expect("the log replays");
+ }
+ assert_eq!(r.seq(), 3, "every batch counts");
+ assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out");
+ assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
+}
diff --git a/crates/collab/src/journal_tests.rs b/crates/collab/src/journal_tests.rs
index 6e41ee76..061efc4b 100644
--- a/crates/collab/src/journal_tests.rs
+++ b/crates/collab/src/journal_tests.rs
@@ -174,3 +174,49 @@ fn a_journal_with_a_gap_is_refused() {
assert_eq!(errors.len(), 1, "the batch after the gap");
assert_eq!(r.seq(), 0);
}
+
+/// Restores `state` with one more batch from another client in its log (and in its snapshot,
+/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it.
+fn resume_past(s: &Server, a: &mut Client, batch: Vec, in_snapshot: bool) -> Server {
+ let mut state = s.state().unwrap();
+ if in_snapshot {
+ state.snapshot.retain(|c| {
+ !batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y))
+ });
+ state.snapshot.extend(batch.iter().cloned());
+ }
+ state.seq += 1;
+ state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch });
+ let mut r = Server::restore(state, "", Limits::default()).unwrap();
+ a.disconnect();
+ let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
+ let (_, out) = r.join(h).unwrap();
+ deliver(a, &out);
+ r
+}
+
+/// A saved log may hold an image a newer server refuses: a client resuming from before it still
+/// catches up, without that image (#1086).
+#[test]
+fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let (key, bytes) = crate::image_tests::forged();
+ let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false);
+ assert!(!a.view().document().images.contains_key(&key));
+ assert_eq!(a.view(), r.replica());
+}
+
+/// A saved log may give an image other bytes under a key the resuming client already holds (an
+/// older server allowed it): the client still catches up, to what the server holds (#1086).
+#[test]
+fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() {
+ let mut s = Server::new(base(), "", Limits::default());
+ let mut a = join_as(&mut s, Access::Edit);
+ let m = edit(&mut a, |d| {
+ d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
+ });
+ send(&mut s, a.id(), m, &mut [&mut a]);
+ let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true);
+ assert_eq!(a.view(), r.replica());
+}
diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs
index 7607ae56..a16413ee 100644
--- a/crates/collab/src/replica.rs
+++ b/crates/collab/src/replica.rs
@@ -75,7 +75,8 @@ impl Replica {
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
// A snapshot saved before the image checks may hold an image they refuse: it opens
// without that image (`docprops::without_refused_images`).
- r.apply(&docprops::without_refused_images(changes), Mode::Strict)?;
+ let changes = docprops::without_refused_images(&r.doc, changes);
+ r.apply(&changes, Mode::Strict)?;
if r.doc.pages.is_empty() {
return Err(Error::Invalid("a snapshot needs a page".into()));
}
diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs
index 077858ee..26b67817 100644
--- a/crates/collab/src/server.rs
+++ b/crates/collab/src/server.rs
@@ -158,7 +158,12 @@ impl Server {
server.deleted = state.deleted;
server.removed = state.removed;
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
- if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) {
+ // A log saved before the image checks may hold an image entry a resuming client would
+ // refuse — an invalid image, or other bytes under a key it already holds, which this
+ // state can't tell apart from a new image. A log that puts images is dropped, so clients
+ // catch up with the (checked) snapshot instead.
+ let puts_images = state.log.iter().flat_map(|a| &a.changes).any(|c| matches!(c, Change::Put { entry: Entry::Image(_), .. }));
+ if contiguous && !puts_images && state.log.last().is_none_or(|a| a.seq == state.seq) {
for a in state.log {
server.remember(a);
}
@@ -204,7 +209,7 @@ impl Server {
}
// A log written before the image checks may hold an image they refuse: the batch
// replays without it, so the edits after it aren't dropped with the rest of the log.
- acc.changes = docprops::without_refused_images(&acc.changes);
+ acc.changes = docprops::without_refused_images(self.state.document(), &acc.changes);
self.accept(acc.author, self.seq, &acc.changes)?;
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
self.next_client = self.next_client.max(acc.author.saturating_add(1));
diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md
index 8023823a..442a313c 100644
--- a/docs/collab-protocol.md
+++ b/docs/collab-protocol.md
@@ -157,8 +157,10 @@ As built:
those bytes (as the app writes them); a side is at most 65 535 px and the decoded bytes at
most 256 MiB (`ImageData::MAX_BYTES`, what the importers read), base64 that can't decode is
refused, and a key that already holds an image can't be given other bytes; a snapshot or a
- server log written before these checks opens without the images they refuse, rather than
- failing, and a file published to a server is kept without them); a
+ server log written before these checks opens without the images they refuse — judged against
+ the state they apply to, so a logged replacement is left out too — rather than failing, and a
+ file published to a server is kept without them; a restarted server whose saved log puts
+ images catches resuming clients up with its snapshot instead of that log); a
board's timer and vote (whole values, `DocProp::Timer` and
`DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting
at once write different entries). The timer travels as its end time (or, paused, the time left),
diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md
index 66816b4b..adc26430 100644
--- a/docs/collaboration-parity.md
+++ b/docs/collaboration-parity.md
@@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
-| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse, instead of the file not opening or a restart dropping every edit after the image. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged |
+| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse (judged against the state they apply to, so a logged replacement under an existing key is left out too), instead of the file not opening or a restart dropping every edit after the image; a restarted server whose saved log puts images catches resuming clients up with its snapshot instead of that log, which it can't check against what each client holds. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged |
| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged |
| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
From bdddf9a2baaa95f99cd941b19b1675de3f46a01a Mon Sep 17 00:00:00 2001
From: samhyun
Date: Thu, 29 Oct 2026 11:16:17 +0900
Subject: [PATCH 58/60] M8: the baseline fixture of the JPEG sampling test is a
well-formed file
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From the review of #1491: patching a component's sampling in the header of a baseline JPEG
`image` wrote leaves its entropy data for 1 × 1 components, so decoding it could only fail and
the test proved nothing past the frame not being refused. The fixture is now a baseline CMYK
JPEG as jpeg-encoder writes it with 2 × 2 sampling (its black sampled finer than its cyan), and
the test asserts it is left to the decoder and decodes.
Co-Authored-By: Claude Fable 5.1
---
crates/render/src/jpeg_sampling_tests.rs | 55 +++++++++++++++++++-----
1 file changed, 44 insertions(+), 11 deletions(-)
diff --git a/crates/render/src/jpeg_sampling_tests.rs b/crates/render/src/jpeg_sampling_tests.rs
index 11e21853..e001e702 100644
--- a/crates/render/src/jpeg_sampling_tests.rs
+++ b/crates/render/src/jpeg_sampling_tests.rs
@@ -1,7 +1,7 @@
//! A progressive JPEG whose frame samples a component more finely than its first is refused from
//! its header, not handed to the decoder: zune-jpeg 0.5.15, the decoder behind `image`, asserts
//! in its upsampler on such a frame (etemesi254/zune-image#236), and in the browser a panic aborts
-//! the page. Baseline frames of the same shape are decoded (or refused) by the decoder itself.
+//! the page. Baseline frames of the same shape are decoded by the decoder itself and left to it.
use std::io::Cursor;
@@ -47,6 +47,44 @@ const TINY_PROGRESSIVE: &[u8] = &[
];
const SECOND_SAMPLING: usize = 34;
+/// An 8 × 8 baseline CMYK JPEG as jpeg-encoder 0.6 writes it at quality 50 with 2 × 2 sampling:
+/// its black is sampled 2 × 2 against 1 × 1 for the others (SOF0 at byte 36), a well-formed file
+/// of the shape the check is about, in a baseline frame.
+const TINY_BASELINE_CMYK: &[u8] = &[
+ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xEE, 0x00, 0x0E,
+ 0x41, 0x64, 0x6F, 0x62, 0x65, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xC0, 0x00, 0x14, 0x08, 0x00, 0x08, 0x00, 0x08, 0x04, 0x00, 0x11,
+ 0x01, 0x01, 0x11, 0x01, 0x02, 0x11, 0x01, 0x03, 0x22, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D,
+ 0x0E, 0x12, 0x11, 0x10, 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, 0x38,
+ 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79,
+ 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42,
+ 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
+ 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63,
+ 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
+ 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
+ 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
+ 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47,
+ 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77,
+ 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5,
+ 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2,
+ 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
+ 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
+ 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
+ 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16,
+ 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46,
+ 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76,
+ 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
+ 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
+ 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6,
+ 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0E, 0x04, 0x00, 0x11, 0x01, 0x11, 0x02, 0x11, 0x03, 0x00, 0x00, 0x3F, 0x00, 0xEE, 0xA6, 0xAA, 0x2A,
+ 0xB3, 0x83, 0x95, 0xED, 0x6D, 0x75, 0xBB, 0xF5, 0xB4, 0x97, 0x6D, 0xB6, 0x5A, 0xD9, 0x2B, 0x8D, 0x39, 0x3A, 0xB3, 0x8F, 0x54, 0xFB, 0xCB, 0xAF,
+ 0xFD, 0xBD, 0x7E, 0x9D, 0xDA, 0xEC, 0x96, 0xB7, 0xE2, 0x95, 0x47, 0x2A, 0x8E, 0x0B, 0x46, 0xB7, 0x77, 0x7A, 0xDD, 0xBE, 0xCD, 0x3F, 0xBD, 0xBF,
+ 0x2B, 0x6C, 0x0E, 0xC3, 0x6E, 0xE9, 0xB6, 0xCA, 0xE0, 0x07, 0x1B, 0xC9, 0x21, 0x95, 0x4E, 0x72, 0x54, 0x0C, 0x8E, 0x00, 0x62, 0x00, 0x5C, 0x28,
+ 0x45, 0xEF, 0x51, 0xFF, 0x00, 0x6B, 0xDA, 0xFF, 0x00, 0x76, 0xC7, 0xFF, 0x00, 0x01, 0x27, 0xA1, 0xFF, 0x00, 0xE3, 0xFB, 0xFE, 0xDD, 0x65, 0xFF,
+ 0x00, 0xD1, 0x31, 0xD6, 0x35, 0x7F, 0xFF, 0xD9,
+];
+
fn with_second_sampled(hv: u8) -> Vec {
let mut b = TINY_PROGRESSIVE.to_vec();
b[SECOND_SAMPLING] = hv;
@@ -83,16 +121,11 @@ fn a_progressive_jpeg_sampling_a_component_finer_than_its_first_is_refused_from_
#[test]
fn baseline_frames_and_other_files_are_left_to_the_decoder() {
- // A baseline JPEG (as `image` writes them) with its second component made finer: the decoder
- // takes baseline frames of this shape, so it isn't refused from its header.
- let mut out = Vec::new();
- image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([x as u8 * 30, y as u8 * 30, 100]))
- .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Jpeg)
- .unwrap();
- let sof = out.windows(2).position(|m| m == [0xFF, 0xC0]).expect("a baseline frame");
- out[sof + 14] = 0x22;
- assert!(!images::jpeg_finer_than_first(&out));
- let _ = images::decode(&out);
+ // A well-formed baseline frame with its black sampled finer than its cyan: the decoder takes
+ // it, so it isn't refused from its header.
+ assert!(!images::jpeg_finer_than_first(TINY_BASELINE_CMYK));
+ let p = images::decode(TINY_BASELINE_CMYK).expect("decoded");
+ assert_eq!((p.width(), p.height()), (8, 8));
// Not JPEGs, or JPEGs that end before their frame.
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
From 57cf1bec48481b241f92f7f3f32415998f79b3ae Mon Sep 17 00:00:00 2001
From: Christopher Linnett
Date: Thu, 29 Oct 2026 10:23:58 +0800
Subject: [PATCH 59/60] fix(collab): keep in-use images and resync clients
after filtered history (#1086)
- An image a layer or style still shows can't be removed: removing it and
putting other bytes under its key would swap the picture that layer
shows, which giving the key other bytes directly can't. A batch made by
the diff stops using an image (node changes, then styles) before it
removes it; an undo keeps the changes that apply, so its image may stay.
- Restore and replay leave out the image entries history written under
older rules can't apply, using one method shared with the undo history
(Replica::apply_kept, extracted from history.rs). It is exact for every
image rule, including removals in use and replacements, and leaves out
only image entries: any other change that doesn't apply is still an
error. The image-only filter it replaces is gone.
- A server whose restore or replay left image entries out, or whose saved
log puts images, catches every resuming client up with its snapshot,
including one at the same sequence number.
- Tests: an_image_in_use_cannot_be_removed,
an_image_no_longer_in_use_can_be_removed, undoing_an_image_layer_still_works,
a_restart_replays_past_a_logged_removal_of_an_image_in_use,
a_resume_after_history_was_left_out_catches_up_with_the_snapshot,
a_resume_after_a_clean_restart_catches_up_with_the_log,
a_snapshot_with_a_broken_layer_change_still_fails_to_open.
- Docs: collab-protocol.md, collaboration-parity.md.
---
crates/collab/src/change.rs | 7 ++
crates/collab/src/docprops.rs | 33 ++-------
crates/collab/src/history.rs | 12 +---
crates/collab/src/image_tests.rs | 43 ++++++++++++
crates/collab/src/journal_tests.rs | 103 +++++++++++++++++++++++++++++
crates/collab/src/replica.rs | 42 ++++++++++--
crates/collab/src/server.rs | 33 ++++++---
docs/collab-protocol.md | 11 +--
docs/collaboration-parity.md | 4 +-
9 files changed, 226 insertions(+), 62 deletions(-)
diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs
index 4ae0015b..dedae208 100644
--- a/crates/collab/src/change.rs
+++ b/crates/collab/src/change.rs
@@ -313,6 +313,13 @@ impl DocProp {
}
}
+impl Change {
+ /// Whether this puts or removes an image entry.
+ pub(crate) fn is_image(&self) -> bool {
+ matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
+ }
+}
+
impl Entry {
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
/// session carry their creator's client id, ids from before and ids of other forms are in
diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs
index cff37300..f53040fb 100644
--- a/crates/collab/src/docprops.rs
+++ b/crates/collab/src/docprops.rs
@@ -176,34 +176,6 @@ fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
-/// `changes` without the image entries applying them to `doc` would refuse ([`image_for`]): an
-/// invalid image, or other bytes under a key `doc` or an earlier change of the batch already
-/// holds. For history written before those checks (a saved snapshot, a server's log): the rest of
-/// it still applies, where refusing the whole would leave a document that can't open or drop
-/// every edit after the image.
-pub(crate) fn without_refused_images(doc: &Document, changes: &[Change]) -> Vec