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>>, + fallbacks: Vec>, + items: Vec, + bidi: Option, +} + +fn shape_text(text: &TextData) -> ShapedText { let chars: Vec = text.characters.chars().collect(); let spans = text.spans(); let mut styles: Vec = Vec::new(); @@ -365,7 +372,6 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Shape each span, split by paragraph, bidi run and fallback face. let bidi = Bidi::of(&text.characters); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); - let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. let cluster_start = crate::breaks::grapheme_starts(&text.characters); let mut items: Vec = Vec::with_capacity(chars.len()); @@ -431,40 +437,21 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } - // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the - // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent - // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the - // indent are on the right, the text ends where they start. - let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect(); - let markers = list_markers(&lists); - let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref()); - let hang_list = if text.hanging_list { step } else { 0.0 }; - // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). - let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; - let indent_at = |char_idx: usize| { - let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; - if start_aligned { indent_px } else { 0.0 } - }; - let list_of = |p: usize| lists.get(p).copied().unwrap_or_default(); - // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). - let line_indent_of = |p: usize, first: bool, ci: usize| { - let pl = list_of(p); - match pl.list { - ListStyle::None if first => indent_at(ci), - ListStyle::None => 0.0, - _ => step * (f64::from(pl.indent) + 1.0) - hang_list, - } - }; - // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge - // of right-aligned and justified ones (left-to-right paragraphs). - let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified); - let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified); + ShapedText { chars, styles, faces, fallbacks, items, bidi } +} - // Line breaking. - let wrap = match text.auto_resize { - TextAutoResize::WidthAndHeight => None, - _ => box_width.filter(|w| w.is_finite() && *w > 0.0), - }; +fn line_break_after(items: &[Item], end: usize) -> bool { + end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch)) +} + +fn break_lines( + items: &[Item], + wrap: Option, + line_indent_of: &impl Fn(usize, bool, usize) -> f64, + rtl_para_at: &impl Fn(usize) -> bool, + hang_start: bool, + hang_end: bool, +) -> Vec<(usize, usize, bool, usize)> { let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph let mut line_start = 0; let mut i = 0; @@ -521,230 +508,49 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } - let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || line_break_after(items, l.1)) { lines.push((line_start, items.len(), true, para)); } - // Line metrics and positions. - let default_style = styles.first().cloned().unwrap_or_default(); - let default_face = faces.first().cloned().flatten(); - let mut out = TextLayout { styles: styles.clone(), ..Default::default() }; - let mut y = 0.0; - let mut para_start_line = true; - let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX }; - // A style's vertical metrics (ascent, descent, line height, cap height) come from its own - // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. - let metrics = |st_idx: usize| -> [f64; 4] { - let st = styles.get(st_idx).unwrap_or(&default_style); - let size = sanitize_size(st.font_size); - match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { - Some(f) => { - let k = size / f.upem; - [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k] - } - None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], - } - }; - // An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space - // the font has no glyph for). - let glyph_at = |it: &Item, pen: f64, baseline: f64| { - let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; - (it.gid != 0 || !it.is_space()).then(|| Glyph { - face: it.face.clone(), - coords: it.coords.clone(), - gid: it.gid, - x: pen + it.dx, - y: baseline - it.dy, - size, - advance: it.adv, - char_start: it.char_start, - char_end: it.char_end, - style: it.style, - rtl: it.level % 2 == 1, - warp: Affine::IDENTITY, - }) - }; - let mut first_cap = 0.0; - for (li, (s, e, para_end, para)) in lines.iter().enumerate() { - if li >= max_lines { - out.truncated = true; - break; - } - // Metrics from the styles present on the line (or the style at the break for empty lines). - let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; - let mut seen = usize::MAX; - for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { - if st != seen { - seen = st; - let m = metrics(st); - (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); - } - } - if seen == usize::MAX { - [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); - } - if li == 0 { - first_cap = cap; - } - if para_start_line && li > 0 { - // Between two items of a list: the list spacing. - let in_list = |p: usize| list_of(p).list != ListStyle::None; - let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing }; - y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 }; - } - let baseline = y + (lh - (asc + desc)) / 2.0 + asc; - // Trailing spaces don't count for width/alignment. - let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { - vis_end -= 1; - } - // Letter spacing separates glyphs: none after the line's last visible one. - let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); - let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; - let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); - let rtl_line = rtl_para_at(first_char); - let line_indent = line_indent_of(*para, para_start_line, first_char); - // Hanging punctuation at the line's edges (the visible text's first and last chars). - let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv); - let hang_r = - items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); - let gstart = out.glyphs.len(); - // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); - let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { - ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) - } - _ => 0.0, - }; - // Where the visible text ends once justified. - let text_end = width + justify_extra * gaps as f64; - // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang - // outside it, so its visible text spans [0, text_end] with the gutter and indent after it. - let trailing: f64 = items - .get(vis_end..*e) - .unwrap_or(&[]) - .iter() - .filter(|it| !is_line_break(it.ch)) - .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) - .sum(); - let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; - // List marker (before the line's text; zero-length char range so carets skip it). - let pl = list_of(*para); - if para_start_line && pl.list != ListStyle::None { - let marker = markers.get(*para).cloned().unwrap_or_default(); - let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); - let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); - // The marker takes the paragraph style's own font (not a fallback the line's first - // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a - // nested bullet the font lacks comes from a fallback font. - let has = |f: &Arc| marker.chars().all(|c| f.has_char(c)); - let own = faces.get(st_idx).cloned().flatten().filter(has); - let marker_face = match (own, styles.get(st_idx)) { - (Some(f), Some(st)) => { - let coords = f.coords(&f.axis_values(st)); - Some((f, coords)) - } - _ => fallbacks - .iter() - .find(|f| has(f)) - .map(|f| (f.clone(), no_coords())) - .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone()))) - .or_else(|| default_face.clone().map(|f| (f, no_coords()))), - }; - if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { - let size = sanitize_size(st.font_size); - let k = size / face.upem; - let numbered = pl.list == ListStyle::Numbered; - // Right to left, the number reads "1." with its full stop on the left (".1"). - let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker }; - let advance = marker_advance(&face, &coords, &marker, size); - // Numbers end (start, right to left) half an em from the text; bullets sit at the - // gutter's outer edge. - let gutter = step * f64::from(pl.indent) - hang_list; - let mut mx = match (rtl_line, numbered) { - (false, true) => gutter + (step - size * 0.5 - advance).max(0.0), - (false, false) => gutter, - (true, true) => text_end + size * 0.5, - (true, false) => text_end + (step - advance).max(0.0), - }; - for c in marker.chars() { - let gid = face.glyph_for(c); - let adv = face.advance_at(gid, &coords) * k; - out.glyphs.push(Glyph { - face: face.clone(), - coords: coords.clone(), - gid, - x: mx, - y: baseline, - size, - advance: adv, - char_start: at, - char_end: at, - style: st_idx, - rtl: false, - warp: Affine::IDENTITY, - }); - mx += adv; - } - } - } - // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's - // direction (rule L1). - let line_items = items.get(*s..*e).unwrap_or(&[]); - let order: Vec = if bidi.is_some() { - let para_level = u8::from(rtl_line); - let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); - visual_order(&levels) - } else { - (0..line_items.len()).collect() - }; - for it in order.iter().filter_map(|k| line_items.get(*k)) { - if is_line_break(it.ch) { - continue; - } - out.glyphs.extend(glyph_at(it, pen, baseline)); - pen += it.adv; - if it.stretches() { - pen += justify_extra; - } - } - let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len()); - let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start); - out.lines.push(Line { - glyph_start: gstart, - glyph_end: out.glyphs.len(), - char_start, - char_end, - top: y, - height: lh, - baseline, - width: width + line_indent - hang_l - hang_r, - x: 0.0, - para_end: *para_end, - break_after: break_after(*e), - rtl: rtl_line, - }); - y += lh; - para_start_line = *para_end; - } - out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); - // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. - let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); - if trim && let Some(first) = out.lines.first() { - let shift = first.baseline - first_cap; - for l in &mut out.lines { - l.top -= shift; - l.baseline -= shift; - } - for g in &mut out.glyphs { - g.y -= shift; - } - } - let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; - out.height = out.lines.last().map_or(y, bottom).max(0.0); + lines +} +#[derive(Clone, Copy)] +struct LayoutFinish<'a> { + text: &'a TextData, + box_width: Option, + box_height: Option, + wrap: Option, + lines: &'a [(usize, usize, bool, usize)], + items: &'a [Item], + styles: &'a [TextStyle], + faces: &'a [Option>], + first_cap: f64, + flow_height: f64, +} + +fn glyph_at(styles: &[TextStyle], item: &Item, pen: f64, baseline: f64) -> Option { + let size = styles.get(item.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * item.scale; + (item.gid != 0 || !item.is_space()).then(|| Glyph { + face: item.face.clone(), + coords: item.coords.clone(), + gid: item.gid, + x: pen + item.dx, + y: baseline - item.dy, + size, + advance: item.adv, + char_start: item.char_start, + char_end: item.char_end, + style: item.style, + rtl: item.level % 2 == 1, + warp: Affine::IDENTITY, + }) +} + +fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) { + let LayoutFinish { text, box_height, wrap, lines, items, styles, .. } = *finish; + let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); + let bottom = |line: &Line| if trim { line.baseline } else { line.top + line.height }; // Truncation by height. if matches!(text.auto_resize, TextAutoResize::Truncate) && let Some(h) = box_height @@ -766,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?))); if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on) && !para_end - && !break_after(e) + && !line_break_after(items, e) && !line.rtl { let baseline = line.baseline; @@ -776,7 +582,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) break; } if !is_line_break(it.ch) { - out.glyphs.extend(glyph_at(it, pen, baseline)); + out.glyphs.extend(glyph_at(styles, it, pen, baseline)); pen += it.adv; } } @@ -785,7 +591,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) l.glyph_end = gend; } } - add_ellipsis(&mut out, wrap); + add_ellipsis(out, wrap); // Carets and selections reach every char the line shows, borrowed ones included. if let Some(l) = out.lines.last() { let shown = @@ -795,6 +601,26 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } } +} + +fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) { + let LayoutFinish { text, box_width, faces, first_cap, flow_height, .. } = finish; + // Vertical trim: the box starts at the first line's cap height and ends at the last baseline. + let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none(); + if trim && let Some(first) = out.lines.first() { + let shift = first.baseline - first_cap; + for l in &mut out.lines { + l.top -= shift; + l.baseline -= shift; + } + for g in &mut out.glyphs { + g.y -= shift; + } + } + let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height }; + out.height = out.lines.last().map_or(flow_height, bottom).max(0.0); + + truncate_layout(out, &finish); // Horizontal alignment within the box. let align_width = match text.auto_resize { @@ -891,8 +717,240 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) out.decorations = decos; out.styles.extend(deco_styles); if let Some(tp) = &text.path { - crate::onpath::place(&mut out, tp, text.align_h); + crate::onpath::place(out, tp, text.align_h); } +} + +/// 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 { + let ShapedText { chars, styles, faces, fallbacks, items, bidi } = shape_text(text); + let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false); + + // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the + // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent + // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the + // indent are on the right, the text ends where they start. + let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect(); + let markers = list_markers(&lists); + let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref()); + let hang_list = if text.hanging_list { step } else { 0.0 }; + // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). + let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + let indent_at = |char_idx: usize| { + let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; + if start_aligned { indent_px } else { 0.0 } + }; + let list_of = |p: usize| lists.get(p).copied().unwrap_or_default(); + // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). + let line_indent_of = |p: usize, first: bool, ci: usize| { + let pl = list_of(p); + match pl.list { + ListStyle::None if first => indent_at(ci), + ListStyle::None => 0.0, + _ => step * (f64::from(pl.indent) + 1.0) - hang_list, + } + }; + // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge + // of right-aligned and justified ones (left-to-right paragraphs). + let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified); + let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified); + + let wrap = match text.auto_resize { + TextAutoResize::WidthAndHeight => None, + _ => box_width.filter(|w| w.is_finite() && *w > 0.0), + }; + let lines = break_lines(&items, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end); + // Line metrics and positions. + let default_style = styles.first().cloned().unwrap_or_default(); + let default_face = faces.first().cloned().flatten(); + let mut out = TextLayout { styles: styles.clone(), ..Default::default() }; + let mut y = 0.0; + let mut para_start_line = true; + let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX }; + // A style's vertical metrics (ascent, descent, line height, cap height) come from its own + // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't. + let metrics = |st_idx: usize| -> [f64; 4] { + let st = styles.get(st_idx).unwrap_or(&default_style); + let size = sanitize_size(st.font_size); + match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) { + Some(f) => { + let k = size / f.upem; + [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k] + } + None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7], + } + }; + let mut first_cap = 0.0; + for (li, (s, e, para_end, para)) in lines.iter().enumerate() { + if li >= max_lines { + out.truncated = true; + break; + } + // Metrics from the styles present on the line (or the style at the break for empty lines). + let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4]; + let mut seen = usize::MAX; + for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) { + if st != seen { + seen = st; + let m = metrics(st); + (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3])); + } + } + if seen == usize::MAX { + [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style)); + } + if li == 0 { + first_cap = cap; + } + if para_start_line && li > 0 { + // Between two items of a list: the list spacing. + let in_list = |p: usize| list_of(p).list != ListStyle::None; + let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing }; + y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 }; + } + let baseline = y + (lh - (asc + desc)) / 2.0 + asc; + // Trailing spaces don't count for width/alignment. + let mut vis_end = *e; + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { + vis_end -= 1; + } + // Letter spacing separates glyphs: none after the line's last visible one. + let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); + let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; + let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); + let rtl_line = rtl_para_at(first_char); + let line_indent = line_indent_of(*para, para_start_line, first_char); + // Hanging punctuation at the line's edges (the visible text's first and last chars). + let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv); + let hang_r = + items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); + let gstart = out.glyphs.len(); + // Justify: extra space per gap. + let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); + let justify_extra = match (text.align_h, wrap) { + (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } + _ => 0.0, + }; + // Where the visible text ends once justified. + let text_end = width + justify_extra * gaps as f64; + // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang + // outside it, so its visible text spans [0, text_end] with the gutter and indent after it. + let trailing: f64 = items + .get(vis_end..*e) + .unwrap_or(&[]) + .iter() + .filter(|it| !is_line_break(it.ch)) + .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) + .sum(); + let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; + // List marker (before the line's text; zero-length char range so carets skip it). + let pl = list_of(*para); + if para_start_line && pl.list != ListStyle::None { + let marker = markers.get(*para).cloned().unwrap_or_default(); + let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); + let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); + // The marker takes the paragraph style's own font (not a fallback the line's first + // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a + // nested bullet the font lacks comes from a fallback font. + let has = |f: &Arc| marker.chars().all(|c| f.has_char(c)); + let own = faces.get(st_idx).cloned().flatten().filter(has); + let marker_face = match (own, styles.get(st_idx)) { + (Some(f), Some(st)) => { + let coords = f.coords(&f.axis_values(st)); + Some((f, coords)) + } + _ => fallbacks + .iter() + .find(|f| has(f)) + .map(|f| (f.clone(), no_coords())) + .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone()))) + .or_else(|| default_face.clone().map(|f| (f, no_coords()))), + }; + if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { + let size = sanitize_size(st.font_size); + let k = size / face.upem; + let numbered = pl.list == ListStyle::Numbered; + // Right to left, the number reads "1." with its full stop on the left (".1"). + let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker }; + let advance = marker_advance(&face, &coords, &marker, size); + // Numbers end (start, right to left) half an em from the text; bullets sit at the + // gutter's outer edge. + let gutter = step * f64::from(pl.indent) - hang_list; + let mut mx = match (rtl_line, numbered) { + (false, true) => gutter + (step - size * 0.5 - advance).max(0.0), + (false, false) => gutter, + (true, true) => text_end + size * 0.5, + (true, false) => text_end + (step - advance).max(0.0), + }; + for c in marker.chars() { + let gid = face.glyph_for(c); + let adv = face.advance_at(gid, &coords) * k; + out.glyphs.push(Glyph { + face: face.clone(), + coords: coords.clone(), + gid, + x: mx, + y: baseline, + size, + advance: adv, + char_start: at, + char_end: at, + style: st_idx, + rtl: false, + warp: Affine::IDENTITY, + }); + mx += adv; + } + } + } + // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's + // direction (rule L1). + let line_items = items.get(*s..*e).unwrap_or(&[]); + let order: Vec = if bidi.is_some() { + let para_level = u8::from(rtl_line); + let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); + visual_order(&levels) + } else { + (0..line_items.len()).collect() + }; + for it in order.iter().filter_map(|k| line_items.get(*k)) { + if is_line_break(it.ch) { + continue; + } + out.glyphs.extend(glyph_at(&styles, it, pen, baseline)); + pen += it.adv; + if it.stretches() { + pen += justify_extra; + } + } + let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len()); + let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start); + out.lines.push(Line { + glyph_start: gstart, + glyph_end: out.glyphs.len(), + char_start, + char_end, + top: y, + height: lh, + baseline, + width: width + line_indent - hang_l - hang_r, + x: 0.0, + para_end: *para_end, + break_after: line_break_after(&items, *e), + rtl: rtl_line, + }); + y += lh; + para_start_line = *para_end; + } + out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max); + finish_layout( + &mut out, + LayoutFinish { text, box_width, box_height, wrap, lines: &lines, items: &items, styles: &styles, faces: &faces, first_cap, flow_height: y }, + ); + out } From 4268bd859f95fc195063284bfcd8d6f9298223dc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 14:00:44 +0000 Subject: [PATCH 26/60] refactor(fig): split mapping and lint test helpers --- crates/format/src/fig/assets_tests.rs | 43 ++++--- crates/format/src/fig/map/layer.rs | 109 ++++++++++++++---- .../format/src/fig/source_identity_tests.rs | 8 ++ crates/format/src/fig/tests.rs | 51 +++++--- .../format/src/rest/proto_variable_tests.rs | 28 +++-- 5 files changed, 178 insertions(+), 61 deletions(-) diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index a271a908..cf115c8f 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() { #[test] fn variables_are_written_as_nodes_on_a_hidden_page() { let nodes = written(&themed()); - let hidden = named(&nodes, "Internal Only Canvas"); + assert_hidden_theme(&nodes); + assert_variable_values(&nodes); + assert_variable_bindings(&nodes); +} + +fn assert_hidden_theme(nodes: &[Value]) { + let hidden = named(nodes, "Internal Only Canvas"); assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false))); - let set = named(&nodes, "Theme"); + let set = named(nodes, "Theme"); assert_eq!(set["type"], "VARIABLE_SET"); assert_eq!(set["parentIndex"]["guid"], hidden["guid"]); let modes = set["variableSetModes"].as_array().unwrap(); assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::>(), ["Light", "Dark"]); - let bg = named(&nodes, "color/bg"); +} + +fn assert_variable_values(nodes: &[Value]) { + let set = named(nodes, "Theme"); + let modes = set["variableSetModes"].as_array().unwrap(); + let bg = named(nodes, "color/bg"); assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR"))); assert_eq!(bg["variableSetID"]["guid"], set["guid"]); assert_eq!(bg["description"], "Behind everything"); assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"])); - assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); + assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"])); let values = bg["variableDataValues"]["entries"].as_array().unwrap(); assert_eq!(values.len(), 2); assert_eq!(values[1]["modeID"], modes[1]["id"]); @@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { (Some("COLOR"), Some(0.25)) ); // An alias names the variable's node; every value says the type it resolves to. - let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"]; + let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"]; assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS"); assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR"); - assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]); - assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); - assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); - assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); + assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]); + assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀"); + assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false); + assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0); +} + +fn assert_variable_bindings(nodes: &[Value]) { + let bg = named(nodes, "color/bg"); + let set = named(nodes, "Theme"); + let modes = set["variableSetModes"].as_array().unwrap(); // Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map. - let card = named(&nodes, "Card"); + let card = named(nodes, "Card"); assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]); assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]); - assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]); + assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]); let fields: Vec<&str> = card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect(); for f in @@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { { assert!(fields.contains(&f), "{f} in {fields:?}"); } - let swatch = named(&nodes, "Swatch"); + let swatch = named(nodes, "Swatch"); assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none()); assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]); assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2); @@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() { assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}"); } // The page sets a mode too. - assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]); + assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]); } #[test] diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index e4793d48..e750a5e4 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref}; use super::connector::connector_has_saved_label; use super::layout::{layout_grid, tracks}; use super::paint::{effect, export_setting, paint, paint_bound, stroke}; -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows, + size, +}; /// How the layers of a resized container follow it when the file gives no derived layout for /// them: a frame's by their constraints, from its main's size to its own; a group's scale with it. @@ -61,6 +64,22 @@ impl Fit { } } +struct LayerChildren<'s, 'a> { + id: &'s str, + inst: Option<&'s Inst<'a>>, + base: &'s Kv<'a>, + child_space: Affine, + view: &'s View<'a>, + object: &'s mut Obj, + ty: &'s str, + rest_id: &'s str, + connector_snapshot: bool, + connector_terminal: bool, + depth: usize, + included: Option<&'s BTreeSet>, + slot_content: Option, +} + impl<'a> Mapper<'a> { /// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the /// parent's coordinates to the enclosing frame's (groups and booleans place their children in @@ -136,16 +155,7 @@ impl<'a> Mapper<'a> { connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); - let ty = match own { - "ROUNDED_RECTANGLE" => "RECTANGLE", - "SYMBOL" => "COMPONENT", - "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET", - "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP", - "VARIABLE" | "VARIABLE_SET" => return None, - "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR", - "CONNECTOR" if connector_snapshot => "FRAME", - t => stand.map_or(t, |s| s.0), - }; + let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?; if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } @@ -225,18 +235,59 @@ impl<'a> Mapper<'a> { source.insert("slotOverride".into(), json!(true)); } let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY }; - let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() }; + let mut kids = self.map_layer_children(LayerChildren { + id, + inst, + base, + child_space, + view: &view, + object: &mut o, + ty, + rest_id: &rest_id, + connector_snapshot, + connector_terminal, + depth, + included, + slot_content, + }); + if stand.is_some_and(|s| s.0 == "FRAME") { + kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); + kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + } + if !kids.is_empty() { + o.insert("children".into(), Value::Array(kids)); + } + Some(Value::Object(o)) + } + + fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec { + let LayerChildren { + id, + inst, + base, + child_space, + view, + object: o, + ty, + rest_id, + connector_snapshot, + connector_terminal, + depth, + included, + slot_content, + } = context; + let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() }; if connector_terminal { // The saved vector is flattened into the connector node when no child slot remains. } else if ty == "INSTANCE" { - if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) { + if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) { let main = self.nodes.get(&sym).map(|m| View::of(m)); let fit = Fit::frame(main.as_ref().and_then(View::size), view.size()); // An instance that records no auto layout of its own has its main's. let view = match main { Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => { let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) }; - self.auto_layout(&mut o, &layered); + self.auto_layout(o, &layered); layered } _ => view.clone(), @@ -252,7 +303,7 @@ impl<'a> Mapper<'a> { content_inst.slot_content = true; } for c in self.children(&content) { - kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included)); + kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included)); } } else { // Inside an instance a layer's children are its main's: they follow it from the main's @@ -263,17 +314,29 @@ impl<'a> Mapper<'a> { (Some(_), _) => Fit::frame(View::of(base).size(), view.size()), }; for c in self.children(id) { - kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included)); + kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included)); } } - if stand.is_some_and(|s| s.0 == "FRAME") { - kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); - kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); + kids + } + + fn mapped_type<'s>( + own: &'s str, + view: &View<'a>, + connector_snapshot: bool, + connector_terminal: bool, + stand: Option<(&'static str, &'static str)>, + ) -> Option<&'s str> { + match own { + "ROUNDED_RECTANGLE" => Some("RECTANGLE"), + "SYMBOL" => Some("COMPONENT"), + "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"), + "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"), + "VARIABLE" | "VARIABLE_SET" => None, + "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"), + "CONNECTOR" if connector_snapshot => Some("FRAME"), + t => Some(stand.map_or(t, |s| s.0)), } - if !kids.is_empty() { - o.insert("children".into(), Value::Array(kids)); - } - Some(Value::Object(o)) } /// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs index 927b0227..09ec0c60 100644 --- a/crates/format/src/fig/source_identity_tests.rs +++ b/crates/format/src/fig/source_identity_tests.rs @@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) { let copied_frame = child(doc, copied_body, 0); assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame"); assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame)); + assert_external_slots(doc, frame, copied_frame, main); + assert_external_copy_state(doc, frame, copied_frame); +} + +fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) { for index in 0..2 { let slot = child(doc, frame, index); let copied_slot = child(doc, copied_frame, index); @@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) { assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0))); assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0); } +} + +fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) { let old = doc.node(child(doc, frame, 0)).unwrap(); let current = doc.node(child(doc, copied_frame, 0)).unwrap(); assert!(!old.visible && old.is_mask && old.opacity < 1.0); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 35d8d08a..4ebb117f 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -630,63 +630,82 @@ fn imports_the_fixture() { assert!(is_fig(&bytes)); let (doc, warnings) = import(&bytes, "fallback").unwrap(); assert_eq!(doc.name, "Fixture"); + assert_fixture_layers(&doc); + assert_fixture_styles(&doc); + assert_fixture_variables(&doc); + assert_fixture_components(&doc); + assert_fixture_prototype(&doc, &warnings); +} + +fn assert_fixture_layers(doc: &Document) { // The internal page is skipped. assert_eq!(doc.pages.len(), 1); let page = doc.node(doc.pages[0]).unwrap(); assert_eq!(page.name, "Page A"); // Siblings by position: the frame comes first even though it's listed after the component. assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen"); - let screen = named(&doc, "Screen"); + let screen = named(doc, "Screen"); let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]); // Image fill from the archive. - let photo = named(&doc, "Photo"); + let photo = named(doc, "Photo"); let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") }; assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1))); assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0)); // Text with a bold run over "the". - let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") }; + let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") }; assert_eq!(t.characters, "Hi there"); assert_eq!(t.style.font_size, 16.0); assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)"); assert_eq!(t.runs.len(), 1); assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold")); // The group's children keep their place on the page (group at 100, 100). - assert!(matches!(named(&doc, "Group").kind, NodeKind::Group)); - let tri = named(&doc, "Tri"); + assert!(matches!(named(doc, "Group").kind, NodeKind::Group)); + let tri = named(doc, "Tri"); assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty())); let abs = doc.world_transform(tri.id).translation(); assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}"); - assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. })); - // The linked style supplies the paint. - let styled = named(&doc, "Styled"); + assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. })); +} + +fn assert_fixture_styles(doc: &Document) { + let styled = named(doc, "Styled"); assert_eq!(styled.styles.fill.as_deref(), Some("1:3")); assert!(doc.styles.iter().any(|s| s.name == "Brand")); let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") }; assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0)); - // Variables, the fill binding and the explicit mode. +} + +fn assert_fixture_variables(doc: &Document) { + let screen = named(doc, "Screen"); assert_eq!(doc.variables.collections.len(), 1); assert_eq!(doc.variables.variables[0].kind, VariableType::Color); - assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); + assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5")); assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91")); - // The component keeps its property; the instance is expanded with the value applied. - let button = named(&doc, "Button"); +} + +fn assert_fixture_components(doc: &Document) { + let button = named(doc, "Button"); assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon"))); - let inst = named(&doc, "Button copy"); + let inst = named(doc, "Button copy"); assert!( matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false))) ); let icon = doc.node(inst.children[0]).unwrap(); assert_eq!(icon.name, "Renamed icon"); assert!(!icon.visible); - // Prototype: the click navigates to the component; the flow starts at the screen. - let it = &photo.interactions[0]; +} + +fn assert_fixture_prototype(doc: &Document, warnings: &[String]) { + let button = named(doc, "Button"); + let screen = named(doc, "Screen"); + let it = &named(doc, "Photo").interactions[0]; assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id)); assert_eq!(it.duration_ms, 250); let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") }; assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id)); // The sticky note comes in as a frame, with a warning. - assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame)); + assert!(matches!(named(doc, "Note").kind, NodeKind::Frame)); assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}"); } diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index cbaa3749..d2fdde9a 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() { #[test] fn interactions_triggers_actions_and_transitions() { let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap(); - let id = |n: &str| named(&doc, n).id; - let button = named(&doc, "Button"); + assert_interactions(&doc, &warnings); + assert_legacy_transition(&doc); + assert_interactions_without_variables(); +} + +fn assert_interactions(doc: &Document, warnings: &[String]) { + let id = |n: &str| named(doc, n).id; + let button = named(doc, "Button"); let its = &button.interactions; assert_eq!(its.len(), 8, "{its:#?}"); @@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its[7].trigger, Trigger::MouseUp); assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped"); - // The legacy single transition. - let details = named(&doc, "Details"); + assert!(!has(warnings, "modifier keys"), "{warnings:?}"); + for w in ["video triggers", "without a destination"] { + assert!(has(warnings, w), "{w}: {warnings:?}"); + } +} + +fn assert_legacy_transition(doc: &Document) { + let id = |n: &str| named(doc, n).id; + let details = named(doc, "Details"); assert_eq!(details.interactions.len(), 1); let legacy = &details.interactions[0]; assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..])); assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn)); +} - assert!(!has(&warnings, "modifier keys"), "{warnings:?}"); - for w in ["video triggers", "without a destination"] { - assert!(has(&warnings, w), "{w}: {warnings:?}"); - } - // Without variables, set-variable actions have nothing to set. +fn assert_interactions_without_variables() { let (doc, warnings) = import(&file_json(), &no_images).unwrap(); assert_eq!(named(&doc, "Button").interactions.len(), 6); assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}"); From bafda992a79dad79acf43d677daec43067eb5280 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 18:03:24 +0000 Subject: [PATCH 27/60] docs: clarify crate lint placement --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe6134a6..f61412ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +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. +- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - `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 23f57c86607669ad28b98be2e6792ab389220bcb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH 28/60] refactor(engine): simplify command handling for strict clippy --- crates/engine/src/cmd/arrange.rs | 4 +- crates/engine/src/cmd/comment.rs | 4 +- crates/engine/src/cmd/component.rs | 11 +- crates/engine/src/cmd/component/instance.rs | 7 +- crates/engine/src/cmd/component/props.rs | 7 +- crates/engine/src/cmd/component/variants.rs | 5 +- crates/engine/src/cmd/create.rs | 4 +- crates/engine/src/cmd/edit.rs | 7 +- crates/engine/src/cmd/edit/clipboard.rs | 5 +- crates/engine/src/cmd/edit/props.rs | 5 +- crates/engine/src/cmd/edit/select.rs | 4 +- crates/engine/src/cmd/file.rs | 7 +- crates/engine/src/cmd/file/load.rs | 2 +- crates/engine/src/cmd/file/version.rs | 2 +- crates/engine/src/cmd/image.rs | 2 +- crates/engine/src/cmd/layout.rs | 2 +- crates/engine/src/cmd/library.rs | 9 +- crates/engine/src/cmd/lists.rs | 4 +- crates/engine/src/cmd/page.rs | 4 +- crates/engine/src/cmd/props.rs | 1117 ++++++++++--------- crates/engine/src/cmd/proto.rs | 10 +- crates/engine/src/cmd/proto_actions.rs | 6 +- crates/engine/src/cmd/section.rs | 2 +- crates/engine/src/cmd/structure.rs | 7 +- crates/engine/src/cmd/styles.rs | 7 +- crates/engine/src/cmd/text.rs | 7 +- crates/engine/src/cmd/variables.rs | 7 +- crates/engine/src/cmd/variables_io.rs | 4 +- crates/engine/src/cmd/view.rs | 4 +- 29 files changed, 698 insertions(+), 568 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 8f8e40d2..4806afb3 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -1,12 +1,12 @@ //! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms. -use figcraft_doc::*; +use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle}; use figcraft_geom::{Affine, Point, Rect}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index d1d360f4..8cff0c42 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -4,11 +4,11 @@ //! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only //! a message's author edits or deletes it, and only the thread's author deletes the thread. -use figcraft_doc::*; +use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, id_of, num, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 5abe79b8..0f59f41e 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -2,13 +2,18 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType, VariableValue}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::components::*; -use crate::params::*; +use crate::components::{ + OVERRIDE_GROUPS, PROP_BINDING, SLOT, copy_group, create_instance, create_instance_at, default_props, default_variant, exposed_copies, + find_variant, inside_instance, instances_of, is_slot, keep_text_overrides, owning_component, owning_instance, prop_default_value, + rebase_instance, root_instance, set_boolean_default, slot_edited, slot_name, slots_of, takes_value, unlink_layers, variant_conflicts, + variant_name, variant_options, variant_props, variant_set, +}; +use crate::params::{boolean, get, id, id_of, num, req_id, req_num, req_string, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; mod instance; diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs index ffc5ec1c..f6600c10 100644 --- a/crates/engine/src/cmd/component/instance.rs +++ b/crates/engine/src/cmd/component/instance.rs @@ -1,5 +1,10 @@ use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, EngineError, NodeId, NodeKind, OVERRIDE_GROUPS, PROP_BINDING, PropDef, Result, SLOT, Session, Value, bad, boolean, + copy_group, create_instance_at, default_props, get, id_of, inside_instance, is_slot, json, keep_text_overrides, main_of, ok, owning_component, + owning_instance, rebase_instance, req_id, req_num, req_targets, root_instance, slot_edited, slot_name, slots_of, string, targets, unlink_layers, + variant_options, +}; pub(super) fn detach(s: &mut Session, p: &Value) -> Result { let cmd = "component.detach"; diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs index 3f2d7002..bb3dba4c 100644 --- a/crates/engine/src/cmd/component/props.rs +++ b/crates/engine/src/cmd/component/props.rs @@ -1,6 +1,11 @@ use super::instance::preferred_arg; use super::variants::{fit_set, wrap_in_set}; -use super::*; +use super::{ + BTreeMap, Document, NodeId, NodeKind, PROP_BINDING, PropDef, Result, SLOT, Session, Value, VariableType, VariableValue, bad, boolean, + default_variant, find_variant, get, id, id_of, inside_instance, instances_of, json, main_of, ok, owning_component, prop_default_value, + req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name, + variant_options, variant_props, variant_set, +}; /// Whether instance `id` has a component property `name` that a `kind` variable can drive (a /// boolean property takes a boolean variable, a text or variant property a string one). diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs index f6e7ac6c..5f9e218a 100644 --- a/crates/engine/src/cmd/component/variants.rs +++ b/crates/engine/src/cmd/component/variants.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance, + req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted, +}; /// Wrap components in a new component set (named after the first) placed around them. pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 414a2310..2e64ce04 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,12 +1,12 @@ //! Creating nodes. use figcraft_color::{ImageScaleMode, Paint}; -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeCap, TextAutoResize, TextData, VectorNetwork}; use figcraft_geom::Point; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string}; use crate::{Result, Session, bad, spec}; /// Frame presets shown by the Frame tool (category, name, width, height). diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7ec63c69..2d51b958 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,12 +2,15 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, Effect, ExportSetting, ImageData, LayoutGrid, Node, NodeId, NodeKind}; use figcraft_geom::Affine; use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{ + blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string, + targets, +}; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; mod clipboard; diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index d0e7b2dc..0df97532 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok, + req_targets, roots, string, targets, z_sorted, +}; /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index f793b50c..91855c2e 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index, + paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted, +}; pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result { let cmd = "object.batchRename"; diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs index 03bba890..ca1da3bb 100644 --- a/crates/engine/src/cmd/edit/select.rs +++ b/crates/engine/src/cmd/edit/select.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Node, NodeId, Result, Session, Value, bad, boolean, json, num, num_or, ok, req_num, req_string, req_targets, roots, +}; pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result { let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index fed4bff1..7c901293 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -3,12 +3,15 @@ use std::sync::Arc; use base64::Engine as _; -use figcraft_doc::*; +use figcraft_doc::{ + BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle, + VdCompat, +}; use figcraft_format::Version; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets}; use crate::{DocState, EngineError, Result, Session, bad, spec}; mod export; diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 071c651e..a033475e 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json}; /// The recovery copy at `path` as a document with unsaved changes and no path. pub(crate) fn recovered(path: &str) -> Result { diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs index 3d311a44..a46fab8e 100644 --- a/crates/engine/src/cmd/file/version.rs +++ b/crates/engine/src/cmd/file/version.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocState, Document, EngineError, Result, Session, Value, Version, bad, get, json, req_index, string}; pub(super) fn version_json(index: usize, v: &Version) -> Value { let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index bb11f011..c82e30d1 100644 --- a/crates/engine/src/cmd/image.rs +++ b/crates/engine/src/cmd/image.rs @@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5a0a4000..e22ca498 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,7 +6,7 @@ use serde_json::Value; use super::props::sizing; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{get, num, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7892102d..0c7edbb2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -5,16 +5,19 @@ use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId}; use serde_json::{Value, json}; use figcraft_collab::message::{LibraryReply, LibraryReq}; use super::CommandSpec; -use crate::library::*; +use crate::library::{ + Pending, Selection, absolute_path, accept_updates, asset_root, default_selection, find_link, import, import_styles, import_variables, + is_remote_variable, pending, prune, publish, publishable, published_collections, published_variables, +}; use crate::library_server::{archive, is_server_link, read_archive, shown_path}; use crate::live::{NetReply, NetRequest, PublishOptions}; -use crate::params::*; +use crate::params::{boolean, get, id_of, num, req_string, string}; use crate::{EngineError, Result, Session, bad, spec}; /// Longest library description kept (chars). diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 287c9ddd..e53291b6 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -6,11 +6,11 @@ //! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top; //! the Design panel shows them in reverse, the topmost row first. -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutGrid, Node, NodeId}; use serde_json::Value; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{req_index, req_targets}; use crate::{Result, Session, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 08e65b0b..44c7b367 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,11 +1,11 @@ //! Pages. use figcraft_doc::proto::Flow; -use figcraft_doc::*; +use figcraft_doc::{Guide, NodeId, NodeKind}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, color, id, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// After a page edit: the file keeps a page that isn't a divider, and the canvas shows one (the diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a10393..469fcb01 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,11 +1,15 @@ //! Property editing: `node.set` and the shorthand keys every surface uses. use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, Brush, ChildLayout, DecorationStyle, DevStatus, Document, Effect, GridTrack, LayoutMode, LetterSpacing, LineHeight, ListStyle, + MAX_LIST_INDENT, MaskType, Node, NodeId, NodeKind, Sizing, StrokeCap, StrokeProps, TextAutoResize, TextCase, TextData, TextDecoration, + WidthPoint, WidthPreset, WidthProfile, style_weight, valid_axis_tag, valid_feature_tag, +}; use figcraft_geom::{Affine, Point}; use serde_json::{Map, Value, json}; -use crate::params::*; +use crate::params::{bool_of, color_of, f64_of, get, paints_of}; use crate::{Result, bad}; /// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left). @@ -409,6 +413,587 @@ fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { Ok(()) } +/// The `geometry` properties of `apply`; `false` when `k` is not one of them. +fn apply_geometry( + cmd: &str, + doc: &mut Document, + id: NodeId, + props: &Map, + k: &str, + v: &Value, + path_offset: &mut (f64, f64), +) -> Result { + match k { + "path" => *path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, + "x" | "y" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; + // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the + // frame it is in (groups have no space of their own). + let n = doc.get(id)?; + let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children())); + let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g)); + let c = (space * n.transform).as_coeffs(); + let (mut tx, mut ty) = (c[4], c[5]); + if k == "x" { + tx = val; + } else { + ty = val; + } + doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]); + } + "width" | "height" | "w" | "h" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let is_w = k.starts_with('w'); + // With the proportions locked (the Layout panel's link) and only one side set, the + // other side follows the node's ratio; both sides in one `node.set` stay as typed. + let (nw, nh) = { + let n = doc.get(id)?; + let (ow, oh) = (n.width, n.height); + let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") }; + let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9; + let derived = if is_w { oh * val / ow } else { ow * val / oh }; + match (is_w, linked && derived.is_finite()) { + (true, true) => (val, derived), + (false, true) => (derived, val), + (true, false) => (val, oh), + (false, false) => (ow, val), + } + }; + resize_node(doc, id, nw, nh)?; + // Explicit size makes text fixed in that dimension. + if let Some(t) = doc.get_mut(id)?.kind.text_mut() { + t.fix_size(is_w, !is_w); + } + fix_axis(doc, id, is_w)?; + } + "rotation" => { + let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; + let n = doc.get_mut(id)?; + n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg); + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `look` properties of `apply`; `false` when `k` is not one of them. +fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?, + "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?, + "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, + "opacity" => { + let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; + let o = if o > 1.0 { o / 100.0 } else { o }; + doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; + } + "blend" | "blendMode" => { + let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?; + doc.get_mut(id)?.blend = b; + } + "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), + "fill" => { + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } + } + "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), + "stroke" => { + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } + } + "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), + "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, + "strokeSides" => { + doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } + } + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { + let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); + let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let st = &mut doc.get_mut(id)?.stroke; + // One side set on its own turns on individual strokes, the others at the weight. + let mut sides = st.sides.unwrap_or([st.weight; 4]); + if let Some(slot) = sides.get_mut(i) { + *slot = w; + } + st.sides = Some(sides); + } + "strokeCap" => { + let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; + let n = doc.get_mut(id)?; + n.stroke.cap_start = c; + n.stroke.cap_end = c; + } + "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, + "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), + "miterAngle" => { + let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?; + doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg); + } + "dashes" => { + let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); + doc.get_mut(id)?.stroke.dashes = d; + } + "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, + "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, + _ => return Ok(false), + } + Ok(true) +} + +/// The `corners` properties of `apply`; `false` when `k` is not one of them. +fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "cornerRadius" => { + let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0)); + let n = doc.get_mut(id)?; + n.corners.radii = r; + // A vector's corner radius rounds every point (edit mode then sets them one by one). + if let NodeKind::Vector { network } = &mut n.kind { + network.vertices.iter_mut().for_each(|p| p.radius = r[0]); + crate::vectoredit::refit(doc, id)?; + } + } + "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => { + let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0); + let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) { + *slot = r; + } + } + "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), + "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, + "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, + "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), + "devStatus" => { + let st = if v.is_null() { + Some(DevStatus::None) + } else { + v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) + }; + let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; + if !st.is_none() && !crate::devinspect::has_status(doc, id) { + return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one")); + } + let n = doc.get_mut(id)?; + n.dev_status = st; + // Marking a layer (again) starts from its current contents. + n.dev_changed = false; + } + "maskType" => { + let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); + doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `layout` properties of `apply`; `false` when `k` is not one of them. +fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "layout" | "layoutMode" => { + let n = doc.get_mut(id)?; + if !n.kind.is_frame_like() { + return Err(bad(cmd, "auto layout needs a frame")); + } + match v { + Value::Null => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) if s.eq_ignore_ascii_case("none") => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) => { + let mode = match s.to_ascii_lowercase().as_str() { + "horizontal" | "row" => LayoutMode::Horizontal, + "vertical" | "column" => LayoutMode::Vertical, + "grid" => LayoutMode::Grid, + _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")), + }; + ensure_layout(n).mode = mode; + } + Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?), + _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), + } + } + "gap" | "itemSpacing" | "columnGap" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "crossGap" | "counterAxisSpacing" | "rowGap" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "padding" => { + let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; + layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); + } + "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { + let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { + *slot = val; + } + } + "paddingHorizontal" => { + let val = f64_of(v).unwrap_or(0.0).max(0.0); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + l.padding[1] = val; + l.padding[3] = val; + } + "paddingVertical" => { + let val = f64_of(v).unwrap_or(0.0).max(0.0); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + l.padding[0] = val; + l.padding[2] = val; + } + "mainAlign" | "primaryAxisAlignItems" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = + enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? + } + "crossAlign" | "counterAxisAlignItems" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? + } + "contentAlign" | "counterAxisAlignContent" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? + } + "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), + "gridColumns" | "gridRows" => { + let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + // Tracks past the new count go away (new tracks are 1fr). + let (n, tracks) = if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; + *n = count; + tracks.truncate(count as usize); + } + "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), + "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), + _ => return Ok(false), + } + Ok(true) +} + +/// The `sizing` properties of `apply`; `false` when `k` is not one of them. +fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "sizingH" | "layoutSizingHorizontal" => { + let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; + set_sizing(cmd, doc, id, s, true)?; + } + "sizingV" | "layoutSizingVertical" => { + let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; + set_sizing(cmd, doc, id, s, false)?; + } + "absolute" | "ignoreAutoLayout" => { + let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; + let cl = &mut doc.get_mut(id)?.child_layout; + cl.absolute = on; + // An absolute child can't fill: it keeps its current size. + if on { + for s in [&mut cl.sizing_h, &mut cl.sizing_v] { + if *s == Sizing::Fill { + *s = Sizing::Fixed; + } + } + } + } + "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { + let val = match v { + Value::Null => None, + other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), + }; + set_limit(&mut doc.get_mut(id)?.child_layout, k, val); + } + "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { + let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; + let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + if k.contains("olumn") { + l.grid_columns = (tracks.len() as u32).clamp(1, 1000); + l.column_tracks = tracks; + } else { + l.grid_rows = (tracks.len() as u32).clamp(1, 1000); + l.row_tracks = tracks; + } + } + "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => { + Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) as u32) + } + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.starts_with("gridColumn") { + cl.grid_column = val; + } else { + cl.grid_row = val; + } + } + "gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.contains('H') { + cl.grid_align_h = val; + } else { + cl.grid_align_v = val; + } + } + "gridColumnSpan" | "gridRowSpan" => { + let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let cl = &mut doc.get_mut(id)?.child_layout; + if k == "gridColumnSpan" { + cl.grid_column_span = span; + } else { + cl.grid_row_span = span; + } + } + "constraints" => { + let n = doc.get_mut(id)?; + if let Some(h) = v.get("horizontal") { + n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?; + } + if let Some(vv) = v.get("vertical") { + n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?; + } + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `meta` properties of `apply`; `false` when `k` is not one of them. +fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?, + "exports" | "exportSettings" => doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?, + "interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?, + "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, + "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, + "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), + _ => return Ok(false), + } + Ok(true) +} + +/// The `text` properties of `apply`; `false` when `k` is not one of them. +fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map, k: &str, v: &Value) -> Result { + match k { + "characters" | "text" => { + let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?; + let n = doc.get_mut(id)?; + let t = text_mut(cmd, n)?; + let len = t.char_count(); + t.replace_range(0, len, &normalize_line_endings(&s)); + n.overrides.insert("characters".into()); + } + "fontFamily" + | "fontStyle" + | "fontWeight" + | "fontSize" + | "lineHeight" + | "letterSpacing" + | "textDecoration" + | "textCase" + | "hyperlink" + | "textFills" + | "fontVariations" + | "variationSettings" + | "textDecorationStyle" + | "textDecorationThickness" + | "textDecorationOffset" + | "textDecorationSkipInk" + | "textDecorationColor" => { + let n = doc.get_mut(id)?; + let t = text_mut(cmd, n)?; + let (start, end) = range_of(props, t.char_count()); + apply_text_style(cmd, t, start, end, k, v)?; + } + "textAlign" | "textAlignHorizontal" => { + text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? + } + "textAlignVertical" => { + text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? + } + "textAutoResize" => { + text_mut(cmd, doc.get_mut(id)?)?.auto_resize = + enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? + } + "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), + "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), + "listStyle" | "indentation" => { + let t = text_mut(cmd, doc.get_mut(id)?)?; + let (start, end) = range_of(props, t.char_count()); + if k == "listStyle" { + let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; + t.set_para_lists(start, end, |p| p.list = l); + } else { + let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; + t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); + } + } + "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), + "hangingPunctuation" | "hangingList" => { + let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; + } + "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, + "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, + "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { + let f = features_of(cmd, v)?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + t.features.clear(); + for (k, on) in f { + if let Some(on) = on { + t.features.insert(k, on); + } + } + } + "start" | "end" => {} + _ => return Ok(false), + } + Ok(true) +} + +/// The `shape` properties of `apply`; `false` when `k` is not one of them. +fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "pointCount" => { + let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round(); + match &mut doc.get_mut(id)?.kind { + // Stars have 3 to 60 points; polygons keep the geometry's 120 (silent). + NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32, + NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32, + _ => return Err(bad(cmd, "pointCount applies to polygons and stars")), + } + } + "innerRadius" | "ratio" => { + let r = f64_of(v).unwrap_or(0.38); + let r = if r > 1.0 { r / 100.0 } else { r }; + match &mut doc.get_mut(id)?.kind { + NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0), + _ => return Err(bad(cmd, "innerRadius applies to stars")), + } + } + "arcStart" | "arcSweep" | "arcInner" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + match &mut doc.get_mut(id)?.kind { + NodeKind::Ellipse { start, sweep, inner } => match k { + "arcStart" => *start = val.to_radians(), + "arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(), + _ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99), + }, + _ => return Err(bad(cmd, "arc applies to ellipses")), + } + } + "description" => match &mut doc.get_mut(id)?.kind { + NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(), + _ => return Err(bad(cmd, "description applies to components")), + }, + "pluginData" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (pk, pv) in o { + if pv.is_null() { + n.plugin_data.remove(pk); + } else { + n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string())); + } + } + } + } + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => { + let sid = string_val(v); + crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?; + } + "boundVariables" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (field, var) in o { + match var.as_str() { + Some(vid) => { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } + n.bound.insert(field.clone(), vid.to_string()); + } + None => { + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + } + } + } + } + } + "variableModes" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (coll, mode) in o { + match mode.as_str() { + Some(m) => { + n.var_modes.insert(coll.clone(), m.to_string()); + } + None => { + n.var_modes.remove(coll); + } + } + } + } + } + "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, + "booleanOperation" => { + if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { + *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; + } + } + "operandOperation" => set_operand_op(doc, id, v, cmd)?, + "sectionContentsHidden" => { + if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { + *contents_hidden = v.as_bool().unwrap_or(false); + } + } + "background" => { + let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?; + match &mut doc.get_mut(id)?.kind { + NodeKind::Page { background, .. } => *background = c, + _ => return Err(bad(cmd, "background applies to pages")), + } + } + "componentProperties" => { + if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind + && let Some(o) = v.as_object() + { + for (pk, pv) in o { + p.insert(pk.clone(), pv.clone()); + } + } + } + _ => return Ok(false), + } + Ok(true) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); @@ -418,524 +1003,18 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map path_offset = set_path(cmd, doc, id, v, props)?, - // A new path takes the size asked for as its scale (`set_path`); the size still fixes - // its auto layout axis. - "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, - "x" | "y" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; - let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; - // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the - // frame it is in (groups have no space of their own). - let n = doc.get(id)?; - let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children())); - let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g)); - let c = (space * n.transform).as_coeffs(); - let (mut tx, mut ty) = (c[4], c[5]); - if k == "x" { - tx = val; - } else { - ty = val; - } - doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]); - } - "width" | "height" | "w" | "h" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - let is_w = k.starts_with('w'); - // With the proportions locked (the Layout panel's link) and only one side set, the - // other side follows the node's ratio; both sides in one `node.set` stay as typed. - let (nw, nh) = { - let n = doc.get(id)?; - let (ow, oh) = (n.width, n.height); - let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") }; - let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9; - let derived = if is_w { oh * val / ow } else { ow * val / oh }; - match (is_w, linked && derived.is_finite()) { - (true, true) => (val, derived), - (false, true) => (derived, val), - (true, false) => (val, oh), - (false, false) => (ow, val), - } - }; - resize_node(doc, id, nw, nh)?; - // Explicit size makes text fixed in that dimension. - if let Some(t) = doc.get_mut(id)?.kind.text_mut() { - t.fix_size(is_w, !is_w); - } - fix_axis(doc, id, is_w)?; - } - "rotation" => { - let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; - let n = doc.get_mut(id)?; - n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg); - } - "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?, - "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?, - "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, - "opacity" => { - let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; - let o = if o > 1.0 { o / 100.0 } else { o }; - doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; - } - "blend" | "blendMode" => { - let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?; - doc.get_mut(id)?.blend = b; - } - "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), - "fill" => { - if paint_none(v) { - set_paints(doc.get_mut(id)?, true, vec![]); - } else { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); - } - } - "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), - "stroke" => { - if paint_none(v) { - set_paints(doc.get_mut(id)?, false, vec![]); - } else { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); - } - } - "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), - "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, - "strokeSides" => { - doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } - } - "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { - let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); - let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - let st = &mut doc.get_mut(id)?.stroke; - // One side set on its own turns on individual strokes, the others at the weight. - let mut sides = st.sides.unwrap_or([st.weight; 4]); - if let Some(slot) = sides.get_mut(i) { - *slot = w; - } - st.sides = Some(sides); - } - "strokeCap" => { - let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; - let n = doc.get_mut(id)?; - n.stroke.cap_start = c; - n.stroke.cap_end = c; - } - "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, - "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), - "miterAngle" => { - let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?; - doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg); - } - "dashes" => { - let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); - doc.get_mut(id)?.stroke.dashes = d; - } - "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, - "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, - "cornerRadius" => { - let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0)); - let n = doc.get_mut(id)?; - n.corners.radii = r; - // A vector's corner radius rounds every point (edit mode then sets them one by one). - if let NodeKind::Vector { network } = &mut n.kind { - network.vertices.iter_mut().for_each(|p| p.radius = r[0]); - crate::vectoredit::refit(doc, id)?; - } - } - "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => { - let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0); - let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) { - *slot = r; - } - } - "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), - "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, - "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, - "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), - "devStatus" => { - let st = if v.is_null() { - Some(DevStatus::None) - } else { - v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) - }; - let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; - if !st.is_none() && !crate::devinspect::has_status(doc, id) { - return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one")); - } - let n = doc.get_mut(id)?; - n.dev_status = st; - // Marking a layer (again) starts from its current contents. - n.dev_changed = false; - } - "maskType" => { - let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); - doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; - } - "layout" | "layoutMode" => { - let n = doc.get_mut(id)?; - if !n.kind.is_frame_like() { - return Err(bad(cmd, "auto layout needs a frame")); - } - match v { - Value::Null => { - n.layout = None; - drop_layout_bindings(n); - } - Value::String(s) if s.eq_ignore_ascii_case("none") => { - n.layout = None; - drop_layout_bindings(n); - } - Value::String(s) => { - let mode = match s.to_ascii_lowercase().as_str() { - "horizontal" | "row" => LayoutMode::Horizontal, - "vertical" | "column" => LayoutMode::Vertical, - "grid" => LayoutMode::Grid, - _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")), - }; - ensure_layout(n).mode = mode; - } - Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?), - _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), - } - } - "gap" | "itemSpacing" | "columnGap" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? - } - "crossGap" | "counterAxisSpacing" | "rowGap" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? - } - "padding" => { - let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; - layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); - } - "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { - let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { - *slot = val; - } - } - "paddingHorizontal" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - l.padding[1] = val; - l.padding[3] = val; - } - "paddingVertical" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - l.padding[0] = val; - l.padding[2] = val; - } - "mainAlign" | "primaryAxisAlignItems" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = - enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? - } - "crossAlign" | "counterAxisAlignItems" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? - } - "contentAlign" | "counterAxisAlignContent" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? - } - "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), - "gridColumns" | "gridRows" => { - let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - // Tracks past the new count go away (new tracks are 1fr). - let (n, tracks) = - if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; - *n = count; - tracks.truncate(count as usize); - } - "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), - "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), - "sizingH" | "layoutSizingHorizontal" => { - let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; - set_sizing(cmd, doc, id, s, true)?; - } - "sizingV" | "layoutSizingVertical" => { - let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; - set_sizing(cmd, doc, id, s, false)?; - } - "absolute" | "ignoreAutoLayout" => { - let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; - let cl = &mut doc.get_mut(id)?.child_layout; - cl.absolute = on; - // An absolute child can't fill: it keeps its current size. - if on { - for s in [&mut cl.sizing_h, &mut cl.sizing_v] { - if *s == Sizing::Fill { - *s = Sizing::Fixed; - } - } - } - } - "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { - let val = match v { - Value::Null => None, - other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), - }; - set_limit(&mut doc.get_mut(id)?.child_layout, k, val); - } - "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { - let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; - let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - if k.contains("olumn") { - l.grid_columns = (tracks.len() as u32).clamp(1, 1000); - l.column_tracks = tracks; - } else { - l.grid_rows = (tracks.len() as u32).clamp(1, 1000); - l.row_tracks = tracks; - } - } - "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { - let val = match v { - Value::Null => None, - Value::String(s) if s.eq_ignore_ascii_case("auto") => None, - other => { - Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) - as u32) - } - }; - let cl = &mut doc.get_mut(id)?.child_layout; - if k.starts_with("gridColumn") { - cl.grid_column = val; - } else { - cl.grid_row = val; - } - } - "gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => { - let val = match v { - Value::Null => None, - Value::String(s) if s.eq_ignore_ascii_case("auto") => None, - other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), - }; - let cl = &mut doc.get_mut(id)?.child_layout; - if k.contains('H') { - cl.grid_align_h = val; - } else { - cl.grid_align_v = val; - } - } - "gridColumnSpan" | "gridRowSpan" => { - let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let cl = &mut doc.get_mut(id)?.child_layout; - if k == "gridColumnSpan" { - cl.grid_column_span = span; - } else { - cl.grid_row_span = span; - } - } - "constraints" => { - let n = doc.get_mut(id)?; - if let Some(h) = v.get("horizontal") { - n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?; - } - if let Some(vv) = v.get("vertical") { - n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?; - } - } - "grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?, - "exports" | "exportSettings" => { - doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))? - } - "interactions" => { - doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))? - } - "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, - "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, - "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), - "characters" | "text" => { - let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?; - let n = doc.get_mut(id)?; - let t = text_mut(cmd, n)?; - let len = t.char_count(); - t.replace_range(0, len, &normalize_line_endings(&s)); - n.overrides.insert("characters".into()); - } - "fontFamily" - | "fontStyle" - | "fontWeight" - | "fontSize" - | "lineHeight" - | "letterSpacing" - | "textDecoration" - | "textCase" - | "hyperlink" - | "textFills" - | "fontVariations" - | "variationSettings" - | "textDecorationStyle" - | "textDecorationThickness" - | "textDecorationOffset" - | "textDecorationSkipInk" - | "textDecorationColor" => { - let n = doc.get_mut(id)?; - let t = text_mut(cmd, n)?; - let (start, end) = range_of(props, t.char_count()); - apply_text_style(cmd, t, start, end, k, v)?; - } - "textAlign" | "textAlignHorizontal" => { - text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? - } - "textAlignVertical" => { - text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? - } - "textAutoResize" => { - text_mut(cmd, doc.get_mut(id)?)?.auto_resize = - enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? - } - "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), - "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), - "listStyle" | "indentation" => { - let t = text_mut(cmd, doc.get_mut(id)?)?; - let (start, end) = range_of(props, t.char_count()); - if k == "listStyle" { - let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; - t.set_para_lists(start, end, |p| p.list = l); - } else { - let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; - t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); - } - } - "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), - "hangingPunctuation" | "hangingList" => { - let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; - let t = text_mut(cmd, doc.get_mut(id)?)?; - *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; - } - "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, - "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, - "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { - let f = features_of(cmd, v)?; - let t = text_mut(cmd, doc.get_mut(id)?)?; - t.features.clear(); - for (k, on) in f { - if let Some(on) = on { - t.features.insert(k, on); - } - } - } - "start" | "end" => {} - "pointCount" => { - let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round(); - match &mut doc.get_mut(id)?.kind { - // Stars have 3 to 60 points; polygons keep the geometry's 120 (silent). - NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32, - NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32, - _ => return Err(bad(cmd, "pointCount applies to polygons and stars")), - } - } - "innerRadius" | "ratio" => { - let r = f64_of(v).unwrap_or(0.38); - let r = if r > 1.0 { r / 100.0 } else { r }; - match &mut doc.get_mut(id)?.kind { - NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0), - _ => return Err(bad(cmd, "innerRadius applies to stars")), - } - } - "arcStart" | "arcSweep" | "arcInner" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; - match &mut doc.get_mut(id)?.kind { - NodeKind::Ellipse { start, sweep, inner } => match k { - "arcStart" => *start = val.to_radians(), - "arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(), - _ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99), - }, - _ => return Err(bad(cmd, "arc applies to ellipses")), - } - } - "description" => match &mut doc.get_mut(id)?.kind { - NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(), - _ => return Err(bad(cmd, "description applies to components")), - }, - "pluginData" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (pk, pv) in o { - if pv.is_null() { - n.plugin_data.remove(pk); - } else { - n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string())); - } - } - } - } - "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => { - let sid = string_val(v); - crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?; - } - "boundVariables" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (field, var) in o { - match var.as_str() { - Some(vid) => { - if n.bound.get(field).map(String::as_str) != Some(vid) { - crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); - } - n.bound.insert(field.clone(), vid.to_string()); - } - None => { - if n.bound.remove(field).is_some() { - crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); - } - } - } - } - } - } - "variableModes" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (coll, mode) in o { - match mode.as_str() { - Some(m) => { - n.var_modes.insert(coll.clone(), m.to_string()); - } - None => { - n.var_modes.remove(coll); - } - } - } - } - } - "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, - "booleanOperation" => { - if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { - *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; - } - } - "operandOperation" => set_operand_op(doc, id, v, cmd)?, - "sectionContentsHidden" => { - if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { - *contents_hidden = v.as_bool().unwrap_or(false); - } - } - "background" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?; - match &mut doc.get_mut(id)?.kind { - NodeKind::Page { background, .. } => *background = c, - _ => return Err(bad(cmd, "background applies to pages")), - } - } - "componentProperties" => { - if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind - && let Some(o) = v.as_object() - { - for (pk, pv) in o { - p.insert(pk.clone(), pv.clone()); - } - } - } - other => unknown.push(other.to_string()), + if apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)? + || apply_look(cmd, doc, id, k, v)? + || apply_corners(cmd, doc, id, k, v)? + || apply_layout(cmd, doc, id, k, v)? + || apply_sizing(cmd, doc, id, k, v)? + || apply_meta(cmd, doc, id, k, v)? + || apply_text(cmd, doc, id, props, k, v)? + || apply_shape(cmd, doc, id, k, v)? + { + continue; } + unknown.push(k.to_string()); } if !unknown.is_empty() { return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", ")))); diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index ce7219f4..c451eeb2 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -1,11 +1,14 @@ //! Prototype commands: interactions, flows, settings, presenting. -use figcraft_doc::proto::*; -use figcraft_doc::*; +use figcraft_doc::proto::{ + Action, DeviceRotation, Easing, Expr, Flow, Interaction, MAX_DELAY_MS, MAX_EXPR_DEPTH, OverlayPosition, PrototypeSettings, Transition, Trigger, + walk_mut, +}; +use figcraft_doc::{Document, NodeId, NodeKind, VariableValue, Variables}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, color, f64_of, get, id, num, parse, req_id, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { @@ -498,6 +501,7 @@ fn set(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; use crate::params::id_of; + use figcraft_doc::proto::{Direction, Overflow, ScrollPosition}; fn frame(s: &mut Session, x: f64) -> NodeId { let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap(); diff --git a/crates/engine/src/cmd/proto_actions.rs b/crates/engine/src/cmd/proto_actions.rs index d288a7d7..95a53a9d 100644 --- a/crates/engine/src/cmd/proto_actions.rs +++ b/crates/engine/src/cmd/proto_actions.rs @@ -10,12 +10,12 @@ use std::collections::BTreeMap; -use figcraft_doc::proto::*; -use figcraft_doc::*; +use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, MAX_ACTION_DEPTH, walk}; +use figcraft_doc::{Variable, VariableType, VariableValue, Variables}; use serde_json::{Value, json}; use super::CommandSpec; -use crate::params::*; +use crate::params::{bool_of, f64_of, get, id, parse, req_index, req_targets, string}; use crate::{Result, Session, bad, spec}; /// Most actions in one list (an interaction's, or a block's). diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index 08a5a7cc..28b02c53 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::CommandSpec; use crate::devinspect::{dev_items, has_status}; -use crate::params::*; +use crate::params::{boolean, get, req_targets, string, targets}; use crate::{Result, Session, bad, spec}; /// Longest change reason kept. diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 700825f2..05823cab 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -1,13 +1,16 @@ //! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps, + VectorNetwork, WindingRule, proto, +}; use figcraft_geom::{Affine, BezPath, Rect, Shape}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{f64_of, get, id_of, num_or, req_id, req_index, req_num, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 9c2922d2..0066602a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -3,11 +3,14 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint, PaintKind}; -use figcraft_doc::*; +use figcraft_doc::{ + Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle, + Variable, VariableId, VariableType, VariableValue, Variables, trim_num, +}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{color_of, f64_of, get, id, num, paints_of, req_string, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3822c5c5..e8433fa5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1,11 +1,14 @@ //! Text menu and text editing commands. -use figcraft_doc::*; +use figcraft_doc::{ + LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle, + VariableId, +}; use serde_json::{Map, Value, json}; use super::props::{styled_name, weight_name}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, num, req_id, req_num, req_string, req_targets, string, targets}; use crate::{Result, Session, bad, spec, textedit}; fn finite(v: f64) -> f64 { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 53b62ee4..155fe519 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -4,12 +4,15 @@ use std::collections::BTreeSet; -use figcraft_doc::*; +use figcraft_doc::{ + CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables, + normalize_scopes, scope_known, +}; use serde_json::{Value, json}; use super::lists::BoundList; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, req_string, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 6bd5bb40..b9e73f13 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -4,13 +4,13 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Collection, CollectionId, Document, Mode, ModeId, Variable, VariableId, VariableValue, Variables, trim_num}; use serde_json::{Value, json}; use super::CommandSpec; use crate::codegen::{css_var_name, css_var_of}; use crate::library::{is_remote_collection, local_collections}; -use crate::params::*; +use crate::params::{get, string}; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7364557f..97b947a5 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,10 +1,10 @@ //! View menu, preferences and tools. View state is per document and drivable like everything else. -use figcraft_doc::*; +use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, string}; use crate::tools::ToolId; use crate::{EngineError, Result, Session, ViewState, bad, spec}; From 5e166839635bc59c2dc2269f0cb65d5f19e6dcfd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH 29/60] refactor(engine): simplify canvas and board handlers --- crates/engine/src/boards/board/connectors.rs | 5 +++- crates/engine/src/boards/board/derived.rs | 5 +++- crates/engine/src/boards/board/edit.rs | 6 ++++- crates/engine/src/boards/board/items.rs | 8 ++++++- crates/engine/src/boards/board/tools.rs | 5 +++- crates/engine/src/canvas/drop.rs | 2 +- crates/engine/src/canvas/shapebuilder.rs | 4 ++-- crates/engine/src/canvas/tools.rs | 2 +- crates/engine/src/canvas/tools/click.rs | 5 +++- crates/engine/src/canvas/tools/drag.rs | 6 ++++- crates/engine/src/canvas/tools/draw.rs | 4 +++- crates/engine/src/canvas/tools/handles.rs | 2 +- crates/engine/src/canvas/tools/keys.rs | 2 +- crates/engine/src/canvas/tools/pen.rs | 5 +++- crates/engine/src/canvas/tools/pointer.rs | 5 +++- crates/engine/src/canvas/vectoredit.rs | 4 ++-- crates/engine/src/canvas/vectoredit/ops.rs | 2 +- .../engine/src/canvas/vectoredit/pointer.rs | 23 +++++++++++-------- .../engine/src/canvas/vectoredit/transform.rs | 2 +- crates/engine/src/canvas/vectoredit/width.rs | 4 ++-- .../canvas/vectoredit/width_sides_tests.rs | 1 + .../src/canvas/vectoredit/width_tests.rs | 1 + 22 files changed, 72 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs index 753550e5..14760331 100644 --- a/crates/engine/src/boards/board/connectors.rs +++ b/crates/engine/src/boards/board/connectors.rs @@ -1,7 +1,10 @@ use super::derived::{ends_now, place_route, route}; use super::edit::set_label; use super::items::add; -use super::*; +use super::{ + BoardItem, Connector, Document, End, LINE, LINE_WEIGHT, NodeId, NodeKind, Paint, Point, Result, Session, Side, StrokeCap, StrokeJoin, Value, + VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string, +}; /// One connector end from the params: a layer on this page, or a point. fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs index ac73df1e..9899632f 100644 --- a/crates/engine/src/boards/board/derived.rs +++ b/crates/engine/src/boards/board/derived.rs @@ -1,6 +1,9 @@ use super::edit::set_font_size; use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of}; -use super::*; +use super::{ + Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork, + is_board, +}; // --------------------------------------------------------------------------------------------- // Derived data (the normalize pass). diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs index 1770852c..9456256e 100644 --- a/crates/engine/src/boards/board/edit.rs +++ b/crates/engine/src/boards/board/edit.rs @@ -1,7 +1,11 @@ use super::connectors::connector_of; use super::derived::ends_now; use super::items::{add, add_text, shape_padding, text_of}; -use super::*; +use super::{ + Affine, AutoLayout, BTreeMap, BoardItem, BoardShape, Color, Connector, DocError, Document, End, INK, NodeId, NodeKind, Paint, Result, Routing, + Session, Sizing, StrokeCap, TextAlignH, TextAutoResize, TextData, Value, VectorNetwork, bad, cap_of, color_param, json, line_shade, num, + req_string, req_targets, require, routing_of, shape_of, string, +}; /// The label of connector `id`, if it has one. pub fn label_of(doc: &Document, id: NodeId) -> Option { diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index 6d96d34b..b76fc898 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -1,4 +1,10 @@ -use super::*; +use super::{ + AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError, + HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP, + STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork, + author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string, + textedit, +}; /// A new layer at the top of `parent`, at `at` in it, made by `f`. pub(super) fn add( diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index 2dc904c9..cb3fc005 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -2,7 +2,10 @@ use super::connectors::{end_json, other_end}; use super::derived::ends_now; use super::edit::label_of; use super::items::edit_text; -use super::*; +use super::{ + BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of, + is_board, json, +}; // --------------------------------------------------------------------------------------------- // The board tools. diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs index d5e8b3cf..9e054630 100644 --- a/crates/engine/src/canvas/drop.rs +++ b/crates/engine/src/canvas/drop.rs @@ -1,7 +1,7 @@ //! Where something dropped at a page point lands: the frame under the point (or the page) and, in //! an auto layout frame, the child slot by pointer position plus the insertion line to draw. -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutMode, NodeId, NodeKind}; use figcraft_geom::Point; use serde::Serialize; diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs index 99d59b45..74a4a9d1 100644 --- a/crates/engine/src/canvas/shapebuilder.rs +++ b/crates/engine/src/canvas/shapebuilder.rs @@ -21,14 +21,14 @@ use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::PathEl; use figcraft_geom::{Affine, BezPath, Point, Rect, Shape}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::cmd::edit::{roots, z_sorted}; -use crate::params::*; +use crate::params::{f64_of, get, string, targets}; use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId}; use crate::{EngineError, Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index fce14f7c..b1dc3eff 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use figcraft_color::{ImageScaleMode, PaintKind}; use figcraft_doc::geometry::select_target; -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAutoResize, TextData, TextPath, VectorNetwork}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; use serde_json::json; diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs index 8f21aaca..a8291f0f 100644 --- a/crates/engine/src/canvas/tools/click.rs +++ b/crates/engine/src/canvas/tools/click.rs @@ -1,5 +1,8 @@ use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; -use super::*; +use super::{ + Affine, Document, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, Sizing, TextAutoResize, TextData, TextPath, + ToolId, frame_at, json, select_target, textedit, within_slop, +}; impl Session { /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 0d406673..d1db8f92 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -2,7 +2,11 @@ use super::handles::{ SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, shape_hint, }; -use super::*; +use super::{ + Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point, + PointerEvent, Rect, ResizeStart, Result, ScaleHint, Session, Sizing, Targets, TextAutoResize, ToolId, Vec2, move_world, roots, select_target, + textedit, within_slop, +}; impl Session { pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index b594ae27..36a12bc2 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -1,6 +1,8 @@ use super::handles::SNAP_PX; use super::pen::take_enclosed; -use super::*; +use super::{ + Affine, Cursor, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, StrokeCap, Targets, ToolId, Vec2, frame_at, textedit, +}; impl Session { pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index fd765f66..7ccaca40 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, BTreeMap, Cursor, Document, Handle, NodeId, NodeKind, Point, ShapeHandle, ShapeHint, roots}; /// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost /// one drawn, if it can be selected (still there, on the page, visible and unlocked). diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs index 591f1479..a369463c 100644 --- a/crates/engine/src/canvas/tools/keys.rs +++ b/crates/engine/src/canvas/tools/keys.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit}; impl Session { /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs index 319c11b7..4857e83c 100644 --- a/crates/engine/src/canvas/tools/pen.rs +++ b/crates/engine/src/canvas/tools/pen.rs @@ -1,5 +1,8 @@ use super::handles::{PENCIL_STROKE, selected_end_at}; -use super::*; +use super::{ + Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2, + VectorNetwork, at_45_degrees, frame_at, json, pen_handles, +}; /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most /// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index 985e3326..550c4a66 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -1,5 +1,8 @@ use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; -use super::*; +use super::{ + Cursor, Document, Gesture, NodeId, NodeKind, Overlay, PenCursor, Point, PointerEvent, PointerKind, Rect, Result, Session, ToolId, at_45_degrees, + frame_at, json, select_target, +}; impl Session { /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 03b22d22..fc263b4f 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -24,14 +24,14 @@ mod width_sides_tests; #[cfg(test)] mod width_tests; -use figcraft_doc::*; +use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::cmd::props::enum_of; -use crate::params::*; +use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; pub(crate) use pointer::VGesture; diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 935f033a..8832e05d 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -2,7 +2,7 @@ //! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in //! `vectoredit.rs` re-fit the node afterwards. -use figcraft_doc::*; +use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use figcraft_geom::{Affine, Point, Vec2}; diff --git a/crates/engine/src/canvas/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs index d977ce87..ebd8e2e3 100644 --- a/crates/engine/src/canvas/vectoredit/pointer.rs +++ b/crates/engine/src/canvas/vectoredit/pointer.rs @@ -5,7 +5,7 @@ use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use super::point_snap; @@ -510,20 +510,25 @@ impl Session { Ok(()) } + /// Another edited layer under the press (or, with the paint bucket, whose area it is in). + fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option { + match hit_any(doc, ve, ev.p(), tol) { + Some((id, _)) => Some(id), + None if ve.tool == VTool::Paint => { + let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); + (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() + } + None => None, + } + } + /// Returns false when the click left edit mode (the caller then runs the normal tool). fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result { let doc = self.doc()?.clone(); let tol = 6.0 / ev.zoom.max(1e-6); // A press on another edited layer's point, handle or segment (or, with the paint bucket, // area) makes it the active layer; a pen drawing a path keeps to its own layer. - let other = match hit_any(&doc, ve, ev.p(), tol) { - Some((id, _)) => Some(id), - None if ve.tool == VTool::Paint => { - let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); - (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() - } - None => None, - }; + let other = Self::pressed_layer(&doc, ve, ev, tol); let switched; let ve = match other { Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => { diff --git a/crates/engine/src/canvas/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs index 5efb5343..0f87a0bd 100644 --- a/crates/engine/src/canvas/vectoredit/transform.rs +++ b/crates/engine/src/canvas/vectoredit/transform.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{boolean, f64_of, get, num, num_or, req_num}; use crate::tools::{Handle, handle_at_corners}; use crate::{Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs index ba51b043..13c7be0c 100644 --- a/crates/engine/src/canvas/vectoredit/width.rs +++ b/crates/engine/src/canvas/vectoredit/width.rs @@ -10,7 +10,7 @@ //! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are //! fractions of each subpath's length). -use figcraft_doc::*; +use figcraft_doc::{Document, NodeId, WidthProfile, WidthSide}; use figcraft_geom::{Point, Vec2}; use figcraft_pathops::width::{Polyline, nearest, polylines}; use serde::Serialize; @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{editing, set_state}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{get, num, req_index, req_num, string}; use crate::tools::{Gesture, PointerEvent}; use crate::{Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs index bf2dbeb0..937245ae 100644 --- a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs +++ b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use super::*; use crate::EngineError; use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ExportFormat, WidthSide}; fn ex(s: &mut Session, c: &str, p: Value) -> Value { s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) diff --git a/crates/engine/src/canvas/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs index 54b4b0e2..8c89d4c6 100644 --- a/crates/engine/src/canvas/vectoredit/width_tests.rs +++ b/crates/engine/src/canvas/vectoredit/width_tests.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::*; use crate::EngineError; use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide}; fn ex(s: &mut Session, c: &str, p: Value) -> Value { s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) From d87f9effe1f2aabd83d6906cf172897e35ba64b7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 16:00:01 +0000 Subject: [PATCH 30/60] refactor(engine): simplify asset and code generation helpers --- crates/engine/src/assets/components.rs | 4 +- crates/engine/src/assets/components/diff.rs | 2 +- crates/engine/src/assets/components/guard.rs | 5 +- .../engine/src/assets/components/instances.rs | 5 +- crates/engine/src/assets/components/patch.rs | 194 ++++---- crates/engine/src/assets/components/rebase.rs | 2 +- crates/engine/src/assets/components/sync.rs | 274 +++++----- crates/engine/src/assets/components/tests.rs | 29 +- .../engine/src/assets/components/variants.rs | 2 +- crates/engine/src/assets/library.rs | 6 +- crates/engine/src/assets/library/tests.rs | 202 ++++---- crates/engine/src/assets/library_swap.rs | 52 +- crates/engine/src/assets/playground.rs | 2 +- crates/engine/src/assets/style_vars.rs | 5 +- crates/engine/src/automation/inspect.rs | 470 +++++++++--------- crates/engine/src/content/synthetic_ds.rs | 5 +- .../engine/src/content/templates/dashboard.rs | 2 +- .../src/content/templates/design_system.rs | 2 +- .../engine/src/content/templates/icon_set.rs | 2 +- .../engine/src/content/templates/landing.rs | 2 +- crates/engine/src/content/templates/mobile.rs | 2 +- crates/engine/src/content/templates/slides.rs | 2 +- crates/engine/src/content/templates/social.rs | 2 +- .../engine/src/content/templates/wireframe.rs | 2 +- crates/engine/src/dev/annotations.rs | 4 +- crates/engine/src/dev/code_connect.rs | 4 +- crates/engine/src/dev/codegen.rs | 7 +- crates/engine/src/dev/codegen/arkui.rs | 225 +++++---- crates/engine/src/dev/codegen/compose.rs | 2 +- crates/engine/src/dev/codegen/css.rs | 241 +++++---- crates/engine/src/dev/codegen/html.rs | 134 ++--- crates/engine/src/dev/codegen/swift.rs | 2 +- crates/engine/src/dev/component_codegen.rs | 4 +- crates/engine/src/dev/dev_resources.rs | 4 +- crates/engine/src/dev/devcompare.rs | 2 +- crates/engine/src/dev/devinspect.rs | 7 +- 36 files changed, 1062 insertions(+), 849 deletions(-) diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index 3932efff..3192c3f1 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -33,7 +33,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{ + ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, +}; use figcraft_geom::Affine; use serde_json::Value; diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs index bf742fed..41c1a424 100644 --- a/crates/engine/src/assets/components/diff.rs +++ b/crates/engine/src/assets/components/diff.rs @@ -1,5 +1,5 @@ use super::instances::PROP_BINDING; -use super::*; +use super::{Document, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, inside_instance, owning_component, variant_set}; /// The property groups whose value layer `n` takes from the same variables as its main layer /// `src` (they differ only where the two resolve in different variable modes). diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs index b320b7c4..0424beff 100644 --- a/crates/engine/src/assets/components/guard.rs +++ b/crates/engine/src/assets/components/guard.rs @@ -1,7 +1,10 @@ use super::diff::geometry_derived; use super::rebase::rebase_instance; use super::variants::instance_issues; -use super::*; +use super::{ + ContentAlign, CrossAlign, Document, MainAlign, Node, NodeId, NodeKind, SLOT, Sizing, enclosing_instance, inside_instance, is_instance, + owning_instance, structure_locked, +}; /// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the /// main's default content until now) stop mirroring the main, and instances among them stand diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs index 9fd4e2c2..ac7ff4ec 100644 --- a/crates/engine/src/assets/components/instances.rs +++ b/crates/engine/src/assets/components/instances.rs @@ -1,6 +1,9 @@ use super::diff::{binding_group, bindings_of}; use super::sync::{insert_linked, sync_instance_depth}; -use super::*; +use super::{ + Affine, BTreeMap, ChildLayout, DocError, Document, Node, NodeId, NodeKind, Value, default_instance_name, default_props, holds_content, is_slot, + slot_edited, +}; /// Create an instance of `component` under `parent` at `(x, y)` (parent space). pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs index 8c61f0b8..8ae01bc1 100644 --- a/crates/engine/src/assets/components/patch.rs +++ b/crates/engine/src/assets/components/patch.rs @@ -2,7 +2,12 @@ use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaul use super::guard::{own_slot_content, relink_copy}; use super::instances::{copy_group, field_group, sync_instance}; use super::sync::insert_linked; -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content, inside_instance, owning_component, + owning_instance, root_instance, scope, slot_edited, slot_scope, +}; + +type OverrideGroups = (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>); /// After an edit: record overrides inside instances, then rebuild instances whose main changed, /// whose properties or main were switched, or whose overrides were reset. @@ -14,93 +19,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } } let changed = changed.as_slice(); - // 0. A slot whose layers were added, removed or reordered holds the instance's own content - // from now on; copies of instance layers placed elsewhere stop mirroring their main. - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - if n.children == b.children || owning_instance(doc, id).is_none() { - continue; - } - if let Some(slot) = slot_scope(doc, id) - && !slot_edited(doc, slot) - { - own_slot_content(doc, slot); - } - } - for &id in changed { - if !before.contains(id) { - relink_copy(doc, id); - } - } + adopt_slot_content(doc, before, changed); let mut resync: BTreeSet = BTreeSet::new(); - // 1. Overrides for layers edited inside instances. - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - let Some(inst) = owning_instance(doc, id) else { continue }; - let top_root = inst == id && !inside_instance(doc, id); - if top_root - && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) - && (c1 != c2 || p1 != p2) - { - // Switching the main or a property value of an instance isn't an override. - resync.insert(id); - } - let raw = diff_groups(b, n); - let mut groups = raw.clone(); - // An instance's own place, resizing, export settings, interactions and variable modes are - // its own, not overrides. - if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); - } - if groups.iter().any(|g| matches!(*g, "size" | "position")) { - groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); - } - // A value equal to the main's isn't an override (setting it back clears the override). - let mut cleared = Vec::new(); - if let Some(src) = n.source.and_then(|s| doc.node(s)) { - let mut differs = diff_groups(src, n); - if inst == id { - differs.retain(|g| *g != "name"); - if n.name != default_instance_name(doc, src.id) { - differs.push("name"); - } - } - // A value from the same variable as the main's (resolved in this layer's modes) isn't an - // override either. - let from_variables = variable_groups(n, src); - groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); - // Binding another variable (or none) where the main binds one is an override. - for (k, _) in b.bound.iter().chain(n.bound.iter()) { - let Some(g) = binding_group(k) else { continue }; - let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); - if rebound && !groups.contains(&g) { - groups.push(g); - } - } - cleared = - raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); - if top_root { - cleared.retain(|g| !matches!(*g, "component" | "props")); - } - } - let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); - let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); - if (!groups.is_empty() || !cleared.is_empty()) - && let Some(m) = doc.node_mut(id) - { - for g in groups { - m.overrides.insert(g.to_string()); - } - for g in cleared { - m.overrides.remove(g); - } - } - if (lost || nested_switch) - && let Some(r) = root_instance(doc, id) - { - resync.insert(r); - } - } + record_overrides(doc, before, changed, &mut resync); // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text, // visibility, …) are carried to the copies directly; anything else rebuilds the instances. let mut comps: BTreeSet = BTreeSet::new(); @@ -172,6 +93,105 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] rebuild_instances(doc, &comps); } +/// Slot content changes: layers added, removed or reordered in a slot become the instance's own. +fn adopt_slot_content(doc: &mut Document, before: &Document, changed: &[NodeId]) { + // 0. A slot whose layers were added, removed or reordered holds the instance's own content + // from now on; copies of instance layers placed elsewhere stop mirroring their main. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.children == b.children || owning_instance(doc, id).is_none() { + continue; + } + if let Some(slot) = slot_scope(doc, id) + && !slot_edited(doc, slot) + { + own_slot_content(doc, slot); + } + } + for &id in changed { + if !before.contains(id) { + relink_copy(doc, id); + } + } +} + +/// Overrides for layers edited inside instances; the instances to rebuild go into `resync`. +fn record_overrides(doc: &mut Document, before: &Document, changed: &[NodeId], resync: &mut BTreeSet) { + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + let Some(inst) = owning_instance(doc, id) else { continue }; + let top_root = inst == id && !inside_instance(doc, id); + if top_root + && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) + && (c1 != c2 || p1 != p2) + { + // Switching the main or a property value of an instance isn't an override. + resync.insert(id); + } + let (raw, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root); + let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); + let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); + if (!groups.is_empty() || !cleared.is_empty()) + && let Some(m) = doc.node_mut(id) + { + for g in groups { + m.overrides.insert(g.to_string()); + } + for g in cleared { + m.overrides.remove(g); + } + } + if (lost || nested_switch) + && let Some(r) = root_instance(doc, id) + { + resync.insert(r); + } + } +} + +/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones +/// that are now overrides and the overrides that were set back to the main's value. +fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups { + let raw = diff_groups(b, n); + let mut groups = raw.clone(); + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. + if top_root { + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); + } + if groups.iter().any(|g| matches!(*g, "size" | "position")) { + groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); + } + // A value equal to the main's isn't an override (setting it back clears the override). + let mut cleared = Vec::new(); + if let Some(src) = n.source.and_then(|s| doc.node(s)) { + let mut differs = diff_groups(src, n); + if inst == id { + differs.retain(|g| *g != "name"); + if n.name != default_instance_name(doc, src.id) { + differs.push("name"); + } + } + // A value from the same variable as the main's (resolved in this layer's modes) isn't an + // override either. + let from_variables = variable_groups(n, src); + groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); + // Binding another variable (or none) where the main binds one is an override. + for (k, _) in b.bound.iter().chain(n.bound.iter()) { + let Some(g) = binding_group(k) else { continue }; + let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); + if rebound && !groups.contains(&g) { + groups.push(g); + } + } + cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); + if top_root { + cleared.retain(|g| !matches!(*g, "component" | "props")); + } + } + (raw, groups, cleared) +} + #[cfg(test)] thread_local! { /// Tests: rebuild instances even for edits that could be patched (to compare both ways). diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs index 748a99d3..ea9cb6f3 100644 --- a/crates/engine/src/assets/components/rebase.rs +++ b/crates/engine/src/assets/components/rebase.rs @@ -1,6 +1,6 @@ use super::diff::{diff_groups, variable_groups}; use super::instances::{field_group, path_map}; -use super::*; +use super::{BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content}; /// Bounded source and parent lookups while a rebase reads an unchanged document. pub(super) struct RebaseSources<'a> { diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs index c502322d..e81bf437 100644 --- a/crates/engine/src/assets/components/sync.rs +++ b/crates/engine/src/assets/components/sync.rs @@ -1,6 +1,9 @@ use super::diff::diff_groups; use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map}; -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, DocError, Document, MAX_DEPTH, Node, NodeId, NodeKind, SLOT, Sizing, Value, default_instance_name, default_props, + holds_content, inside_instance, is_slot, owning_component, variant_set, +}; /// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested /// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. @@ -16,31 +19,10 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize // A missing main, or an instance inside its own main (would recurse forever). return; } - let mut eff_props = default_props(&comp); - for (k, v) in root_props { - if eff_props.contains_key(k) { - eff_props.insert(k.clone(), v.clone()); - } - } + let eff_props = effective_props(&comp, root_props); // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only // one layer mirrors it) and, only when those don't match, by name path. - let mut old_ids = old_layers(doc, inst); - old_ids.extend(spare.keys().copied()); - old_ids.sort(); - old_ids.dedup(); - let mut old: BTreeMap> = spare; - let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); - let mut by_src: BTreeMap> = BTreeMap::new(); - for i in &old_ids { - let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; - if let Some(s) = n.source { - if let Some(p) = n.parent { - by_source.entry((p, s)).or_insert(*i); - } - by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); - } - old.insert(*i, n); - } + let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare); let mut by_path: Option> = None; let mut main_paths: Option> = None; // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another @@ -124,23 +106,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize // Component properties: the main layer's bindings use this instance's values; layers of // nested instances use the nested instance's values (one level per source hop). let enc = enclosing.get(&parent).cloned().unwrap_or_default(); - let mut chain: Vec<&Node> = vec![orig]; - while chain.len() < enc.len() { - let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; - chain.push(next); - } - for (j, level) in chain.iter().enumerate().rev() { - let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; - for (field, prop) in &level.prop_refs { - let g = field_group(field); - if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { - continue; - } - if let Some(val) = props.get(prop) { - apply_prop(doc, &mut n, field, val); - } - } - } + apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props); if let NodeKind::Instance { component: c, props } = &n.kind { inst_props.insert(nid, props.clone()); let mut e = enc; @@ -167,6 +133,155 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize p.children.retain(|c| *c != id); } } + let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map); + if let Some(r) = doc.node_mut(inst) { + *r = fresh; + } + // A resized instance: children follow their constraints (auto layout re-flows later). + let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); + if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { + figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); + remeasure = map.values().copied().collect(); + } + follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure); + remeasure_text(doc, remeasure); + rebuild_nested(doc, nested, &old, &used, depth); +} + +/// Geometry overrides of children win over constraints. +fn follow_geometry_overrides( + doc: &mut Document, + src: &[NodeId], + map: &BTreeMap, + old: &BTreeMap>, + remeasure: &mut Vec, +) { + for s in src { + let Some(nid) = map.get(s).copied() else { continue }; + let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } + if !o.overrides.contains("size") && !o.overrides.contains("position") { + continue; + } + remeasure.push(nid); + let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; + if o.overrides.contains("size") { + // Through set_size so vector networks scale with the box. + figcraft_layout::set_size(doc, nid, o.width, o.height); + if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { + figcraft_layout::apply_constraints(doc, nid, ow, oh); + } + } + if let Some(n) = doc.node_mut(nid) { + carry_overrides(o, n, Carry::Geometry); + } + } +} + +/// Nested instances showing another main than the main's copy (swapped): rebuild from it, +/// with the old layers below them this rebuild didn't reuse. +fn rebuild_nested(doc: &mut Document, nested: Vec, old: &BTreeMap>, used: &BTreeSet, depth: usize) { + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); + for n in nested { + let mut below: BTreeMap> = unused + .iter() + .filter(|(_, x)| { + let mut cur = x.parent; + for _ in 0..=MAX_DEPTH * 4 { + match cur { + Some(p) if p == n => return true, + Some(p) => cur = unused.get(&p).and_then(|a| a.parent), + None => return false, + } + } + false + }) + .map(|(i, x)| (*i, x.clone())) + .collect(); + take_placing_overrides(doc, n, old, &mut below); + sync_instance_depth(doc, n, depth + 1, below); + } +} + +fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap>) { + let mut chain: Vec<&Node> = vec![orig]; + while chain.len() < enc.len() { + let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; + chain.push(next); + } + for (j, level) in chain.iter().enumerate().rev() { + let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; + for (field, prop) in &level.prop_refs { + let g = field_group(field); + if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { + continue; + } + if let Some(val) = props.get(prop) { + apply_prop(doc, n, field, val); + } + } + } +} + +/// Text whose characters or style differ from the main re-measures. +fn remeasure_text(doc: &mut Document, remeasure: Vec) { + for nid in remeasure { + if let Some(n) = doc.node(nid) + && let Some((w, h)) = figcraft_text::auto_size(n) + && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + { + figcraft_layout::set_size(doc, nid, w, h); + } + } +} + +/// The component's property defaults with the instance's values for the ones it has. +fn effective_props(comp: &Node, root_props: &BTreeMap) -> BTreeMap { + let mut eff_props = default_props(comp); + for (k, v) in root_props { + if eff_props.contains_key(k) { + eff_props.insert(k.clone(), v.clone()); + } + } + eff_props +} + +type OldIndex = (BTreeMap>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap>); + +/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None` +/// when several layers mirror it). +fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap>) -> OldIndex { + let mut old_ids = old_layers(doc, inst); + old_ids.extend(spare.keys().copied()); + old_ids.sort(); + old_ids.dedup(); + let mut old = spare; + let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); + let mut by_src: BTreeMap> = BTreeMap::new(); + for i in &old_ids { + let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; + if let Some(s) = n.source { + if let Some(p) = n.parent { + by_source.entry((p, s)).or_insert(*i); + } + by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); + } + old.insert(*i, n); + } + (old, by_source, by_src) +} + +/// The rebuilt root of instance `inst` of `comp`. +fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap) -> Node { // Root: follows the main except its own placement, name, size and overrides. let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect(); let mut fresh = comp.clone(); @@ -175,7 +290,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize fresh.children = order; fresh.transform = root.transform; fresh.kind = root.kind.clone(); - carry_prop_bindings(&root, &mut fresh); + carry_prop_bindings(root, &mut fresh); // A nested instance keeps mirroring its layer of the outer main. let nested_copy = inside_instance(doc, inst); fresh.source = if nested_copy { root.source } else { Some(component) }; @@ -212,81 +327,12 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; // Switched to another variant: its own overrides stay where both variants agreed. if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { - let differs = diff_groups(prev, &comp); + let differs = diff_groups(prev, comp); root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); } - carry_overrides(&root, &mut fresh, Carry::Root); - if let Some(r) = doc.node_mut(inst) { - *r = fresh; - } - // A resized instance: children follow their constraints (auto layout re-flows later). - let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); - if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { - figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); - remeasure = map.values().copied().collect(); - } - // Geometry overrides of children win over constraints. - for s in &src { - let Some(nid) = map.get(s).copied() else { continue }; - let Some(o) = old.get(&nid) else { continue }; - // A frame that kept the size its own resizing gave it: its layers (copied at their main - // layers' places) follow their constraints from its main layer's size. - if o.overrides.contains("resizing") - && !o.overrides.contains("size") - && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) - && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) - { - figcraft_layout::apply_constraints(doc, nid, mw, mh); - } - if !o.overrides.contains("size") && !o.overrides.contains("position") { - continue; - } - remeasure.push(nid); - let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; - if o.overrides.contains("size") { - // Through set_size so vector networks scale with the box. - figcraft_layout::set_size(doc, nid, o.width, o.height); - if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { - figcraft_layout::apply_constraints(doc, nid, ow, oh); - } - } - if let Some(n) = doc.node_mut(nid) { - carry_overrides(o, n, Carry::Geometry); - } - } - // Text whose characters or style differ from the main re-measures. - for nid in remeasure { - if let Some(n) = doc.node(nid) - && let Some((w, h)) = figcraft_text::auto_size(n) - && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) - { - figcraft_layout::set_size(doc, nid, w, h); - } - } - // Nested instances showing another main than the main's copy (swapped): rebuild from it, - // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = - old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); - for n in nested { - let mut below: BTreeMap> = unused - .iter() - .filter(|(_, x)| { - let mut cur = x.parent; - for _ in 0..=MAX_DEPTH * 4 { - match cur { - Some(p) if p == n => return true, - Some(p) => cur = unused.get(&p).and_then(|a| a.parent), - None => return false, - } - } - false - }) - .map(|(i, x)| (*i, x.clone())) - .collect(); - take_placing_overrides(doc, n, &old, &mut below); - sync_instance_depth(doc, n, depth + 1, below); - } + carry_overrides(root, &mut fresh, Carry::Root); + fresh } /// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs index 30254651..82aabc63 100644 --- a/crates/engine/src/assets/components/tests.rs +++ b/crates/engine/src/assets/components/tests.rs @@ -383,6 +383,22 @@ fn resized_instances_follow_constraints_and_auto_layout() { healthy(&s); } +/// The tail of `component_properties_apply_to_instances`: unbind, delete, then a variant property. +fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId, dot: NodeId) { + // Unbind and delete. + ex(s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"})); + assert!(node(s, copy_of(s, i, dot)).visible); + ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"})); + assert!(node(s, title).prop_refs.is_empty()); + assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading"))); + let desc = ex(s, "component.properties", json!({"id": i})); + assert_eq!(desc["properties"].as_array().unwrap().len(), 1); + // A variant property on a lone component makes it a component set. + let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"}))); + assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(s, c).name, "Theme=Light"); +} + #[test] fn component_properties_apply_to_instances() { let mut s = Session::new(); @@ -414,18 +430,7 @@ fn component_properties_apply_to_instances() { assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err()); assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err()); assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err()); - // Unbind and delete. - ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"})); - assert!(node(&s, copy_of(&s, i, dot)).visible); - ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"})); - assert!(node(&s, title).prop_refs.is_empty()); - assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading"))); - let desc = ex(&mut s, "component.properties", json!({"id": i})); - assert_eq!(desc["properties"].as_array().unwrap().len(), 1); - // A variant property on a lone component makes it a component set. - let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"}))); - assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. })); - assert_eq!(node(&s, c).name, "Theme=Light"); + unbind_delete_and_vary(&mut s, c, i, title, dot); healthy(&s); } diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs index 40ac23ea..bc0d25b7 100644 --- a/crates/engine/src/assets/components/variants.rs +++ b/crates/engine/src/assets/components/variants.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BTreeMap, Document, MAX_DEPTH, Node, NodeId, NodeKind, holds_content, inside_instance}; /// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then /// the left-most). diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 56b0fc07..24699ef2 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -15,7 +15,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use figcraft_doc::proto::Action; -use figcraft_doc::*; +use figcraft_doc::{ + Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink, + LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId, + VariableValue, Variables, node_images, paint_images, used_images, +}; use serde_json::Value; use crate::components::inside_instance; diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index 27c9e616..c8ef2e49 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String { r["id"].as_str().unwrap().to_string() } +/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest. +fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) { + let m = &lib.manifest; + // Saved and read back: the author's file and a reader of the library see the same manifest. + let bytes = figcraft_format::save_bytes(doc, None).unwrap(); + let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap(); + assert!(issues.is_empty(), "{issues:?}"); + assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m)); + let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap(); + assert_eq!(read.manifest, *m); + assert_eq!(read.doc.node_count(), lib.doc.node_count()); + // Older files and files never published have no library; readers that don't know libraries + // are unaffected (the entries are extra archive members). + let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap(); + assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none()); + assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none()); +} + +/// `publish_manifest_roundtrip`: a republish bumps only what changed. +fn republish_bumps_touched(s: &mut Session, k: &Kit) { + // Nothing changed: refused. A change bumps only what it touches (and what uses it). + let e = s.execute("library.publish", &json!({})).unwrap_err(); + assert!(e.to_string().contains("no changes"), "{e}"); + ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}})); + let r = ex(s, "library.publish", json!({})); + assert_eq!(r["version"], 2); + let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); + assert_eq!(m.component(k.card).map(|a| a.version), Some(2)); + assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version"); + assert_eq!(m.description, "Our kit", "the description carries over"); + // Excluding an asset; a used component still comes along (not offered). + let r = ex(s, "library.publish", json!({"exclude": ["Button"]})); + assert_eq!(r["components"], 1); + let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); + assert!(m.component(k.button).is_some_and(|a| !a.published)); + // Unknown assets are an error, not ignored. + assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err()); + assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err()); + // Undo takes the publish back. + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2)); +} + #[test] fn publish_manifest_roundtrip() { let mut s = Session::new(); @@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() { assert!(lib.doc.node(k.label).is_some()); assert!(lib.doc.validate().is_empty()); assert_eq!(lib.doc.styles.len(), 1); - // Saved and read back: the author's file and a reader of the library see the same manifest. - let bytes = figcraft_format::save_bytes(doc, None).unwrap(); - let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap(); - assert!(issues.is_empty(), "{issues:?}"); - assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m)); - let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap(); - assert_eq!(read.manifest, *m); - assert_eq!(read.doc.node_count(), lib.doc.node_count()); - // Older files and files never published have no library; readers that don't know libraries - // are unaffected (the entries are extra archive members). - let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap(); - assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none()); - assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none()); + saved_and_read_back(doc, &lib); - // Nothing changed: refused. A change bumps only what it touches (and what uses it). - let e = s.execute("library.publish", &json!({})).unwrap_err(); - assert!(e.to_string().contains("no changes"), "{e}"); - ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}})); - let r = ex(&mut s, "library.publish", json!({})); - assert_eq!(r["version"], 2); - let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); - assert_eq!(m.component(k.card).map(|a| a.version), Some(2)); - assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version"); - assert_eq!(m.description, "Our kit", "the description carries over"); - // Excluding an asset; a used component still comes along (not offered). - let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]})); - assert_eq!(r["components"], 1); - let m = s.doc().unwrap().published.clone().unwrap().manifest.clone(); - assert!(m.component(k.button).is_some_and(|a| !a.published)); - // Unknown assets are an error, not ignored. - assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err()); - assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err()); - // Undo takes the publish back. - s.undo().unwrap(); - assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2)); + republish_bumps_touched(&mut s, &k); +} + +/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back. +fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) { + let unchanged = s.doc().unwrap().clone(); + let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); + assert!(e.to_string().contains("can't be edited"), "{e}"); + assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); + for (cmd, args) in + [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] + { + assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); + assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); + } + let style = s.doc().unwrap().styles[0].id.clone(); + assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); + assert!(s.execute("style.delete", &json!({"style": style})).is_err()); + let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err(); + assert!(e.to_string().contains("library"), "{e}"); + assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected"); + assert!(s.doc().unwrap().pages.contains(&s.page().unwrap())); + assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err()); + assert_eq!(fill(s, remote_button), "0000FF"); +} + +/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance. +fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId { + // The library changes the Button (fill via its style, and its label) and publishes again. + ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"})); + ex(lib, "text.set", json!({"id": k.label, "characters": "Go"})); + ex(lib, "library.publish", json!({})); + ex(lib, "file.save", json!({})); + + let u = ex(s, "library.updates", json!({})); + assert_eq!(u["count"], 3, "Button, Card and the style: {u}"); + let label_ids: Vec = s.doc().unwrap().subtree(b); + ex(s, "library.acceptUpdates", json!({})); + assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main"); + assert_eq!(chars(s, b_label), "Buy", "its override is kept"); + assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept"); + assert_eq!(fill(s, remote_button), "00FF00"); + let style = s.doc().unwrap().styles[0].clone(); + assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00")); + let doc = s.doc().unwrap(); + assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2)); + // The Card's nested Button follows too. + let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap(); + let nested = node(s, card_inst).children[0]; + assert_eq!(fill(s, nested), "00FF00"); + assert_eq!(ex(s, "library.updates", json!({}))["count"], 0); + healthy(s); + // A structural change: the Button gains a layer (the Card's nested Button too). + ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}})); + ex(lib, "library.publish", json!({})); + ex(lib, "file.save", json!({})); + ex(s, "library.acceptUpdates", json!({"ids": [lid]})); + assert_eq!(node(s, b).children.len(), 2); + assert_eq!(node(s, nested).children.len(), 2); + assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update"); + assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3)); + healthy(s); + // Overrides still work after the update; resetting shows the library's text. + ex(s, "component.resetInstance", json!({"ids": [b]})); + assert_eq!(chars(s, node(s, b).children[0]), "Go"); + card_inst } #[test] @@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() { NodeKind::Instance { component, .. } => component, _ => panic!("not an instance"), }; - let unchanged = s.doc().unwrap().clone(); - let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err(); - assert!(e.to_string().contains("can't be edited"), "{e}"); - assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back"); - for (cmd, args) in - [("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))] - { - assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies"); - assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back"); - } - let style = s.doc().unwrap().styles[0].id.clone(); - assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); - assert!(s.execute("style.delete", &json!({"style": style})).is_err()); - let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err(); - assert!(e.to_string().contains("library"), "{e}"); - assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected"); - assert!(s.doc().unwrap().pages.contains(&s.page().unwrap())); - assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err()); - assert_eq!(fill(&s, remote_button), "0000FF"); + assert_remote_read_only(&mut s, b, remote_button); - // The library changes the Button (fill via its style, and its label) and publishes again. - ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"})); - ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"})); - ex(&mut lib, "library.publish", json!({})); - ex(&mut lib, "file.save", json!({})); - - let u = ex(&mut s, "library.updates", json!({})); - assert_eq!(u["count"], 3, "Button, Card and the style: {u}"); - let label_ids: Vec = s.doc().unwrap().subtree(b); - ex(&mut s, "library.acceptUpdates", json!({})); - assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main"); - assert_eq!(chars(&s, b_label), "Buy", "its override is kept"); - assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept"); - assert_eq!(fill(&s, remote_button), "00FF00"); - let style = s.doc().unwrap().styles[0].clone(); - assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00")); - let doc = s.doc().unwrap(); - assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2)); - // The Card's nested Button follows too. - let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap(); - let nested = node(&s, card_inst).children[0]; - assert_eq!(fill(&s, nested), "00FF00"); - assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0); - healthy(&s); - // A structural change: the Button gains a layer (the Card's nested Button too). - ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}})); - ex(&mut lib, "library.publish", json!({})); - ex(&mut lib, "file.save", json!({})); - ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]})); - assert_eq!(node(&s, b).children.len(), 2); - assert_eq!(node(&s, nested).children.len(), 2); - assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update"); - assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3)); - healthy(&s); - // Overrides still work after the update; resetting shows the library's text. - ex(&mut s, "component.resetInstance", json!({"ids": [b]})); - assert_eq!(chars(&s, node(&s, b).children[0]), "Go"); + let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]); // Saved and reopened, the document keeps its links, copies and records. let user_path = path_str(&d.join("app.figcraft")); diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs index 1220d24c..1dfc1691 100644 --- a/crates/engine/src/assets/library_swap.rs +++ b/crates/engine/src/assets/library_swap.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue}; use serde_json::Value; use crate::cmd::component::{main_of, swap_instance}; @@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt } } +/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`). +fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap { + let mut keys = BTreeMap::new(); + for r in recs { + let Some(st) = doc.style(&r.local) else { continue }; + let kind = st.kind.type_name(); + match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) { + Some(x) => { + keys.insert(r.local.clone(), x.id.clone()); + } + None => out.unmatched.push(format!("style {}", st.name)), + } + } + keys +} + +/// (local component root, the library's component id) by name; the rest go to `out.unmatched`. +fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> { + let mut pairs = Vec::new(); + for r in records { + let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default(); + match m.components.iter().find(|a| a.published && a.name == name) { + Some(a) => pairs.push((r.root, a.id)), + None => out.unmatched.push(format!("component {name}")), + } + } + pairs +} + /// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs). pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result { let m = &to.manifest; @@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re }) .collect(); let records: Vec = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect(); - let mut pairs = Vec::new(); - for r in &records { - let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default(); - match m.components.iter().find(|a| a.published && a.name == name) { - Some(a) => pairs.push((r.root, a.id)), - None => out.unmatched.push(format!("component {name}")), - } - } + let pairs = match_components(doc, &records, m, &mut out); let keys: Vec = pairs.iter().map(|(_, k)| *k).collect(); let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? }; // A's mains (each variant) and roots → B's. @@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re // Styles the document's own layers link to: by name and type. let linked: BTreeSet = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect(); let style_recs: Vec = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect(); - let mut style_keys: BTreeMap = BTreeMap::new(); - for r in &style_recs { - let Some(st) = doc.style(&r.local) else { continue }; - let kind = st.kind.type_name(); - match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) { - Some(x) => { - style_keys.insert(r.local.clone(), x.id.clone()); - } - None => out.unmatched.push(format!("style {}", st.name)), - } - } + let style_keys = match_styles(doc, &style_recs, m, &mut out); let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false); let styles: BTreeMap = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect(); out.styles = styles.len(); diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs index 90a627b0..5c05c3b9 100644 --- a/crates/engine/src/assets/playground.rs +++ b/crates/engine/src/assets/playground.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value, json}; use crate::cmd::CommandSpec; use crate::codegen::CodeLang; -use crate::params::*; +use crate::params::{get, id_of, num, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// The component a playground for `target` shows: an instance's component, a component or a diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index b2e5c182..7fb30fd0 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -8,7 +8,10 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::PaintKind; -use figcraft_doc::*; +use figcraft_doc::{ + CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId, + VariableType, VariableValue, Variables, +}; use crate::cmd::lists::{BoundList, list_binding, stop_binding}; diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 7dccd706..886d2d85 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -1,6 +1,6 @@ //! Document inspection as JSON, so agents can verify their work without screenshots. -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration}; use serde_json::{Value, json}; use crate::Session; @@ -8,7 +8,6 @@ use crate::Session; /// A node and (to `depth`) its children, with the properties a designer would check. pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; - let c = doc.frame_relative(id).as_coeffs(); let mut v = json!({ "id": n.id, "name": n.name, @@ -16,226 +15,259 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { }); let Some(o) = v.as_object_mut() else { return v }; if !n.is_page() { - o.insert("x".into(), json!(round(c[4]))); - o.insert("y".into(), json!(round(c[5]))); - o.insert("width".into(), json!(round(n.width))); - o.insert("height".into(), json!(round(n.height))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 1e-6 { - o.insert("rotation".into(), json!(round(rot))); - } - if let Some(b) = doc.world_bounds(id) { - o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); - } - if !n.visible { - o.insert("visible".into(), json!(false)); - } - if n.locked { - o.insert("locked".into(), json!(true)); - } - if n.opacity < 1.0 { - o.insert("opacity".into(), json!(n.opacity)); - } - if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { - o.insert("blend".into(), json!(n.blend)); - } - if !n.fills.is_empty() { - o.insert("fills".into(), paints(&n.fills)); - } - if !n.strokes.is_empty() { - o.insert("strokes".into(), paints(&n.strokes)); - o.insert("strokeWeight".into(), json!(n.stroke.weight)); - o.insert("strokeAlign".into(), json!(n.stroke.align)); - if n.stroke.sides.is_some() { - o.insert("strokeSides".into(), json!(n.stroke.sides)); - } - if !n.stroke.dashes.is_empty() { - o.insert("dashes".into(), json!(n.stroke.dashes)); - } - if n.stroke.join != StrokeJoin::Miter { - o.insert("strokeJoin".into(), json!(n.stroke.join)); - } else if n.stroke.miter_limit != 4.0 { - o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); - } - if let Some(p) = n.stroke.profile() { - let points = crate::cmd::props::width_points_json(Some(p)); - o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); - } - if let Some(b) = &n.stroke.brush { - o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); - } - } - if !n.effects.is_empty() { - o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); - } - if n.corners.radii.iter().any(|r| *r > 0.0) { - o.insert( - "cornerRadius".into(), - match n.corners.uniform() { - Some(r) => json!(r), - None => json!(n.corners.radii), - }, - ); - if n.corners.smoothing > 0.0 { - o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); - } - } - if let Some(l) = &n.layout { - o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); - } - let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); - if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { - o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); - } - let cl = &n.child_layout; - if cl.absolute { - o.insert("absolute".into(), json!(true)); - } - for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { - if let Some(v) = v { - o.insert(k.into(), json!(v)); - } - } - if let Some(cell) = parent_al - .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) - .flatten() - .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) - { - o.insert( - "gridCell".into(), - json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, - "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), - ); - } - if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { - o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); - } - if n.kind.is_frame_like() { - o.insert("clip".into(), json!(n.clip)); - } - if !n.dev_status.is_none() { - o.insert("devStatus".into(), json!(n.dev_status.id())); - } - if n.dev_changed { - o.insert("devChanged".into(), json!(true)); - } - if !n.dev_note.is_empty() { - o.insert("devNote".into(), json!(n.dev_note)); - } - if let NodeKind::Section { contents_hidden } = n.kind { - o.insert("sectionContentsHidden".into(), json!(contents_hidden)); - } - if n.is_mask { - o.insert("isMask".into(), json!(true)); - o.insert("maskType".into(), json!(n.mask_type.id())); - } - if !n.grids.is_empty() { - o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); - } - if !n.exports.is_empty() { - o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); - } - if let Some(b) = &n.board { - o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); - } - if !n.annotations.is_empty() { - o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); - } - if !n.code_links.is_empty() { - o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); - } - if !n.measurements.is_empty() { - o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); - } - let dev = crate::dev_resources::list_json(doc, n.id); - if dev.as_array().is_some_and(|a| !a.is_empty()) { - o.insert("devResources".into(), dev); - } - if !n.interactions.is_empty() { - o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); - } - if !n.styles.is_empty() { - o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); - } - if !n.bound.is_empty() { - o.insert("boundVariables".into(), json!(n.bound)); - } - if !n.overrides.is_empty() { - o.insert("overrides".into(), json!(n.overrides)); - } - if let Some(slot) = crate::components::slot_name(doc, n.id) { - o.insert("slot".into(), json!(slot)); - } - // An operand combining by an operation of its own. - if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { - o.insert("operandOperation".into(), json!(op)); - } + base_props(doc, n, id, o); + paint_props(n, o); + layout_props(doc, n, o); + dev_props(doc, n, o); } // Layer data written with `node.set {props: {pluginData}}` (pages too). if !n.plugin_data.is_empty() { o.insert("pluginData".into(), json!(n.plugin_data)); } match &n.kind { - NodeKind::Text(t) => { - o.insert("characters".into(), json!(t.characters)); - o.insert("fontFamily".into(), json!(t.style.font_family)); - o.insert("fontStyle".into(), json!(t.style.font_style)); - o.insert("fontSize".into(), json!(t.style.font_size)); - if !t.style.variations.is_empty() { - o.insert("fontVariations".into(), json!(t.style.variations)); - } - o.insert("lineHeight".into(), json!(t.style.line_height.label())); - o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); - o.insert("textAlign".into(), json!(t.align_h)); - o.insert("textAutoResize".into(), json!(t.auto_resize)); - if t.max_lines > 0 { - o.insert("maxLines".into(), json!(t.max_lines)); - } - if let Some(tp) = &t.path { - o.insert("textPath".into(), json!(tp)); - } - if t.style.decoration != TextDecoration::None { - o.insert("textDecoration".into(), json!(t.style.decoration)); - } - if let Some(l) = t.style.decoration_look.as_deref() { - o.insert("textDecorationStyle".into(), json!(l.style)); - o.insert("textDecorationThickness".into(), json!(l.thickness.label())); - o.insert("textDecorationOffset".into(), json!(l.offset.label())); - o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); - o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); - } - // Lists: one style for the layer, or each paragraph's type, nesting level and start. - if t.paragraphs.is_empty() { - if t.list != figcraft_doc::ListStyle::None { - o.insert("listStyle".into(), json!(t.list)); - } - } else { - o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); - o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); - if t.paragraphs.iter().any(|p| p.start_offset > 0) { - o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); - } - } - if t.list_spacing != 0.0 { - o.insert("listSpacing".into(), json!(t.list_spacing)); - } - for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { - if on { - o.insert(k.into(), json!(true)); - } - } - if t.leading_trim != figcraft_doc::LeadingTrim::None { - o.insert("leadingTrim".into(), json!(t.leading_trim)); - } - if !t.runs.is_empty() { - o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); - } - // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). - let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); - if !linked.is_empty() { - o.insert("textStyleRanges".into(), Value::Array(linked)); - } + NodeKind::Text(t) => text_props(t, o), + _ => kind_props(doc, n, o), + } + if !n.children.is_empty() { + if depth > 0 { + o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); + } else { + o.insert("childCount".into(), json!(n.children.len())); } + } + v +} + +fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map) { + let c = doc.frame_relative(id).as_coeffs(); + o.insert("x".into(), json!(round(c[4]))); + o.insert("y".into(), json!(round(c[5]))); + o.insert("width".into(), json!(round(n.width))); + o.insert("height".into(), json!(round(n.height))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 1e-6 { + o.insert("rotation".into(), json!(round(rot))); + } + if let Some(b) = doc.world_bounds(id) { + o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); + } + if !n.visible { + o.insert("visible".into(), json!(false)); + } + if n.locked { + o.insert("locked".into(), json!(true)); + } + if n.opacity < 1.0 { + o.insert("opacity".into(), json!(n.opacity)); + } + if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { + o.insert("blend".into(), json!(n.blend)); + } +} + +fn paint_props(n: &Node, o: &mut serde_json::Map) { + if !n.fills.is_empty() { + o.insert("fills".into(), paints(&n.fills)); + } + if !n.strokes.is_empty() { + o.insert("strokes".into(), paints(&n.strokes)); + o.insert("strokeWeight".into(), json!(n.stroke.weight)); + o.insert("strokeAlign".into(), json!(n.stroke.align)); + if n.stroke.sides.is_some() { + o.insert("strokeSides".into(), json!(n.stroke.sides)); + } + if !n.stroke.dashes.is_empty() { + o.insert("dashes".into(), json!(n.stroke.dashes)); + } + if n.stroke.join != StrokeJoin::Miter { + o.insert("strokeJoin".into(), json!(n.stroke.join)); + } else if n.stroke.miter_limit != 4.0 { + o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); + } + if let Some(p) = n.stroke.profile() { + let points = crate::cmd::props::width_points_json(Some(p)); + o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); + } + if let Some(b) = &n.stroke.brush { + o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); + } + } + if !n.effects.is_empty() { + o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); + } + if n.corners.radii.iter().any(|r| *r > 0.0) { + o.insert( + "cornerRadius".into(), + match n.corners.uniform() { + Some(r) => json!(r), + None => json!(n.corners.radii), + }, + ); + if n.corners.smoothing > 0.0 { + o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); + } + } +} + +fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + if let Some(l) = &n.layout { + o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); + } + let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { + o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); + } + let cl = &n.child_layout; + if cl.absolute { + o.insert("absolute".into(), json!(true)); + } + for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { + if let Some(v) = v { + o.insert(k.into(), json!(v)); + } + } + if let Some(cell) = parent_al + .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) + .flatten() + .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) + { + o.insert( + "gridCell".into(), + json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, + "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), + ); + } + if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { + o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); + } + if n.kind.is_frame_like() { + o.insert("clip".into(), json!(n.clip)); + } +} + +fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + if !n.dev_status.is_none() { + o.insert("devStatus".into(), json!(n.dev_status.id())); + } + if n.dev_changed { + o.insert("devChanged".into(), json!(true)); + } + if !n.dev_note.is_empty() { + o.insert("devNote".into(), json!(n.dev_note)); + } + if let NodeKind::Section { contents_hidden } = n.kind { + o.insert("sectionContentsHidden".into(), json!(contents_hidden)); + } + if n.is_mask { + o.insert("isMask".into(), json!(true)); + o.insert("maskType".into(), json!(n.mask_type.id())); + } + if !n.grids.is_empty() { + o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); + } + if !n.exports.is_empty() { + o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); + } + if let Some(b) = &n.board { + o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + } + if !n.annotations.is_empty() { + o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); + } + if !n.code_links.is_empty() { + o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); + } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } + let dev = crate::dev_resources::list_json(doc, n.id); + if dev.as_array().is_some_and(|a| !a.is_empty()) { + o.insert("devResources".into(), dev); + } + if !n.interactions.is_empty() { + o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); + } + if !n.styles.is_empty() { + o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); + } + if !n.bound.is_empty() { + o.insert("boundVariables".into(), json!(n.bound)); + } + if !n.overrides.is_empty() { + o.insert("overrides".into(), json!(n.overrides)); + } + if let Some(slot) = crate::components::slot_name(doc, n.id) { + o.insert("slot".into(), json!(slot)); + } + // An operand combining by an operation of its own. + if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { + o.insert("operandOperation".into(), json!(op)); + } +} + +fn text_props(t: &TextData, o: &mut serde_json::Map) { + o.insert("characters".into(), json!(t.characters)); + o.insert("fontFamily".into(), json!(t.style.font_family)); + o.insert("fontStyle".into(), json!(t.style.font_style)); + o.insert("fontSize".into(), json!(t.style.font_size)); + if !t.style.variations.is_empty() { + o.insert("fontVariations".into(), json!(t.style.variations)); + } + o.insert("lineHeight".into(), json!(t.style.line_height.label())); + o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); + o.insert("textAlign".into(), json!(t.align_h)); + o.insert("textAutoResize".into(), json!(t.auto_resize)); + if t.max_lines > 0 { + o.insert("maxLines".into(), json!(t.max_lines)); + } + if let Some(tp) = &t.path { + o.insert("textPath".into(), json!(tp)); + } + if t.style.decoration != TextDecoration::None { + o.insert("textDecoration".into(), json!(t.style.decoration)); + } + if let Some(l) = t.style.decoration_look.as_deref() { + o.insert("textDecorationStyle".into(), json!(l.style)); + o.insert("textDecorationThickness".into(), json!(l.thickness.label())); + o.insert("textDecorationOffset".into(), json!(l.offset.label())); + o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); + o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); + } + // Lists: one style for the layer, or each paragraph's type, nesting level and start. + if t.paragraphs.is_empty() { + if t.list != figcraft_doc::ListStyle::None { + o.insert("listStyle".into(), json!(t.list)); + } + } else { + o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); + o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } + } + if t.list_spacing != 0.0 { + o.insert("listSpacing".into(), json!(t.list_spacing)); + } + for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { + if on { + o.insert(k.into(), json!(true)); + } + } + if t.leading_trim != figcraft_doc::LeadingTrim::None { + o.insert("leadingTrim".into(), json!(t.leading_trim)); + } + if !t.runs.is_empty() { + o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); + } + // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). + let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); + if !linked.is_empty() { + o.insert("textStyleRanges".into(), Value::Array(linked)); + } +} + +fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + match &n.kind { NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => { o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); } @@ -276,14 +308,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } _ => {} } - if !n.children.is_empty() { - if depth > 0 { - o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); - } else { - o.insert("childCount".into(), json!(n.children.len())); - } - } - v } fn round(v: f64) -> f64 { diff --git a/crates/engine/src/content/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs index c3beacd5..bb62871c 100644 --- a/crates/engine/src/content/synthetic_ds.rs +++ b/crates/engine/src/content/synthetic_ds.rs @@ -11,7 +11,10 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, Collection, CollectionId, CrossAlign, DocError, DocResult, Document, LayoutMode, Mode, ModeId, Node, NodeId, NodeKind, PropDef, + Sizing, Style, StyleKind, TextAutoResize, TextData, TextStyle, Variable, VariableType, VariableValue, +}; use serde_json::Value; use crate::components::{create_instance, sync_instance}; diff --git a/crates/engine/src/content/templates/dashboard.rs b/crates/engine/src/content/templates/dashboard.rs index 23ed506a..099b37bf 100644 --- a/crates/engine/src/content/templates/dashboard.rs +++ b/crates/engine/src/content/templates/dashboard.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, RIGHT_EDGE, SOFT, V, merged}; use crate::Result; const NAV: [(&str, Glyph); 6] = [ diff --git a/crates/engine/src/content/templates/design_system.rs b/crates/engine/src/content/templates/design_system.rs index 6f47712c..f56f5356 100644 --- a/crates/engine/src/content/templates/design_system.rs +++ b/crates/engine/src/content/templates/design_system.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; -use super::kit::*; +use super::kit::{H, Kit, TYPE, V, merged}; use crate::Result; const THEME: &str = "Theme"; diff --git a/crates/engine/src/content/templates/icon_set.rs b/crates/engine/src/content/templates/icon_set.rs index 2d6671d4..caf390d5 100644 --- a/crates/engine/src/content/templates/icon_set.rs +++ b/crates/engine/src/content/templates/icon_set.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs; -use super::kit::*; +use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, PAGE, V, merged}; use crate::Result; const STROKE: &str = "Icon/Stroke"; diff --git a/crates/engine/src/content/templates/landing.rs b/crates/engine/src/content/templates/landing.rs index 7edaef03..a3b7c85f 100644 --- a/crates/engine/src/content/templates/landing.rs +++ b/crates/engine/src/content/templates/landing.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SOFT, SUNKEN, TOP_EDGE, V, merged, shadow}; use crate::Result; /// The widest the content gets. diff --git a/crates/engine/src/content/templates/mobile.rs b/crates/engine/src/content/templates/mobile.rs index 863d3725..77e6ba6d 100644 --- a/crates/engine/src/content/templates/mobile.rs +++ b/crates/engine/src/content/templates/mobile.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SUNKEN, TOP_EDGE, V, shadow}; use crate::Result; const W: f64 = 390.0; diff --git a/crates/engine/src/content/templates/slides.rs b/crates/engine/src/content/templates/slides.rs index 322b3407..54fd376c 100644 --- a/crates/engine/src/content/templates/slides.rs +++ b/crates/engine/src/content/templates/slides.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs; -use super::kit::*; +use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, DEEP, H, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, TOP_EDGE, V, merged}; use crate::Result; const W: f64 = 1920.0; diff --git a/crates/engine/src/content/templates/social.rs b/crates/engine/src/content/templates/social.rs index 61525260..73b82735 100644 --- a/crates/engine/src/content/templates/social.rs +++ b/crates/engine/src/content/templates/social.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs; -use super::kit::*; +use super::kit::{ACCENT, DEEP, H, INK, Kit, ON_BRAND, V}; use crate::Result; /// (name, width, height, x, y) on the canvas. diff --git a/crates/engine/src/content/templates/wireframe.rs b/crates/engine/src/content/templates/wireframe.rs index 68426c07..1eb13893 100644 --- a/crates/engine/src/content/templates/wireframe.rs +++ b/crates/engine/src/content/templates/wireframe.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs; -use super::kit::*; +use super::kit::{BOTTOM_EDGE, H, Kit, TOP_EDGE, TYPE, V, merged}; use crate::Result; const WIRE_INK: &str = "Wire/Ink"; diff --git a/crates/engine/src/dev/annotations.rs b/crates/engine/src/dev/annotations.rs index e6937d0e..0b27d03e 100644 --- a/crates/engine/src/dev/annotations.rs +++ b/crates/engine/src/dev/annotations.rs @@ -7,12 +7,12 @@ use std::sync::LazyLock; -use figcraft_doc::*; +use figcraft_doc::{Annotation, AnnotationCategory, Document, NodeId}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::devinspect::{Prop, Section, properties}; -use crate::params::*; +use crate::params::{color_of, get, id, req_index, req_string, string, targets}; use crate::{bad, spec}; /// Most annotations on one layer. diff --git a/crates/engine/src/dev/code_connect.rs b/crates/engine/src/dev/code_connect.rs index 271193e0..8524c25a 100644 --- a/crates/engine/src/dev/code_connect.rs +++ b/crates/engine/src/dev/code_connect.rs @@ -10,14 +10,14 @@ //! keeps JSX's braces: `size={size}` → `size="Large"`, `{label}` → `{"Buy"}`), and //! `{prop ? "a" : "b"}` picks one of two strings. Any other `{…}` stays as written. -use figcraft_doc::*; +use figcraft_doc::{CodeLink, Document, NodeId, NodeKind}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::codegen::{CodeLang, Ctx}; use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr}; use crate::components::{slots_of, variant_set}; -use crate::params::*; +use crate::params::{get, id_of, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// Longest template (chars). diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 093a8fd9..37dae893 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -6,12 +6,15 @@ //! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; -use figcraft_doc::*; +use figcraft_doc::{ + ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Shadow, Sizing, + TextAlignH, TextCase, TextDecoration, Variable, +}; use serde_json::json; use crate::cmd::CommandSpec; use crate::component_codegen::{Component, Param}; -use crate::params::*; +use crate::params::{get, id_of, num_or, string, targets}; use crate::{bad, spec}; mod arkui; diff --git a/crates/engine/src/dev/codegen/arkui.rs b/crates/engine/src/dev/codegen/arkui.rs index 1d9816f6..6ce54ca9 100644 --- a/crates/engine/src/dev/codegen/arkui.rs +++ b/crates/engine/src/dev/codegen/arkui.rs @@ -1,5 +1,8 @@ use super::swift::one_line; -use super::*; +use super::{ + CodeLang, Color, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, TextAlignH, TextCase, TextDecoration, + drop_shadow, first_fill, indent, literal, num, radius, stroke_color, +}; // ------------------------------------------------------------------ ArkUI (ArkTS) @@ -37,8 +40,121 @@ pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra arkui_layer(ctx, n, depth, out, extra); } -fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { +fn text_layer(ctx: &Ctx, n: &Node, t: &figcraft_doc::TextData, pad: &str, out: &mut String, mods: &mut Vec) { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); + out.push_str(&format!("{pad}Text({text})\n")); + let s = &t.style; + mods.push(format!(".fontSize({})", num(s.font_size))); + mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); + mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); + if s.italic() { + mods.push(".fontStyle(FontStyle.Italic)".into()); + } + if let Some(c) = first_fill(n) { + mods.push(format!(".fontColor({})", ark_color(c))); + } + match s.line_height { + LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), + LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + _ => {} + } + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), + TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), + TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), + } + // Aligning needs a box to align in: the text layer's own width. + if !matches!(t.align_h, TextAlignH::Left) { + mods.push(format!(".width({})", num(n.width))); + } + match s.case { + TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), + TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), + _ => {} + } + match s.decoration { + TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), + TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), + TextDecoration::None => {} + } +} + +fn frame_layer(ctx: &Ctx, n: &Node, depth: usize, pad: &str, out: &mut String, mods: &mut Vec) { let doc = ctx.doc; + let open = match n.layout.as_ref().map(|l| (l.mode, l)) { + Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), + Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), + None => "Stack({ alignContent: Alignment.TopStart })".to_string(), + }; + out.push_str(&format!("{pad}{open} {{\n")); + match ctx.param(n, "slot") { + // A slot shows the instance's content, or else the main's (its default builder). + Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), + None => { + for c in ctx.kids(n) { + // Free children sit at their position inside a Stack, and so does a child + // that ignores its auto layout parent. + let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { + Some(cn) => { + let [_, _, _, _, x, y] = cn.transform.as_coeffs(); + vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] + } + None => Vec::new(), + }; + arkui(ctx, c, depth + 1, out, &pos); + } + } + } + out.push_str(&format!("{pad}}}\n")); + if let Some(l) = &n.layout { + layout_mods(l, mods); + } + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + if radius(n) > 0.0 { + mods.push(".clip(true)".into()); + } + ark_radius(n, mods); +} + +fn layout_mods(l: &figcraft_doc::AutoLayout, mods: &mut Vec) { + let [t, r, b, lft] = l.padding; + if [t, r, b, lft].iter().any(|v| *v > 0.0) { + mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); + } + let justify = match l.main_align { + MainAlign::Min => None, + MainAlign::Center => Some("FlexAlign.Center"), + MainAlign::Max => Some("FlexAlign.End"), + MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), + MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), + MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), + }; + if let Some(j) = justify { + mods.push(format!(".justifyContent({j})")); + } + let cross = match l.cross_align { + CrossAlign::Min => None, + CrossAlign::Center => Some("Center"), + CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), + CrossAlign::Baseline => None, + }; + if let Some(c) = cross { + let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; + mods.push(format!(".alignItems({ty}.{c})")); + } +} + +fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { let pad = indent(depth); if ctx.too_deep(depth) { out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); @@ -68,48 +184,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str let mut mods: Vec = Vec::new(); match &n.kind { NodeKind::Text(t) => { - let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); - out.push_str(&format!("{pad}Text({text})\n")); - let s = &t.style; - mods.push(format!(".fontSize({})", num(s.font_size))); - mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); - mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); - if s.italic() { - mods.push(".fontStyle(FontStyle.Italic)".into()); - } - if let Some(c) = first_fill(n) { - mods.push(format!(".fontColor({})", ark_color(c))); - } - match s.line_height { - LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), - LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), - LineHeight::Auto => {} - } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), - LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), - _ => {} - } - match t.align_h { - TextAlignH::Left => {} - TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), - TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), - TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), - } - // Aligning needs a box to align in: the text layer's own width. - if !matches!(t.align_h, TextAlignH::Left) { - mods.push(format!(".width({})", num(n.width))); - } - match s.case { - TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), - TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), - _ => {} - } - match s.decoration { - TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), - TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), - TextDecoration::None => {} - } + text_layer(ctx, n, t, &pad, out, &mut mods); } NodeKind::Ellipse { .. } => { out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height))); @@ -131,67 +206,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str mods.push(format!(".height({})", num(n.height))); } k if k.is_frame_like() || matches!(k, NodeKind::Group) => { - let open = match n.layout.as_ref().map(|l| (l.mode, l)) { - Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), - Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), - None => "Stack({ alignContent: Alignment.TopStart })".to_string(), - }; - out.push_str(&format!("{pad}{open} {{\n")); - match ctx.param(n, "slot") { - // A slot shows the instance's content, or else the main's (its default builder). - Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), - None => { - for c in ctx.kids(n) { - // Free children sit at their position inside a Stack, and so does a child - // that ignores its auto layout parent. - let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { - Some(cn) => { - let [_, _, _, _, x, y] = cn.transform.as_coeffs(); - vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] - } - None => Vec::new(), - }; - arkui(ctx, c, depth + 1, out, &pos); - } - } - } - out.push_str(&format!("{pad}}}\n")); - if let Some(l) = &n.layout { - let [t, r, b, lft] = l.padding; - if [t, r, b, lft].iter().any(|v| *v > 0.0) { - mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); - } - let justify = match l.main_align { - MainAlign::Min => None, - MainAlign::Center => Some("FlexAlign.Center"), - MainAlign::Max => Some("FlexAlign.End"), - MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), - MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), - MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), - }; - if let Some(j) = justify { - mods.push(format!(".justifyContent({j})")); - } - let cross = match l.cross_align { - CrossAlign::Min => None, - CrossAlign::Center => Some("Center"), - CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), - CrossAlign::Baseline => None, - }; - if let Some(c) = cross { - let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; - mods.push(format!(".alignItems({ty}.{c})")); - } - } - mods.push(format!(".width({})", num(n.width))); - mods.push(format!(".height({})", num(n.height))); - if let Some(c) = first_fill(n) { - mods.push(format!(".backgroundColor({})", ark_color(c))); - } - if radius(n) > 0.0 { - mods.push(".clip(true)".into()); - } - ark_radius(n, &mut mods); + frame_layer(ctx, n, depth, &pad, out, &mut mods); } _ => { out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label())); diff --git a/crates/engine/src/dev/codegen/compose.rs b/crates/engine/src/dev/codegen/compose.rs index 5915ca9b..fd3ff01a 100644 --- a/crates/engine/src/dev/codegen/compose.rs +++ b/crates/engine/src/dev/codegen/compose.rs @@ -1,5 +1,5 @@ use super::swift::one_line; -use super::*; +use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color}; // ------------------------------------------------------------------ Jetpack Compose diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index c7982652..ef8fe43b 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PaintKind, TextAlignH, + TextCase, TextDecoration, Variable, css_borders, num, +}; // ------------------------------------------------------------------ CSS @@ -85,51 +88,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo put("height", tok(doc, n, &["height"], px(n.height), t)); } if let Some(l) = &n.layout { - put("display", "flex".into()); - put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); - if l.wrap { - put("flex-wrap", "wrap".into()); - } - put("gap", tok(doc, n, &["gap"], px(l.gap), t)); - let [pt, pr, pb, pl] = l.padding; - let sides = [ - tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), - tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), - tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), - tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), - ]; - put("padding", sides.join(" ")); - let justify = match l.main_align { - MainAlign::Min => "flex-start", - MainAlign::Center => "center", - MainAlign::Max => "flex-end", - MainAlign::SpaceBetween => "space-between", - MainAlign::SpaceAround => "space-around", - MainAlign::SpaceEvenly => "space-evenly", - }; - put("justify-content", justify.into()); - let align = match l.cross_align { - CrossAlign::Min => "flex-start", - CrossAlign::Center => "center", - CrossAlign::Max => "flex-end", - CrossAlign::Baseline => "baseline", - }; - put("align-items", align.into()); - if l.wrap { - // The lines: spread, or packed where the items align (baseline: at the start). - let spread = l.content_align == ContentAlign::SpaceBetween; - put( - "align-content", - if spread { - "space-between" - } else if align == "baseline" { - "flex-start" - } else { - align - } - .into(), - ); - } + layout_decls(doc, n, l, t, &mut put); } if matches!(n.kind, NodeKind::Ellipse { .. }) { put("border-radius", "50%".into()); @@ -169,6 +128,65 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo for (key, value) in css_borders(doc, n, t) { put(&key, value); } + effect_decls(n, &mut put); + if let NodeKind::Text(tx) = &n.kind { + text_decls(doc, n, tx, t, &mut put); + } + out +} + +/// The flex declarations of auto layout `l` on `n`. +fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut Tokens, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + put("display", "flex".into()); + put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); + if l.wrap { + put("flex-wrap", "wrap".into()); + } + put("gap", tok(doc, n, &["gap"], px(l.gap), t)); + let [pt, pr, pb, pl] = l.padding; + let sides = [ + tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), + tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), + tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), + tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), + ]; + put("padding", sides.join(" ")); + let justify = match l.main_align { + MainAlign::Min => "flex-start", + MainAlign::Center => "center", + MainAlign::Max => "flex-end", + MainAlign::SpaceBetween => "space-between", + MainAlign::SpaceAround => "space-around", + MainAlign::SpaceEvenly => "space-evenly", + }; + put("justify-content", justify.into()); + let align = match l.cross_align { + CrossAlign::Min => "flex-start", + CrossAlign::Center => "center", + CrossAlign::Max => "flex-end", + CrossAlign::Baseline => "baseline", + }; + put("align-items", align.into()); + if l.wrap { + // The lines: spread, or packed where the items align (baseline: at the start). + let spread = l.content_align == ContentAlign::SpaceBetween; + put( + "align-content", + if spread { + "space-between" + } else if align == "baseline" { + "flex-start" + } else { + align + } + .into(), + ); + } +} + +/// Shadows and blurs of `n`. +fn effect_decls(n: &Node, put: &mut impl FnMut(&str, String)) { let shadows: Vec = n .effects .iter() @@ -196,75 +214,82 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo _ => {} } } - if let NodeKind::Text(tx) = &n.kind { - let s = &tx.style; - put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); - put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); - put("font-weight", s.weight().to_string()); - if s.italic() { - put("font-style", "italic".into()); +} + +/// The type declarations of text layer `n`. +fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tokens, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + let s = &tx.style; + put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); + put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); + put("font-weight", s.weight().to_string()); + if s.italic() { + put("font-style", "italic".into()); + } + match s.line_height { + LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), + LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), + LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), + _ => {} + } + match tx.align_h { + TextAlignH::Left => {} + TextAlignH::Center => put("text-align", "center".into()), + TextAlignH::Right => put("text-align", "right".into()), + TextAlignH::Justified => put("text-align", "justify".into()), + } + match s.case { + TextCase::Original => {} + TextCase::Upper => put("text-transform", "uppercase".into()), + TextCase::Lower => put("text-transform", "lowercase".into()), + TextCase::Title => put("text-transform", "capitalize".into()), + TextCase::SmallCaps => put("font-variant", "small-caps".into()), + } + match s.decoration { + TextDecoration::None => {} + TextDecoration::Underline => put("text-decoration", "underline".into()), + TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), + } + decoration_decls(s, put); + if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + put("text-box", "trim-both cap alphabetic".into()); + } +} + +fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + if s.decoration != TextDecoration::None { + let l = s.look(); + let len = |m: LineHeight| match m { + LineHeight::Auto => None, + LineHeight::Pixels(v) => Some(px(v)), + LineHeight::Percent(p) => Some(format!("{}%", num(p))), + }; + match l.style { + figcraft_doc::DecorationStyle::Solid => {} + figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), + figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), } - match s.line_height { - LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), - LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), - LineHeight::Auto => {} + if let Some(v) = len(l.thickness) { + put("text-decoration-thickness", v); } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), - LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), - _ => {} + if let Some(c) = l.color { + put("text-decoration-color", c.to_css()); } - match tx.align_h { - TextAlignH::Left => {} - TextAlignH::Center => put("text-align", "center".into()), - TextAlignH::Right => put("text-align", "right".into()), - TextAlignH::Justified => put("text-align", "justify".into()), - } - match s.case { - TextCase::Original => {} - TextCase::Upper => put("text-transform", "uppercase".into()), - TextCase::Lower => put("text-transform", "lowercase".into()), - TextCase::Title => put("text-transform", "capitalize".into()), - TextCase::SmallCaps => put("font-variant", "small-caps".into()), - } - match s.decoration { - TextDecoration::None => {} - TextDecoration::Underline => put("text-decoration", "underline".into()), - TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), - } - if s.decoration != TextDecoration::None { - let l = s.look(); - let len = |m: LineHeight| match m { - LineHeight::Auto => None, - LineHeight::Pixels(v) => Some(px(v)), - LineHeight::Percent(p) => Some(format!("{}%", num(p))), - }; - match l.style { - figcraft_doc::DecorationStyle::Solid => {} - figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), - figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), + if s.decoration == TextDecoration::Underline { + if let Some(v) = len(l.offset) { + put("text-underline-offset", v); } - if let Some(v) = len(l.thickness) { - put("text-decoration-thickness", v); + // Browsers skip ink by default; the canvas only when asked. + if !l.skip_ink { + put("text-decoration-skip-ink", "none".into()); } - if let Some(c) = l.color { - put("text-decoration-color", c.to_css()); - } - if s.decoration == TextDecoration::Underline { - if let Some(v) = len(l.offset) { - put("text-underline-offset", v); - } - // Browsers skip ink by default; the canvas only when asked. - if !l.skip_ink { - put("text-decoration-skip-ink", "none".into()); - } - } - } - if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { - put("text-box", "trim-both cap alphabetic".into()); } } - out } /// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs index 3ae746f1..d1b144ca 100644 --- a/crates/engine/src/dev/codegen/html.rs +++ b/crates/engine/src/dev/codegen/html.rs @@ -1,5 +1,5 @@ use super::css::{Tokens, css_decls}; -use super::*; +use super::{CodeLang, Ctx, Document, LayoutMode, Node, NodeId, NodeKind, Param, Sizing, indent, num, with_v_if}; // ------------------------------------------------------------------ HTML and React markup @@ -127,6 +127,32 @@ pub(crate) struct Markup<'c, 'a> { pub fluid_leaves: f64, } +/// How `n` sits in its parent: in the flex flow, or placed at its position. +fn placement_decls(n: &Node, parent: Option<&Node>, root: bool, in_flow: bool) -> String { + let mut decls = String::new(); + if root { + decls.push_str("position: relative;\n"); + } else if in_flow { + let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); + let (main, cross) = + if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; + if main == Sizing::Fill { + decls.push_str("flex: 1 1 0;\n"); + } + if cross == Sizing::Fill { + decls.push_str("align-self: stretch;\n"); + } + } else { + let c = n.transform.as_coeffs(); + decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 { + decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); + } + } + decls +} + impl<'c, 'a> Markup<'c, 'a> { pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { Markup { @@ -196,6 +222,52 @@ impl<'c, 'a> Markup<'c, 'a> { self.rules.push((path.to_string(), class.to_string(), decls)); } + /// An instance as a call to its component (see `element`); true when it was written. + fn instance_call(&mut self, n: &Node, depth: usize, path: &str, class: &str, decls: &str, root: bool) -> bool { + let pad = self.pad(depth); + let attr = if self.lang == CodeLang::React { "className" } else { "class" }; + let connected = self.inline && crate::code_connect::link(self.ctx.doc, n.id, CodeLang::React).is_some(); + if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) { + let at = depth + usize::from(!decls.is_empty()); + let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" }; + let (inner, css) = match self.ctx.param(n, "mainComponent") { + Some(p) if self.lang == CodeLang::Vue => (Some(format!("{}\n", self.pad(at), self.ident(p))), String::new()), + Some(p) => (Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), String::new()), + None => self.ctx.call(n, self.lang, at).map_or((None, String::new()), |(code, css)| (Some(code), css)), + }; + self.styles.push_str(&css); + if let Some(inner) = inner { + // A Vue guard set by `node` rides on the call when there is no wrapper. + let guard = if self.lang == CodeLang::Vue { self.v_if.take() } else { None }; + if decls.is_empty() { + self.markup.push_str(&with_v_if(&inner, guard.as_deref())); + } else { + let guard = guard.map(|g| format!(" v-if=\"{g}\"")).unwrap_or_default(); + self.markup.push_str(&format!("{pad}
\n{inner}{pad}
\n")); + self.rule(path, class, decls.to_string()); + } + return true; + } + } + false + } + + fn text_body(&self, n: &Node, t: &figcraft_doc::TextData) -> String { + match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) { + (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)), + (Some(p), CodeLang::Vue) => format!("{{{{ {} }}}}", self.ident(p)), + (Some(p), _) => format!("{{{}}}", p.ident), + (None, CodeLang::React) => jsx_text(&t.characters), + // `view!` takes text as string literals and void elements self-closed. + (None, CodeLang::Leptos) => { + t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join("
") + } + // A Vue template reads `{{ }}` as interpolation, so braces are escaped. + (None, CodeLang::Vue) => html_escape(&t.characters).replace('{', "{").replace('}', "}"), + (None, _) => html_escape(&t.characters), + } + } + fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) { let doc = self.ctx.doc; let root = n.id == self.ctx.root; @@ -215,53 +287,13 @@ impl<'c, 'a> Markup<'c, 'a> { let pad = self.pad(depth); let attr = if self.lang == CodeLang::React { "className" } else { "class" }; // How it sits in its parent: in the flex flow, or placed at its position. - let mut decls = String::new(); let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; - if root { - decls.push_str("position: relative;\n"); - } else if in_flow { - let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); - let (main, cross) = - if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; - if main == Sizing::Fill { - decls.push_str("flex: 1 1 0;\n"); - } - if cross == Sizing::Fill { - decls.push_str("align-self: stretch;\n"); - } - } else { - let c = n.transform.as_coeffs(); - decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 0.01 { - decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); - } - } + let mut decls = placement_decls(n, parent, root, in_flow); // Nested instances are calls (component code), in a wrapper that places them when needed. // Page code inlines instances, except those connected to code (codeConnect.set): their // snippet, e.g. a design-system `