From ee2640d18e1ac307b05e829cb332509124dc1735 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Thu, 15 Oct 2026 10:34:20 +0200 Subject: [PATCH 01/77] Swedish interface translation (sv) Adds Swedish (Svenska) as an interface language: 2165 strings, written from the English source. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/locales/sv.toml | 2181 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 17 +- docs/localization-parity.md | 6 +- docs/mcp.md | 2 +- docs/translating.md | 3 +- 6 files changed, 2204 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/locales/sv.toml diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..c577a97d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`, 100%, native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml new file mode 100644 index 00000000..d29e25c3 --- /dev/null +++ b/crates/ui-egui/locales/sv.toml @@ -0,0 +1,2181 @@ +# FigCraft interface strings: Swedish. +# +# Written from the English catalog with ordinary Swedish software vocabulary; no other catalog and no +# proprietary localisation was translated from. Brand names and keyboard glyphs stay as they are. + +[meta] +name = "Svenska" + +[ui] + +"% of all commits" = "% av alla commits" +"(no longer published)" = "(publiceras inte längre)" +"({n} warnings: some entries were skipped)" = { one = "({n} varning: vissa poster hoppades över)", other = "({n} varningar: vissa poster hoppades över)" } +"+ Create variable" = "+ Skapa variabel" +".fig (experimental)" = ".fig (experimentell)" +"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} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler" +"A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" +"A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild" +"A to Z" = "A till Ö" +"About" = "Om" +"About FigCraft" = "Om FigCraft" +"About this server" = "Om den här servern" +"Action" = "Åtgärd" +"Actions" = "Åtgärder" +"Actions for {name}" = "Åtgärder för {name}" +"Actions…" = "Åtgärder…" +"Active" = "Aktiv" +"Actual size (100%)" = "Verklig storlek (100 %)" +"Add a category" = "Lägg till en kategori" +"Add a comment" = "Lägg till en kommentar" +"Add a description" = "Lägg till en beskrivning" +"Add a frame (F) or a shape (R) to get started" = "Lägg till en ram (F) eller en form (R) för att komma igång" +"Add a layer or component to tile first" = "Lägg först till ett lager eller en komponent att lägga sida vid sida" +"Add a note for developers" = "Lägg till en anteckning för utvecklare" +"Add action" = "Lägg till åtgärd" +"Add actions for when no condition holds" = "Lägg till åtgärder för när inget villkor uppfylls" +"Add an annotation for developers" = "Lägg till en annotering för utvecklare" +"Add another condition" = "Lägg till ett villkor till" +"Add auto layout" = "Lägg till automatisk layout" +"Add background behind overlay" = "Lägg till bakgrund bakom överlägg" +"Add content to this slot" = "Lägg till innehåll i facket" +"Add dev resource" = "Lägg till utvecklarresurs" +"Add effect" = "Lägg till effekt" +"Add export setting" = "Lägg till exportinställning" +"Add fill" = "Lägg till fyllning" +"Add interaction" = "Lägg till interaktion" +"Add layout grid" = "Lägg till layoutrutnät" +"Add library" = "Lägg till bibliotek" +"Add library file…" = "Lägg till biblioteksfil…" +"Add max height" = "Lägg till maxhöjd" +"Add max width" = "Lägg till maxbredd" +"Add min height" = "Lägg till minhöjd" +"Add min width" = "Lägg till minbredd" +"Add mode" = "Lägg till läge" +"Add new page" = "Lägg till ny sida" +"Add or update collections from a variables JSON" = "Lägg till eller uppdatera samlingar från en variabel-JSON" +"Add properties to make instances configurable" = "Lägg till egenskaper för att göra instanser konfigurerbara" +"Add reaction" = "Lägg till reaktion" +"Add starting point" = "Lägg till startpunkt" +"Add stroke" = "Lägg till kontur" +"Add variant" = "Lägg till variant" +"Add variant with a new value" = "Lägg till variant med ett nytt värde" +"Added" = "Tillagd" +"Added the library \"{name}\"" = "Biblioteket ”{name}” har lagts till" +"Adjust" = "Justera" +"Adjustment presets" = "Justeringsförinställningar" +"Advanced" = "Avancerat" +"Advanced stroke" = "Avancerad kontur" +"After delay" = "Efter fördröjning" +"After delay {ms}ms" = "Efter fördröjning på {ms} ms" +"Align" = "Justera" +"Align bottom" = "Justera nederkant" +"Align center" = "Centrera" +"Align horizontal centers" = "Justera horisontella mittpunkter" +"Align left" = "Justera vänster" +"Align middle" = "Justera i mitten" +"Align right" = "Justera höger" +"Align text baselines" = "Justera textens baslinjer" +"Align top" = "Justera överkant" +"Align vertical centers" = "Justera vertikala mittpunkter" +"Alignment" = "Justering" +"All" = "Alla" +"All categories" = "Alla kategorier" +"All comments" = "Alla kommentarer" +"All components" = "Alla komponenter" +"All fonts" = "Alla typsnitt" +"All supported properties" = "Alla egenskaper som stöds" +"All variables" = "Alla variabler" +"Alpha" = "Alfa" +"Alphabetical" = "I bokstavsordning" +"Alt+drag" = "Alt+dra" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "En analysskärm på ett rutnät: figurer, diagram ritade med vektorer och en tabell" +"An invite link that opens FigCraft and joins" = "En inbjudningslänk som öppnar FigCraft och ansluter" +"Anchor" = "Ankare" +"Android" = "Android" +"Angular" = "Vinkelmått" +"Animation" = "Animering" +"Annotations" = "Annoteringar" +"Annotations shown on the canvas" = "Annoteringar som visas på arbetsytan" +"Another server, or one that needs its key. The usual server is set in Preferences." = "En annan server, eller en som kräver sin nyckel. Standardservern anges i Inställningar." +"Any status" = "Valfri status" +"Anyone on your network with this code can edit the file, while this app stays open." = "Alla i ditt nätverk som har den här koden kan redigera filen så länge appen är öppen." +"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." = "Alla med redigeringslänken kan ändra filen; visningslänken låter bara andra titta. Länkarna innehåller sina hemligheter: dela dem som lösenord. `figcraft-server links` skapar fler och återkallar dem." +"Anyone with this code can edit the file." = "Alla med den här koden kan redigera filen." +"Anyone with this code can look at the file, not change it." = "Alla med den här koden kan titta på filen, men inte ändra den." +"Appearance" = "Utseende" +"Apply a component property" = "Använd en komponentegenskap" +"Apply variable" = "Använd variabel" +"Apply variable to text" = "Använd variabel på text" +"Apply variable to visibility" = "Använd variabel för synlighet" +"Apps" = "Appar" +"Apr" = "apr" +"Arc" = "Båge" +"Arc ratio" = "Bågkvot" +"Arc start" = "Bågstart" +"Arc sweep" = "Bågsvep" +"Archive" = "Arkivera" +"Archived" = "Arkiverad" +"Arrange" = "Ordna" +"Arrow" = "Pil" +"As typed" = "Som inskrivet" +"Asking the server…" = "Frågar servern…" +"Assets" = "Tillgångar" +"Aug" = "aug" +"Auto" = "Auto" +"Auto (space around)" = "Auto (utrymme runt om)" +"Auto (space between)" = "Auto (utrymme mellan)" +"Auto (space evenly)" = "Auto (jämnt utrymme)" +"Auto ({mode})" = "Auto ({mode})" +"Auto columns" = "Automatiska kolumner" +"Auto gap" = "Automatiskt mellanrum" +"Auto height" = "Automatisk höjd" +"Auto layout" = "Automatisk layout" +"Auto rows" = "Automatiska rader" +"Auto width" = "Automatisk bredd" +"Back to the FigCraft server" = "Tillbaka till FigCraft-servern" +"Back to the font's value" = "Tillbaka till teckensnittets värde" +"Back" = "Tillbaka" +"Back to Ready for dev" = "Tillbaka till Redo för utveckling" +"Back to Ready for dev: the next change compares with how it is now" = "Tillbaka till Redo för utveckling: nästa ändring jämförs med hur det ser ut nu" +"Back to assets" = "Tillbaka till tillgångar" +"Back to current" = "Tillbaka till aktuell" +"Back to styles" = "Tillbaka till formatmallar" +"Back to the canvas from a focused region" = "Tillbaka till arbetsytan från en fokuserad region" +"Back to the current version" = "Tillbaka till den aktuella versionen" +"Back to the values the font style implies" = "Tillbaka till värdena som teckenstilen anger" +"Background" = "Bakgrund" +"Background blur" = "Bakgrundsoskärpa" +"Basics" = "Grunder" +"Before" = "Före" +"Bend" = "Böj" +"Bevel" = "Fasning" +"Binary assets added" = "Binära resurser tillagda" +"Binary assets removed" = "Binära resurser borttagna" +"Black & white" = "Svartvitt" +"Bleed" = "Utfall" +"Bleed marks" = "Utfallsmärken" +"Bleed marks need a bleed" = "Utfallsmärken kräver utfall" +"Blend" = "Blanda" +"Blend mode" = "Blandningsläge" +"Blue" = "Blå" +"Blur" = "Oskärpa" +"Boolean" = "Boolesk" +"Boolean groups" = "Booleska grupper" +"Both directions" = "Båda riktningarna" +"Bottom" = "Nederkant" +"Bottom left radius" = "Radie nere till vänster" +"Bottom right radius" = "Radie nere till höger" +"Branch" = "Gren" +"Branch archived" = "Grenen har arkiverats" +"Branch merged" = "Grenen har sammanfogats" +"Branch name" = "Grennamn" +"Branch of {file}" = "Gren av {file}" +"Branch of {file} · merged or archived" = "Gren av {file} · sammanfogad eller arkiverad" +"Branch updated from main" = "Grenen har uppdaterats från main" +"Branch {name}" = "Gren {name}" +"Branches" = "Grenar" +"Bound to {name}. Click to detach the variable." = "Bunden till {name}. Klicka för att koppla bort variabeln." +"Bring forward" = "Flytta framåt" +"Bring the main file's changes into the branch" = "Hämta huvudfilens ändringar till grenen" +"Bring to front" = "Flytta längst fram" +"Bristle" = "Borst" +"Browse" = "Bläddra" +"Brush" = "Pensel" +"Brush rotation jitter" = "Variation i penselrotation" +"Brush scatter" = "Penselspridning" +"Brush size jitter" = "Variation i penselstorlek" +"Brush spacing" = "Penselavstånd" +"Bulleted list" = "Punktlista" +"C or Shift+C" = "C eller Shift+C" +"C or ⇧C" = "C eller ⇧C" +"CMYK colour" = "CMYK-färg" +"Can view" = "Kan visa" +"Checking the server…" = "Kontrollerar servern…" +"Clear from this list" = "Ta bort från listan" +"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" +"CSS custom properties…" = "Anpassade CSS-egenskaper…" +"Can edit" = "Kan redigera" +"Can't read this library: {e}" = "Det går inte att läsa det här biblioteket: {e}" +"Cancel" = "Avbryt" +"Canvas" = "Arbetsyta" +"Canvas stacking" = "Stapling på arbetsytan" +"Cap height to baseline" = "Versalhöjd till baslinje" +"Case" = "Skiftläge" +"Case-sensitive forms" = "Skiftlägeskänsliga former" +"Categories" = "Kategorier" +"Center" = "Center" +"Change alias" = "Ändra alias" +"Change resolved" = "Ändringen har lösts" +"Change style" = "Byt formatmall" +"Change to" = "Ändra till" +"Change to needs a layer inside a variant" = "Ändra till kräver ett lager inuti en variant" +"Changed" = "Ändrad" +"Changed on both sides: keep one" = "Ändrad på båda sidor: behåll en" +"Changes on main" = "Ändringar i main" +"Changes on the branch" = "Ändringar i grenen" +"Changes unknown" = "Okända ändringar" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Välj React, Leptos, SwiftUI eller Compose ovan för att koppla den här komponenten till dess kod." +"Choose another profile to change it." = "Välj en annan profil för att ändra den." +"Choose collection" = "Välj samling" +"Choose image" = "Välj bild" +"Choose mode" = "Välj läge" +"Choose variable" = "Välj variabel" +"Circle" = "Cirkel" +"Clear recent" = "Rensa senaste" +"Click + to replace mixed content" = "Klicka på + för att ersätta blandat innehåll" +"Click anywhere on the canvas to leave one." = "Klicka var som helst på arbetsytan för att lämna en." +"Click or drag to merge · {alt} removes" = "Klicka eller dra för att sammanfoga · {alt} tar bort" +"Click or drag to remove · {alt} merges" = "Klicka eller dra för att ta bort · {alt} sammanfogar" +"Click to record a key" = "Klicka för att spela in en tangent" +"Clip content" = "Beskär innehåll" +"Clip to shape" = "Beskär till form" +"Close" = "Stäng" +"Close overlay" = "Stäng överlägg" +"Close preview" = "Stäng förhandsgranskning" +"Close tab" = "Stäng flik" +"Close version history" = "Stäng versionshistorik" +"Close when clicking outside" = "Stäng vid klick utanför" +"Close without saving" = "Stäng utan att spara" +"Code" = "Kod" +"Code syntax" = "Kodsyntax" +"Collapse" = "Komprimera" +"Collapse panel" = "Komprimera panel" +"Collection" = "Samling" +"Collections" = "Samlingar" +"Color" = "Färg" +"Color burn" = "Färgefterbelysning" +"Color dodge" = "Färgskuggning" +"Color format" = "Färgformat" +"Color profile" = "Färgprofil" +"Color styles" = "Färgformatmallar" +"Color values in this file are in this color space." = "Färgvärden i den här filen anges i den här färgrymden." +"Color variables" = "Färgvariabler" +"Colour" = "Färg" +"Column" = "Kolumn" +"Column gap" = "Kolumnmellanrum" +"Column sizes" = "Kolumnstorlekar" +"Column span" = "Kolumnspann" +"Columns" = "Kolumner" +"Columns {count}" = "Kolumner {count}" +"Comment" = "Kommentar" +"Comments" = "Kommentarer" +"Commits" = "Commits" +"Company" = "Företag" +"Compare changes" = "Jämför ändringar" +"Completed" = "Slutförd" +"Component" = "Komponent" +"Component name" = "Komponentnamn" +"Component property" = "Komponentegenskap" +"Component set" = "Komponentuppsättning" +"Component sets" = "Komponentuppsättningar" +"Components" = "Komponenter" +"Conditional" = "Villkorlig" +"Confetti" = "Konfetti" +"Conflicts" = "Konflikter" +"Connect to code" = "Koppla till kod" +"Connected" = "Kopplad" +"Connected to code: {from}" = "Kopplad till kod: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Anslutningen bröts, återansluter (försök {attempt})…" +"Constrain proportions" = "Behåll proportioner" +"Constraints" = "Begränsningar" +"Content" = "Innehåll" +"Contents hidden" = "Innehållet är dolt" +"Contents only" = "Endast innehåll" +"Contextual alternates" = "Kontextuella alternativ" +"Contrast" = "Kontrast" +"Contributors" = "Bidragsgivare" +"Contributors: {people} · Commits: {commits}" = "Bidragsgivare: {people} · Commits: {commits}" +"Controller" = "Handkontroll" +"Cool" = "Kall" +"Copied as CSS" = "Kopierat som CSS" +"Copied as PNG" = "Kopierat som PNG" +"Copied as SVG" = "Kopierat som SVG" +"Copied as text" = "Kopierat som text" +"Copied {value}" = "Kopierade {value}" +"Copy" = "Kopiera" +"Copy as" = "Kopiera som" +"Copy as CSS" = "Kopiera som CSS" +"Copy as PNG" = "Kopiera som PNG" +"Copy as SVG" = "Kopiera som SVG" +"Copy code" = "Kopiera kod" +"Copy link" = "Kopiera länk" +"Copy the {label}" = "Kopiera {label}" +"Copy {value}" = "Kopiera {value}" +"Corner radius" = "Hörnradie" +"Corner smoothing" = "Hörnutjämning" +"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Count" = "Antal" +"Create" = "Skapa" +"Create a variable collection first" = "Skapa en variabelsamling först" +"Create a variable first" = "Skapa en variabel först" +"Create alias" = "Skapa alias" +"Create branch" = "Skapa gren" +"Create collection" = "Skapa samling" +"Create component" = "Skapa komponent" +"Create component property" = "Skapa komponentegenskap" +"Create components, styles or variables to publish them as a library." = "Skapa komponenter, stilar eller variabler för att publicera dem som ett bibliotek." +"Create property…" = "Skapa egenskap…" +"Create style" = "Skapa stil" +"Create style from the selected text" = "Skapa stil från den markerade texten" +"Created" = "Skapad" +"Crop" = "Beskär" +"Crop marks" = "Skärmärken" +"Ctrl+arrows" = "Ctrl+piltangenter" +"Ctrl+click" = "Ctrl+klick" +"Ctrl+scroll" = "Ctrl+rullning" +"Current name" = "Aktuellt namn" +"Current version" = "Aktuell version" +"Cursor chat" = "Markörchatt" +"Custom" = "Anpassad" +"Cut" = "Klipp ut" +"Dark" = "Mörk" +"Darken" = "Mörkare" +"Dash" = "Streck" +"Dashboard" = "Instrumentpanel" +"Date created" = "Skapandedatum" +"Dec" = "Dec" +"Decoration style" = "Dekorationsstil" +"Decoration" = "Dekoration" +"Deep select" = "Djupmarkering" +"Default" = "Standard" +"Default content" = "Standardinnehåll" +"Default value (the top-left variant)" = "Standardvärde (varianten längst upp till vänster)" +"Delay" = "Fördröjning" +"Delete" = "Radera" +"Delete category (its annotations stay)" = "Radera kategori (dess anteckningar finns kvar)" +"Delete collection" = "Radera samling" +"Delete group" = "Radera grupp" +"Delete mode" = "Radera läge" +"Delete page" = "Radera sida" +"Delete preset" = "Radera förinställning" +"Delete property" = "Radera egenskap" +"Delete the style (layers keep their values)" = "Radera stilen (lagren behåller sina värden)" +"Delete thread" = "Radera tråd" +"Delete variable" = "Radera variabel" +"Delete “{name}”? This can't be undone." = "Radera ”{name}”? Det går inte att ångra." +"Density" = "Täthet" +"Depth" = "Djup" +"Describe how and when to use this component" = "Beskriv hur och när den här komponenten ska användas" +"Describe what changed" = "Beskriv vad som har ändrats" +"Description" = "Beskrivning" +"Description (optional)" = "Beskrivning (valfritt)" +"Design" = "Design" +"Design system starter" = "Startmall för designsystem" +"Desktop" = "Skrivbord" +"Destination" = "Mål" +"Detach alias" = "Koppla bort alias" +"Detach instance" = "Koppla bort instans" +"Detach property" = "Koppla bort egenskap" +"Detach style" = "Koppla bort stil" +"Detach styles" = "Koppla bort stilar" +"Detach variable" = "Koppla bort variabel" +"Details" = "Detaljer" +"Dev Mode" = "Utvecklarläge" +"Dev resources" = "Utvecklarresurser" +"Development" = "Utveckling" +"Device" = "Enhet" +"Device at 100%" = "Enhet i 100 %" +"Diamond" = "Romb" +"Difference" = "Differens" +"Dimensions" = "Dimensioner" +"Direction" = "Riktning" +"Disconnect from code" = "Koppla från koden" +"Dispersion" = "Spridning" +"Display name" = "Visningsnamn" +"Dissolve" = "Övertoning" +"Distribute and tidy" = "Fördela och städa upp" +"Distribute horizontal spacing" = "Fördela horisontellt avstånd" +"Distribute vertical spacing" = "Fördela vertikalt avstånd" +"Divider" = "Avdelare" +"Don't truncate" = "Korta inte av" +"Done" = "Klart" +"Done editing" = "Klar med redigeringen" +"Dots" = "Punkter" +"Dotted" = "Prickad" +"Down" = "Ned" +"Drafts" = "Utkast" +"Drag the stroke to add a width point" = "Dra i konturen för att lägga till en breddpunkt" +"Draw" = "Rita" +"Draw a frame" = "Rita en ram" +"Drawing tools" = "Ritverktyg" +"Drop shadow" = "Släppskugga" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dubbellicensierad under MIT OR Apache-2.0. Skapad av ArtCraft-teamet och gemenskapen." +"Duo" = "Duo" +"Duplicate" = "Duplicera" +"Duplicate by dragging" = "Duplicera genom att dra" +"Duplicate mode" = "Duplicera läge" +"Duplicate page" = "Duplicera sida" +"Edit" = "Redigera" +"Edit as an expression" = "Redigera som uttryck" +"Edit colour" = "Redigera färg" +"Edit comment" = "Redigera kommentar" +"Edit dev resource" = "Redigera utvecklarresurs" +"Edit link" = "Redigeringslänk" +"Edit link copied" = "Redigeringslänken har kopierats" +"Edit style" = "Redigera stil" +"Edit variable" = "Redigera variabel" +"Edit with the builder" = "Redigera med byggaren" +"Edited {when}" = "Ändrat {when}" +"Effect settings" = "Effektinställningar" +"Effect styles" = "Effektstilar" +"Effects" = "Effekter" +"Ellipse" = "Ellips" +"Ellipses" = "Ellipser" +"Else" = "Annars" +"Else if" = "Annars om" +"Embedded profile" = "Inbäddad profil" +"Empty" = "Tom" +"End" = "Slut" +"End point" = "Slutpunkt" +"Ends the session; everyone keeps their copy" = "Avslutar sessionen; alla behåller sin kopia" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Ange adressen till en figcraft-server och tryck på Bläddra för att se vilka plugin-program den erbjuder." +"Enter to save · Esc to cancel" = "Enter för att spara · Esc för att avbryta" +"Eraser" = "Suddgummi" +"Esc" = "Esc" +"Essential" = "Grundläggande" +"Every conflict has a choice." = "Alla konflikter har ett val." +"Every top-level frame already starts a flow" = "Varje ram på översta nivån startar redan ett flöde" +"Exact opacity (4 then 5: 45%)" = "Exakt opacitet (4 och sedan 5: 45 %)" +"Exclude" = "Uteslut" +"Excluded" = "Utesluten" +"Excluded from layout" = "Utesluten från layouten" +"Excludes" = "Utesluter" +"Exclusion" = "Uteslutning" +"Exit focus view (Esc)" = "Lämna fokusvyn (Esc)" +"Expand" = "Expandera" +"Export" = "Exportera" +"Export all" = "Exportera alla" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportera ramar och lager som PNG, JPG, SVG eller PDF" +"Export page as PNG" = "Exportera sidan som PNG" +"Export settings" = "Exportinställningar" +"Export variables" = "Exportera variabler" +"Export {name}" = "Exportera {name}" +"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 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" } +"Export…" = "Exportera…" +"Expose properties to instances" = "Exponera egenskaper för instanser" +"Exposure" = "Exponering" +"Expression" = "Uttryck" +"Extend collection" = "Utöka samlingen" +"Extends \"{name}\": overridden values are marked" = "Utökar \"{name}\": åsidosatta värden är markerade" +"Eye" = "Öga" +"F" = "F" +"Fade" = "Toning" +"False" = "Falskt" +"Feb" = "feb" +"Fetching from the server…" = "Hämtar från servern…" +"File" = "Fil" +"File name" = "Filnamn" +"File not found" = "Filen hittades inte" +"File published" = "Filen har publicerats" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Filerna är inte end-to-end-krypterade: serverns operatör kan läsa dem." +"Files nobody opens for {n} days are deleted." = { one = "Filer som ingen öppnar på {n} dag raderas.", other = "Filer som ingen öppnar på {n} dagar raderas." } +"Fill" = "Fyll" +"Fill (fr)" = "Fyll (fr)" +"Fill container" = "Fyll behållaren" +"Fill window" = "Fyll fönstret" +"Fills" = "Fyllningar" +"Fill|sizing" = "Fyll" +"Filter and sort" = "Filtrera och sortera" +"Find" = "Sök" +"Find and replace" = "Sök och ersätt" +"Find layers" = "Sök lager" +"First" = "Första" +"First commit" = "Första commit" +"First on top" = "Första överst" +"Fit" = "Anpassa" +"Fit device to window" = "Anpassa enheten till fönstret" +"Fit to contents" = "Anpassa till innehållet" +"Fit to window" = "Anpassa till fönstret" +"Fit width" = "Anpassa bredd" +"Fixed" = "Fast" +"Fixed gap" = "Fast mellanrum" +"Fixed size" = "Fast storlek" +"Flatten" = "Platta till" +"Flip" = "Vänd" +"Flip horizontal" = "Vänd vågrätt" +"Flip text to the other side" = "Vänd texten till andra sidan" +"Flip vertical" = "Vänd lodrätt" +"Flip width profile" = "Vänd breddprofil" +"Flow starting point" = "Startpunkt för flöde" +"Flow starting points" = "Startpunkter för flöden" +"Flows" = "Flöden" +"Focus view" = "Fokusvy" +"Fold autosaves" = "Fäll ihop autosparningar" +"Folder: click to collapse" = "Mapp: klicka för att komprimera" +"Folder: click to expand" = "Mapp: klicka för att expandera" +"Follow" = "Följ" +"Follow {name}" = "Följ {name}" +"Following" = "Följer" +"Following {name} · {key} to stop" = "Följer {name} · {key} för att sluta" +"Font" = "Teckensnitt" +"Font not installed" = "Teckensnittet är inte installerat" +"Font size" = "Teckenstorlek" +"Font style" = "Teckenstil" +"Forget on this computer" = "Glöm på den här datorn" +"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" = "Ram" +"Frame selection" = "Rama in markering" +"Frame tools" = "Ramverktyg" +"Frames" = "Ramar" +"From the library \"{name}\": read-only" = "Från biblioteket \"{name}\": skrivskyddad" +"From {name}" = "Från {name}" +"From {server}" = "Från {server}" +"Frost" = "Frost" +"Gap" = "Mellanrum" +"Gap between columns" = "Avstånd mellan kolumner" +"Gap between items" = "Avstånd mellan objekt" +"Gap between rows" = "Avstånd mellan rader" +"Getting started" = "Kom igång" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Ange adressen till en server som är öppen för alla, eller en servers adress med dess nyckel (`figcraft-server key` skriver ut den)." +"Glass" = "Glas" +"Go to main component" = "Gå till huvudkomponent" +"Go to {name}" = "Gå till {name}" +"Grab bag" = "Blandat" +"Gradient" = "Gradient" +"Gray" = "Grå" +"Green" = "Grön" +"Greyscale components and two example screens to sketch a flow with" = "Komponenter i gråskala och två exempelskärmar att skissa ett flöde med" +"Grid" = "Rutnät" +"Grid ({size})" = "Rutnät ({size})" +"Grid styles" = "Formatmallar för rutnät" +"Grid {size}" = "Rutnät {size}" +"Group" = "Grupp" +"Group selection" = "Gruppera markering" +"Groups" = "Grupper" +"Gutter" = "Spaltmellanrum" +"Hand tool" = "Handverktyg" +"Hanging list" = "Hängande lista" +"Hanging punctuation" = "Hängande skiljetecken" +"Hard light" = "Hårt ljus" +"Height" = "Höjd" +"Help" = "Hjälp" +"Help and resources" = "Hjälp och resurser" +"Help page" = "Hjälpsida" +"Here: {names}" = "Här: {names}" +"Hidden" = "Dold" +"Hide contents" = "Dölj innehåll" +"Hide from publishing" = "Dölj vid publicering" +"Hide section contents" = "Dölj avsnittets innehåll" +"Hide these tips (Getting started in the Help menu shows them again)" = "Dölj de här tipsen (Kom igång i Hjälp-menyn visar dem igen)" +"Hide when publishing" = "Dölj vid publicering" +"Highlight layers on hover" = "Framhäv lager vid hovring" +"Highlights" = "Högdagrar" +"Hold Alt" = "Håll ned Alt" +"Hold Alt+Z" = "Håll ned Alt+Z" +"Hold Ctrl" = "Håll ned Ctrl" +"Hold Z" = "Håll ned Z" +"Hold ⌃ or ⌘" = "Håll ned ⌃ eller ⌘" +"Hold ⌥" = "Håll ned ⌥" +"Hold ⌥Z" = "Håll ned ⌥Z" +"Home" = "Start" +"Horizontal" = "Horisontell" +"Horizontal align" = "Horisontell justering" +"Horizontal hex" = "Horisontell hex" +"Horizontal padding" = "Horisontell utfyllnad" +"Horizontal spacing" = "Horisontellt avstånd" +"Hosted by {operator}" = "Värd: {operator}" +"Hover a layer to see its properties" = "Hovra över ett lager för att se dess egenskaper" +"Hover other layers to measure the distance" = "Hovra över andra lager för att mäta avståndet" +"Hue" = "Nyans" +"Hug" = "Omslut" +"Hug contents" = "Omslut innehåll" +"Icon components on a 24 px grid with keylines and a size preview" = "Ikonkomponenter på ett 24 px-rutnät med hjälplinjer och en förhandsgranskning av storlekar" +"Icon set starter" = "Startpaket för ikonuppsättning" +"If" = "Om" +"If {condition}" = "Om {condition}" +"Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" +"Image" = "Bild" +"Import" = "Importera" +"Import path" = "Importsökväg" +"Import plugin from manifest" = "Importera plugin från manifest" +"Import plugin from manifest…" = "Importera plugin från manifest…" +"Import variables" = "Importera variabler" +"Imported {name}" = "Importerade {name}" +"Imported {n} new variables, updated {updated}" = { one = "Importerade {n} ny variabel, uppdaterade {updated}", other = "Importerade {n} nya variabler, uppdaterade {updated}" } +"Import…" = "Importera…" +"In an instance, this comes from the main component" = "I en instans kommer detta från huvudkomponenten" +"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)." = "I webbläsaren kan du ansluta till sessioner som skrivbordsappen är värd för och till filer på en FigCraft-server. En sida som öppnats via https når andra datorer endast via wss://-länkar (en server bakom TLS)." +"Include bounding box" = "Inkludera avgränsningsram" +"Include id attribute" = "Inkludera id-attribut" +"Included" = "Inkluderad" +"Included in layout" = "Inkluderad i layouten" +"Individual corners" = "Enskilda hörn" +"Individual strokes" = "Enskilda konturer" +"Ink" = "Bläck" +"Inner shadow" = "Inre skugga" +"Inside" = "Inuti" +"Inspect" = "Inspektera" +"Inspect the layer under the pointer: size, colours, typography" = "Inspektera lagret under pekaren: storlek, färger, typografi" +"Install plugin" = "Installera plugin" +"Install" = "Installera" +"Installed {name}" = "Installerade {name}" +"Installed" = "Installerade" +"Installing…" = "Installerar…" +"Instance" = "Instans" +"Instance properties" = "Instansegenskaper" +"Instance swap" = "Instansbyte" +"Instance swap (no other component)" = "Instansbyte (ingen annan komponent)" +"Instances" = "Instanser" +"Instant" = "Direkt" +"Interaction" = "Interaktion" +"Interactions" = "Interaktioner" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps." +"Intersect" = "Skärning" +"Intersects" = "Skär" +"Invite link" = "Inbjudningslänk" +"Invite link copied" = "Inbjudningslänken har kopierats" +"It asks permission to:" = "Den ber om behörighet att:" +"Jan" = "jan" +"Join" = "Gå med" +"Join code copied" = "Anslutningskoden har kopierats" +"Join code or invite link" = "Anslutningskod eller inbjudningslänk" +"Join live session" = "Gå med i livesession" +"Join us on Discord" = "Gå med oss på Discord" +"Join with a code" = "Anslut med en kod" +"Joined" = "Ansluten" +"Joined {file}" = "Ansluten till {file}" +"Joining…" = "Ansluter…" +"Join|stroke" = "Hörntyp" +"Jul" = "jul" +"Jun" = "jun" +"Just now" = "Nyss" +"Justify" = "Marginaljustera" +"Keep appearance" = "Behåll utseende" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." +"Keep tool selected after use" = "Behåll verktyget markerat efter användning" +"Keep values" = "Behåll värden" +"Key/Gamepad" = "Tangent/spelkontroll" +"Key/Gamepad {key}" = "Tangent/spelkontroll {key}" +"Keyboard shortcuts" = "Kortkommandon" +"Kits" = "Kit" +"Landing page" = "Landningssida" +"Language" = "Språk" +"Lasso" = "Lasso" +"Last" = "Senaste" +"Last change: {note}" = "Senaste ändring: {note}" +"Last commit" = "Senaste commit" +"Last modified" = "Senast ändrad" +"Last on top" = "Senaste överst" +"Layer blur" = "Lageroskärpa" +"Layer name" = "Lagernamn" +"Layers" = "Lager" +"Layers in an instance take their position from the main component" = "Lager i en instans får sin position från huvudkomponenten" +"Layout" = "Layout" +"Layout grid" = "Layoutrutnät" +"Layout grid settings" = "Inställningar för layoutrutnät" +"Layout grids" = "Layoutrutnät" +"Leave session" = "Lämna sessionen" +"Leaves" = "Löv" +"Left" = "Vänster" +"Left & right" = "Vänster och höger" +"Left side" = "Vänster sida" +"Left side width" = "Bredd på vänster sida" +"Letter case" = "Skiftläge" +"Letter spacing" = "Teckenavstånd" +"Libraries" = "Bibliotek" +"Libraries in this file" = "Bibliotek i den här filen" +"Library updates available" = "Biblioteksuppdateringar finns" +"Library: click to collapse" = "Bibliotek: klicka för att komprimera" +"Library: click to expand" = "Bibliotek: klicka för att expandera" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Bibliotek: dess variabler är skrivskyddade här. Redigera dem i biblioteksfilen och publicera den." +"Light" = "Ljus" +"Lighten" = "Ljusare" +"Light|glass" = "Ljus" +"Line" = "Linje" +"Line arrow" = "Linjepil" +"Line delta" = "Raddifferens" +"Line height" = "Radhöjd" +"Linear" = "Linjär" +"Lines" = "Rader" +"Lines +/−" = "Rader +/−" +"Lines added" = "Tillagda rader" +"Lines deleted" = "Borttagna rader" +"Link" = "Länk" +"Link copied" = "Länken har kopierats" +"Link docs, code or tickets for developers." = "Länka dokumentation, kod eller ärenden för utvecklare." +"Link frames on the Prototype tab, then present the flow" = "Länka ramar på fliken Prototyp och presentera sedan flödet" +"Link this component to its code component: instances then show your real snippet with their values." = "Länka den här komponenten till dess kodkomponent: instanserna visar då ditt riktiga kodavsnitt med sina värden." +"List spacing" = "Listavstånd" +"List style" = "Liststil" +"Live session" = "Livesession" +"Live · {people}" = "Live · {people}" +"Local components" = "Lokala komponenter" +"Local network" = "Lokalt nätverk" +"Local styles" = "Lokala stilar" +"Local variables" = "Lokala variabler" +"Lock/Unlock" = "Lås/lås upp" +"Locked" = "Låst" +"Lowercase" = "Gemener" +"Luminance" = "Luminans" +"Luminosity" = "Luminositet" +"Main" = "Main" +"Main component deleted" = "Huvudkomponenten har raderats" +"Main menu" = "Huvudmeny" +"Make a component" = "Skapa en komponent" +"Make slot" = "Gör till plats" +"Mar" = "Mar" +"Margin" = "Marginal" +"Mark all as read" = "Märk alla som lästa" +"Mark as resolved" = "Märk som löst" +"Mark as unread" = "Märk som oläst" +"Mark as unresolved" = "Märk som olöst" +"Mask" = "Mask" +"Mass · Stiffness · Damping" = "Massa · Styvhet · Dämpning" +"Match (empty: the whole name)" = "Matcha (tomt: hela namnet)" +"Max H" = "Max H" +"Max W" = "Max B" +"Max lines" = "Max antal rader" +"Maximize" = "Maximera" +"May" = "Maj" +"Measure to the selection" = "Mät avståndet till markeringen" +"Merge into main" = "Sammanfoga med main" +"Merge regions" = "Sammanfoga regioner" +"Merge the branch into the main file (one undo step) and archive it" = "Sammanfoga grenen med huvudfilen (ett ångrasteg) och arkivera den" +"Merged" = "Sammanfogad" +"Merged PRs" = "Sammanslagna PR:er" +"Min H" = "Min H" +"Min W" = "Min B" +"Minimize" = "Minimera" +"Mirror angle" = "Spegla vinkel" +"Mirror angle and length" = "Spegla vinkel och längd" +"Mirroring" = "Spegling" +"Missing" = "Saknas" +"Missing layer" = "Lager saknas" +"Missing style" = "Stil saknas" +"Missing variable" = "Variabel saknas" +"Miter" = "Spets" +"Miter angle" = "Spetsvinkel" +"Mixed" = "Blandat" +"Mixed fonts" = "Blandade typsnitt" +"Mixed interactions: click + to replace them" = "Blandade interaktioner: klicka på + för att ersätta dem" +"Mobile app" = "Mobilapp" +"Mode" = "Läge" +"Model" = "Modell" +"Models" = "Modeller" +"Mono" = "Mono" +"More" = "Mer" +"More instance actions" = "Fler instansåtgärder" +"Mouse down" = "Musknapp nedtryckt" +"Mouse enter" = "Muspekare in" +"Mouse leave" = "Muspekare ut" +"Mouse up" = "Musknapp släppt" +"Move along the toolbar or the layer rows" = "Flytta längs verktygsfältet eller lagerraderna" +"Move" = "Flytta" +"Move column left" = "Flytta kolumn åt vänster" +"Move column right" = "Flytta kolumn åt höger" +"Move down" = "Flytta ned" +"Move in" = "Flytta in" +"Move out" = "Flytta ut" +"Move tools" = "Flyttverktyg" +"Move up" = "Flytta upp" +"Moved to {name}" = "Flyttat till {name}" +"Multi" = "Flera" +"Multiply" = "Multiplicera" +"Muted" = "Dämpad" +"Name" = "Namn" +"Name (A–Z)" = "Namn (A–Ö)" +"Named instance" = "Namngiven instans" +"Navigate to" = "Navigera till" +"Nested instance" = "Kapslad instans" +"New design file" = "Ny designfil" +"Newest first" = "Nyaste först" +"Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen" +"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." +"No boolean variables fit here." = "Inga booleska variabler passar här." +"No boolean variables yet. Create them in Local variables." = "Inga booleska variabler än. Skapa dem i Lokala variabler." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Inga grenar. En gren är en kopia av filen där du kan prova ändringar och sedan granska och sammanfoga dem." +"No category" = "Ingen kategori" +"No changes" = "Inga ändringar" +"No colour variables fit here." = "Inga färgvariabler passar här." +"No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler." +"No comments yet" = "Inga kommentarer än" +"No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." +"No conflicts." = "Inga konflikter." +"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian." +"No fonts match" = "Inga typsnitt matchar" +"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." +"No list" = "Ingen lista" +"No matching comments" = "Inga matchande kommentarer" +"No matching components" = "Inga matchande komponenter" +"No matching layers" = "Inga matchande lager" +"No matching styles" = "Inga matchande stilar" +"No matching variables" = "Inga matchande variabler" +"No mirroring" = "Ingen spegling" +"No model credits were built into this copy." = "Inga modellkrediteringar finns inbyggda i den här kopian." +"No number variables fit here." = "Inga nummervariabler passar här." +"No number variables yet. Create them in Local variables." = "Inga nummervariabler än. Skapa dem i Lokala variabler." +"No paints" = "Inga färger" +"No plugins on {server}." = "Inga plugin-program på {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Inga plugin-program än. Importera ett plugin-program under utveckling från dess figcraft-plugin.json." +"No preferred components" = "Inga föredragna komponenter" +"No preset" = "Ingen förinställning" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Inga sparade versioner än. Spara en version för att markera en punkt i arbetet som du kan återvända till." +"No scrolling" = "Ingen rullning" +"No shortcuts match" = "Inga kortkommandon matchar" +"No status" = "Ingen status" +"No string variables fit here." = "Inga strängvariabler passar här." +"No string variables yet. Create them in Local variables." = "Inga strängvariabler än. Skapa dem i Lokala variabler." +"No styles yet." = "Inga stilar än." +"No styles yet. Click + to create one from the selection." = "Inga stilar än. Klicka på + för att skapa en från markeringen." +"No templates match your search" = "Inga mallar matchar sökningen" +"No text styles yet. Click + to create one from the selected text." = "Inga textstilar än. Klicka på + för att skapa en från den markerade texten." +"No variables fit here." = "Inga variabler passar här." +"No variables yet. Create them in Local variables." = "Inga variabler än. Skapa dem i Lokala variabler." +"Nobody else is here yet" = "Ingen annan är här än" +"Noise" = "Brus" +"None" = "Ingen" +"None." = "Ingen." +"Normal" = "Normal" +"Not bound" = "Inte kopplad" +"Not in a live session" = "Inte i en livesession" +"Not in this font" = "Finns inte i det här teckensnittet" +"Not properties, kept as written: {names}" = "Inte egenskaper, behölls som de skrevs: {names}" +"Not read yet" = "Inte läst än" +"Not shared" = "Inte delad" +"Note {n}" = "Anteckning {n}" +"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 rams 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." +"Nothing to export" = "Inget att exportera" +"Nov" = "nov" +"Now" = "Nu" +"Nudge (Shift: big nudge)" = "Knuffa (Shift: stor knuff)" +"Nudge amount" = "Knuffavstånd" +"Number" = "Nummer" +"Number ↑" = "Nummer ↑" +"Number ↓" = "Nummer ↓" +"Numbered list" = "Numrerad lista" +"Object" = "Objekt" +"Oct" = "okt" +"Off" = "Av" +"Offered first when filling the slot (any content is allowed)" = "Föreslås först när platsen fylls (allt innehåll tillåts)" +"Offered first when swapping the instance (any component is allowed)" = "Föreslås först när instansen byts ut (alla komponenter tillåts)" +"Offset" = "Förskjutning" +"Oldest first" = "Äldsta först" +"On click" = "Vid klick" +"On drag" = "Vid dragning" +"On this page" = "På den här sidan" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Ett meddelande i fem vanliga storlekar: kvadrat, stående, story, länk och banner" +"Online" = "Online" +"Online files" = "Onlinefiler" +"Online sharing is off: files are shared on the local network only." = "Onlinedelning är av: filer delas bara i det lokala nätverket." +"Only the first changed layers are listed." = "Endast de första ändrade lagren visas." +"Only the first {n} changed layers are listed." = "Endast de första {n} ändrade lagren visas." +"Only your threads" = "Bara dina trådar" +"Opacity" = "Opacitet" +"Opacity & blend" = "Opacitet och blandning" +"Opacity 10%…90%, 0: 100%" = "Opacitet 10 %…90 %, 0: 100 %" +"Open" = "Öppna" +"Open .figcraft, .svg or images" = "Öppna .figcraft, .svg eller bilder" +"Open branch" = "Öppna gren" +"Open file" = "Öppna fil" +"Open files" = "Öppna filer" +"Open in focus view" = "Öppna i fokusvy" +"Open in new window" = "Öppna i nytt fönster" +"Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft" +"Open link" = "Öppna länk" +"Open link {url}" = "Öppna länken {url}" +"Open overlay" = "Öppna överlägg" +"Open prototype preview" = "Öppna prototypförhandsgranskning" +"Open variables ({count})" = "Öppna variabler ({count})" +"Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik" +"Open|comments" = "Öppna" +"Options" = "Alternativ" +"Orange" = "Orange" +"Order" = "Ordning" +"Other apps may not open every .fig file exported here." = "Andra appar kanske inte kan öppna alla .fig-filer som exporteras här." +"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" +"Outline text" = "Konvertera text till konturer" +"Outlines" = "Konturlinjer" +"Outside" = "Utanför" +"Overflow" = "Överflöde" +"Overlay" = "Överlägg" +"Overlay position" = "Överläggsposition" +"Overridden here (the collection it extends has another value)" = "Åsidosatt här (samlingen som den utökar har ett annat värde)" +"Overrides" = "Åsidosättningar" +"Owner token" = "Ägartoken" +"Owner token copied" = "Ägartoken kopierad" +"PDF print options" = "PDF-utskriftsalternativ" +"PDF print settings" = "PDF-utskriftsinställningar" +"Padding" = "Utfyllnad" +"Page" = "Sida" +"Pages" = "Sidor" +"Pages: {pages}" = "Sidor: {pages}" +"Paint bucket" = "Färghink" +"Pan the canvas" = "Panorera arbetsytan" +"Pan with nothing selected (Shift: farther)" = "Panorera när inget är markerat (Shift: längre)" +"Panels" = "Paneler" +"Paper" = "Papper" +"Paragraph indent" = "Styckeindrag" +"Paragraph indent applies to left-aligned text" = "Styckeindrag gäller för vänsterjusterad text" +"Paragraph spacing" = "Styckeavstånd" +"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 here" = "Klistra in här" +"Paste to replace" = "Klistra in och ersätt" +"Pattern" = "Mönster" +"Pattern alignment" = "Mönsterjustering" +"Pattern scale" = "Mönsterskala" +"Pattern source" = "Mönsterkälla" +"Pen" = "Penna" +"Pencil" = "Blyertspenna" +"People in this file" = "Personer i den här filen" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Andra kan ansluta varifrån som helst med en kod. Filen lagras på {server} medan du delar den." +"People on your network join with a code. Nothing goes through a server." = "Personer i ditt nätverk ansluter med en kod. Ingenting går via en server." +"Phone" = "Telefon" +"Pick color" = "Hämta färg" +"Pick color (I)" = "Hämta färg (I)" +"Pick one font family first" = "Välj en teckensnittsfamilj först" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Välj ramverktyget och dra sedan på arbetsytan eller välj en enhetsstorlek" +"Pin a property" = "Fäst en egenskap" +"Pinch" = "Nyp" +"Pixel grid" = "Pixelrutnät" +"Pixel preview 1x" = "Pixelförhandsgranskning 1x" +"Pixel preview 2x" = "Pixelförhandsgranskning 2x" +"Place a shape or frame (its tool chosen)" = "Placera en form eller ram (när dess verktyg är valt)" +"Place automatically" = "Placera automatiskt" +"Place image" = "Placera bild" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugin-program körs i FigCrafts sandlåda, utan filer, nätverk eller klocka. Installera bara plugin-program du litar på." +"Plugins" = "Plugin-program" +"Plus darker" = "Plus mörkare" +"Plus lighter" = "Plus ljusare" +"Point" = "Punkt" +"Points" = "Punkter" +"Polygon" = "Polygon" +"Polygons" = "Polygoner" +"Port" = "Port" +"Position" = "Position" +"Post" = "Publicera" +"Preferences" = "Inställningar" +"Preferred" = "Föredragna" +"Present" = "Presentera" +"Present or preview" = "Presentera eller förhandsgranska" +"Present this flow" = "Presentera det här flödet" +"Presentation" = "Presentation" +"Presentation slides" = "Presentationsbilder" +"Presenting: comments" = "Presentation: kommentarer" +"Presenting: cycle the size options" = "Presentation: växla mellan storleksalternativen" +"Presenting: full screen" = "Presentation: helskärm" +"Presenting: next frame" = "Presentation: nästa ram" +"Presenting: previous frame" = "Presentation: föregående ram" +"Presenting: restart" = "Presentation: starta om" +"Preserve scroll position" = "Bevara rullningsposition" +"Presets" = "Förinställningar" +"Press a key…" = "Tryck på en tangent…" +"Playground" = "Testyta" +"Preview" = "Förhandsgranskning" +"Preview this flow" = "Förhandsgranska det här flödet" +"Preview this frame" = "Förhandsgranska den här ramen" +"Previous ready for dev" = "Föregående redo för utveckling" +"Progressive" = "Progressiv" +"Properties" = "Egenskaper" +"Prototype" = "Prototyp" +"Prototype preview" = "Förhandsgranskning av prototyp" +"Prototype settings" = "Prototypinställningar" +"Publish" = "Publicera" +"Publish changes" = "Publicera ändringar" +"Publish to another server…" = "Publicera på en annan server…" +"Publish to server" = "Publicera på server" +"Published version {v}" = "Version {v} publicerad" +"Published version {v} to the server" = "Version {v} publicerad på servern" +"Published version {v}: save the file so other files can use it" = "Version {v} publicerad: spara filen så att andra filer kan använda den" +"Published · {people}" = "Publicerad · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publicering på en server kräver skrivbordsappen; i webbläsaren kan du ansluta till en servers filer" +"Publishing with the server's key." = "Publicerar med serverns nyckel." +"Publishing…" = "Publicerar…" +"Push" = "Skjut" +"Push changes to main component" = "Skicka ändringar till huvudkomponenten" +"Put text on path" = "Placera text på bana" +"R" = "R" +"Radial" = "Radiell" +"Radius" = "Radie" +"Ratio" = "Kvot" +"Reading the library…" = "Läser biblioteket…" +"Ready for dev" = "Redo för utveckling" +"Real name" = "Riktigt namn" +"Recent" = "Senaste" +"Recent files" = "Senaste filer" +"Recents" = "Senaste" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Återställde en osparad fil efter att appen avslutades oväntat: spara den för att behålla den", other = "Återställde {n} osparade filer efter att appen avslutades oväntat: spara dem för att behålla dem" } +"Rectangle" = "Rektangel" +"Rectangles" = "Rektanglar" +"Red" = "Röd" +"Reduce motion" = "Minska rörelse" +"Refraction" = "Brytning" +"Regular expression" = "Reguljärt uttryck" +"Remove" = "Ta bort" +"Remove all" = "Ta bort alla" +"Remove annotation" = "Ta bort anteckning" +"Remove auto layout" = "Ta bort automatisk layout" +"Remove dev resource" = "Ta bort utvecklarresurs" +"Remove export setting" = "Ta bort exportinställning" +"Remove from recents" = "Ta bort från Senaste" +"Remove from server" = "Ta bort från servern" +"Remove from server…" = "Ta bort från servern…" +"Remove guide" = "Ta bort stödlinje" +"Remove interaction" = "Ta bort interaktion" +"Remove library from this file" = "Ta bort biblioteket från den här filen" +"Remove min and max" = "Ta bort min och max" +"Remove min and max height" = "Ta bort min- och maxhöjd" +"Remove min and max width" = "Ta bort min- och maxbredd" +"Remove regions" = "Ta bort områden" +"Remove slot" = "Ta bort plats" +"Remove starting point" = "Ta bort startpunkt" +"Remove stop" = "Ta bort stopp" +"Remove text from path" = "Ta bort text från bana" +"Remove the else and its actions" = "Ta bort else-grenen och dess åtgärder" +"Remove this condition and its actions" = "Ta bort det här villkoret och dess åtgärder" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Vill du ta bort den här filen från servern? Den kan inte längre delas eller öppnas därifrån. Din kopia förblir öppen här." +"Remove {name} from {server}?" = "Vill du ta bort {name} från {server}?" +"Removed" = "Borttaget" +"Removed from the server" = "Borttagen från servern" +"Removed since it was marked" = "Borttaget sedan det märktes" +"Rename" = "Byt namn" +"Rename duplicated layers" = "Byt namn på duplicerade lager" +"Rename folder" = "Byt namn på mapp" +"Rename group" = "Byt namn på grupp" +"Rename layers" = "Byt namn på lager" +"Rename mode" = "Byt namn på läge" +"Rename on the server" = "Byt namn på servern" +"Rename on the server…" = "Byt namn på servern…" +"Rename page" = "Byt namn på sida" +"Rename to" = "Nytt namn" +"Replace all" = "Ersätt alla" +"Replace mixed export settings" = "Ersätt blandade exportinställningar" +"Replace with" = "Ersätt med" +"Replace with a new interaction" = "Ersätt med en ny interaktion" +"Reply" = "Svara" +"Report" = "Rapport" +"Report this file…" = "Rapportera den här filen…" +"Reset" = "Återställ" +"Reset adjustments" = "Återställ justeringar" +"Reset all changes" = "Återställ alla ändringar" +"Reset all overrides on this layer" = "Återställ alla åsidosättningar på det här lagret" +"Reset overrides" = "Återställ åsidosättningar" +"Reset slot" = "Återställ plats" +"Reset this override" = "Återställ den här åsidosättningen" +"Reset to style" = "Återställ till formatmall" +"Reset to the inherited value" = "Återställ till ärvt värde" +"Resize by the nudge amount" = "Ändra storlek med knuffavståndet" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ändra storlek på ramar efter enhetens skärm (eller fönstret utan enhet): deras begränsningar och automatiska layout anpassas" +"Resize to fit" = "Anpassa storlek" +"Resolve" = "Lös" +"Resolve change" = "Lös ändring" +"Resolved" = "Löst" +"Resources" = "Resurser" +"Responsive" = "Responsiv" +"Restart" = "Starta om" +"Restore" = "Återställ" +"Restore main component" = "Återställ huvudkomponent" +"Restore this version" = "Återställ den här versionen" +"Restored “{name}”" = "Återställde ”{name}”" +"Reverse the order" = "Vänd på ordningen" +"Reversed triangle" = "Omvänd triangel" +"Review and merge" = "Granska och sammanfoga" +"Ribbon" = "Verktygspanel" +"Right" = "Höger" +"Right side" = "Höger sida" +"Right side width" = "Bredd på höger sida" +"Rotate 90°" = "Rotera 90°" +"Rotate 90° right" = "Rotera 90° åt höger" +"Rotate to landscape" = "Rotera till liggande" +"Rotate to portrait" = "Rotera till stående" +"Rotation" = "Rotation" +"Rotation jitter" = "Rotationsdarr" +"Round" = "Rund" +"Rounded" = "Rundad" +"Row" = "Rad" +"Row gap" = "Radmellanrum" +"Row sizes" = "Radstorlekar" +"Row span" = "Radspann" +"Rows" = "Rader" +"Rows {count}" = "Rader {count}" +"Rulers" = "Linjaler" +"Run" = "Kör" +"Run {label}" = "Kör {label}" +"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" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Samma som lagret" +"Saturation" = "Mättnad" +"Save" = "Spara" +"Save as" = "Spara som" +"Save as preset…" = "Spara som förinställning…" +"Save version {key}" = "Spara version {key}" +"Saved" = "Sparat" +"Saved {name}" = "Sparade {name}" +"Say something" = "Skriv något" +"Scale" = "Skala" +"Scatter" = "Spridning" +"Scoping: where pickers offer it" = "Omfång: var väljarna erbjuder den" +"Screen" = "Skärm" +"Scroll behavior" = "Rullningsbeteende" +"Scroll to" = "Rulla till" +"Scroll to layer" = "Rulla till lager" +"Scroll with parent" = "Rulla med överordnat element" +"Search plugins" = "Sök plugin-program" +"Search" = "Sök" +"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" +"Search assets" = "Sök tillgångar" +"Search comments" = "Sök i kommentarer" +"Search fonts" = "Sök typsnitt" +"Search layers" = "Sök lager" +"Search shortcuts" = "Sök kortkommandon" +"Section" = "Avsnitt" +"Section color" = "Avsnittsfärg" +"Sections" = "Avsnitt" +"Select a frame and add a starting point to present it as a flow." = "Markera en ram och lägg till en startpunkt för att presentera den som ett flöde." +"Select a frame to preview it" = "Markera en ram för att förhandsgranska den" +"Select a layer to see its properties and code" = "Markera ett lager för att se dess egenskaper och kod" +"Select all" = "Markera alla" +"Select all with same" = "Markera alla med samma" +"Select layer" = "Markera lager" +"Select layers to apply the style to" = "Markera de lager som formatmallen ska användas på" +"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" = "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" +"Send backward" = "Flytta bakåt" +"Send report" = "Skicka rapport" +"Send to back" = "Flytta längst bak" +"Sep" = "sep" +"Server address (ws://host:47900/)" = "Serveradress (ws://host:47900/)" +"Server address" = "Serveradress" +"Server for online sharing" = "Server för delning online" +"Set as default" = "Ange som standard" +"Set variable" = "Ange variabel" +"Set variable mode" = "Ange variabelläge" +"Set variable {name}" = "Ange variabel {name}" +"Set {name} to {value}" = "Ange {name} till {value}" +"Shadows" = "Skuggor" +"Shape" = "Form" +"Shape builder" = "Formbyggare" +"Shape tools" = "Formverktyg" +"Share" = "Dela" +"Share this file" = "Dela den här filen" +"Share with a code" = "Dela med en kod" +"Share: live session, export" = "Dela: livesession, export" +"Shared" = "Delad" +"Shared: copy its code from the file's menu" = "Delad: kopiera koden från filens meny" +"Sharing a file needs the desktop app; here you can join one." = "Att dela en fil kräver skrivbordsappen; här kan du ansluta till en." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delar i det lokala nätverket (delning online är av: ingen server har angetts i Inställningar)." +"Sharing on your network · {people}" = "Delar i ditt nätverk · {people}" +"Sharing online · {people}" = "Delar online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Delningen stoppades: filen ligger kvar på servern och är bara öppen för dig" +"Show" = "Visa" +"Show all templates" = "Visa alla mallar" +"Show as grid" = "Visa som rutnät" +"Show as list" = "Visa som lista" +"Show autosaves" = "Visa autosparade versioner" +"Show behind transparent areas" = "Visa bakom transparenta ytor" +"Show comments (Shift+C)" = "Visa kommentarer (Shift+C)" +"Show device frame" = "Visa enhetsram" +"Show dimensions on objects" = "Visa dimensioner på objekt" +"Show hotspot hints" = "Visa tips om aktiva punkter" +"Show in Explorer" = "Visa i Utforskaren" +"Show in Finder" = "Visa i Finder" +"Show in folder" = "Visa i mapp" +"Show only this on the canvas" = "Visa bara detta på arbetsytan" +"Show open" = "Visa öppna" +"Show section contents" = "Visa avsnittets innehåll" +"Show this layer" = "Visa det här lagret" +"Show usage" = "Visa användning" +"Show/Hide" = "Visa/dölj" +"Show/Hide UI" = "Visa/dölj gränssnitt" +"Side by side" = "Sida vid sida" +"Sidebar" = "Sidofält" +"Simplify stroke" = "Förenkla kontur" +"Size" = "Storlek" +"Size jitter" = "Storleksvariation" +"Skip ink" = "Hoppa över bläck" +"Slashed zero" = "Nolla med snedstreck" +"Slice" = "Skiva" +"Slices" = "Skivor" +"Slide in" = "Glid in" +"Slide out" = "Glid ut" +"Slot" = "Fack" +"Slot content" = "Fackinnehåll" +"Small capitals" = "Kapitäler" +"Small caps" = "Kapitäler" +"Smart animate" = "Smart animering" +"Snap to pixel grid" = "Fäst mot pixelrutnät" +"Social and slides" = "Sociala medier och presentationer" +"Social media" = "Sociala medier" +"Social post sizes" = "Storlekar för inlägg i sociala medier" +"Soft light" = "Mjukt ljus" +"Solid" = "Enfärgad" +"Some variants have the same property values: give each one a unique combination" = "Vissa varianter har samma egenskapsvärden: ge var och en en unik kombination" +"Sort" = "Sortera" +"Sort by" = "Sortera efter" +"Sort by date" = "Sortera efter datum" +"Sort by page" = "Sortera efter sida" +"Sort by {column}" = "Sortera efter {column}" +"Space around" = "Avstånd runt" +"Space between" = "Avstånd mellan" +"Space drag" = "Blanksteg och dra" +"Space evenly" = "Jämnt avstånd" +"Space+drag" = "Blanksteg+dra" +"Spacing" = "Avstånd" +"Spam, stolen work, abuse…" = "Skräppost, stulet material, kränkningar…" +"Splay" = "Utbredning" +"Spotlight me" = "Rikta strålkastaren mot mig" +"Spread" = "Spridning" +"Square" = "Kvadrat" +"Standard" = "Standard" +"Standard ligatures" = "Standardligaturer" +"Star" = "Stjärna" +"Stars" = "Stjärnor" +"Start" = "Start" +"Start ascending from / stop descending at" = "Börja uppåt från / sluta nedåt vid" +"Start from a blank canvas" = "Börja med en tom arbetsyta" +"Start from a template" = "Börja med en mall" +"Start on path" = "Början på banan" +"Start point" = "Startpunkt" +"Start sharing" = "Börja dela" +"Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får" +"Starting to share…" = "Börjar dela…" +"Status" = "Status" +"Sticky" = "Fast" +"Stop" = "Stoppa" +"Stop following {name}" = "Sluta följa {name}" +"Stop session" = "Avsluta session" +"Stop sharing" = "Sluta dela" +"Stop spotlighting" = "Sluta använda strålkastaren" +"Stop {n} {hex}" = "Stopp {n} {hex}" +"Stretch" = "Sträck" +"Strikethrough" = "Genomstruken" +"String" = "Sträng" +"Stroke" = "Kontur" +"Stroke style" = "Konturstil" +"Stroke weight" = "Konturtjocklek" +"Strokes" = "Konturer" +"Style" = "Stil" +"Styles" = "Stilar" +"Stylistic set 1" = "Stiluppsättning 1" +"Stylistic set 2" = "Stiluppsättning 2" +"Stylistic set 3" = "Stiluppsättning 3" +"Stylistic set 4" = "Stiluppsättning 4" +"Stylistic set 5" = "Stiluppsättning 5" +"Subtract" = "Subtrahera" +"Subtracts" = "Subtraktioner" +"Suffix" = "Suffix" +"Suspend snapping while dragging" = "Stäng av fästning medan du drar" +"Swap instance" = "Byt instans" +"Swap library: use another library's assets of the same names" = "Byt bibliotek: använd tillgångar med samma namn från ett annat bibliotek" +"Swap orientation" = "Växla orientering" +"Swap overlay" = "Byt överlägg" +"Swap to {name}" = "Byt till {name}" +"Swap width and height" = "Byt plats på bredd och höjd" +"Swapped instance" = "Utbytt instans" +"Swapped {n} assets" = { one = "Bytte {n} tillgång", other = "Bytte {n} tillgångar" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Bytte {n} tillgång; {missed} saknade motsvarighet och använder fortfarande det gamla biblioteket", other = "Bytte {n} tillgångar; {missed} saknade motsvarighet och använder fortfarande det gamla biblioteket" } +"Sweep" = "Svep" +"System" = "Enligt systemet" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabell" +"Tablet" = "Surfplatta" +"Tabular numbers" = "Tabellsiffror" +"Take the values from the first selected layer" = "Hämta värdena från det första markerade lagret" +"Taper" = "Avsmalning" +"Taper both ends" = "Avsmalna båda ändarna" +"Tell the server's operator what is wrong with it" = "Berätta för serverns operatör vad som är fel med den" +"Temperature" = "Temperatur" +"Template" = "Mall" +"Templates" = "Mallar" +"Terms" = "Villkor" +"Text" = "Text" +"Text align" = "Textjustering" +"Text content" = "Textinnehåll" +"Text style" = "Textstil" +"Text styles" = "Textstilar" +"Texts" = "Texter" +"Texture" = "Textur" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Arbetsytan förhandsgranskar färger i sRGB; exporterade PNG-, JPG- och WebP-filer märks med Display P3." +"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." = "Filen raderas från servern: ingen kan längre öppna eller ansluta till den därifrån, och detta kan inte ångras. Kopior som är öppna finns kvar hos dem som har dem." +"The file now holds another library" = "Filen innehåller nu ett annat bibliotek" +"The file now holds another library (\"{name}\")" = "Filen innehåller nu ett annat bibliotek (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Filerna du har delat eller publicerat online från den här datorn. De ligger kvar på servern tills du tar bort dem." +"The link carries the session's secret: share it only with people you invite." = "Länken innehåller sessionens hemlighet: dela den bara med personer du bjuder in." +"The name in your online files and on the server. The document inside keeps its own name." = "Namnet i dina onlinefiler och på servern. Dokumentet inuti behåller sitt eget namn." +"The selection uses several text styles: click to apply one" = "Markeringen använder flera textstilar: klicka för att använda en" +"The server deletes this file if nobody opens it before {date}." = "Servern raderar filen om ingen öppnar den före {date}." +"The whole file" = "Hela filen" +"Theme" = "Tema" +"There's no destination for that action" = "Det finns inget mål för den åtgärden" +"Thickness" = "Tjocklek" +"This branch can't be reviewed: {e}" = "Den här grenen kan inte granskas: {e}" +"This branch was merged or archived." = "Den här grenen har sammanfogats eller arkiverats." +"This computer only" = "Endast den här datorn" +"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." = "Den här datorn kommer ihåg ägartoken, så filen kan hanteras från startskärmens onlinelista. Kopiera den om du vill hantera filen från en annan plats: den fungerar som ett lösenord för filen och visas inte igen." +"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 frame is new on the branch." = "Den här ramen är ny på grenen." +"This frame isn't on the branch." = "Den här ramen finns inte på grenen." +"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." +"Tidy up" = "Snygga till" +"Tile" = "Sida vid sida" +"Tile type" = "Upprepningstyp" +"Tint" = "Färgton" +"Title" = "Titel" +"Title case" = "Inledande versaler" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om du vill dela biblioteket med ditt team publicerar du filen på en server (Dela ▸ Publicera på server…) och publicerar sedan biblioteket där." +"Toggle" = "Visa/dölj" +"Toggle light/dark theme" = "Växla mellan ljust och mörkt tema" +"Toggle visibility" = "Visa/dölj" +"Tools" = "Verktyg" +"Top" = "Överkant" +"Top & bottom" = "Över- och nederkant" +"Top left radius" = "Radie uppe till vänster" +"Top right radius" = "Radie uppe till höger" +"Transform" = "Transformera" +"Triangle arrow" = "Triangelpil" +"Trigger" = "Utlösare" +"True" = "Sant" +"Truncate text" = "Korta av text" +"Truncate with an ellipsis" = "Korta av med ellips" +"Try again" = "Försök igen" +"Try another search or filter." = "Prova en annan sökning eller ett annat filter." +"Try another word, or look at every kind." = "Prova ett annat ord, eller visa alla typer." +"Turn a layer into a component, reuse it, and change every copy at once" = "Gör om ett lager till en komponent, återanvänd den och ändra alla kopior på en gång" +"Two digits" = "Två siffror" +"Type" = "Typ" +"Type settings" = "Textinställningar" +"Type the join code you were given, or paste an invite link." = "Skriv anslutningskoden du fick, eller klistra in en inbjudningslänk." +"Typography" = "Typografi" +"Underline" = "Understrykning" +"Undo takes back only your own changes; what others changed since stays." = "Ångra tar bara tillbaka dina egna ändringar; det som andra har ändrat sedan dess ligger kvar." +"Ungroup" = "Dela upp grupp" +"Uniform" = "Enhetlig" +"Union" = "Förena" +"Unions" = "Föreningar" +"Unknown — treated as sRGB" = "Okänd – behandlas som sRGB" +"Unread" = "Olästa" +"Unresolve" = "Markera som olöst" +"Unsaved changes" = "Osparade ändringar" +"Unsaved changes in {names}. Close the window and lose them?" = "Osparade ändringar i {names}. Vill du stänga fönstret och förlora dem?" +"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 plugin" = "Uppdatera plugin" +"Update" = "Uppdatera" +"Update from main" = "Uppdatera från main" +"Update from selection" = "Uppdatera från markering" +"Updated {components} and {styles}" = "{components} och {styles} har uppdaterats" +"Updated {name}" = "{name} har uppdaterats" +"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." = "Laddar upp filen till en FigCraft-server och håller fliken synkroniserad med den. Du får en länk för redigering och en för visning." +"Uppercase" = "Versaler" +"Use a fixed value" = "Använd ett fast värde" +"Use an expression, e.g. count + 1" = "Använd ett uttryck, t.ex. count + 1" +"Use as mask" = "Använd som mask" +"Use auto layout" = "Använd automatisk layout" +"Use number keys for opacity" = "Använd siffertangenterna för opacitet" +"Use the layer's fill" = "Använd lagrets fyllning" +"Used in {files} · {instances}" = "Används i {files} · {instances}" +"Username" = "Användarnamn" +"Value" = "Värde" +"Uses variables: each layer shows them in its own mode" = "Använder variabler: varje lager visar dem i sitt eget läge" +"Variable" = "Variabel" +"Variable collections" = "Variabelsamlingar" +"Variable modes" = "Variabellägen" +"Variable width" = "Variabel bredd" +"Variables" = "Variabler" +"Variables JSON…" = "Variabler som JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Variabler lagrar återanvändbara värden (färger, tal, strängar, booleska värden) med lägen som Ljust och Mörkt." +"Variant" = "Variant" +"Vector" = "Vektor" +"Vectors" = "Vektorer" +"Version" = "Version" +"Version history" = "Versionshistorik" +"Version {version}" = "Version {version}" +"Version {v} · {components} · {styles} · {variables}" = "Version {v} · {components} · {styles} · {variables}" +"Vertical" = "Vertikal" +"Vertical align" = "Vertikal justering" +"Vertical hex" = "Vertikal hex" +"Vertical padding" = "Vertikal utfyllnad" +"Vertical spacing" = "Vertikalt avstånd" +"Vertical trim" = "Vertikal trimning" +"View" = "Visa" +"View link" = "Visningslänk" +"View link copied" = "Visningslänken har kopierats" +"View only" = "Endast visning" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Endast visning: du ser alla ändringar direkt men kan inte redigera. Lämna för att behålla en kopia som du kan redigera." +"Viewing “{name}”" = "Visar ”{name}”" +"Violet" = "Violett" +"Visibility" = "Synlighet" +"Visible" = "Synlig" +"Vivid" = "Livfull" +"Warm" = "Varm" +"Watch" = "Bevaka" +"Wavy" = "Vågig" +"Web" = "Webb" +"Websites" = "Webbplatser" +"WebSocket URL" = "WebSocket-URL" +"WebSocket URL copied" = "WebSocket-URL kopierad" +"Wedge" = "Kil" +"Weight" = "Tjocklek" +"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 ramar 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." +"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" +"Why it changed" = "Varför det ändrades" +"Widgets" = "Widgetar" +"Width" = "Bredd" +"Width point" = "Breddpunkt" +"Width profile" = "Breddprofil" +"Wireframe kit" = "Trådramspaket" +"With the layers' export settings" = "Med lagrens exportinställningar" +"Wrap" = "Radbryt" +"Wrap in new section" = "Lägg i nytt avsnitt" +"Yellow" = "Gul" +"Yesterday" = "I går" +"You" = "Du" +"You'll get the shared file as a new tab and edit it together." = "Du får den delade filen som en ny flik och redigerar den tillsammans." +"You're spotlighting: everyone follows your view" = "Du har spotlight: alla följer din vy" +"Your copy of the file stays open" = "Din kopia av filen förblir öppen" +"Your copy stays open; the file stays on the server" = "Din kopia förblir öppen; filen ligger kvar på servern" +"Your name" = "Ditt namn" +"Your name in comments" = "Ditt namn i kommentarer" +"Your system asks for reduced motion: animations stay off." = "Systemet begär minskad rörelse: animeringar förblir avstängda." +"Z" = "Z" +"Z to A" = "Ö till A" +"Zoom" = "Zoom" +"Zoom around the pointer" = "Zooma runt pekaren" +"Zoom device to fill window" = "Zooma enheten så att den fyller fönstret" +"Zoom in" = "Zooma in" +"Zoom out" = "Zooma ut" +"Zoom to 100%" = "Zooma till 100 %" +"Zoom to 200%" = "Zooma till 200 %" +"Zoom to 50%" = "Zooma till 50 %" +"Zoom to fit" = "Zooma för att anpassa" +"Zoom to selection" = "Zooma till markering" +"Zoom tool: click to zoom in, drag an area" = "Zoomverktyg: klicka för att zooma in, dra över ett område" +"Zoom tool: zoom out" = "Zoomverktyg: zooma ut" +"and {n} more" = { one = "och {n} till", other = "och {n} till" } +"bleed marks" = "utfallsmärken" +"by {author}" = "av {author}" +"call any command its access allows" = "anropa alla kommandon som dess åtkomst tillåter" +"call only: {commands}" = "anropa endast: {commands}" +"can edit the document" = "kan redigera dokumentet" +"crop marks" = "skärmärken" +"development: {path}" = "utveckling: {path}" +"e.g. count + 1" = "t.ex. count + 1" +"e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen." +"from {server}" = "från {server}" +"hidden" = "dold" +"iOS" = "iOS" +"just now" = "nyss" +"locked" = "låst" +"no bleed" = "inget utfall" +"nobody else here yet" = "ingen annan här än" +"now" = "nu" +"or" = "eller" +"put a widget on the canvas" = "placera en widget på arbetsytan" +"read and edit the document" = "läsa och redigera dokumentet" +"read the document" = "läsa dokumentet" +"reads the document" = "läser dokumentet" +"the plugin" = "pluginet" +"this session only" = "endast den här sessionen" +"yesterday" = "i går" +"{instances} in {files}" = "{instances} i {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: utkast till översättning, väntar på granskning av en modersmålstalare." +"{length} bleed" = "{length} utfall" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "{name} har tagits bort från listan" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}" +"{name} is on the server." = "{name} finns på servern." +"{name} is spotlighting" = "{name} har spotlight" +"{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen" +"{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}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den." +"{name} (from {library})" = "{name} (från {library})" +"{name} (you)" = "{name} (du)" +"{name} ({layers} layers)" = "{name} ({layers} lager)" +"{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}, standard {default}. Dubbelklicka på reglaget för att återgå till formatmallens värde; Esc under dragning avbryter." +"{name} from {path}" = "{name} från {path}" +"{name} was removed from the server" = "{name} togs bort från servern" +"{name} · deleted on main" = "{name} · raderad i main" +"{name} · deleted on the branch" = "{name} · raderad i grenen" +"{name} · not published" = "{name} · inte publicerad" +"{name} · on a server" = "{name} · på en server" +"{name} · published version {v}" = "{name} · publicerad version {v}" +"{name} · version {v} on the server" = "{name} · version {v} på servern" +"{name}, in a live session" = "{name}, i en livesession" +"{name}, online, open only to you" = "{name}, online, öppen bara för dig" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} gånger {h}, markerad" +"{n} added" = { one = "{n} tillagd", other = "{n} tillagda" } +"{n} autosave versions" = { one = "{n} autosparad version", other = "{n} autosparade versioner" } +"{n} changed" = { one = "{n} ändrad", other = "{n} ändrade" } +"{n} columns" = { one = "{n} kolumn", other = "{n} kolumner" } +"{n} components" = { one = "{n} komponent", other = "{n} komponenter" } +"{n} components and styles have newer versions" = { one = "{n} komponent eller formatmall har en nyare version", other = "{n} komponenter och formatmallar har nyare versioner" } +"{n} conflicts to resolve" = { one = "{n} konflikt att lösa", other = "{n} konflikter att lösa" } +"{n} days ago" = { one = "för {n} dag sedan", other = "för {n} dagar sedan" } +"{n} files" = { one = "{n} fil", other = "{n} filer" } +"{n} hours ago" = { one = "för {n} timme sedan", other = "för {n} timmar sedan" } +"{n} instances" = { one = "{n} instans", other = "{n} instanser" } +"{n} layers" = { one = "{n} lager", other = "{n} lager" } +"{n} layers as one file" = { one = "{n} lager som en fil", other = "{n} lager som en fil" } +"{n} layers selected" = { one = "{n} lager markerat", other = "{n} lager markerade" } +"{n} layers · {parts}" = { one = "{n} lager · {parts}", other = "{n} lager · {parts}" } +"{n} matches" = { one = "{n} träff", other = "{n} träffar" } +"{n} minutes ago" = { one = "för {n} minut sedan", other = "för {n} minuter sedan" } +"{n} months ago" = { one = "för {n} månad sedan", other = "för {n} månader sedan" } +"{n} of {total}" = "{n} av {total}" +"{n} people joined" = { one = "{n} person anslöt", other = "{n} personer anslöt" } +"{n} preferred components" = { one = "{n} föredragen komponent", other = "{n} föredragna komponenter" } +"{n} ready" = { one = "{n} klar", other = "{n} klara" } +"{n} removed" = { one = "{n} borttagen", other = "{n} borttagna" } +"{n} replies" = { one = "{n} svar", other = "{n} svar" } +"{n} rows" = { one = "{n} rad", other = "{n} rader" } +"{n} styles" = { one = "{n} formatmall", other = "{n} formatmallar" } +"{n} variables" = { one = "{n} variabel", other = "{n} variabler" } +"{n} weeks ago" = { one = "för {n} vecka sedan", other = "för {n} veckor sedan" } +"{n} years ago" = { one = "för {n} år sedan", other = "för {n} år sedan" } +"{n}d" = "{n} d" +"{n}h" = "{n} h" +"{n}m" = "{n} min" +"{n}w" = "{n} v" +"{page}, nothing selected" = "{page}, inget markerat" +"{profile} (current)" = "{profile} (aktuell)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} är dess värde; {property ? \"a\" : \"b\"} väljer en sträng. Andra {…} lämnas som de är skrivna." +"{server} can't say what became of your files (it is older than this app)." = "{server} kan inte säga vad som hände med dina filer (den är äldre än den här appen)." +"{server} · No longer on the server" = "{server} · Finns inte längre på servern" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Öppnad {date}" +"{tool} hold {key}" = "{tool} håll ned {key}" +"{value} isn't a colour" = "{value} är inte en färg" +"{when} · edited" = "{when} · ändrad" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, blanksteg eller N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ pilar" +"⌘ click" = "⌘ klicka" +"⌘ scroll" = "⌘ rulla" +"⌥ drag" = "⌥ dra" + +[commands] + +"align.bottom" = "align.bottom" +"align.hcenter" = "align.hcenter" +"align.left" = "align.left" +"align.right" = "align.right" +"align.top" = "align.top" +"align.vcenter" = "align.vcenter" +"annotation.add" = "annotation.add" +"annotation.addCategory" = "annotation.addCategory" +"annotation.categories" = "annotation.categories" +"annotation.edit" = "annotation.edit" +"annotation.editCategory" = "annotation.editCategory" +"annotation.list" = "annotation.list" +"annotation.remove" = "annotation.remove" +"annotation.removeCategory" = "annotation.removeCategory" +"app.actions" = "app.actions" +"app.preferences" = "app.preferences" +"arrange.back" = "arrange.back" +"arrange.backward" = "arrange.backward" +"arrange.forward" = "arrange.forward" +"arrange.front" = "arrange.front" +"arrange.packH" = "arrange.packH" +"arrange.packV" = "arrange.packV" +"arrange.roundToPixels" = "arrange.roundToPixels" +"arrange.tidy" = "arrange.tidy" +"bool.exclude" = "bool.exclude" +"bool.flatten" = "bool.flatten" +"bool.intersect" = "bool.intersect" +"bool.subtract" = "bool.subtract" +"bool.union" = "bool.union" +"branch.archive" = "branch.archive" +"branch.create" = "branch.create" +"branch.list" = "branch.list" +"branch.merge" = "branch.merge" +"branch.open" = "branch.open" +"branch.review" = "branch.review" +"branch.updateFromMain" = "branch.updateFromMain" +"codeConnect.export" = "codeConnect.export" +"codeConnect.import" = "codeConnect.import" +"codeConnect.remove" = "codeConnect.remove" +"codeConnect.set" = "codeConnect.set" +"collab.cursor" = "collab.cursor" +"collab.cursorChat" = "collab.cursorChat" +"collab.diff" = "collab.diff" +"collab.files" = "collab.files" +"collab.follow" = "collab.follow" +"collab.forgetFile" = "collab.forgetFile" +"collab.host" = "collab.host" +"collab.join" = "collab.join" +"collab.leave" = "collab.leave" +"collab.manageFile" = "collab.manageFile" +"collab.openFile" = "collab.openFile" +"collab.peers" = "collab.peers" +"collab.publish" = "collab.publish" +"collab.recoverFile" = "collab.recoverFile" +"collab.registerLinks" = "collab.registerLinks" +"collab.report" = "collab.report" +"collab.serverInfo" = "collab.serverInfo" +"collab.share" = "collab.share" +"collab.spotlight" = "collab.spotlight" +"collab.stop" = "collab.stop" +"command.batch" = "command.batch" +"comment.add" = "comment.add" +"comment.delete" = "comment.delete" +"comment.edit" = "comment.edit" +"comment.link" = "comment.link" +"comment.list" = "comment.list" +"comment.markRead" = "comment.markRead" +"comment.move" = "comment.move" +"comment.next" = "comment.next" +"comment.open" = "comment.open" +"comment.people" = "comment.people" +"comment.react" = "comment.react" +"comment.reply" = "comment.reply" +"comment.resolve" = "comment.resolve" +"comment.zoom" = "comment.zoom" +"component.addProperty" = "component.addProperty" +"component.addVariant" = "component.addVariant" +"component.bindProperty" = "component.bindProperty" +"component.combineVariants" = "component.combineVariants" +"component.create" = "component.create" +"component.createMultiple" = "component.createMultiple" +"component.deleteProperty" = "component.deleteProperty" +"component.detach" = "component.detach" +"component.editProperty" = "component.editProperty" +"component.exposeInstance" = "component.exposeInstance" +"component.goToMain" = "component.goToMain" +"component.hideWhenPublishing" = "component.hideWhenPublishing" +"component.list" = "component.list" +"component.makeSlot" = "component.makeSlot" +"component.playground" = "component.playground" +"component.properties" = "component.properties" +"component.pushChanges" = "component.pushChanges" +"component.removeSlot" = "component.removeSlot" +"component.resetInstance" = "component.resetInstance" +"component.resetOverride" = "component.resetOverride" +"component.restoreMain" = "component.restoreMain" +"dev.compare" = "dev.compare" +"dev.focus" = "dev.focus" +"dev.resolveChange" = "dev.resolveChange" +"dev.unfocus" = "dev.unfocus" +"devResource.add" = "devResource.add" +"devResource.edit" = "devResource.edit" +"devResource.list" = "devResource.list" +"devResource.remove" = "devResource.remove" +"distribute.horizontal" = "distribute.horizontal" +"distribute.vertical" = "distribute.vertical" +"doc.home" = "doc.home" +"doc.list" = "doc.list" +"doc.nextTab" = "doc.nextTab" +"doc.previousTab" = "doc.previousTab" +"doc.switch" = "doc.switch" +"edit.copy" = "edit.copy" +"edit.copyAsCss" = "edit.copyAsCss" +"edit.copyAsPng" = "edit.copyAsPng" +"edit.copyAsSvg" = "edit.copyAsSvg" +"edit.copyAsText" = "edit.copyAsText" +"edit.copyLink" = "edit.copyLink" +"edit.copyProperties" = "edit.copyProperties" +"edit.cut" = "edit.cut" +"edit.delete" = "edit.delete" +"edit.duplicate" = "Duplicera" +"edit.findReplace" = "Sök och ersätt" +"edit.paste" = "Klistra in" +"edit.pasteOver" = "Klistra in över" +"edit.pasteProperties" = "Klistra in egenskaper" +"edit.pasteReplace" = "Klistra in och ersätt" +"edit.pickColor" = "Välj färg" +"edit.redo" = "Gör om" +"edit.repeat" = "Upprepa" +"edit.setDefaultProperties" = "Ange som standardegenskaper" +"edit.undo" = "Ångra" +"effect.add" = "Lägg till effekt" +"effect.move" = "Flytta effekt" +"effect.remove" = "Ta bort effekt" +"effect.set" = "Ange effekt" +"export.add" = "Lägg till export" +"export.remove" = "Ta bort export" +"export.update" = "Uppdatera export" +"file.close" = "Stäng" +"file.colorProfile" = "Färgprofil" +"file.duplicate" = "Duplicera fil" +"file.export" = "Exportera" +"file.exportFramesPdf" = "Exportera ramar som PDF" +"file.exportLayers" = "Exportera lager" +"file.exportSvg" = "Exportera som SVG" +"file.import" = "Importera" +"file.new" = "Nytt" +"file.newFromTemplate" = "Nytt från mall" +"file.open" = "Öppna" +"file.openRecent" = "Öppna senaste" +"file.placeImage" = "Placera bild" +"file.recover" = "Återställ" +"file.rename" = "Byt namn" +"file.revert" = "Återgå" +"file.save" = "Spara" +"file.saveAs" = "Spara som" +"file.saveVersion" = "Spara version" +"file.versionHistory" = "Versionshistorik" +"fill.add" = "Lägg till fyllning" +"fill.move" = "Flytta fyllning" +"fill.pattern" = "Mönster" +"fill.remove" = "Ta bort fyllning" +"fill.set" = "Ange fyllning" +"frame.create" = "Skapa ram" +"frame.presets" = "Ramförinställningar" +"frame.swapOrientation" = "Växla orientering" +"grid.add" = "Lägg till rutnät" +"grid.move" = "Flytta rutnät" +"grid.remove" = "Ta bort rutnät" +"guide.add" = "Lägg till stödlinje" +"guide.clear" = "Rensa stödlinjer" +"guide.move" = "Flytta stödlinje" +"guide.remove" = "Ta bort stödlinje" +"guide.select" = "Markera stödlinje" +"help.about" = "Om FigCraft" +"help.gettingStarted" = "Kom igång" +"help.page" = "Hjälpsida" +"help.shortcuts" = "Kortkommandon" +"image.adjust" = "Justera bild" +"image.deletePreset" = "Radera förinställning" +"image.place" = "Placera bild" +"image.presets" = "Bildförinställningar" +"image.savePreset" = "Spara förinställning" +"inspect.assets" = "Tillgångar" +"inspect.code" = "Kod" +"inspect.componentCode" = "Komponentkod" +"inspect.properties" = "Egenskaper" +"inspect.readyForDev" = "Redo för utveckling" +"instance.create" = "Skapa instans" +"instance.exposed" = "Exponerade egenskaper" +"instance.place" = "Placera instans" +"instance.resetSlot" = "Återställ fack" +"instance.setProperties" = "Ange instansegenskaper" +"instance.slotInsert" = "Infoga i fack" +"instance.swap" = "Byt instans" +"instance.swapAt" = "Byt instans vid position" +"layers.collapseAll" = "Fäll ihop alla" +"layout.add" = "Lägg till automatisk layout" +"layout.remove" = "Ta bort automatisk layout" +"layout.setTrack" = "Ange spår" +"layout.suggest" = "Föreslå layout" +"libraries.open" = "Öppna bibliotek" +"libraries.publish" = "Publicera bibliotek" +"library.acceptUpdates" = "Godkänn uppdateringar" +"library.add" = "Lägg till bibliotek" +"library.aliasVariable" = "Skapa variabelalias" +"library.applyStyle" = "Använd formatmall" +"library.bindVariable" = "Bind variabel" +"library.extendCollection" = "Utöka samling" +"library.import" = "Importera från bibliotek" +"library.importVariable" = "Importera variabel" +"library.list" = "Lista bibliotek" +"library.place" = "Placera från bibliotek" +"library.publish" = "Publicera bibliotek" +"library.publishToServer" = "Publicera till server" +"library.remove" = "Ta bort bibliotek" +"library.setVariableMode" = "Ange variabelläge" +"library.swap" = "Byt från bibliotek" +"library.swapAt" = "Byt från bibliotek vid position" +"library.updates" = "Biblioteksuppdateringar" +"library.usage" = "Biblioteksanvändning" +"measure.distances" = "Mät avstånd" +"measure.padding" = "Mät utfyllnad" +"node.create" = "Skapa nod" +"node.get" = "Hämta nod" +"node.reorder" = "Ändra ordning" +"node.reparent" = "Byt överordnad nod" +"node.set" = "Ange nodegenskaper" +"object.batchRename" = "Byt namn på flera" +"object.convertToFrame" = "Konvertera till ram" +"object.convertToGroup" = "Konvertera till grupp" +"object.flipH" = "Vänd vågrätt" +"object.flipV" = "Vänd lodrätt" +"object.frameSelection" = "Rama in markering" +"object.group" = "Gruppera" +"object.hideOthers" = "Dölj övriga" +"object.mask" = "Använd som mask" +"object.moveToPage" = "Flytta till sida" +"object.opacityKeys" = "Nyckelbildrutor för opacitet" +"object.rasterize" = "Rastrera" +"object.releaseMask" = "object.releaseMask" +"object.removeFill" = "object.removeFill" +"object.removeFrame" = "object.removeFrame" +"object.removeStroke" = "object.removeStroke" +"object.rename" = "object.rename" +"object.resizeToFit" = "object.resizeToFit" +"object.rotate180" = "object.rotate180" +"object.rotateLeft" = "object.rotateLeft" +"object.rotateRight" = "object.rotateRight" +"object.setThumbnail" = "object.setThumbnail" +"object.swapFillStroke" = "object.swapFillStroke" +"object.toggleLock" = "object.toggleLock" +"object.toggleVisible" = "object.toggleVisible" +"object.ungroup" = "object.ungroup" +"object.wrapInSection" = "object.wrapInSection" +"page.add" = "page.add" +"page.delete" = "page.delete" +"page.duplicate" = "page.duplicate" +"page.list" = "page.list" +"page.move" = "page.move" +"page.rename" = "page.rename" +"page.select" = "page.select" +"page.setBackground" = "page.setBackground" +"paint.documentColors" = "paint.documentColors" +"plugin.close" = "plugin.close" +"plugin.directory.install" = "plugin.directory.install" +"plugin.directory.list" = "plugin.directory.list" +"plugin.directory.publish" = "plugin.directory.publish" +"plugin.directory.unpublish" = "plugin.directory.unpublish" +"plugin.event" = "plugin.event" +"plugin.import" = "plugin.import" +"plugin.list" = "plugin.list" +"plugin.remove" = "plugin.remove" +"plugin.run" = "plugin.run" +"plugin.ui" = "plugin.ui" +"plugins.manage" = "plugins.manage" +"plugins.runLast" = "plugins.runLast" +"pref.highlightHover" = "pref.highlightHover" +"pref.keepTool" = "pref.keepTool" +"pref.language" = "pref.language" +"pref.nudge" = "pref.nudge" +"pref.numberKeysOpacity" = "pref.numberKeysOpacity" +"pref.publishServer" = "pref.publishServer" +"pref.reduceMotion" = "pref.reduceMotion" +"pref.renameDuplicates" = "pref.renameDuplicates" +"pref.showDimensions" = "pref.showDimensions" +"pref.userName" = "pref.userName" +"proto.action.add" = "proto.action.add" +"proto.action.move" = "proto.action.move" +"proto.action.remove" = "proto.action.remove" +"proto.action.set" = "proto.action.set" +"proto.closePreview" = "proto.closePreview" +"proto.conditional.addBlock" = "proto.conditional.addBlock" +"proto.conditional.removeBlock" = "proto.conditional.removeBlock" +"proto.conditional.setCondition" = "proto.conditional.setCondition" +"proto.evaluate" = "proto.evaluate" +"proto.flow.add" = "proto.flow.add" +"proto.flow.remove" = "proto.flow.remove" +"proto.flow.rename" = "proto.flow.rename" +"proto.interaction.add" = "proto.interaction.add" +"proto.interaction.clear" = "proto.interaction.clear" +"proto.interaction.remove" = "proto.interaction.remove" +"proto.interaction.set" = "proto.interaction.set" +"proto.present" = "proto.present" +"proto.presentOptions" = "proto.presentOptions" +"proto.preview" = "proto.preview" +"proto.settings" = "proto.settings" +"section.toggleContents" = "section.toggleContents" +"select.add" = "select.add" +"select.all" = "select.all" +"select.area" = "select.area" +"select.byName" = "select.byName" +"select.children" = "select.children" +"select.inverse" = "select.inverse" +"select.layersAt" = "select.layersAt" +"select.matching" = "select.matching" +"select.next" = "select.next" +"select.none" = "select.none" +"select.parent" = "select.parent" +"select.previous" = "select.previous" +"select.remove" = "select.remove" +"select.sameEffect" = "select.sameEffect" +"select.sameFill" = "select.sameFill" +"select.sameFont" = "select.sameFont" +"select.sameInstance" = "select.sameInstance" +"select.sameProperties" = "select.sameProperties" +"select.sameStroke" = "select.sameStroke" +"select.sameText" = "select.sameText" +"select.set" = "select.set" +"shape.arrow" = "shape.arrow" +"shape.ellipse" = "shape.ellipse" +"shape.line" = "shape.line" +"shape.polygon" = "shape.polygon" +"shape.rectangle" = "shape.rectangle" +"shape.star" = "shape.star" +"shapeBuilder.merge" = "shapeBuilder.merge" +"shapeBuilder.mode" = "shapeBuilder.mode" +"shapeBuilder.regions" = "shapeBuilder.regions" +"shapeBuilder.remove" = "shapeBuilder.remove" +"stroke.add" = "stroke.add" +"stroke.brush" = "stroke.brush" +"stroke.move" = "stroke.move" +"stroke.remove" = "stroke.remove" +"stroke.set" = "stroke.set" +"stroke.widthProfile" = "stroke.widthProfile" +"style.apply" = "style.apply" +"style.bind" = "style.bind" +"style.create" = "style.create" +"style.delete" = "style.delete" +"style.detach" = "style.detach" +"style.duplicate" = "style.duplicate" +"style.list" = "style.list" +"style.move" = "style.move" +"style.renameFolder" = "style.renameFolder" +"style.update" = "style.update" +"template.list" = "template.list" +"text.alignCenter" = "text.alignCenter" +"text.alignJustify" = "text.alignJustify" +"text.alignLeft" = "text.alignLeft" +"text.alignRight" = "text.alignRight" +"text.bold" = "text.bold" +"text.bulletList" = "text.bulletList" +"text.case" = "text.case" +"text.caseLower" = "text.caseLower" +"text.caseOriginal" = "text.caseOriginal" +"text.caseSmallCaps" = "text.caseSmallCaps" +"text.caseTitle" = "text.caseTitle" +"text.caseUpper" = "text.caseUpper" +"text.create" = "text.create" +"text.decoration" = "text.decoration" +"text.edit.begin" = "text.edit.begin" +"text.edit.caret" = "text.edit.caret" +"text.edit.end" = "text.edit.end" +"text.edit.insert" = "text.edit.insert" +"text.edit.key" = "text.edit.key" +"text.edit.preedit" = "text.edit.preedit" +"text.edit.selectLine" = "text.edit.selectLine" +"text.edit.selectWord" = "text.edit.selectWord" +"text.edit.selection" = "text.edit.selection" +"text.endPreview" = "text.endPreview" +"text.fonts" = "text.fonts" +"text.italic" = "text.italic" +"text.lineHeightDown" = "text.lineHeightDown" +"text.lineHeightUp" = "text.lineHeightUp" +"text.link" = "text.link" +"text.listIndent" = "text.listIndent" +"text.listOutdent" = "text.listOutdent" +"text.listStyle" = "text.listStyle" +"text.maxLines" = "text.maxLines" +"text.numberList" = "text.numberList" +"text.onPath" = "text.onPath" +"text.paragraphIndent" = "text.paragraphIndent" +"text.paragraphSpacing" = "text.paragraphSpacing" +"text.pathOptions" = "text.pathOptions" +"text.preview" = "text.preview" +"text.removeFromPath" = "text.removeFromPath" +"text.set" = "text.set" +"text.setFeatures" = "text.setFeatures" +"text.setVariations" = "text.setVariations" +"text.sizeDown" = "text.sizeDown" +"text.sizeUp" = "text.sizeUp" +"text.strikethrough" = "text.strikethrough" +"text.style" = "text.style" +"text.trackingDown" = "text.trackingDown" +"text.trackingUp" = "text.trackingUp" +"text.underline" = "text.underline" +"text.weightDown" = "text.weightDown" +"text.weightUp" = "text.weightUp" +"tool.arrow" = "tool.arrow" +"tool.comment" = "tool.comment" +"tool.ellipse" = "tool.ellipse" +"tool.frame" = "tool.frame" +"tool.hand" = "tool.hand" +"tool.line" = "tool.line" +"tool.move" = "tool.move" +"tool.pen" = "tool.pen" +"tool.pencil" = "tool.pencil" +"tool.pencilBrush" = "tool.pencilBrush" +"tool.place" = "tool.place" +"tool.polygon" = "tool.polygon" +"tool.rectangle" = "tool.rectangle" +"tool.scale" = "tool.scale" +"tool.scaleAnchor" = "tool.scaleAnchor" +"tool.section" = "tool.section" +"tool.set" = "tool.set" +"tool.shapeBuilder" = "tool.shapeBuilder" +"tool.slice" = "tool.slice" +"tool.star" = "tool.star" +"tool.text" = "tool.text" +"transform.move" = "transform.move" +"transform.moveTo" = "transform.moveTo" +"transform.nudge" = "transform.nudge" +"transform.nudgeSize" = "transform.nudgeSize" +"transform.resize" = "transform.resize" +"transform.rotate" = "transform.rotate" +"transform.rotationOrigin" = "transform.rotationOrigin" +"transform.scale" = "transform.scale" +"transform.setRotationOrigin" = "transform.setRotationOrigin" +"variable.addMode" = "variable.addMode" +"variable.bind" = "variable.bind" +"variable.create" = "variable.create" +"variable.createCollection" = "variable.createCollection" +"variable.delete" = "variable.delete" +"variable.deleteCollection" = "variable.deleteCollection" +"variable.duplicate" = "variable.duplicate" +"variable.export" = "variable.export" +"variable.import" = "variable.import" +"variable.list" = "variable.list" +"variable.moveMode" = "variable.moveMode" +"variable.removeMode" = "variable.removeMode" +"variable.renameCollection" = "variable.renameCollection" +"variable.renameMode" = "variable.renameMode" +"variable.resetOverride" = "variable.resetOverride" +"variable.set" = "variable.set" +"variable.setMode" = "variable.setMode" +"variable.update" = "variable.update" +"vector.create" = "vector.create" +"vector.deleteHeal" = "vector.deleteHeal" +"vector.edit.addVertex" = "vector.edit.addVertex" +"vector.edit.begin" = "vector.edit.begin" +"vector.edit.bend" = "vector.edit.bend" +"vector.edit.bounds" = "vector.edit.bounds" +"vector.edit.connect" = "vector.edit.connect" +"vector.edit.cornerRadius" = "vector.edit.cornerRadius" +"vector.edit.cut" = "vector.edit.cut" +"vector.edit.delete" = "vector.edit.delete" +"vector.edit.end" = "vector.edit.end" +"vector.edit.erase" = "vector.edit.erase" +"vector.edit.lasso" = "vector.edit.lasso" +"vector.edit.mirroring" = "vector.edit.mirroring" +"vector.edit.moveHandles" = "vector.edit.moveHandles" +"vector.edit.moveVertices" = "vector.edit.moveVertices" +"vector.edit.nudge" = "vector.edit.nudge" +"vector.edit.paint" = "vector.edit.paint" +"vector.edit.pen" = "vector.edit.pen" +"vector.edit.penEnd" = "vector.edit.penEnd" +"vector.edit.position" = "vector.edit.position" +"vector.edit.resize" = "vector.edit.resize" +"vector.edit.rotate" = "vector.edit.rotate" +"vector.edit.select" = "vector.edit.select" +"vector.edit.selectAll" = "vector.edit.selectAll" +"vector.edit.setHandle" = "vector.edit.setHandle" +"vector.edit.smooth" = "vector.edit.smooth" +"vector.edit.state" = "vector.edit.state" +"vector.edit.strokeCap" = "vector.edit.strokeCap" +"vector.edit.strokeJoin" = "vector.edit.strokeJoin" +"vector.edit.tool" = "vector.edit.tool" +"vector.edit.transform" = "vector.edit.transform" +"vector.edit.widthPoint" = "vector.edit.widthPoint" +"vector.flatten" = "vector.flatten" +"vector.join" = "vector.join" +"vector.offset" = "vector.offset" +"vector.outlineStroke" = "vector.outlineStroke" +"vector.reverse" = "vector.reverse" +"vector.simplify" = "vector.simplify" +"vector.smoothJoin" = "vector.smoothJoin" +"version.autosave" = "version.autosave" +"version.delete" = "version.delete" +"version.duplicate" = "version.duplicate" +"version.exitPreview" = "version.exitPreview" +"version.list" = "version.list" +"version.preview" = "version.preview" +"version.rename" = "version.rename" +"version.restore" = "version.restore" +"view.assets" = "view.assets" +"view.comments" = "view.comments" +"view.contextMenu" = "view.contextMenu" +"view.design" = "view.design" +"view.designPrototype" = "view.designPrototype" +"view.devMode" = "view.devMode" +"view.frameOutlines" = "view.frameOutlines" +"view.fullscreen" = "view.fullscreen" +"view.get" = "view.get" +"view.layers" = "view.layers" +"view.layoutGrids" = "view.layoutGrids" +"view.leftSidebar" = "view.leftSidebar" +"view.maskOutlines" = "view.maskOutlines" +"view.multiplayerCursors" = "view.multiplayerCursors" +"view.nextFrame" = "view.nextFrame" +"view.nextPage" = "view.nextPage" +"view.nextRegion" = "view.nextRegion" +"view.outlines" = "view.outlines" +"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" +"view.outlinesObjectBounds" = "view.outlinesObjectBounds" +"view.outlinesSelection" = "view.outlinesSelection" +"view.pan" = "view.pan" +"view.panelWidths" = "view.panelWidths" +"view.pixelGrid" = "view.pixelGrid" +"view.pixelPreview" = "view.pixelPreview" +"view.prevFrame" = "view.prevFrame" +"view.prevPage" = "view.prevPage" +"view.previousRegion" = "view.previousRegion" +"view.proofSrgb" = "view.proofSrgb" +"view.prototype" = "view.prototype" +"view.rulers" = "view.rulers" +"view.set" = "view.set" +"view.slices" = "view.slices" +"view.snapGeometry" = "view.snapGeometry" +"view.snapObjects" = "view.snapObjects" +"view.snapPixel" = "view.snapPixel" +"view.themeDark" = "view.themeDark" +"view.themeLight" = "view.themeLight" +"view.themeSystem" = "view.themeSystem" +"view.toggleUi" = "view.toggleUi" +"view.zoom100" = "view.zoom100" +"view.zoom200" = "view.zoom200" +"view.zoom50" = "view.zoom50" +"view.zoomAt" = "view.zoomAt" +"view.zoomFit" = "view.zoomFit" +"view.zoomIn" = "view.zoomIn" +"view.zoomOut" = "view.zoomOut" +"view.zoomSelection" = "view.zoomSelection" +"widget.click" = "widget.click" +"widget.insert" = "widget.insert" +"widget.list" = "widget.list" +"window.focus" = "window.focus" +"window.new" = "window.new" + +[menu] + +"bool.exclude" = "bool.exclude" +"bool.flatten" = "bool.flatten" +"bool.intersect" = "bool.intersect" +"bool.subtract" = "bool.subtract" +"bool.union" = "bool.union" +"file.duplicate" = "file.duplicate" +"file.import" = "file.import" +"file.rename" = "file.rename" +"libraries.open" = "libraries.open" +"select.sameEffect" = "select.sameEffect" +"select.sameFill" = "select.sameFill" +"select.sameFont" = "select.sameFont" +"select.sameInstance" = "select.sameInstance" +"select.sameProperties" = "select.sameProperties" +"select.sameStroke" = "select.sameStroke" +"select.sameText" = "select.sameText" +"text.alignCenter" = "text.alignCenter" +"text.alignJustify" = "text.alignJustify" +"text.alignLeft" = "text.alignLeft" +"text.alignRight" = "text.alignRight" +"view.assets" = "view.assets" +"view.design" = "view.design" +"view.layers" = "view.layers" +"view.outlines" = "view.outlines" +"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" +"view.outlinesObjectBounds" = "view.outlinesObjectBounds" +"view.outlinesSelection" = "view.outlinesSelection" +"view.prototype" = "view.prototype" +"view.themeDark" = "view.themeDark" +"view.themeLight" = "view.themeLight" +"view.themeSystem" = "view.themeSystem" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 768434e3..7d98bdd7 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use super::*; /// Languages that must translate every UI string and command label. -const COMPLETE: &[&str] = &["es"]; +const COMPLETE: &[&str] = &["es", "sv"]; /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. @@ -315,10 +315,23 @@ fn system_languages_pick_the_first_one_with_a_catalog() { // An OS list: the first language there is a catalog for (English counts). assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de"); assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en"); - assert_eq!(code("system", Some("nl,sv")), "en"); + assert_eq!(code("system", Some("nl,da")), "en"); + assert_eq!(code("system", Some("nl,sv")), "sv"); assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's"); } +#[test] +fn swedish_is_picked_for_swedish_locales() { + let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; + for tag in ["sv", "sv-SE", "sv-FI", "sv_SE.UTF-8"] { + assert_eq!(code("system", Some(tag)), "sv", "{tag}"); + } + let sv = LANGS.iter().position(|l| l.code == "sv").unwrap(); + assert_eq!(LANGS[sv].name, "Svenska"); + assert!(!LANGS[sv].draft); + assert!(!plural_one(sv, 0) && plural_one(sv, 1) && !plural_one(sv, 2)); +} + #[test] fn latin_american_spanish_overlays_spanish() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 99f0f1a7..0aaf84dd 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -29,7 +29,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español - (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a + (Latinoamérica), Français, 日本語, 한국어, Português (Brasil), Svenska, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (Windows' language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other @@ -91,7 +91,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: none shipped (Figma ships none beyond the eight above). +Other languages: Swedish (`sv`), every UI string, command label and menu wording translated (a +catalog test checks it stays complete); native review welcome. Figma ships none beyond the eight +above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..960fdca9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,7 +349,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/translating.md b/docs/translating.md index b7c26a40..9f209491 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -14,7 +14,8 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review. +and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and +Swedish (`sv`), which translates every string. ## The catalogs From e8a30df94448e74da7ff6625e3da8be7c2fe1078 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:57:13 +0200 Subject: [PATCH 02/77] Give case-colliding variant properties distinct data attributes Fixes #456 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/component_codegen.rs | 17 +++++++----- crates/engine/src/component_codegen_tests.rs | 29 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index c810cf1f..1416b15f 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { } } -/// `data-*` attribute names for variant parameters. -fn data_attr(p: &Param) -> String { +/// `data-*` attribute names for variant parameters, unique among them (in order). +fn data_attrs(variants: &[&Param]) -> Vec { let mut used = Vec::new(); - format!("data-{}", class_name(&p.prop, &mut used)) + variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect() } /// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom @@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default(); let root_class = class_name(&root_name, &mut Vec::new()); let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect(); + let attr_names = data_attrs(&variants); let pass = |root: NodeId| { let vctx = Ctx { doc, root, comp: Some(c), base: 2 }; let mut m = Markup::new(&vctx, lang); @@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String m.root_class = Some(root_class.clone()); m.root_attrs = variants .iter() - .map(|p| match lang { - CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)), - _ => format!(" {}={{{}}}", data_attr(p), p.ident), + .zip(&attr_names) + .map(|(p, attr)| match lang { + CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)), + _ => format!(" {attr}={{{}}}", p.ident), }) .collect(); m.node(c.comp, None, 2, ""); @@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String let pairs = variant_props(&vn.name); let attrs: String = variants .iter() - .filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], "")))) + .zip(&attr_names) + .filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], "")))) .collect(); for (path, _, decls) in pass(v) { let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue }; diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index c92815a7..4cd3d776 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() { let react = code(&mut s, c, "react"); assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}"); } + +#[test] +fn case_colliding_variant_properties_get_distinct_data_attributes() { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() { + let name = format!("Size={first}, size={second}"); + let f = idv(&ex( + &mut s, + "frame.create", + json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}), + )); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants}))); + ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}})); + for lang in ["react", "leptos"] { + let v = component_code(&mut s, set, lang); + let def = v["definition"].as_str().unwrap_or_default(); + let root = def.lines().find(|l| l.trim_start().starts_with("
= + root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect(); + assert_eq!(attrs.len(), 2, "{lang}: {root}"); + assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}"); + let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]); + assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}"); + assert!(def.contains("#FF0000"), "{lang}:\n{def}"); + } +} From c6fdf9bd4cd0f1b5cc3ced1b6e94638303888034 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:08:53 +0200 Subject: [PATCH 03/77] Keep layer text and string defaults unchanged in rem code output Fixes #454 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/codegen.rs | 52 ++++++++++++++++++++++++- crates/engine/src/html_codegen_tests.rs | 40 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 513bf509..7d35229f 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String { } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end -/// in "px" are left alone. +/// in "px", markup lines (layer text), string literals and comments are left alone. pub fn px_to_rem(code: &str, root: f64) -> String { + let mut out = String::with_capacity(code.len()); + let mut in_comment = false; + for line in code.split_inclusive('\n') { + if !in_comment && line.trim_start().starts_with('<') { + out.push_str(line); + continue; + } + // Lengths outside strings and comments convert; `plain` is where that stretch starts. + let mut plain = 0; + let mut chars = line.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if in_comment { + if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') { + chars.next(); + out.push_str(line.get(plain..i + 2).unwrap_or("")); + plain = i + 2; + in_comment = false; + } + } else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') { + chars.next(); + out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); + plain = i; + in_comment = true; + } else if c == '"' { + out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root)); + let mut end = line.len(); + while let Some((j, s)) = chars.next() { + if s == '\\' { + chars.next(); + } else if s == '"' { + end = j + 1; + break; + } + } + out.push_str(line.get(i..end).unwrap_or("")); + plain = end; + } + } + let rest = line.get(plain..).unwrap_or(""); + if in_comment { + out.push_str(rest); + } else { + out.push_str(&lengths_to_rem(rest, root)); + } + } + out +} + +/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone. +fn lengths_to_rem(code: &str, root: f64) -> String { let ident = |c: char| c.is_alphanumeric() || c == '_'; let mut out = String::with_capacity(code.len()); let mut rest = code; diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index 95b8f40d..b7fbb9ac 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() { let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap(); assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]); } + +fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String { + let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap(); + out["code"].as_str().unwrap().to_string() +} + +#[test] +fn rem_preserves_text_in_web_markup() { + let mut s = Session::new(); + let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap(); + let mut changed = Vec::new(); + for language in ["html", "react", "leptos"] { + let px = web_code(&mut s, frame, language, "px"); + let rem = web_code(&mut s, frame, language, "rem"); + assert!(px.contains("Tile 24px"), "{language}: px control: {px}"); + assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}"); + assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}"); + if !rem.contains("Tile 24px") { + changed.push(language); + } + } + assert!(changed.is_empty(), "text rewritten in {changed:?}"); +} + +#[test] +fn rem_preserves_text_property_default() { + let mut s = Session::new(); + let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap()); + let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap()); + let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap()); + s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap(); + for unit in ["px", "rem"] { + let code = web_code(&mut s, component, "react", unit); + assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}"); + if unit == "rem" { + assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}"); + } + } +} From 6a2eeec928f9e7c65ed81193ab878f25e6dd7467 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:16:00 +0200 Subject: [PATCH 04/77] Make empty ranges link and unlink text styles on all characters Fixes #453 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/tests.rs | 22 ++++++++++++++++++++++ crates/doc/src/text.rs | 2 ++ 2 files changed, 24 insertions(+) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b9..671214f9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -371,3 +371,25 @@ fn custom_easings() { assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); } + +#[test] +fn text_style_empty_range_links_and_unlinks_all() { + let def = TextStyle { font_style: "Bold".into(), ..Default::default() }; + let mut t = TextData::new("abcd"); + t.link_text_style(2, 2, &def, "S"); + assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold")); + t.unlink_text_style(2, 2); + assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold")); + // Partial ranges stay partial. + t.link_text_style(0, 4, &def, "S"); + t.unlink_text_style(1, 3); + assert_eq!(t.style_at(0).style_id.as_deref(), Some("S")); + assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none()); + assert_eq!(t.style_at(3).style_id.as_deref(), Some("S")); + // Empty text changes the base style. + let mut e = TextData::new(""); + e.link_text_style(0, 0, &def, "S"); + assert_eq!(e.style.style_id.as_deref(), Some("S")); + e.unlink_text_style(0, 0); + assert!(e.style.style_id.is_none()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609d..3ddf6559 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -675,11 +675,13 @@ impl TextData { /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; self.restyle(start, end, |s| s.take_text_style(def, id)); } /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; self.restyle(start, end, |s| s.style_id = None); } From e4b4fb0cac7d914025e9e8b415c6e17df81bf2ac Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:32:07 +0200 Subject: [PATCH 05/77] Fix paint bucket leaving a face filled when its region is duplicated Fixes #450 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/vector_faces.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs index a34c5036..050ab821 100644 --- a/crates/doc/src/vector_faces.rs +++ b/crates/doc/src/vector_faces.rs @@ -136,8 +136,8 @@ impl VectorNetwork { } let k = key(&face.loops); match (self.regions.iter().position(|r| key(&r.loops) == k), want) { - (Some(i), None | Some(false)) => { - self.regions.remove(i); + (Some(_), None | Some(false)) => { + self.regions.retain(|r| key(&r.loops) != k); Some(false) } (Some(_), Some(true)) => Some(true), @@ -525,4 +525,21 @@ mod tests { let _ = j.paint(Point::new(f64::NAN, 5.0)); assert!(interior_point(&BezPath::new()).is_none()); } + + #[test] + fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() { + let c = Point::new(50.0, 50.0); + let mut n = square(0.0, 0.0, 100.0); + n.regions.push(n.regions[0].clone()); + assert_eq!(n.paint(c), Some(false)); + assert!(!n.fill_contains(c)); + assert_eq!(n.region_at(c), None); + assert_eq!(n.paint(c), Some(true)); + assert!(n.fill_contains(c)); + + let mut e = square(0.0, 0.0, 100.0); + e.regions.push(e.regions[0].clone()); + assert_eq!(e.paint_to(c, Some(false)), Some(false)); + assert!(!e.fill_contains(c)); + } } From c3330c4260e5eb6d0b4d2c84d69626b52ad3ed37 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:33:53 +0000 Subject: [PATCH 06/77] Leave missing vertices out of region point queries Fixes #451 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/vector_regions.rs | 12 ++++++++++-- crates/doc/src/vector_regions_tests.rs | 13 +++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/vector_regions.rs b/crates/doc/src/vector_regions.rs index 1d1cd794..b1739160 100644 --- a/crates/doc/src/vector_regions.rs +++ b/crates/doc/src/vector_regions.rs @@ -102,10 +102,18 @@ impl VectorNetwork { (0..self.regions.len()).rev().find(|r| self.region_contains(*r, p)) } - /// Every point on region `r`'s loops (holes' too), each once, sorted. + /// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that + /// don't exist are left out). pub fn region_points(&self, r: usize) -> Vec { let Some(region) = self.regions.get(r) else { return Vec::new() }; - let mut out: Vec = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect(); + let mut out: Vec = region + .loops + .iter() + .flatten() + .filter_map(|i| self.segments.get(*i)) + .flat_map(|s| [s.a, s.b]) + .filter(|v| *v < self.vertices.len()) + .collect(); out.sort_unstable(); out.dedup(); out diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs index d1e3d0d8..c3e71163 100644 --- a/crates/doc/src/vector_regions_tests.rs +++ b/crates/doc/src/vector_regions_tests.rs @@ -186,6 +186,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() { } } +#[test] +fn region_points_leave_out_segment_ends_that_do_not_exist() { + let mut n = VectorNetwork::default(); + let ring = square(&mut n, 0.0, 0.0, 100.0); + n.regions = vec![region(vec![ring], WindingRule::NonZero)]; + assert_eq!(n.region_points(0), vec![0, 1, 2, 3]); + n.segments[3] = Segment::line(3, 9); + assert_eq!(n.region_points(0), vec![0, 1, 2, 3]); + assert!(n.region_path(0).is_some()); + n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)]; + assert!(n.region_points(0).is_empty()); +} + #[test] fn the_topology_key_ignores_moved_points_but_not_new_segments() { let mut n = nested(); From ef10edfe29681a3885a91f14c7382857f2b7883e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:39:06 +0200 Subject: [PATCH 07/77] Skip missing segments when starting a region loop walk Fixes #449 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/vector_regions.rs | 5 +++-- crates/doc/src/vector_regions_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/vector_regions.rs b/crates/doc/src/vector_regions.rs index 1d1cd794..ec0c2770 100644 --- a/crates/doc/src/vector_regions.rs +++ b/crates/doc/src/vector_regions.rs @@ -18,8 +18,9 @@ impl VectorNetwork { /// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken /// as stored. Missing segments are skipped. pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> { - let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() }; - let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) { + let mut existing = lp.iter().filter_map(|i| self.segments.get(*i)); + let Some(first) = existing.next() else { return Vec::new() }; + let forward_first = match existing.next() { Some(second) => first.b == second.a || first.b == second.b, None => true, }; diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs index d1e3d0d8..432b5c25 100644 --- a/crates/doc/src/vector_regions_tests.rs +++ b/crates/doc/src/vector_regions_tests.rs @@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() { assert_eq!(n.region_at(p(300.0, 100.0)), None); } +#[test] +fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() { + use figcraft_geom::Shape; + let mut n = VectorNetwork::default(); + square(&mut n, 0.0, 0.0, 200.0); + n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)]; + let control = n.region_path(0).unwrap(); + let control_walk = n.loop_walk(&[0, 1, 2]); + for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] { + n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)]; + assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}"); + assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}"); + assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}"); + let path = n.region_path(0).unwrap(); + assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}"); + assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}"); + } + n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)]; + assert!(n.region_path(0).is_none()); + assert!(!n.region_contains(0, p(100.0, 100.0))); +} + #[test] fn a_regions_points_are_listed_once() { let mut n = nested(); From 7dd71bc2da657681a6762177126799607dcaf20a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:45:13 +0000 Subject: [PATCH 08/77] Brace variable names in expression text that would not read back Fixes #448 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/proto_expr.rs | 14 +++++++++++++- crates/doc/src/proto_expr_parse_tests.rs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index 2ec8e91f..f4404dc7 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -143,7 +143,7 @@ impl Expr { return "…".into(); } match self { - Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()), + Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), Expr::Value(VariableValue::String(s)) => quote(s), // Exact numbers and `#` colours, so the text reads back as the same expression. Expr::Value(VariableValue::Number(n)) => format!("{n}"), @@ -178,6 +178,18 @@ impl Expr { } } +/// A variable as written in an expression: its bare name when that reads back as this variable, +/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or +/// `"q"` don't read back as literals or errors. +fn var_ref(vars: &Variables, v: &crate::Variable) -> String { + let this = Expr::var(v.id.clone()); + if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) { + return v.name.clone(); + } + let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id); + if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) } +} + /// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has /// spaces at its ends, quotes or brackets. fn name_arg(name: &str) -> String { diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index 52f909fd..e106121d 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -176,3 +176,22 @@ fn hostile_text_is_bounded() { } } } + +#[test] +fn names_that_look_like_literals_read_back_as_variables() { + for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") { + var.name = name.into(); + } + for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + } + // Plain names stay bare, and literals still read as literals. + let v = vars(); + assert_eq!(Expr::var("n").display(&v), "n-name"); + assert_eq!(parse("1"), n(1.0)); + assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); +} From dee13aefc5361c08dfcf9c598aaccb02e0c5caa0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:48:39 +0200 Subject: [PATCH 09/77] Keep hidden export roots from enlarging the exported image Fixes #447 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 15 ++++++-- crates/mcp/tests/export_hidden_roots.rs | 47 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 crates/mcp/tests/export_hidden_roots.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..89a92876 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -765,12 +765,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: } /// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); ids.iter() .filter_map(|i| { - let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) } + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } }) .reduce(|a, b| a.union(b)) } diff --git a/crates/mcp/tests/export_hidden_roots.rs b/crates/mcp/tests/export_hidden_roots.rs new file mode 100644 index 00000000..ded6f731 --- /dev/null +++ b/crates/mcp/tests/export_hidden_roots.rs @@ -0,0 +1,47 @@ +//! Hidden top-level layers in an export take no room in the image (issue #447). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use common::{Client, png_dims}; +use serde_json::{Value, json}; + +fn dimensions(result: Value) -> (u32, u32) { + assert_eq!(result["isError"], false, "{result}"); + let image = result["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap(); + png_dims(image["data"].as_str().unwrap()) +} + +#[test] +fn hidden_root_does_not_expand_export() { + let mut client = Client::new(); + let visible = client.create(json!({ + "type":"frame", "x":0, "y":0, "width":100, "height":100, + "props":{"fill":"#FF0000"} + })); + let hidden = client.create(json!({ + "type":"frame", "x":900, "y":0, "width":300, "height":300, + "props":{"visible":false} + })); + assert_eq!(client.node(&hidden)["visible"], false); + + let control = dimensions(client.call("export", json!({"ids":[visible], "format":"png", "size":"200w"}))); + assert_eq!(control, (200, 200)); + let mixed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "size":"200w"}))); + assert_eq!(mixed, control, "hidden roots must not enlarge the painted area"); + let natural = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png"}))); + assert_eq!(natural, (100, 100)); + + client.ok("select", json!({"ids":[visible, hidden]})); + let selected = dimensions(client.call("export", json!({"format":"png", "size":"200w"}))); + assert_eq!(selected, (200, 200)); + client.ok("select", json!({"ids":[]})); + let page = dimensions(client.call("export", json!({"format":"png"}))); + assert_eq!(page, (100, 100)); + + // Controls: the bounding-box option and a hidden-only export keep their areas. + let boxed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "includeBoundingBox":true}))); + assert_eq!(boxed, (1200, 300)); + let alone = dimensions(client.call("export", json!({"ids":[hidden], "format":"png"}))); + assert_eq!(alone, (300, 300)); +} From 4cf1da167c771f0a6e9f673876fa154e9d7cdd02 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:56:15 +0000 Subject: [PATCH 10/77] Default CLI render and export to the first non-divider page Fixes #446 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/src/files.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 9b0f9b55..4fb485e1 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> { fn page_named(doc: &Document, name: Option<&str>) -> CliResult { match name { - None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())), + None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())), Some(n) => doc .pages .iter() @@ -380,6 +380,22 @@ mod tests { assert_eq!(sanitize(""), "layer"); } + #[test] + fn default_page_skips_leading_divider() { + let mut doc = Document::new("divider"); + let page = doc.first_page().unwrap(); + doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap(); + let divider = doc.add_page("- Divider"); + doc.pages.retain(|p| *p != divider); + doc.pages.insert(0, divider); + assert!(doc.is_divider(divider)); + assert_eq!(page_named(&doc, None).unwrap(), page); + assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider); + let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap(); + assert_eq!((pm.width(), pm.height()), (40, 30)); + assert!(render_page(&doc, divider, 1.0).is_err()); + } + #[test] fn export_depth_errors_preserve_conversion_destinations() { let mut doc = Document::new("export depth"); From 61bc027072e5179df64687a4957c82b66c0b6324 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:56:32 +0200 Subject: [PATCH 11/77] Fix MCP layer screenshots exceeding maxSize Fixes #445 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++ crates/mcp/src/tools.rs | 15 +++++- crates/mcp/tests/screenshot_pixel_limit.rs | 53 ++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 crates/mcp/tests/screenshot_pixel_limit.rs diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31..db1f2b54 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,7 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad..389d75de 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -721,8 +721,19 @@ fn screenshot(b: &mut dyn Backend, a: &Map) -> Result (u32, u32) { + let result = client.call("screenshot", args); + assert_eq!(result["isError"], false, "{result}"); + let size = png_dims(result["content"][0]["data"].as_str().unwrap()); + let meta: Value = serde_json::from_str(result["content"][1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(meta["width"], size.0, "{meta}"); + assert_eq!(meta["height"], size.1, "{meta}"); + size +} + +#[test] +fn layer_screenshots_fit_the_requested_size() { + let mut shadow_client = Client::new(); + let shadow = shadow_client.create(json!({ + "type": "rectangle", "x": 0, "y": 0, + "width": 100, "height": 100, + "props": {"effects": [{ + "type": "dropShadow", "color": "#00000080", + "offset": [0, 40], "radius": 60 + }]} + })); + let shadow_size = dimensions(&mut shadow_client, json!({"ids": [shadow], "maxSize": 100})); + + let mut frame_client = Client::new(); + let frame = frame_client.create(json!({ + "type": "frame", "x": 0, "y": 0, + "width": 100, "height": 100, "props": {"clip": false} + })); + frame_client.create(json!({ + "type": "rectangle", "parent": frame, + "x": 900, "y": 900, "width": 100, "height": 100 + })); + let frame_size = dimensions(&mut frame_client, json!({"ids": [frame], "maxSize": 100})); + + assert!(shadow_size.0 <= 100 && shadow_size.1 <= 100, "shadow screenshot exceeds maxSize: {shadow_size:?}"); + assert!(shadow_size.0 >= 90 && shadow_size.1 >= 90, "shadow screenshot shrank too far: {shadow_size:?}"); + assert!(frame_size.0 <= 100 && frame_size.1 <= 100, "frame screenshot exceeds maxSize: {frame_size:?}"); + assert!(frame_size.0 >= 90 && frame_size.1 >= 90, "frame screenshot shrank too far: {frame_size:?}"); +} + +#[test] +fn plain_layer_screenshot_sizes_are_unchanged() { + let mut client = Client::new(); + let rect = client.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100})); + assert_eq!(dimensions(&mut client, json!({"ids": [rect], "maxSize": 100})), (100, 100)); + assert_eq!(dimensions(&mut client, json!({"ids": [rect], "scale": 2})), (200, 200)); +} From 376d524a5777bb8a7702453e83a4971a2bf128d7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:09:40 +0200 Subject: [PATCH 12/77] Keep the colour picker and open settings on their item after a removal Fixes #442 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/list_drag.rs | 16 +++ crates/ui-egui/src/panel_remove_tests.rs | 125 +++++++++++++++++++++++ crates/ui-egui/src/right.rs | 44 ++++++-- 4 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/panel_remove_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..56e23468 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -118,6 +118,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod panel_remove_tests; +#[cfg(test)] mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; diff --git a/crates/ui-egui/src/list_drag.rs b/crates/ui-egui/src/list_drag.rs index 73155032..b701a15a 100644 --- a/crates/ui-egui/src/list_drag.rs +++ b/crates/ui-egui/src/list_drag.rs @@ -181,6 +181,22 @@ pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usiz }); } +/// Keep per-row flags (`Id::new((salt, index))`) with their rows when item `removed` of a +/// `len`-long list goes; its own flag goes with it. +pub fn remove_flags(ui: &Ui, salt: &'static str, len: usize, removed: usize) { + ui.data_mut(|d| { + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + for i in 0..len { + d.remove::(Id::new((salt, i))); + } + for (i, f) in flags.into_iter().enumerate() { + if let (Some(j), Some(v)) = (figcraft_engine::cmd::lists::removed_index(i, removed), f) { + d.insert_temp(Id::new((salt, j)), v); + } + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs new file mode 100644 index 00000000..299a4b6f --- /dev/null +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -0,0 +1,125 @@ +//! Removing fill, effect and layout grid rows in the Design panel, driven through egui headless: an +//! open colour picker and open settings stay with their items, and go when their item goes. + +use egui::{Event, Id, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; +use crate::list_drag::grip_id; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![Event::PointerMoved(Pos2::new(1430.0, 890.0))]); +} + +/// The Remove button in item `index`'s row of list `list` (found in the AccessKit tree). +fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { + // Let the layout settle (settings just opened). + for _ in 0..5 { + u.frame(vec![]); + } + // Scroll the panel until the row is in view. + for _ in 0..20 { + let row = u.ctx.read_response(grip_id(list, index)).unwrap().rect; + if row.max.y < 860.0 { + break; + } + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -100.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(Pos2::new(1300.0, 500.0)), wheel]); + // Let the scroll settle. + for _ in 0..30 { + u.frame(vec![]); + } + } + let out = u.frame_output(vec![]); + let row = u.ctx.read_response(grip_id(list, index)).unwrap().rect; + let all: Vec = out + .platform_output + .accesskit_update + .unwrap() + .nodes + .into_iter() + .filter(|(_, n)| n.label() == Some("Remove")) + .filter_map(|(_, n)| n.bounds()) + .map(|b| Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) + .collect(); + all.iter() + .copied() + .find(|p| p.y > row.min.y && p.y < row.min.y + 32.0) + .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) +} + +fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { + u.ctx.data(|d| d.get_temp::(Id::new((salt, i)))).unwrap_or(false) +} + +#[test] +fn removing_a_fill_keeps_the_picker_on_its_paint() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); + s.execute("fill.add", &json!({"ids": [r], "color": "#00FF00"})).unwrap(); + s.execute("fill.add", &json!({"ids": [r], "color": "#0000FF"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + // Green's swatch, then red's Remove: the picker follows green to index 0. + let sw = u.ctx.read_response(Id::new(("swatch", true, 1usize))).unwrap().rect.center(); + click(&mut u, sw); + assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); + let rm = remove_button(&mut u, "fills", 0); + click(&mut u, rm); + let fills: Vec = u.app.session.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect(); + assert_eq!(fills, ["00FF00", "0000FF"]); + assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![r] })); + // Removing the paint being edited closes the picker. + let rm = remove_button(&mut u, "fills", 0); + click(&mut u, rm); + assert_eq!(u.app.ui.picker, None); +} + +#[test] +fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + for _ in 0..3 { + s.execute("effect.add", &json!({"ids": [f], "type": "dropShadow"})).unwrap(); + } + s.execute("grid.add", &json!({"ids": [f], "grid": {"kind": "columns"}})).unwrap(); + s.execute("grid.add", &json!({"ids": [f], "grid": {"kind": "rows"}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + // Effect 2's settings open and its colour in the picker; effect 0 goes. + u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 2usize)), true)); + u.app.ui.picker = Some(PickerTarget::Effect { ids: vec![f], index: 2, secondary: false }); + let rm = remove_button(&mut u, "effects", 0); + click(&mut u, rm); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().effects.len(), 2); + assert!(flag(&u, "fx_open", 1) && !flag(&u, "fx_open", 2)); + assert_eq!(u.app.ui.picker, Some(PickerTarget::Effect { ids: vec![f], index: 1, secondary: false })); + // Removing the open effect closes its settings and the picker. + let rm = remove_button(&mut u, "effects", 1); + click(&mut u, rm); + assert!(!flag(&u, "fx_open", 0) && !flag(&u, "fx_open", 1)); + assert_eq!(u.app.ui.picker, None); + // Grid 1's settings open; grid 0 goes. + u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 1usize)), true)); + let rm = remove_button(&mut u, "grids", 0); + click(&mut u, rm); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids.len(), 1); + assert!(flag(&u, "grid_open", 0) && !flag(&u, "grid_open", 1)); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index d1244ff4..0f1f8f76 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -3,13 +3,13 @@ use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::{BlendMode, Paint, PaintKind}; use figcraft_doc::*; -use figcraft_engine::cmd::lists::moved_index; +use figcraft_engine::cmd::lists::{moved_index, removed_index}; use serde_json::{Value, json}; use crate::a11y::{self, Tip}; use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; -use crate::list_drag::{ListDrag, move_flags}; +use crate::list_drag::{ListDrag, move_flags, remove_flags}; use crate::style_picker::{self, StyleSlot}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD}; @@ -1596,8 +1596,16 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle visibility")).clicked() { app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible})); } - if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() { - app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})); + if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() + && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() + && let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker + && *f == fills + { + // An open colour picker keeps editing the paint it was on, or closes with it. + match removed_index(*index, i) { + Some(k) => *index = k, + None => app.ui.picker = None, + } } }) } @@ -1627,6 +1635,7 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect); let Some(list) = list else { return }; let mut drag = ListDrag::new(ui, "effects"); + let mut removed = None; for (i, e) in list.iter().enumerate().rev() { let mut open = effect_open(ui.ctx(), i); let header = row_rect(ui, 32.0, |ui| { @@ -1651,8 +1660,10 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { e2.visible = !e2.visible; app.run("effect.set", json!({"ids": ids, "index": i, "effect": e2})); } - if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() { - app.run("effect.remove", json!({"ids": ids, "index": i})); + if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() + && app.run("effect.remove", json!({"ids": ids, "index": i})).is_some() + { + removed = Some(i); } }); if open { @@ -1667,6 +1678,16 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { *index = moved_index(*index, from, to); } } + // Open settings and an open colour picker stay with their effects, or go with theirs. + if let Some(i) = removed { + remove_flags(ui, FX_OPEN, list.len(), i); + if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker { + match removed_index(*index, i) { + Some(k) => *index = k, + None => app.ui.picker = None, + } + } + } } /// Salt of the flags that keep effect rows' settings open. @@ -1925,6 +1946,7 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let Some(list) = list else { return }; // The last grid first, like paints and effects; rows drag to reorder. let mut drag = ListDrag::new(ui, "grids"); + let mut removed = None; for (i, g) in list.iter().enumerate().rev() { let open_id = Id::new(("grid_open", i)); let mut open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); @@ -1951,8 +1973,10 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() { app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)})); } - if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() { - app.run("grid.remove", json!({"ids": ids, "index": i})); + if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() + && app.run("grid.remove", json!({"ids": ids, "index": i})).is_some() + { + removed = Some(i); } }); if open { @@ -1964,6 +1988,10 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.run("grid.move", json!({"ids": ids, "from": from, "to": to})); move_flags(ui, "grid_open", list.len(), from, to); } + // Open settings stay with their grids, or go with theirs. + if let Some(i) = removed { + remove_flags(ui, "grid_open", list.len(), i); + } } /// A layout grid's name in its row ("Grid (10)", "5 columns"). From 8e56820168a7d7e4d2b7c6dcaa47252260cd21e3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:14:49 +0000 Subject: [PATCH 13/77] Fix variable.update scopes: null not resetting to all supported Fixes #443 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/variables.rs | 2 +- crates/engine/tests/scope_reset.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/scope_reset.rs diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index ae627abe..3e6dc69d 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } None => None, }; - let scopes = match get(p, "scopes") { + let scopes = match p.get("scopes") { None => None, Some(Value::Null) => Some(None), Some(Value::Array(a)) => { diff --git a/crates/engine/tests/scope_reset.rs b/crates/engine/tests/scope_reset.rs new file mode 100644 index 00000000..6d875d12 --- /dev/null +++ b/crates/engine/tests/scope_reset.rs @@ -0,0 +1,26 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::Session; +use serde_json::json; + +fn scopes(session: &Session, variable: &str) -> Option> { + session.doc().unwrap().variables.get(variable).unwrap().scopes.clone() +} + +#[test] +fn null_restores_all_supported_properties() { + let mut session = Session::new(); + session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap(); + let variable = result["id"].as_str().unwrap(); + assert_eq!(scopes(&session, variable), None); + session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap(); + assert_eq!(scopes(&session, variable), Some(vec![])); + session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap(); + assert_eq!(scopes(&session, variable), Some(vec![])); + assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true})); + assert_eq!(scopes(&session, variable), None); + session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap(); + session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap(); + assert_eq!(scopes(&session, variable), None); +} From 43e6cba2ef87a7df3473826a48d2a38613ef0d87 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:26:22 +0200 Subject: [PATCH 14/77] Keep committed line height and letter spacing in the text style editor Fixes #438 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/style_picker.rs | 5 ++-- crates/ui-egui/src/style_picker_tests.rs | 37 ++++++++++++++++++++++++ crates/ui-egui/src/typography.rs | 17 +++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..3b405f1b 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -733,12 +733,13 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { }); ui.horizontal(|ui| { if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height")) - && let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| serde_json::from_value(v).ok()) + && let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v)) { set(out, &|d| d.line_height = lh); } if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing")) - && let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| serde_json::from_value(v).ok()) + && let Some(ls) = + crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| crate::typography::letter_spacing_of(&v)) { set(out, &|d| d.letter_spacing = ls); } diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index 6567e426..becc42ce 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -211,3 +211,40 @@ fn edit_local_for_test(app: &mut FigApp, ctx: &egui::Context) { let mut out = ctx.run_ui(RawInput::default(), |ui| edit_local(app, ui, StyleSlot::Fill, "Danger")); out.textures_delta.clear(); } + +/// Commits `typed` in the text style editor's field `field` ("ts_lh" / "ts_ls") with Enter and +/// returns the redefined style, if any. +fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option { + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(Id::new((field, "S")).with("editing"), typed.to_string())); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events: vec![enter], ..Default::default() }; + let mut f = Frame { act: None, hover: None, budget: 0 }; + let mut out = ctx.run_ui(input, |ui| text_properties(ui, "S", def, &mut f)); + out.textures_delta.clear(); + match f.act { + Some(Act::SetText(id, d)) if id == "S" => Some(d), + _ => None, + } +} + +#[test] +fn text_style_editor_commits_line_height_and_letter_spacing() { + use figcraft_doc::{LetterSpacing, LineHeight}; + let def = TextStyle { line_height: LineHeight::Auto, letter_spacing: LetterSpacing::Percent(0.0), ..Default::default() }; + let lh = |def: &TextStyle, s: &str| commit_text_field(def, "ts_lh", s).map(|d| d.line_height); + let ls = |def: &TextStyle, s: &str| commit_text_field(def, "ts_ls", s).map(|d| d.letter_spacing); + assert_eq!(lh(&def, "150%"), Some(LineHeight::Percent(150.0))); + assert_eq!(lh(&def, "24px"), Some(LineHeight::Pixels(24.0))); + assert_eq!(lh(&def, "24"), Some(LineHeight::Pixels(24.0))); + let pct = TextStyle { line_height: LineHeight::Percent(120.0), ..def.clone() }; + assert_eq!(lh(&pct, "130"), Some(LineHeight::Percent(130.0)), "a bare number keeps percent"); + assert_eq!(lh(&pct, "Auto"), Some(LineHeight::Auto)); + assert_eq!(ls(&def, "2%"), Some(LetterSpacing::Percent(2.0))); + assert_eq!(ls(&def, "1.5px"), Some(LetterSpacing::Pixels(1.5))); + assert_eq!(ls(&def, "3"), Some(LetterSpacing::Percent(3.0))); + // Only the edited property changes. + let d = commit_text_field(&def, "ts_lh", "150%").expect("redefined"); + assert_eq!(TextStyle { line_height: def.line_height, ..d }, def); + assert_eq!(lh(&def, "abc"), None); +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..d1b0f03c 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1026,6 +1026,23 @@ pub(crate) fn parse_letter_spacing(s: &str, cur: Option) -> Optio } } +/// A [`parse_line_height`] value as a [`LineHeight`]: "auto", "150%" or a number of pixels. +pub(crate) fn line_height_of(v: &Value) -> Option { + match v { + Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto), + Value::String(s) => s.strip_suffix('%')?.trim().parse().ok().filter(|p: &f64| p.is_finite()).map(LineHeight::Percent), + other => other.as_f64().filter(|v| v.is_finite()).map(LineHeight::Pixels), + } +} + +/// A [`parse_letter_spacing`] value as a [`LetterSpacing`]: "2%" or a number of pixels. +pub(crate) fn letter_spacing_of(v: &Value) -> Option { + match v { + Value::String(s) => s.strip_suffix('%')?.trim().parse().ok().filter(|p: &f64| p.is_finite()).map(LetterSpacing::Percent), + other => other.as_f64().filter(|v| v.is_finite()).map(LetterSpacing::Pixels), + } +} + // ---- Font picker ---- const RECENT_KEY: &str = "figcraft_recent_fonts"; From ec9b1197c9d787dcafc7ff9f4d785fff40cc6326 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:37:47 +0000 Subject: [PATCH 15/77] Save the PDF from File > Export frames to PDF Fixes #437 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 10 ++++ crates/ui-egui/tests/file_menu_pdf.rs | 68 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 crates/ui-egui/tests/file_menu_pdf.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7e0552a..8edace2a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -84,6 +84,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { "file.saveVersion" => crate::versions::start_save(app), "branch.create" => crate::versions::start_branch(app), "file.export" | "file.exportSvg" => app.ui.export_preview = true, + "file.exportFramesPdf" => export_frames_pdf(app), "edit.copyAsCss" => { let ids = app.session.selection(); if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { @@ -156,6 +157,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { } } +/// Every visible top-level frame of the page as one PDF page each, through a save dialog. +fn export_frames_pdf(app: &mut FigApp) { + let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"]) else { return }; + if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + } +} + fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() diff --git a/crates/ui-egui/tests/file_menu_pdf.rs b/crates/ui-egui/tests/file_menu_pdf.rs new file mode 100644 index 00000000..140dbc6a --- /dev/null +++ b/crates/ui-egui/tests/file_menu_pdf.rs @@ -0,0 +1,68 @@ +//! File > Export frames to PDF: the menu asks where to save and writes the frames' PDF there, +//! one visible top-level frame per page. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, Services, menus}; +use serde_json::json; + +/// Services whose save dialog always returns the given path. +struct SaveTo(PathBuf); +impl Services for SaveTo { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { + Some(self.0.clone()) + } +} + +fn out_path(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("figcraft-{}-{name}.pdf", std::process::id())); + let _ = std::fs::remove_file(&p); + p +} + +fn frame(app: &mut FigApp, x: i32, y: i32) -> serde_json::Value { + app.session.execute("node.create", &json!({"type": "frame", "x": x, "y": y, "width": 100, "height": 80})).unwrap() +} + +/// The number of `/Type /Page` objects (not `/Pages`) in a PDF. +fn page_count(pdf: &[u8]) -> usize { + let s = String::from_utf8_lossy(pdf).replace("/Type/Page", "/Type /Page"); + s.match_indices("/Type /Page").filter(|(i, _)| !s[i + 11..].starts_with('s')).count() +} + +#[test] +fn frames_pdf_menu_saves_the_pdf() { + let path = out_path("one"); + let mut app = FigApp::new(Session::new(), Box::new(SaveTo(path.clone()))); + frame(&mut app, 0, 0); + menus::invoke(&mut app, "file.exportFramesPdf"); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); + let bytes = std::fs::read(&path).expect("the menu writes the PDF where the user chose"); + let _ = std::fs::remove_file(&path); + assert!(bytes.starts_with(b"%PDF")); + assert_eq!(page_count(&bytes), 1); + + // The command still returns the data when called without a destination. + let value = app.session.execute("file.exportFramesPdf", &json!({})).unwrap(); + assert_eq!(value["format"], "pdf"); + assert_eq!(value["pages"], 1); + assert!(value["data"].as_str().unwrap().starts_with("JVBERi0")); +} + +#[test] +fn frames_pdf_menu_one_page_per_visible_frame() { + let path = out_path("two"); + let mut app = FigApp::new(Session::new(), Box::new(SaveTo(path.clone()))); + frame(&mut app, 0, 0); + frame(&mut app, 300, 0); + let hidden = frame(&mut app, 600, 0); + let id = hidden["id"].clone(); + app.session.execute("node.set", &json!({"id": id, "visible": false})).unwrap(); + menus::invoke(&mut app, "file.exportFramesPdf"); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); + let bytes = std::fs::read(&path).expect("the menu writes the PDF where the user chose"); + let _ = std::fs::remove_file(&path); + assert_eq!(page_count(&bytes), 2); +} From 72a3c8522f838d37c00d730ccdf7e6730bdcc812 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:37:55 +0200 Subject: [PATCH 16/77] Keep consecutive spaces in recent file accessible names Fixes #436 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/a11y.rs | 5 ++ crates/ui-egui/src/home_recents.rs | 4 +- crates/ui-egui/tests/recent_file_names.rs | 64 +++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/recent_file_names.rs diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index fc634243..824defd4 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -813,6 +813,11 @@ pub fn button(resp: &Response, tip: &str) { }); } +/// A button named by `label` as it is: no key is split off (a file name may hold two spaces). +pub fn named_button(resp: &Response, label: &str) { + node(resp, label, |n| n.set_role(Role::Button)); +} + /// A link, named by its text. pub fn link(resp: &Response, label: &str) { node(resp, label, |n| n.set_role(Role::Link)); diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index dbc58506..881a0a4e 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -738,7 +738,7 @@ fn grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 for (i, file) in row.iter().enumerate() { let rect = Rect::from_min_size(pos2(r.min.x + i as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h)); let resp = ui.interact(rect, card_id(&file.path), Sense::click()); - a11y::button(&resp, &spoken(file, now)); + a11y::named_button(&resp, &spoken(file, now)); let resp = resp.on_hover_text(&file.path); let visible = ui.is_rect_visible(rect); let picture = recents.picture(&file.path, visible, time); @@ -878,7 +878,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 for file in files { let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover()); let resp = ui.interact(r, card_id(&file.path), Sense::click()); - a11y::button(&resp, &spoken(file, now)); + a11y::named_button(&resp, &spoken(file, now)); let resp = resp.on_hover_text(&file.path); if let Some(a) = interact(ui, &resp, recents, file) { action = Some(a); diff --git a/crates/ui-egui/tests/recent_file_names.rs b/crates/ui-egui/tests/recent_file_names.rs new file mode 100644 index 00000000..461d0c73 --- /dev/null +++ b/crates/ui-egui/tests/recent_file_names.rs @@ -0,0 +1,64 @@ +//! Recent file rows keep their full accessible name. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use egui::{Context, Id, Pos2, RawInput, Rect, accesskit::Role, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; + +/// Render the home screen in `view` with `path` (and a single-space control) as recents and return +/// the row's (label, shortcut, focused name). +fn row(view: &str, path: &str, prefix: &str, exclude: &str) -> (String, Option, String) { + let mut session = Session::empty(); + session.prefs.recent_files = vec![path.into(), "Q3 report.figcraft".into()]; + session.prefs.getting_started_dismissed = true; + session.prefs.home_views.insert("recents".into(), view.into()); + let mut app = FigApp::new(session, Box::new(NoServices)); + app.ui.home = true; + let ctx = Context::default(); + ctx.enable_accesskit(); + let mut found = None; + for frame in 0..4 { + if frame >= 2 { + ctx.memory_mut(|m| m.request_focus(Id::new(("home_recent_file", path)))); + } + let mut out = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), + time: Some(frame as f64 / 60.0), + ..Default::default() + }, + |ui| app.ui(ui), + ); + out.textures_delta.clear(); + if frame == 3 { + let nodes = out.platform_output.accesskit_update.unwrap().nodes; + let mut matches = nodes.iter().map(|(_, n)| n).filter(|n| { + n.role() == Role::Button && n.label().is_some_and(|s| s.starts_with(prefix) && (exclude.is_empty() || !s.starts_with(exclude))) + }); + let n = matches.next().expect("file row"); + assert!(matches.next().is_none(), "{view}: more than one row matches {prefix:?}"); + found = Some(( + n.label().unwrap_or_default().to_string(), + n.keyboard_shortcut().map(str::to_string), + figcraft_ui_egui::a11y::focused_name(&ctx), + )); + } + } + found.expect("rendered") +} + +#[test] +fn recent_file_names_preserve_spaces() { + for view in ["grid", "list"] { + for (path, prefix, full) in [("Q3 report.figcraft", "Q3", "Q3 report"), ("Notes A.figcraft", "Notes", "Notes A")] { + // The single-space control "Q3 report" also starts with "Q3"; it is not the row under test. + let (label, shortcut, focused) = row(view, path, prefix, "Q3 report"); + assert!(label.starts_with(full), "{view}: label {label:?}"); + assert_eq!(shortcut, None, "{view}: {path}"); + assert!(focused.starts_with(full), "{view}: focused {focused:?}"); + } + let (label, shortcut, _) = row(view, "Q3 report.figcraft", "Q3 report", ""); + assert!(label.starts_with("Q3 report"), "{view}: control {label:?}"); + assert_eq!(shortcut, None); + } +} From 831b8fed43ba9c6cdb605441ab19b76f69c5bee9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:42:05 +0200 Subject: [PATCH 17/77] Fix panic drawing a selected guide label in a zero-width canvas Fixes #434 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/guides.rs | 4 ++- crates/ui-egui/tests/guide_label_narrow.rs | 33 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/guide_label_narrow.rs diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 409236f5..b4a7cc0d 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -316,7 +316,9 @@ fn label(app: &FigApp, painter: &egui::Painter, r: Rect, g: &Placed) { let galley = painter.layout_no_wrap(crate::canvas::fmt(g.offset), semibold(10.0), Color32::WHITE); let size = galley.size() + vec2(8.0, 4.0); let [a, _] = segment(app, g, r); - let at = pos2(a.x.clamp(r.min.x + RULER, r.max.x) + 4.0, a.y.clamp(r.min.y + RULER, r.max.y) + 4.0); + // A canvas narrower or shorter than the ruler leaves `min > max`; keep the tag at the ruler edge then. + let (lo_x, lo_y) = (r.min.x + RULER, r.min.y + RULER); + let at = pos2(a.x.clamp(lo_x, r.max.x.max(lo_x)) + 4.0, a.y.clamp(lo_y, r.max.y.max(lo_y)) + 4.0); let tag = Rect::from_min_size(at, size); painter.rect_filled(tag, CornerRadius::same(3), GUIDE); painter.galley(tag.min + vec2(4.0, 2.0), galley, Color32::WHITE); diff --git a/crates/ui-egui/tests/guide_label_narrow.rs b/crates/ui-egui/tests/guide_label_narrow.rs new file mode 100644 index 00000000..6c4276ad --- /dev/null +++ b/crates/ui-egui/tests/guide_label_narrow.rs @@ -0,0 +1,33 @@ +#![allow(clippy::unwrap_used)] + +use egui::{Pos2, RawInput, Rect, Vec2, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +fn frame(ctx: &egui::Context, app: &mut FigApp, size: Vec2) { + let mut out = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), ..Default::default() }, |ui| app.ui(ui)); + out.textures_delta.clear(); +} + +fn scene(selected: bool, rulers: bool) -> (egui::Context, FigApp) { + let mut session = Session::new(); + session.execute("view.rulers", &json!({"on": rulers})).unwrap(); + session.execute("guide.add", &json!({"vertical": true, "offset": 100.0})).unwrap(); + if selected { + session.execute("guide.select", &json!({"index": 0})).unwrap(); + } + let ctx = egui::Context::default(); + let mut app = FigApp::new(session, Box::new(NoServices)); + frame(&ctx, &mut app, vec2(1440.0, 900.0)); + frame(&ctx, &mut app, vec2(1440.0, 900.0)); + (ctx, app) +} + +#[test] +fn selected_guide_frame_completes_in_narrow_layout() { + for (selected, rulers, width) in [(false, true, 200.0), (true, false, 200.0), (true, true, 800.0), (true, true, 200.0)] { + let (ctx, mut app) = scene(selected, rulers); + frame(&ctx, &mut app, vec2(width, 200.0)); + } +} From 42e6f267b86addc42f3f901ba9848079c9542695 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:43:25 +0000 Subject: [PATCH 18/77] Reveal the comment's page when opening it via ui.set commentOpen Fixes #435 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/control.rs | 9 +++- .../ui-egui/tests/comment_open_other_page.rs | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/comment_open_other_page.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 480824d0..7cc94b51 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -427,7 +427,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul // Comment thread popover: an id opens it, null closes it. if let Some(v) = p.get("commentOpen") { match v.as_u64() { - Some(id) => app.ui.comments.show(id), + Some(id) => { + // Pins are drawn for the current page only: reveal the thread's page first. + let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page); + if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) { + app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?; + } + app.ui.comments.show(id); + } None => app.ui.comments.close(), } } diff --git a/crates/ui-egui/tests/comment_open_other_page.rs b/crates/ui-egui/tests/comment_open_other_page.rs new file mode 100644 index 00000000..1036c604 --- /dev/null +++ b/crates/ui-egui/tests/comment_open_other_page.rs @@ -0,0 +1,41 @@ +use egui::{Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices, control}; +use serde_json::json; + +fn frame(ctx: &egui::Context, app: &mut FigApp, time: f64) { + let mut out = ctx + .run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(time), ..Default::default() }, |ui| { + app.ui(ui) + }); + out.textures_delta.clear(); +} + +#[test] +fn comment_open_other_page() { + let mut session = Session::new(); + let first = session.page().unwrap(); + let second = session.execute("page.add", &json!({})).unwrap()["id"].as_u64().unwrap(); + session.execute("page.select", &json!({"id": second})).unwrap(); + let comment = session.execute("comment.add", &json!({"x": 50, "y": 50, "text": "Review this"})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(session, Box::new(NoServices)); + for i in 1..=3 { + frame(&ctx, &mut app, i as f64 / 60.0); + } + // Same page: stays open. + control::handle(&mut app, &ctx, "ui.set", &json!({"commentOpen": comment})).unwrap(); + frame(&ctx, &mut app, 4.0 / 60.0); + assert_eq!(app.inspect()["comments"]["open"], json!(comment)); + // null closes it. + control::handle(&mut app, &ctx, "ui.set", &json!({"commentOpen": null})).unwrap(); + frame(&ctx, &mut app, 5.0 / 60.0); + assert_eq!(app.inspect()["comments"]["open"], json!(null)); + // Another page: the page is revealed and the thread stays open across a frame. + control::handle(&mut app, &ctx, "engine.execute", &json!({"command": "page.select", "params": {"id": first}})).unwrap(); + let reply = control::handle(&mut app, &ctx, "ui.set", &json!({"commentOpen": comment})).unwrap(); + assert_eq!(reply["comments"]["open"], json!(comment)); + frame(&ctx, &mut app, 6.0 / 60.0); + assert_eq!(app.session.page().unwrap().0, second); + assert_eq!(app.inspect()["comments"]["open"], json!(comment), "ui.set should keep the requested popover open across a frame"); +} From 3293a125426a870fdda0ec5a906410c38e3930e0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:48:25 +0200 Subject: [PATCH 19/77] Fix view jump when returning after a pan released over a panel Fixes #433 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/tests/canvas_pan_release.rs | 96 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 crates/ui-egui/tests/canvas_pan_release.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de..e8628b03 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -375,6 +375,10 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { app.session.vector_pointer_left(); } + // A pan whose button was released off the canvas is over. + if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) { + app.canvas.middle_pan = None; + } return true; }; // A press on the canvas gives the keyboard back to it (arrows nudge again). diff --git a/crates/ui-egui/tests/canvas_pan_release.rs b/crates/ui-egui/tests/canvas_pan_release.rs new file mode 100644 index 00000000..c54d20dc --- /dev/null +++ b/crates/ui-egui/tests/canvas_pan_release.rs @@ -0,0 +1,96 @@ +//! A canvas pan (middle button or Space + primary) ends when its button is released, even when the +//! release happens over a panel: returning to the canvas with no button held must not move the view. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +struct Frames { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +impl Frames { + fn new() -> Self { + let mut session = Session::new(); + session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let mut f = Frames { ctx: egui::Context::default(), app: FigApp::new(session, Box::new(NoServices)), time: 0.0 }; + for _ in 0..3 { + f.frame(vec![]); + } + f + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let mut output = self.ctx.run_ui(input, |ui| self.app.ui(ui)); + output.textures_delta.clear(); + } + + fn cx(&self) -> f64 { + self.app.session.ds().unwrap().view.cx + } +} + +fn button(pos: Pos2, button: PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos, button, pressed, modifiers: Default::default() } +} + +fn space(pressed: bool) -> Event { + Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() } +} + +fn check(space_pan: bool, outside: bool) { + let mut u = Frames::new(); + let rect = u.app.canvas.rect; + let start = Pos2::new(rect.min.x + 200.0, rect.center().y); + let dragged = Pos2::new(rect.min.x + 600.0, rect.center().y); + let reentered = Pos2::new(rect.min.x + 300.0, rect.center().y); + assert!(rect.contains(start) && rect.contains(dragged) && rect.contains(reentered)); + u.frame(vec![Event::PointerMoved(start)]); + if space_pan { + u.frame(vec![space(true)]); + } + let b = if space_pan { PointerButton::Primary } else { PointerButton::Middle }; + let before = u.cx(); + u.frame(vec![button(start, b, true)]); + u.frame(vec![Event::PointerMoved(dragged)]); + let after_drag = u.cx(); + assert_ne!(before, after_drag, "the test must start an actual pan"); + let release_at = if outside { Pos2::new(rect.min.x - 100.0, rect.center().y) } else { dragged }; + u.frame(vec![Event::PointerMoved(release_at)]); + u.frame(vec![button(release_at, b, false)]); + let released = u.cx(); + assert_eq!(released, after_drag, "release must leave the view unchanged"); + assert!(!u.ctx.input(|i| i.pointer.any_down())); + u.frame(vec![Event::PointerMoved(reentered)]); + assert_eq!(u.cx(), released, "pointer motion after release must not pan"); + u.frame(vec![Event::PointerMoved(dragged)]); + assert_eq!(u.cx(), released, "pointer motion after release must not pan"); + + // A new intentional pan still moves the view. + u.frame(vec![button(dragged, b, true)]); + u.frame(vec![Event::PointerMoved(reentered)]); + assert_ne!(u.cx(), released, "a new pan after reentry must move the view"); + u.frame(vec![button(reentered, b, false)]); +} + +#[test] +fn middle_pan_released_over_panel_does_not_move_view_on_return() { + check(false, true); +} + +#[test] +fn space_pan_released_over_panel_does_not_move_view_on_return() { + check(true, true); +} + +#[test] +fn middle_pan_released_inside_canvas_keeps_view() { + check(false, false); +} From 8b0649da3adc87e42c22dc4f238938154c4adcd1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:52:47 +0000 Subject: [PATCH 20/77] Drop the draw or marquee gesture a canvas double-click starts Fixes #432 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tools.rs | 5 +- .../ui-egui/src/canvas_double_click_tests.rs | 47 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/canvas_double_click_tests.rs diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..e1c00ef4 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1786,9 +1786,10 @@ impl Session { self.widget_click(ev.p(), ev.tol() * 2.0)?; return Ok(()); } - // The second press began a move or a resize: a double-click isn't one. - if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) { + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; self.cancel_interaction()?; } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; diff --git a/crates/ui-egui/src/canvas_double_click_tests.rs b/crates/ui-egui/src/canvas_double_click_tests.rs new file mode 100644 index 00000000..1c89757f --- /dev/null +++ b/crates/ui-egui/src/canvas_double_click_tests.rs @@ -0,0 +1,47 @@ +//! A double-click on the empty canvas, driven through egui headless: the gesture its second press +//! began is dropped, so the tool is idle once the button is up. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// Two presses and releases at the bottom left of the canvas, with `modifiers` held. +fn double_click(u: &mut Ui, modifiers: Modifiers) { + let pos = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); + u.frame_with(vec![Event::PointerMoved(pos)], modifiers); + for _ in 0..2 { + for pressed in [true, false] { + u.frame_with(vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }], modifiers); + } + } + u.frame_with(vec![], modifiers); +} + +#[test] +fn a_double_click_leaves_the_shape_tool_idle() { + let mut s = Session::new(); + s.execute("pref.keepTool", &json!({"on": true})).unwrap(); + s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap(); + let mut u = Ui::new(s); + assert_eq!(u.app.inspect()["tool"]["busy"], false); + double_click(&mut u, Modifiers::default()); + assert_eq!(u.app.inspect()["tool"]["busy"], false, "a finished double-click leaves the tool idle"); + assert!(u.app.session.execute("tool.place", &json!({})).is_ok(), "Enter places a shape"); +} + +#[test] +fn a_shift_double_click_keeps_the_selection_handles() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let handles = |u: &Ui| u.app.inspect()["tool"]["shapeHandles"].as_array().map_or(0, Vec::len); + assert_eq!(handles(&u), 4); + double_click(&mut u, Modifiers::SHIFT); + assert_eq!(u.app.session.selection(), vec![r], "Shift keeps the selection"); + assert_eq!(u.app.inspect()["tool"]["busy"], false, "a finished double-click leaves the tool idle"); + assert_eq!(handles(&u), 4, "the corner radius handles show again"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..aa938c73 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -68,6 +68,8 @@ mod a11y_tests; #[cfg(test)] mod branch_ui_tests; #[cfg(test)] +mod canvas_double_click_tests; +#[cfg(test)] mod canvas_drag_tests; #[cfg(test)] mod canvas_fixes_tests; From 9687c0ffc001c57240fe3acdde91c1a41ac3e551 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:12:25 +0000 Subject: [PATCH 21/77] Draw layer blurs up to the documented 10,000 radius Fixes #430 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/render/src/effects_behaviour_tests.rs | 26 ++++++++++++++++++++ crates/render/src/fx.rs | 7 ++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index 3feaf077..5a4b6289 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -377,3 +377,29 @@ fn exports_include_what_effects_paint_outside_the_layer() { d.get_mut(f).unwrap().effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))]; assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported"); } + +/// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×. +fn blurred_frame(radius: f64) -> Pixmap { + let mut d = Document::new("Blur range"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Square", 156.0, 156.0, 200.0, 200.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.fills = vec![Paint::solid(Color::BLACK)]; + n.effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(radius)))]; + let view = View { origin: (0.0, 0.0), scale: 1.0, width: 512, height: 512 }; + render_view(&d, p, &view, &Options::default()).unwrap() +} + +#[test] +fn layer_blur_radii_up_to_the_documented_limit_all_draw() { + // The documented blur range runs to 10,000: a larger radius in it blurs further, it doesn't + // stop at some smaller one. + let differ = |a: &Pixmap, b: &Pixmap| a.data().chunks_exact(4).zip(b.data().chunks_exact(4)).filter(|(a, b)| a != b).count(); + let sharp = blurred_frame(0.0); + let small = blurred_frame(100.0); + let medium = blurred_frame(1_000.0); + let large = blurred_frame(10_000.0); + assert!(differ(&sharp, &small) > 0, "radius 0 draws the sharp shape"); + assert!(differ(&small, &medium) > 0, "100 vs 1000"); + assert!(differ(&medium, &large) > 0, "radii 1000 and 10000 draw the same pixels"); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 8c769134..5d65667c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -70,8 +70,11 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) { } } -/// The largest blur standard deviation drawn (pixels): wider blurs look the same. -pub const MAX_SIGMA: f64 = 500.0; +/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384 +/// pixels a side), so wider blurs look the same (everything averages out to nothing). The box +/// passes cost the same at any radius, so the documented blur radii (up to 10,000 units) draw +/// in full at any zoom. +pub const MAX_SIGMA: f64 = 1_000_000.0; /// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels). pub fn blur(p: &mut Pixmap, sigma: f64) { From be56ca2836134d81305434cb87abc1e0cda55d34 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:18:33 +0000 Subject: [PATCH 22/77] Keep CMYK conversion inside PDF vector pattern tiles Fixes #426 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/draw.rs | 10 ++++-- crates/pdf/src/text.rs | 2 +- crates/pdf/tests/pattern_cmyk.rs | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 crates/pdf/tests/pattern_cmyk.rs diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 8a1147d4..99f4e390 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -35,6 +35,8 @@ pub(crate) struct Exporter<'a> { pub links: Vec<(Rect, String)>, /// Above zero while drawing into a soft mask (nothing there is visible or clickable). pub(crate) masking: usize, + /// Above zero while drawing a pattern tile (links and backdrops belong to the page). + pub(crate) tiling: usize, /// The page's frame and the bleed its fills and clip reach out to. pub bleed_root: Option<(NodeId, f64)>, pub(crate) fonts: HashMap>, @@ -215,6 +217,7 @@ impl<'a> Exporter<'a> { page_ids: Vec::new(), links: Vec::new(), masking: 0, + tiling: 0, bleed_root: None, fonts: HashMap::new(), images: HashMap::new(), @@ -246,7 +249,8 @@ impl<'a> Exporter<'a> { let op = n.opacity.clamp(0.0, 1.0); // Background blur and glass: an image drawn from what lies under the node, under its // content (nothing in a soft mask is seen). - let back = if !as_mask && self.masking == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None }; + let back = + if !as_mask && self.masking == 0 && self.tiling == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None }; if fx.iter().any(|k| matches!(k, EffectKind::LayerBlur { .. } | EffectKind::Noise(_) | EffectKind::Texture(_))) { // Effects that change every pixel: the renderer draws the whole node. if let Some(b) = &back { @@ -521,14 +525,14 @@ impl<'a> Exporter<'a> { let mut sb = s.stream_builder(); let mut ps = sb.surface(); // Links and backdrops belong to the page, not to a tile. - self.masking += 1; + self.tiling += 1; let _ = figcraft_render::pattern::nested(|| { for (tx, ty) in &cell.tiles { self.node(&mut ps, src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0, false); } Some(()) }); - self.masking -= 1; + self.tiling -= 1; ps.finish(); sb.finish() }; diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 87a14301..c425404d 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -41,7 +41,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine if !affine_ok(gxf) { return; } - if ex.masking == 0 { + if ex.masking == 0 && ex.tiling == 0 { record_links(ex, &layout, gxf); } let (w, h) = (n.width, n.height); diff --git a/crates/pdf/tests/pattern_cmyk.rs b/crates/pdf/tests/pattern_cmyk.rs new file mode 100644 index 00000000..1f98fe46 --- /dev/null +++ b/crates/pdf/tests/pattern_cmyk.rs @@ -0,0 +1,60 @@ +//! CMYK export converts the colours inside vector pattern tiles (#426). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeKind, PrintSettings}; +use figcraft_pdf::{PdfOptions, export_pdf}; +use std::io::Read; + +/// Every zlib stream of `pdf`, inflated and joined. +fn streams(pdf: &[u8]) -> String { + let mut out = String::new(); + let mut rest = pdf; + while let Some(i) = rest.windows(7).position(|w| w == b"stream\n") { + rest = &rest[i + 7..]; + let end = rest.windows(9).position(|w| w == b"endstream").unwrap(); + let mut bytes = Vec::new(); + if flate2::read::ZlibDecoder::new(&rest[..end]).read_to_end(&mut bytes).is_ok() { + out.push_str(&String::from_utf8_lossy(&bytes)); + out.push('\n'); + } + rest = &rest[end + 9..]; + } + out +} + +fn export(pattern: bool, cmyk: bool) -> (String, String) { + let mut doc = Document::new("fixture"); + let page = doc.first_page().unwrap(); + let source = doc.create(page, NodeKind::Rectangle, "Source", 40.0, 0.0, 10.0, 10.0).unwrap(); + let green = Paint::solid(Color::from_hex("00FF00").unwrap()); + doc.get_mut(source).unwrap().fills = vec![green.clone()]; + let target = doc.create(page, NodeKind::Rectangle, "Target", 0.0, 0.0, 20.0, 20.0).unwrap(); + doc.get_mut(target).unwrap().fills = vec![if pattern { Paint::pattern(source.0) } else { green }]; + let opts = PdfOptions { print: PrintSettings { cmyk, ..Default::default() }, ..Default::default() }; + let pdf = export_pdf(&doc, &[target], &opts).unwrap(); + (String::from_utf8_lossy(&pdf).into_owned(), streams(&pdf)) +} + +fn has_rgb_op(content: &str) -> bool { + content.split_whitespace().any(|op| op == "rg" || op == "RG") +} + +#[test] +fn pattern_cmyk_controls() { + let (_, solid) = export(false, true); + assert!(solid.contains("1 0 1 0 k"), "{solid}"); + assert!(!has_rgb_op(&solid), "{solid}"); + let (pdf, pattern) = export(true, false); + assert!(pdf.contains("/PatternType 1")); + assert!(pattern.contains("0 1 0 rg"), "{pattern}"); +} + +#[test] +fn pattern_colors_follow_cmyk_setting() { + let (pdf, content) = export(true, true); + assert!(pdf.contains("/PatternType 1")); + assert!(!has_rgb_op(&content), "RGB operator remains in CMYK pattern: {content}"); + assert!(content.contains("1 0 1 0 k"), "green must convert to CMYK: {content}"); +} From b3b6b14eb42d78095193a0ab212722f345974900 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:18:59 +0200 Subject: [PATCH 23/77] Anti-alias rectangular frame clips on fractional device edges Fixes #428 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/render/src/draw.rs | 18 ++++++++++++++---- crates/render/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b27..e4425938 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -665,13 +665,17 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept // layers behind the frame. let Some(mut layer) = pix.clone_rect(ir) else { return }; let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf; - let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf); - // The clip (a rectangular one is the layer's own box) is known before the children draw, so - // their backdrops are clipped the way the layer will be. + let square_aligned = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf); + let rect_clip = square_aligned && on_whole_pixels(dev); + // The clip (a rectangular one on whole pixels is the layer's own box) is known before the + // children draw, so their backdrops are clipped the way the layer will be. An axis-aligned + // square frame with fractional edges gets an anti-aliased mask of its box so its edge pixels + // are only partly covered. let mask = if rect_clip { None } else { - clip.and_then(ts_path).and_then(|cp| { + let own_box = (square_aligned && clip.is_none()).then(|| n.local_rect().to_path(0.1)); + clip.or(own_box.as_ref()).and_then(ts_path).and_then(|cp| { let mut m = Mask::new(ir.width(), ir.height())?; m.fill_path(&cp, FillRule::Winding, true, ts_transform(lxf)); Some(m) @@ -695,3 +699,9 @@ fn is_axis_aligned(xf: Affine) -> bool { fn fract_ok(_v: f64) -> bool { true } + +/// Whether all four edges of the device rectangle `r` lie on whole pixels, up to the float noise +/// `snap_px` forgives. +fn on_whole_pixels(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].into_iter().all(|v| (snap_px(v) - v.round()).abs() < 1e-9) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 167cc742..3596fb6d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -299,3 +299,28 @@ fn inner_shadow_reaches_every_edge() { assert!(px(&pix, 11, 25)[0] < 235, "{:?}", px(&pix, 11, 25)); assert!(px(&pix, 35, 30)[0] > 245); } + +#[test] +fn rect_clip_frame_matches_same_rect_fill_on_fractional_edges() { + let white = Options { background: Some(Color::WHITE), ..Default::default() }; + for (x, y, w, h) in [(0.5, 0.5, 10.0, 10.0), (0.0, 0.0, 10.5, 10.5), (0.0, 0.0, 10.0, 10.0)] { + let mut clipped = figcraft_doc::Document::new("clipped"); + let p = clipped.first_page().unwrap(); + let f = clipped.create(p, NodeKind::Frame, "F", x, y, w, h).unwrap(); + clipped.get_mut(f).unwrap().fills.clear(); + clipped.get_mut(f).unwrap().clip = true; + let c = clipped.create(f, NodeKind::Rectangle, "C", -5.0, -5.0, w + 10.0, h + 10.0).unwrap(); + clipped.get_mut(c).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + + let mut direct = figcraft_doc::Document::new("direct"); + let dp = direct.first_page().unwrap(); + let r = direct.create(dp, NodeKind::Rectangle, "R", x, y, w, h).unwrap(); + direct.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + + let a = render_view(&clipped, p, &view(12, 12), &white).unwrap(); + let e = render_view(&direct, dp, &view(12, 12), &white).unwrap(); + for (px_x, px_y) in [(0, 0), (0, 5), (5, 0), (5, 5), (10, 5), (5, 10), (10, 10), (11, 11)] { + assert_eq!(px(&a, px_x, px_y), px(&e, px_x, px_y), "frame {x},{y} {w}x{h} at ({px_x},{px_y})"); + } + } +} From 34565964b0489ebd44621d5ba8a8cf422beb86e0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 13:29:22 +0200 Subject: [PATCH 24/77] Keep nested frames out of the ready-for-dev list (fixes #455) --- crates/engine/src/devfocus_tests.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/devinspect.rs | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 5583efe9..1607ce56 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() { click(&mut s, 350.0, 100.0); assert_eq!(s.selection(), vec![b], "everything is hit again"); } + +#[test] +fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() { + let mut s = Session::new(); + let screen = frame(&mut s, 0.0, "Screen"); + let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"}))); + // A status a nested frame already stores (an older file, or set through node.set) is ignored. + ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}})); + assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([])); + assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready"); + ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); + let ids = |v: serde_json::Value| -> Vec { + v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default() + }; + assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]); + ex(&mut s, "dev.focus", json!({"id": screen})); + for direction in ["next", "previous"] { + let v = ex(&mut s, "dev.focus", json!({"direction": direction})); + assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame"); + } + // A frame directly inside a section still counts. + let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300}))); + let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"}))); + ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}})); + assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index e94e88b5..fe400989 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool { } /// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page), -/// in page and layer order (bounded). +/// in page and layer order (bounded). Only layers that can carry a status are listed. pub fn dev_items(doc: &Document, status: Option, page: Option) -> Vec { let mut out = Vec::new(); let mut visited = 0usize; @@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option, page: Option return out; } let Some(n) = doc.node(id) else { continue }; - if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) { + if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) { out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status }); } stack.extend(n.children.iter().rev().copied()); From ac7c91fc9c487b1963391ea0103d83f685666a16 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:44:17 +0200 Subject: [PATCH 25/77] Reject layer IDs as targets of page.rename, delete and setBackground Fixes #414 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/page.rs | 15 ++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/page_target_tests.rs | 63 ++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/page_target_tests.rs diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd607..03dbef7a 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -23,6 +23,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> { Ok(()) } +/// The page a command targets: the explicit `id` (which must be a page) or the open page. +fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result { + let Some(target) = id(p, "id") else { return s.page() }; + if !s.doc()?.pages.contains(&target) { + return Err(bad(cmd, format!("{target} is not a page"))); + } + Ok(target) +} + pub fn specs() -> Vec { vec![ spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| { @@ -71,13 +80,13 @@ pub fn specs() -> Vec { }), spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| { let name = req_string("page.rename", p, "name")?; - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.rename", p)?; s.doc_mut()?.get_mut(id)?.name = name; settle(s, "page.rename")?; ok() }), spec!("page.delete", "Delete page", "{id?}", true, |s, p| { - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.delete", p)?; s.doc_mut()?.remove(id)?; settle(s, "page.delete")?; ok() @@ -114,7 +123,7 @@ pub fn specs() -> Vec { }), spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| { let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?; - let id = id(p, "id").unwrap_or(s.page()?); + let id = page_target(s, "page.setBackground", p)?; if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind { *background = c; } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..f39cd28d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_target_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/page_target_tests.rs b/crates/engine/src/page_target_tests.rs new file mode 100644 index 00000000..fd88685d --- /dev/null +++ b/crates/engine/src/page_target_tests.rs @@ -0,0 +1,63 @@ +//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about +//! the layer changes, as `page.select` already does. + +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn name(s: &Session, id: NodeId) -> String { + s.doc().unwrap().get(id).unwrap().name.clone() +} + +#[test] +fn page_commands_reject_a_rectangle() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let before = name(&s, r); + assert!(s.execute("page.select", &json!({"id": r})).is_err()); + assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer"); + assert_eq!(name(&s, r), before, "the layer keeps its name"); + assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer"); + assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer"); + assert!(s.doc().unwrap().contains(r), "the layer is still there"); + assert!(s.doc().unwrap().contains(page)); +} + +#[test] +fn page_delete_keeps_a_frame_and_its_child() { + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}))); + assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f)); + assert!(s.execute("page.delete", &json!({"id": f})).is_err()); + assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain"); + assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err()); + assert_ne!(name(&s, f), "Not a page"); +} + +#[test] +fn page_commands_still_work_on_pages() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let second = id_of(&ex(&mut s, "page.add", json!({}))); + ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"})); + assert_eq!(name(&s, second), "Flows"); + ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"})); + match s.doc().unwrap().get(second).unwrap().kind { + NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"), + _ => panic!("not a page"), + } + ex(&mut s, "page.delete", json!({"id": second})); + assert!(!s.doc().unwrap().contains(second)); + assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays"); +} From 7d6a3ce1375b2cf51797663ae016dba44bcd7218 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:49:53 +0000 Subject: [PATCH 26/77] Skip divider rows in Next page and Previous page Fixes #413 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/view.rs | 12 +++++++++-- crates/engine/src/panels_behaviour_tests.rs | 22 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5df..6f1320e6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result { let pages = &ds.doc.pages; let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64; let n = pages.len() as i64; - let next = (i + dir).clamp(0, n - 1) as usize; - let Some(pg) = pages.get(next).copied() else { return ok() }; + // Dividers never open: step over them, and stay put when only dividers lie ahead. + let mut next = i + dir; + let pg = loop { + let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied()); + match page { + Some(p) if !ds.doc.is_divider(p) => break p, + Some(_) if dir != 0 && next >= 0 && next < n => next += dir, + _ => break ds.page, + } + }; let ds = s.ds_mut()?; ds.page = pg; ds.selection.clear(); diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index c1016c29..a7b857b6 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -414,3 +414,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); } + +#[test] +fn page_navigation_skips_dividers() { + // A trailing divider: Next page stays on the ordinary page. + let mut s = Session::new(); + let first = s.page().unwrap(); + let div = id(&ex(&mut s, "page.add", json!({"name": "-"}))); + assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open"); + let r = ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider"); + assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page"); + // A middle divider: Previous page crosses it, Next page crosses it back. + let later = id(&ex(&mut s, "page.add", json!({"name": "Later"}))); + assert_eq!(s.page().unwrap(), later); + let r = ex(&mut s, "view.prevPage", json!({})); + assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider"); + assert_eq!(id_of(&r["page"]), Some(first)); + ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), later, "Next page crosses a divider"); + ex(&mut s, "view.nextPage", json!({})); + assert_eq!(s.page().unwrap(), later, "Next page stops on the last page"); +} From bf506712ba0a24619aa0c9fca6d85a74156f823c Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:33:40 +0300 Subject: [PATCH 27/77] fix(ui): show error toasts raised by a slow frame for their full 3 s A toast's clock started from the time of the frame that first drew it. That time is taken when the frame starts, before whatever raised the toast ran: the Open dialog and a failed open blocked that frame for longer than 3 s, so the error expired before it was ever seen and the open failed silently. The clock now starts on the frame after the first one that draws the toast. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/idle_tests.rs | 19 ++++++++++++++++ crates/ui-egui/src/lib.rs | 39 +++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e5757..3f82edc0 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -190,6 +190,25 @@ fn a_toast_wakes_the_ui_only_to_go_away() { assert!(h.app.ui.toast.is_none(), "gone after 3 s"); } +#[test] +fn a_toast_raised_by_a_slow_frame_still_shows_for_3_s() { + let (mut h, _) = document(); + h.settle(); + // The Open dialog and a failed open block the frame that raises the error: the next frame + // comes 5 s after it started. + h.app.toast("can't open file.fig"); + h.frame(vec![]); + h.time += 5.0; + h.frame(vec![]); + assert!(h.app.ui.toast.is_some(), "shown, not expired by the wait"); + h.time += 2.0; + h.frame(vec![]); + assert!(h.app.ui.toast.is_some()); + h.time += 1.5; + h.frame(vec![]); + assert!(h.app.ui.toast.is_none(), "gone after 3 s"); +} + #[test] fn a_finished_background_render_wakes_the_ui() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..03aaf099 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -274,6 +274,17 @@ pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; +/// How long a toast has been showing ([`UiState::toast`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ToastClock { + /// Raised, not drawn yet. + Raised, + /// Drawn once; the next frame starts the clock. + Drawn, + /// Showing since this time (UI clock, seconds). + Since(f64), +} + /// Draw another frame `secs` from now (nothing else needs drawing until then: an idle window draws /// no frames). #[track_caller] @@ -337,8 +348,8 @@ pub struct UiState { pub title_bar: TitleBar, /// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`). pub icon_gallery: bool, - /// A message at the bottom of the window and when it first showed (UI clock, seconds). - pub toast: Option<(String, Option)>, + /// A message at the bottom of the window and how long it has been showing. + pub toast: Option<(String, ToastClock)>, pub present: Option, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. @@ -648,7 +659,7 @@ impl FigApp { } pub fn toast(&mut self, msg: impl Into) { - self.ui.toast = Some((msg.into(), None)); + self.ui.toast = Some((msg.into(), ToastClock::Raised)); } /// Say that `n` documents left unsaved by a crash were opened again from recovery copies. @@ -1135,16 +1146,32 @@ impl FigApp { } fn toast_ui(&mut self, ctx: &egui::Context) { - let Some((msg, shown)) = &mut self.ui.toast else { return }; + let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); - let left = TOAST_SECS - (now - *shown.get_or_insert(now)); + // The clock starts on the frame after the one that first draws the toast: that frame's + // time was taken before whatever raised it ran (a file dialog, a long open), and would + // count that wait against it, so a failed open's message expired before it was seen. + let left = match *clock { + ToastClock::Raised => { + *clock = ToastClock::Drawn; + ctx.request_repaint(); + TOAST_SECS + } + ToastClock::Drawn => { + *clock = ToastClock::Since(now); + TOAST_SECS + } + ToastClock::Since(t) => TOAST_SECS - (now - t), + }; if left <= 0.0 { self.ui.toast = None; return; } let msg = msg.clone(); // Nothing moves while it shows: the next frame it needs is the one that hides it. - repaint_in(ctx, left); + if matches!(clock, ToastClock::Since(_)) { + repaint_in(ctx, left); + } let screen = ctx.content_rect(); egui::Area::new(egui::Id::new("toast")) .fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0)) From 2f4d3894b59591590e5f77e0470197264eb02636 Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:33:40 +0300 Subject: [PATCH 28/77] contributors: add my entry --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index af5d0576..6a65bbe2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,3 +27,6 @@ display_name = "VanTan" [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.volkovskey] +real_name = "Andrii Volkov" From 9853ffce615723606f40f50c55626936e46aee24 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:56:19 +0200 Subject: [PATCH 29/77] Stop a guide drag released off the canvas from continuing on the next click Fixes #431 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/guide_release_tests.rs | 85 +++++++++++++++++++++++ crates/ui-egui/src/guides.rs | 13 ++++ crates/ui-egui/src/lib.rs | 2 + 4 files changed, 101 insertions(+) create mode 100644 crates/ui-egui/src/guide_release_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index beec9aa3..b4e87bb9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -167,6 +167,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); + crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); request_render(app, worker, ppp); diff --git a/crates/ui-egui/src/guide_release_tests.rs b/crates/ui-egui/src/guide_release_tests.rs new file mode 100644 index 00000000..b2e0f015 --- /dev/null +++ b/crates/ui-egui/src/guide_release_tests.rs @@ -0,0 +1,85 @@ +//! Ruler guide drags released off the canvas, driven through egui headless: the gesture ends there, +//! and a later click on the canvas neither adds a guide nor moves one. + +use egui::{Event, Pos2, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn rulers() -> Ui { + let mut s = Session::new(); + s.execute("view.rulers", &json!({"on": true})).unwrap(); + Ui::new(s) +} + +fn offsets(u: &Ui) -> Vec { + let ds = u.app.session.ds().unwrap(); + ds.doc.node(ds.page).unwrap().guides().iter().map(|g| g.offset).collect() +} + +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.frame(vec![Event::PointerMoved(from)]); + u.button(from, true); + u.frame(vec![Event::PointerMoved(to)]); + u.button(to, false); +} + +fn click(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + u.button(p, true); + u.button(p, false); +} + +#[test] +fn a_ruler_drag_released_off_the_canvas_adds_no_guide_on_a_later_click() { + for left in [false, true] { + let mut u = rulers(); + let r = u.app.canvas.rect; + let start = if left { Pos2::new(r.min.x + 5.0, r.min.y + 60.0) } else { Pos2::new(r.min.x + 60.0, r.min.y + 5.0) }; + drag(&mut u, start, Pos2::new(r.min.x - 100.0, r.center().y)); + let released = offsets(&u); + click(&mut u, r.center() + vec2(30.0, 60.0)); + assert_eq!(offsets(&u), released, "left ruler: {left}"); + } +} + +#[test] +fn a_ruler_drag_released_off_the_canvas_ends_even_when_the_next_frame_presses() { + let mut u = rulers(); + let r = u.app.canvas.rect; + let start = Pos2::new(r.min.x + 60.0, r.min.y + 5.0); + let off = Pos2::new(r.min.x - 100.0, r.center().y); + u.frame(vec![Event::PointerMoved(start)]); + u.button(start, true); + u.frame(vec![Event::PointerMoved(off)]); + u.button(off, false); + let p = r.center() + vec2(30.0, 60.0); + u.button(p, true); + u.button(p, false); + assert!(offsets(&u).is_empty()); +} + +#[test] +fn a_guide_moved_off_the_canvas_doesnt_follow_a_later_click() { + let mut u = rulers(); + let r = u.app.canvas.rect; + drag(&mut u, Pos2::new(r.min.x + 60.0, r.min.y + 5.0), r.center()); + assert_eq!(offsets(&u).len(), 1); + drag(&mut u, r.center(), Pos2::new(r.min.x - 100.0, r.center().y)); + let released = offsets(&u); + click(&mut u, r.center() + vec2(30.0, 60.0)); + assert_eq!(offsets(&u), released); +} + +#[test] +fn a_ruler_drag_released_on_the_canvas_adds_one_guide() { + let mut u = rulers(); + let r = u.app.canvas.rect; + drag(&mut u, Pos2::new(r.min.x + 60.0, r.min.y + 5.0), r.center()); + assert_eq!(offsets(&u).len(), 1); + click(&mut u, r.center() + vec2(30.0, 60.0)); + assert_eq!(offsets(&u).len(), 1); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert!(offsets(&u).is_empty()); +} diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 409236f5..548a2642 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -279,6 +279,19 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { false } +/// Ends a guide drag whose button is up by the end of the frame without [`input`] taking the +/// release (released off the canvas, or over something on it): the gesture is dropped, so a later +/// click doesn't add or move a guide. +pub fn end_released(ctx: &egui::Context) { + if ctx.input(|i| i.pointer.primary_down()) { + return; + } + ctx.data_mut(|d| { + d.remove::(drag_id()); + d.remove::(preview_id()); + }); +} + /// Draw the page's and its frames' guides (under the rulers) and the one being dragged with its /// position. pub fn draw(app: &FigApp, painter: &egui::Painter) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..f7dc12a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -91,6 +91,8 @@ mod gap_handle_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod guide_release_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; From fbe746a776025fcce764795d9cf42345549de82a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:58:59 +0200 Subject: [PATCH 30/77] Cap startup operator problems at the latest 200 Fixes #409 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/store.rs | 17 ++++---- .../tests/problem_retention.rs | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 apps/figcraft-server/tests/problem_retention.rs diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 8dfbb708..409e8613 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -347,14 +347,14 @@ impl Store { let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let (directory, problems) = Directory::open(root, cfg.directory); + let (directory, startup_problems) = Directory::open(root, cfg.directory); let abuse = Abuse::new(cfg.hosting.abuse); let mut store = Store { root: root.to_path_buf(), cfg, key, files: BTreeMap::new(), - problems, + problems: Vec::new(), directory, abuse, served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST), @@ -368,6 +368,9 @@ impl Store { free: None, _lock: lock, }; + for problem in startup_problems { + store.note(problem); + } store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); @@ -385,7 +388,7 @@ impl Store { match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) { Ok(list) => list, Err(e) => { - self.problems.push(e); + self.note(e); Vec::new() } } @@ -394,7 +397,7 @@ impl Store { fn scan(&mut self, dir: &Path) { let entries = match fs::read_dir(dir) { Ok(e) => e, - Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())), + Err(e) => return self.note(format!("can't list {}: {e}", dir.display())), }; let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect(); ids.sort(); @@ -403,18 +406,18 @@ impl Store { continue; } if !valid_id(&id) { - self.problems.push(format!("skipped {}: not a file folder of this server", path.display())); + self.note(format!("skipped {}: not a file folder of this server", path.display())); continue; } if self.files.len() >= self.cfg.max_files { - self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); + self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files)); continue; } match self.check(&path) { Ok(entry) => { self.files.insert(id, entry); } - Err(why) => self.problems.push(format!("skipped file {id}: {why}")), + Err(why) => self.note(format!("skipped file {id}: {why}")), } } } diff --git a/apps/figcraft-server/tests/problem_retention.rs b/apps/figcraft-server/tests/problem_retention.rs new file mode 100644 index 00000000..221eaa6e --- /dev/null +++ b/apps/figcraft-server/tests/problem_retention.rs @@ -0,0 +1,40 @@ +use figcraft_server::config::Config; +use figcraft_server::store::{FILES, Store}; + +#[test] +fn startup_problem_retention() { + let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id())); + assert!(!root.exists()); + let result = (|| -> Result<(), String> { + for count in [3u32, 200, 201] { + let data = root.join(count.to_string()); + for i in 0..count { + std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?; + } + let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() }; + cfg.check()?; + let store = Store::open(&data, cfg.store())?; + let expected = (count as usize).min(200); + let problems = store.problems().to_vec(); + let no_loaded_files = store.is_empty(); + drop(store); + if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files { + return Err("unexpected diagnostic or loaded file".into()); + } + if problems.len() != expected { + return Err(format!("expected {expected} retained problems, got {}", problems.len())); + } + // The latest problems stay, in scan order: the first folder's is the one dropped. + let first_kept = count as usize - expected; + for (n, p) in problems.iter().enumerate() { + let id = format!("{:016x}", first_kept + n); + if !p.contains(&id) { + return Err(format!("problem {n} should be about {id}: {p}")); + } + } + } + Ok(()) + })(); + std::fs::remove_dir_all(&root).unwrap(); + assert!(result.is_ok(), "{}", result.unwrap_err()); +} From f6437548ba9b0cf01cd54d3912d5b6c370fe5ebb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:08:55 +0000 Subject: [PATCH 31/77] Keep a styled layer's fill variable through .fig round trips Fixes #399 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/assets_tests.rs | 32 +++++++++++++++++++++++++++ crates/format/src/fig/map.rs | 15 ++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index f3b869ff..4777be18 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() { assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]); } +#[test] +fn a_styled_layers_fill_variable_survives_a_round_trip() { + let mut doc = Document::new("Overlap"); + let page = doc.first_page().unwrap(); + let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() }; + doc.variables.collections = + vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }]; + let light = Color::new(0.1, 0.3, 0.7, 1.0); + let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0)); + doc.variables.variables = + vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])]; + doc.styles = vec![Style { + id: "s-accent".into(), + name: "Accent".into(), + description: String::new(), + kind: StyleKind::Paint { paints: vec![Paint::solid(light)] }, + bound: Default::default(), + }]; + add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| { + n.fills = vec![Paint::solid(light)]; + n.styles.fill = Some("s-accent".into()); + n.bound.insert("fill".into(), "v-accent".into()); + }); + let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap(); + let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone(); + let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap(); + let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var); + let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap(); + let on_style = style.bound.get("fills/0/color") == Some(&var); + assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound); +} + #[test] fn text_styles_on_part_of_a_text_are_written_as_values() { let mut doc = styled(); diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4..02810478 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -492,13 +492,17 @@ impl<'a> Mapper<'a> { } let t = v.get("transform").and_then(matrix).unwrap_or_default(); o.insert("relativeTransform".into(), rows(space * t)); - // Paints, from the shared style when one is linked (the layer's own copy can be stale). + // Paints, from the shared style when one is linked (the layer's own copy can be stale), + // unless the layer's own paints are bound to variables: those bindings must not be lost. let mut styles = Obj::new(); + let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound)); let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); - let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()); + let fills = own("fillPaints") + .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); - let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()); + let strokes = own("strokePaints") + .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); @@ -1387,6 +1391,11 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) { } } +/// Whether a paint's colour or one of its gradient stops is bound to a variable. +fn paint_bound(p: &Kv) -> bool { + alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) +} + fn paint(p: &Kv) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); From 2d78f40e606af720ebbd590dc564aec56f39a4d8 Mon Sep 17 00:00:00 2001 From: Seun Samuel-Ipaye Date: Thu, 15 Oct 2026 08:11:40 -0400 Subject: [PATCH 32/77] Pen tool: a click's small wobble pulls out no handle --- crates/engine/src/cursor_tests.rs | 18 ++++++++++++++++++ crates/engine/src/tools.rs | 3 ++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 0975f3fa..05a95f66 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() { assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone"); } +#[test] +fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + // A real click: the mouse moves a pixel or two between press and release. + s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap(); + let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o); + assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out"); + assert!(s.tool.overlay.pen_handles.is_empty()); + // Past the slop it is a drag: the handle follows the pointer from where it was pressed. + s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap(); + let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y)); + assert_eq!(out, Some((4.0, 0.0))); +} + #[test] fn the_pen_shows_what_a_click_would_do() { // The Pen tool drawing a new path: three points, then over each of them. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ddd276f9..e9db0faa 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1444,7 +1444,8 @@ impl Session { } Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), Gesture::PenDrag { anchor } => { - let out = ev.p() - *anchor; + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; if let Some(pen) = self.tool.pen.as_mut() && let Some(last) = pen.points.last_mut() { From 908906f3f48241f6c2e542e026f19ae30e66b9c1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:15:35 +0200 Subject: [PATCH 33/77] Close a layer's colour picker when the selection changes Fixes #396 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/colorpicker.rs | 10 +++- .../src/colorpicker_selection_tests.rs | 54 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/colorpicker_selection_tests.rs diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..581c65a2 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -400,7 +400,15 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { return }; - let Some(mut paint) = current_paint(app, &target) else { + // A layer's picker belongs to the selection it was opened for: once that changes, close it + // rather than keep editing layers that are no longer selected. + let stale = match &target { + PickerTarget::Paint { ids, .. } | PickerTarget::Effect { ids, .. } => *ids != app.session.selection(), + PickerTarget::TextRange { id, .. } => !app.session.selection().contains(id), + _ => false, + }; + let current = if stale { None } else { current_paint(app, &target) }; + let Some(mut paint) = current else { app.ui.picker = None; return; }; diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs new file mode 100644 index 00000000..57aae197 --- /dev/null +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -0,0 +1,54 @@ +//! The colour picker of a layer's paint or effect closes when that layer is no longer the +//! selection, so it never keeps editing a layer that was deselected (#396). + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn rect(s: &mut Session, x: f64) -> NodeId { + let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap(); + NodeId(v["id"].as_u64().unwrap()) +} + +fn two() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let a = rect(&mut s, 0.0); + let b = rect(&mut s, 40.0); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + (Ui::new(s), a, b) +} + +#[test] +fn picker_closes_when_its_layer_is_deselected() { + for effect in [false, true] { + let (mut u, a, _) = two(); + let target = if effect { + u.app.run("effect.add", json!({"ids": [a], "type": "dropShadow"})); + PickerTarget::Effect { ids: vec![a], index: 0, secondary: false } + } else { + PickerTarget::Paint { fills: true, index: 0, ids: vec![a] } + }; + u.app.ui.picker = Some(target.clone()); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.ui.picker, Some(target.clone()), "the picker opens while its layer is selected"); + u.app.run("select.none", json!({})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.ui.picker.is_none(), "deselecting closes {target:?}"); + } +} + +#[test] +fn picker_closes_when_another_layer_is_selected() { + let (mut u, a, b) = two(); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + u.frame(vec![]); + u.app.run("select.set", json!({"ids": [b]})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..6e12762b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -75,6 +75,8 @@ mod canvas_drag_tests; #[cfg(test)] mod canvas_fixes_tests; #[cfg(test)] +mod colorpicker_selection_tests; +#[cfg(test)] mod comments_tests; #[cfg(test)] mod cursors_tests; From d0285d236ea48eb65f7276af8664c967d1a97564 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:23:20 +0200 Subject: [PATCH 34/77] Keep a Rectangle in the smallest synthetic perf documents Fixes #393 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/synthetic.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/synthetic.rs index 0cb3e148..c2a9f5d6 100644 --- a/crates/engine/src/synthetic.rs +++ b/crates/engine/src/synthetic.rs @@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { made += 4; } // A card group (5 layers). - if budget(made) >= 5 { + // The card and the instances leave one layer for the grid, which starts with a rectangle. + if budget(made) > 5 { let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; // Children are in the group's space; normalising fits the group around them. let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; @@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { } // Three button instances (3 layers each). for k in 0..3 { - if budget(made) < 3 { + if budget(made) < 3 + 1 { break; } crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?; @@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { for k in 0..rest { let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; - let id = match k % 5 { + // The grid starts with a rectangle so even the smallest workload has one. + let kind = (k + 2) % 5; + let id = match kind { 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, }; - if k % 5 != 0 { + if kind != 0 { fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); if let Some(n) = doc.node_mut(id) { n.corners.radii = [3.0; 4]; @@ -124,4 +127,16 @@ mod tests { assert!(doc.page_content_bounds(page).is_some()); } } + + #[test] + fn small_documents_hold_the_benchmark_targets() { + for n in [10, 11, 12, 13, 14, 15] { + let (doc, page) = super::large_document(n).unwrap(); + let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect(); + let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node)); + assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle"); + assert!(has(&|x| x.name == "Title"), "{n}: no title"); + assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component"); + } + } } From b29cc01d55d0675cb8919d62d1aca307bdc104b9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:34:16 +0000 Subject: [PATCH 35/77] Fix perf failing with 'no card' on small instance counts Fixes #394 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-cli/src/perf.rs | 98 +++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index c08ddc04..03190306 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))), ); let doc = s.doc().map_err(failed)?.clone(); - let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?; - table.put( - col, - "switch a card's variant (nested overrides kept)", - mean(5, |i| { - exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) - }), - ); - let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?; - table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); - table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); - let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?; - table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); - // A button glyph the drop really lands on: screens can overlap (text sizes differ without - // fonts), so ask the hit test the swap uses. - let now = s.doc().map_err(failed)?.clone(); - let page = s.page().map_err(failed)?; - let b = std::iter::once(bglyph) - .chain(inst.iter().filter_map(|i| { - let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; - (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? - })) - .filter_map(|g| now.world_bounds(g)) - .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()) - .ok_or_else(|| failed("no button glyph to drop on"))?; - let other = ds.icons.get(5).copied().unwrap_or(icon0); - table.put( - col, - "Alt-drop swap onto an instance", - timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), - ); - exec(&mut s, "edit.copy", json!({"ids": [nav]})); - table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + // Small workloads may not contain every instance type; the rows needing a missing one stay empty. + if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) { + table.put( + col, + "switch a card's variant (nested overrides kept)", + mean(5, |i| { + exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}})) + }), + ); + } else { + table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN); + } + let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav); + if let Some(nav) = nav { + table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true)); + table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true)); + } else { + table.put(col, "reset all changes on a nav", f64::NAN); + table.put(col, "detach a nav", f64::NAN); + } + if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) { + table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true)); + // A button glyph the drop really lands on: screens can overlap (text sizes differ without + // fonts), so ask the hit test the swap uses. + let now = s.doc().map_err(failed)?.clone(); + let page = s.page().map_err(failed)?; + let drop_at = std::iter::once(bglyph) + .chain(inst.iter().filter_map(|i| { + let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None }; + (figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))? + })) + .filter_map(|g| now.world_bounds(g)) + .find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some()); + let other = ds.icons.get(5).copied().unwrap_or(icon0); + table.put( + col, + "Alt-drop swap onto an instance", + match drop_at { + Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true), + None => f64::NAN, + }, + ); + } else { + table.put(col, "push a button's changes to its main", f64::NAN); + table.put(col, "Alt-drop swap onto an instance", f64::NAN); + } + if let Some(nav) = nav { + exec(&mut s, "edit.copy", json!({"ids": [nav]})); + table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true)); + } else { + table.put(col, "copy + paste a nav", f64::NAN); + } table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo())); table.put(col, "redo", mean(10, |_| _ = s.redo())); // As a library: publish it, use it from another file, publish a change, accept the update. - let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id())); + // One directory per run, so concurrent runs in a process don't delete each other's files. + static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id())); std::fs::create_dir_all(&dir).map_err(failed)?; let path = dir.join("design-system.figcraft").to_string_lossy().into_owned(); let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?; @@ -533,6 +555,14 @@ mod tests { assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows); } + #[test] + fn measures_components_with_a_single_instance() { + let mut t = super::Table { rows: Vec::new() }; + super::measure_components(&mut t, 0, 1).unwrap(); + assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows); + assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows); + } + #[test] fn measures_a_small_document() { let mut t = super::Table { rows: Vec::new() }; From 8199623267e79ee496ed0ebcf4e244c8868964cd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:36:34 +0200 Subject: [PATCH 36/77] Include empty outlines in no-op shapeBuilder.merge response Fixes #424 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/shapebuilder.rs | 2 +- crates/engine/tests/shape_builder_response.rs | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/shape_builder_response.rs diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/shapebuilder.rs index 09a6758a..99d59b45 100644 --- a/crates/engine/src/shapebuilder.rs +++ b/crates/engine/src/shapebuilder.rs @@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result { let whole_shape = touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x))); if merge && whole_shape { - return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick})); + return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick})); } let doc = s.doc_mut()?; let created = if merge { diff --git a/crates/engine/tests/shape_builder_response.rs b/crates/engine/tests/shape_builder_response.rs new file mode 100644 index 00000000..e1426823 --- /dev/null +++ b/crates/engine/tests/shape_builder_response.rs @@ -0,0 +1,36 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use figcraft_engine::Session; +use serde_json::{Value, json}; + +fn rectangle(s: &mut Session, x: f64) -> Value { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone() +} + +#[test] +fn merge_response_includes_outlines_for_no_op() { + let mut s = Session::new(); + let id = rectangle(&mut s, 0.0); + let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap(); + assert_eq!(regions["regions"].as_array().unwrap().len(), 1); + let before = serde_json::to_value(s.doc().unwrap()).unwrap(); + let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before); + assert_eq!(result["id"], Value::Null); + assert_eq!(result["changed"], json!([])); + assert_eq!(result["removed"], json!([])); + assert_eq!(result["regions"], json!([0])); + assert_eq!(result.get("outlines"), Some(&json!([]))); +} + +#[test] +fn merge_response_control_includes_outlines() { + let mut s = Session::new(); + let a = rectangle(&mut s, 0.0); + let b = rectangle(&mut s, 50.0); + let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(regions["regions"].as_array().unwrap().len(), 3); + let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap(); + assert!(result["id"].is_u64()); + assert_eq!(result.get("outlines"), Some(&json!([]))); + assert_eq!(result.as_object().unwrap().len(), 5); +} From 0d125374d39ec9e5668013a4416aedf3ea51f8fb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:05:07 +0000 Subject: [PATCH 37/77] Don't record an undo step for an abandoned empty text box Fixes #423 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/textedit.rs | 13 ++--- crates/engine/tests/empty_text_box_undo.rs | 60 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/empty_text_box_undo.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba94217..9785b7ce 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> { let _ = doc.remove(te.id); s.ds_mut()?.selection.clear(); } - s.commit_interaction()?; if empty && te.created { - // Nothing to undo for a text box that was never filled. + // Nothing to undo for a text box that was never filled. The gesture snapshot was taken + // after the layer was added, so it still holds it: re-base it on the document without the + // layer, and committing then records no step (and leaves the redo stack alone). let ds = s.ds_mut()?; - if let Some(last) = ds.history.undo.last() - && *last.doc == *ds.doc - { - ds.history.undo.pop(); + let doc = ds.doc.clone(); + if let Some(it) = ds.interaction.as_mut() { + it.doc = doc; } } + s.commit_interaction()?; let ds = s.ds_mut()?; ds.revision += 1; Ok(()) diff --git a/crates/engine/tests/empty_text_box_undo.rs b/crates/engine/tests/empty_text_box_undo.rs new file mode 100644 index 00000000..ec73b83a --- /dev/null +++ b/crates/engine/tests/empty_text_box_undo.rs @@ -0,0 +1,60 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::{ + Session, + doc::NodeKind, + tools::{PointerEvent, PointerKind}, +}; +use serde_json::json; + +fn text_count(s: &Session) -> usize { + let doc = s.doc().unwrap(); + doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count() +} + +fn rect_count(s: &Session) -> usize { + let doc = s.doc().unwrap(); + doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s) +} + +fn click_and_abandon(s: &mut Session) { + s.execute("tool.text", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap(); + assert_eq!(text_count(s), 1); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(text_count(s), 0); +} + +#[test] +fn abandoning_empty_text_does_not_create_an_undo_step() { + let mut s = Session::new(); + assert_eq!(s.undo_depth(), (0, 0)); + click_and_abandon(&mut s); + assert_eq!(s.undo_depth(), (0, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer"); +} + +#[test] +fn abandoning_empty_text_keeps_the_previous_undo_step_first() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + click_and_abandon(&mut s); + assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_count(&s), 0); + assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle"); +} + +#[test] +fn abandoning_empty_text_keeps_the_redo_stack() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.undo_depth(), (0, 1)); + click_and_abandon(&mut s); + assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(rect_count(&s), 1); +} From 72b826d65b1c1792f3dbc46e63882a84879cb3c0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:17:21 +0200 Subject: [PATCH 38/77] Fix cross-axis arrows and drop slot on single-item wrapped lines Fixes #422 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/al_keys_tests.rs | 21 +++++++++++++++++++++ crates/engine/src/drop.rs | 7 +++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/al_keys_tests.rs b/crates/engine/src/al_keys_tests.rs index 1053997e..76256466 100644 --- a/crates/engine/src/al_keys_tests.rs +++ b/crates/engine/src/al_keys_tests.rs @@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() { assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column"); } +#[test] +fn single_item_wrapped_lines_move_and_drop_by_line() { + let mut s = Session::new(); + // Full-width items: one per row. + let row = al(&mut s, None, "horizontal", 160.0, 200.0); + set(&mut s, row, json!({"wrap": true})); + let k: Vec = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect(); + assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row"); + let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]); + assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0])))); + arrow(&mut s, &[k[1]], 0, -1); + assert_eq!(kids(&s, row), vec![k[1], k[0]]); + + // Full-height items in a vertical wrap: one per column. + let col = al(&mut s, None, "vertical", 200.0, 160.0); + set(&mut s, col, json!({"wrap": true})); + let c: Vec = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect(); + arrow(&mut s, &[c[2]], -1, 0); + assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]); +} + #[test] fn wrapped_rows_move_up_and_down_a_row() { let mut s = Session::new(); diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index f478735e..d5e8b3cf 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect { /// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each /// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children -/// wrap in flow order, so a new row starts where a child sits before the one before. +/// wrap in flow order, so a new row starts where a child sits before the one before, or where it +/// does not follow the one before along the flow but sits past it across (one child per line). pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec> { let mut rows: Vec> = Vec::new(); for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) { match rows.last_mut() { - Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)), + Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => { + row.push((i, b)) + } _ => rows.push(vec![(i, b)]), } } From 524b5389082794cf688c5b8d62db9e051180f05f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:21:05 +0000 Subject: [PATCH 39/77] Report changed=false when list indentation is capped Fixes #425 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/textedit.rs | 10 ++- crates/engine/tests/list_indent_response.rs | 71 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/list_indent_response.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba94217..cb39250f 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -146,9 +146,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result { } /// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like -/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is). +/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already +/// at the deepest level stay as they are). pub fn list_indent(s: &mut Session, out: bool) -> Result { - list_key(s, "Tab", out) + let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) }; + let before = text_of(s, id)?; + if !list_key(s, "Tab", out)? { + return Ok(false); + } + Ok(text_of(s, id)? != before) } /// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word diff --git a/crates/engine/tests/list_indent_response.rs b/crates/engine/tests/list_indent_response.rs new file mode 100644 index 00000000..1dae8754 --- /dev/null +++ b/crates/engine/tests/list_indent_response.rs @@ -0,0 +1,71 @@ +//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_engine::{ + Session, + doc::{ListStyle, NodeId, NodeKind}, +}; +use serde_json::json; + +fn editing_list(indent: u8) -> (Session, NodeId) { + let mut s = Session::new(); + let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap(); + let id = NodeId(value["id"].as_u64().unwrap()); + s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap(); + s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap(); + s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap(); + (s, id) +} + +fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) { + let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") }; + let p = t.para_list(0); + (p.list, p.indent) +} + +#[test] +fn indent_at_cap_reports_false() { + let (mut s, id) = editing_list(8); + let before = s.doc().unwrap().clone(); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8)); + assert_eq!(before, *s.doc().unwrap()); + assert_eq!(result["changed"], json!(false)); +} + +#[test] +fn indent_from_zero_reports_true() { + let (mut s, id) = editing_list(0); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1)); + assert_eq!(result["changed"], json!(true)); +} + +#[test] +fn outdent_from_zero_reports_true() { + let (mut s, id) = editing_list(0); + let result = s.execute("text.listOutdent", &json!({})).unwrap(); + assert_eq!(list_state(&s, id).0, ListStyle::None); + assert_eq!(result["changed"], json!(true)); +} + +#[test] +fn indent_plain_text_reports_false() { + let mut s = Session::new(); + let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap(); + let id = NodeId(value["id"].as_u64().unwrap()); + s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap(); + let before = s.doc().unwrap().clone(); + let result = s.execute("text.listIndent", &json!({})).unwrap(); + assert_eq!(before, *s.doc().unwrap()); + assert_eq!(result["changed"], json!(false)); +} + +#[test] +fn tab_keys_still_nest_and_unnest() { + let (mut s, id) = editing_list(0); + s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1)); + s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap(); + assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0)); +} From b61122cccada2f571c7af50485d03d4dc32c58c0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:28:43 +0200 Subject: [PATCH 40/77] Fix eraser missing brush crossings narrower than the sample spacing Fixes #421 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/vectoredit/ops.rs | 54 ++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 093d0854..cce23d15 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -3,7 +3,7 @@ //! `vectoredit.rs` re-fit the node afterwards. use figcraft_doc::*; -use figcraft_geom::kurbo::{CubicBez, ParamCurve}; +use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use figcraft_geom::{Affine, Point, Vec2}; fn tvec(t: (f64, f64)) -> Vec2 { @@ -766,6 +766,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64, if ia { b } else { a } }; let n = 64; + // Upper bound on the curve's speed: no point of a sample interval is farther than the whole + // interval length from either end, so only intervals that come that close need a closer look. + let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max); + let reach = speed / f64::from(n); + // A crossing narrower than an interval whose ends are both outside: the closest point. + let dip = |a: f64, b: f64| -> Option { + if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach { + return None; + } + let near = cb.subsegment(a..b).nearest(c, 1e-12); + let m = a + (b - a) * near.t; + (m > a && m < b && inside(m)).then_some(m) + }; let mut out = Vec::new(); let mut start: Option = if inside(0.0) { Some(0.0) } else { None }; let mut prev = 0.0; @@ -773,6 +786,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64, let t = f64::from(i) / f64::from(n); match (start, inside(t)) { (None, true) => start = Some(edge(prev, t)), + (None, false) => { + if let Some(m) = dip(prev, t) { + out.push((edge(prev, m), edge(m, t))); + } + } (Some(s0), false) => { out.push((s0, edge(prev, t))); start = None; @@ -999,4 +1017,38 @@ mod tests { assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0)); assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0)); } + + #[test] + fn eraser_catches_narrow_crossing_between_samples() { + // A radius-1 brush on a long line: the crossing fits between the sampled parameters. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0)); + assert_eq!(n.segments.len(), 2); + let ends: Vec = n.vertices.iter().map(|v| v.x).collect(); + assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}"); + assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}"); + // A curved segment that only grazes the brush between samples. + let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + c.segments[0].ta = (0.0, 3000.0); + c.segments[0].tb = (0.0, 3000.0); + let cb = c.cubic(0).unwrap(); + let t = 0.7039; + let p = cb.eval(t); + let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize(); + let normal = Vec2::new(-d.y, d.x); + assert!(erase(&mut c, p + normal * 0.5, 1.0)); + assert_eq!(c.segments.len(), 2); + } + + #[test] + fn eraser_catches_crossing_at_full_speed_between_samples() { + // The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false); + let centre = n.cubic(0).unwrap().eval(32.5 / 64.0); + assert!(erase(&mut n, centre, 0.5)); + assert_eq!(n.segments.len(), 2); + let ends: Vec = n.vertices.iter().map(|v| v.x).collect(); + assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}"); + assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}"); + } } From 01c0b38669226ee6b0d28a61f36d0528a7fc3168 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 15 Oct 2026 19:03:24 +0500 Subject: [PATCH 41/77] fix(rest): keep a text hyperlink's destination whole on import (#459) A hyperlink longer than 200 characters came back as a different 200-character URL -- one that still looks like a working link -- and the import reported nothing. It applied to a text layer's base style and to a character style override alike. The destination went through `clip_str`, which exists so that names and labels from a file stay readable when they are quoted in a warning. Every other caller is a name, a description, a style id or a layer type; a hyperlink is data, and clipping it silently rewrote it. Two things say so: the FIG importer already keeps the URL whole, so the two importers disagreed, and `rest::props` bounds numbers and list lengths but no string beyond that label helper -- the text content itself is not clipped either. The reporter's integration test is included, with its assertions unchanged. Its helper is restructured to return what it found rather than unwrap: the crate denies `unwrap_used` and `panic`, and `clippy.toml` allows them only inside a `#[test]` function, so the original shape failed the gate before it ran. Its failure message now reports the input and output lengths. --- crates/format/src/rest/text.rs | 5 +- crates/format/tests/import_link_contract.rs | 57 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 crates/format/tests/import_link_contract.rs diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 5fbb368d..7b97d215 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -126,7 +126,10 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { } if let Some(h) = s.get("hyperlink") { match (text(h, "type"), text(h, "url")) { - (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()), + // The destination is data, not a label: `clip_str` is for names that go into warnings, + // and clipping a URL changed it into a different, working-looking one without a word + // (#459). The FIG importer already keeps it whole. + (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()), _ => {} } } diff --git a/crates/format/tests/import_link_contract.rs b/crates/format/tests/import_link_contract.rs new file mode 100644 index 00000000..30a3408a --- /dev/null +++ b/crates/format/tests/import_link_contract.rs @@ -0,0 +1,57 @@ +//! A text hyperlink's destination survives a REST import whole, however long it is (#459). +//! +//! The assertions are the reporter's; the helper returns its findings instead of unwrapping, so +//! that the crate's `unwrap_used`/`panic` lints hold outside a `#[test]` function. + +use figcraft_doc::NodeKind; +use figcraft_format::rest; +use serde_json::json; + +/// The link of the imported text, and the warnings the import reported. `None` for a link means +/// the layer was missing, was not text, or carried no link at all. +fn imported_link(length: usize, override_style: bool) -> (String, Option, Vec) { + let url = format!("https://example.com/{}", "a".repeat(length - 20)); + assert_eq!(url.len(), length); + let mut text = json!({"id":"1:2","type":"TEXT","name":"Link","characters":"A","relativeTransform":[[1,0,0],[0,1,0]]}); + let style = json!({"hyperlink":{"type":"URL","url":url}}); + if override_style { + text["characterStyleOverrides"] = json!([1]); + text["styleOverrideTable"] = json!({"1":style}); + } else { + text["style"] = style; + } + let input = json!({"document":{"type":"DOCUMENT","children":[{"id":"1:1","type":"CANVAS","children":[text]}]}}); + assert!(rest::is_rest_json(&input)); + let Ok((doc, warnings)) = rest::import(&input, &rest::no_images) else { + return (url, None, vec!["import failed".into()]); + }; + let link = match doc.nodes().find(|n| n.name == "Link").map(|n| &n.kind) { + Some(NodeKind::Text(data)) if override_style => data.runs.first().and_then(|r| r.style.link.clone()), + Some(NodeKind::Text(data)) => data.style.link.clone(), + _ => None, + }; + (url, link, warnings) +} + +/// Every length in turn: the destination comes back exactly, and nothing is reported. +fn check(override_style: bool) { + let mut mismatches = Vec::new(); + for length in [200, 201, 240] { + let (url, link, warnings) = imported_link(length, override_style); + assert!(warnings.is_empty(), "import warned at {length}: {warnings:?}"); + if link.as_deref() != Some(url.as_str()) { + mismatches.push((length, link.map_or(0, |l| l.len()))); + } + } + assert!(mismatches.is_empty(), "URLs changed, (input length, output length): {mismatches:?}"); +} + +#[test] +fn base_hyperlink_preserves_url() { + check(false); +} + +#[test] +fn override_hyperlink_preserves_url() { + check(true); +} From d769320d454bff35e742316a67d40fbc2a150b9c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Thu, 15 Oct 2026 10:04:09 -0400 Subject: [PATCH 42/77] test(canvas): grow the slow zoom-preview scene until it crosses the threshold (M8) The browser-worker test assumed a fixed 2400x2400 glass scene renders in at least 12 ms; a faster machine or renderer (the glass speedups) would make it quick and the test fail. The scene now gets more and more blurred rectangles until renders from scratch of the views the test uses take three times the threshold. Refs #295 --- crates/ui-egui/src/render_worker.rs | 2 +- crates/ui-egui/src/zoom_preview_tests.rs | 43 ++++++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 3ad9c54c..e2fb9a20 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -20,7 +20,7 @@ use web_time::Instant; /// A preview has this many times fewer pixels each way than the view it stands in for. pub const PREVIEW_FACTOR: f64 = 4.0; /// Full renders quicker than this (about a frame) show without a preview first. -const PREVIEW_AFTER_MS: f64 = 12.0; +pub const PREVIEW_AFTER_MS: f64 = 12.0; #[derive(Clone)] pub struct Job { diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index 68704571..0fcd9a29 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -13,7 +13,7 @@ use figcraft_render::{View, Work}; use serde_json::json; use super::{CanvasState, shows}; -use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; +use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, coarse_view, render_now}; /// A page of ellipses with a rectangle on top; the session and the rectangle. fn document() -> (Session, NodeId) { @@ -27,6 +27,35 @@ fn document() -> (Session, NodeId) { (s, rect) } +/// A page whose renders from scratch of each of `views` take at least `ms`: the rectangle with glass +/// and a background blur, and more and more blurred rectangles over the views until they do (a +/// fixed scene would turn quick on a faster machine or renderer). +fn slow_scene(views: &[View], ms: f64) -> Session { + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap(); + let area = views.iter().map(View::page_rect).reduce(|a, b| a.union(b)).unwrap(); + let mut more = 8; + for _ in 0..12 { + let ds = s.ds().unwrap(); + let quickest = views.iter().map(|v| render_now(&job(&ds.doc, ds.page, ds.uid, v.clone())).unwrap().ms).fold(f64::INFINITY, f64::min); + if quickest >= ms { + return s; + } + let (w, h) = (area.width() / 4.0, area.height() / 4.0); + let ids: Vec = (0..more) + .map(|k| { + let (fx, fy) = (f64::from(k * 37 % 101) / 101.0, f64::from(k * 59 % 103) / 103.0); + let p = json!({"x": area.x0 + fx * (area.width() - w), "y": area.y0 + fy * (area.height() - h), "width": w, "height": h}); + id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap() + }) + .collect(); + s.execute("effect.add", &json!({"ids": ids, "type": "BACKGROUND_BLUR"})).unwrap(); + more *= 2; + } + panic!("no scene renders slower than {ms} ms"); +} + /// A `w` × `h` device-pixel view at `scale` centred on page point `c`. fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View { View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h } @@ -254,18 +283,18 @@ fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() { #[test] fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() { - // Big enough that a render from scratch takes well over a frame: blurs and glass on 2400². - let (mut s, rect) = document(); - s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); - s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap(); + // Renders from scratch at 200% and 100% (whose times decide the previews below) well over the + // threshold, wherever this runs. + 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_at((300.0, 200.0), scale, 2400, 2400)); + 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()); 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"); - assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); + assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); // A new zoom: the preview this frame, the refined image the next. w.submit(at(1.0)); let p = w.poll().expect("a preview"); From 7689af50d67e848dfe6a5010aa3dbec64841966a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:31:53 +0200 Subject: [PATCH 43/77] Keep character-range text styles when publishing and placing components Fixes #463 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 2 + crates/engine/src/library.rs | 7 +- .../engine/src/library_range_styles_tests.rs | 66 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/library_range_styles_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..5bcb0802 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1500,6 +1500,8 @@ mod invite_links_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] +mod library_range_styles_tests; +#[cfg(test)] mod library_variables_tests; #[cfg(test)] mod live_access_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 936d798a..6b98196f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec { out } -/// The style ids a layer links to (fill, stroke, effect, grid, text). +/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on +/// character ranges). pub(crate) fn style_links(n: &Node) -> impl Iterator { - [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten() + let chars = + n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref()); + [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars) } /// Style ids the layers under `roots` link to. diff --git a/crates/engine/src/library_range_styles_tests.rs b/crates/engine/src/library_range_styles_tests.rs new file mode 100644 index 00000000..077470e7 --- /dev/null +++ b/crates/engine/src/library_range_styles_tests.rs @@ -0,0 +1,66 @@ +//! Text styles linked on character ranges travel with components (publish and place). + +use crate::Session; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} + +fn node(value: Value) -> NodeId { + NodeId(value["id"].as_u64().unwrap()) +} + +/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style). +fn fixture() -> (Session, NodeId, String, String) { + let mut s = Session::new(); + let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40}))); + let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"}))); + let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned(); + run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2})); + let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"}))); + let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned(); + run(&mut s, "style.apply", json!({"id":whole_text, "style":whole})); + let component = node(run(&mut s, "component.create", json!({"ids":[frame]}))); + let source = s.doc().unwrap().node(text).unwrap(); + let chars = source.kind.text().unwrap(); + assert!(source.styles.text.is_none()); + assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str())); + assert_eq!(chars.style_at(2).style_id, None); + (s, component, range, whole) +} + +#[test] +fn component_publish_keeps_range_text_style() { + let (mut s, component, range, whole) = fixture(); + run(&mut s, "library.publish", json!({"include":[component]})); + let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc; + assert!(snapshot.style(&whole).is_some()); + assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot"); +} + +#[test] +fn component_place_imports_range_text_style() { + let (mut source, component, range, whole) = fixture(); + let publication = run(&mut source, "library.publish", json!({})); + let library = source.doc().unwrap().published.as_ref().unwrap().clone(); + assert!(library.doc.style(&range).is_some()); + let mut target = Session::new(); + let path = "figcraft://library.test/range-styles"; + target.libraries.insert(path, Ok(library)); + run(&mut target, "library.add", json!({"path":path})); + let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component}))); + let doc = target.doc().unwrap(); + let children = &doc.node(instance).unwrap().children; + let ranged = doc.node(children[0]).unwrap().kind.text().unwrap(); + let full = doc.node(children[1]).unwrap().kind.text().unwrap(); + let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap(); + assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str())); + assert_eq!(ranged.style_at(0).font_size, 32.0); + assert!(ranged.style_at(2).style_id.is_none()); + let record = doc.libraries.styles.iter().find(|r| r.key == range); + assert!(record.is_some(), "character-range style missing after placement"); + assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str())); + assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some()); +} From 330b237c9ca5195d85bdf5ea49e1ede1e0285692 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:45:47 +0000 Subject: [PATCH 44/77] Refresh linked layers when a library update changes only a style Fixes #461 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 4 +- .../engine/tests/library_style_acceptance.rs | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/library_style_acceptance.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..ff9fbd17 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1180,8 +1180,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { pub fn normalize(doc: &mut Document, before: &Document) { // Only what was touched since the last normalisation (edited, added or removed). let mut touched: BTreeSet = doc.take_dirty(); - // A variable edit changes no layer directly, but bound layers must follow it. - if touched.is_empty() && doc.variables == before.variables { + // A variable or style edit changes no layer directly, but bound and linked layers must follow it. + if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles { return; } let m = measure::TextMeasure; diff --git a/crates/engine/tests/library_style_acceptance.rs b/crates/engine/tests/library_style_acceptance.rs new file mode 100644 index 00000000..8398fc72 --- /dev/null +++ b/crates/engine/tests/library_style_acceptance.rs @@ -0,0 +1,72 @@ +//! Accepting a library update that changes only a paint style refreshes the layers linked to it. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_doc::{NodeId, StyleKind}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() +} + +fn layer_paint(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex() +} + +fn style_paint(s: &Session, id: &str) -> String { + match &s.doc().unwrap().style(id).unwrap().kind { + StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(), + _ => panic!("expected paint style"), + } +} + +struct Fixture(std::path::PathBuf); + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn accepted_library_paint_reaches_linked_layer() { + let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&fixture.0); + std::fs::create_dir(&fixture.0).unwrap(); + let path = fixture.0.join("library.figcraft"); + let mut publisher = Session::new(); + run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"})); + let publication = run(&mut publisher, "library.publish", json!({})); + assert_eq!(publication["components"], 0); + assert_eq!(publication["styles"], 1); + run(&mut publisher, "file.saveAs", json!({"path":path})); + let mut consumer = Session::new(); + let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40})); + let layer = NodeId(rectangle["id"].as_u64().unwrap()); + run(&mut consumer, "library.add", json!({"path":path})); + let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer})); + let style = applied["style"].as_str().unwrap(); + assert_eq!(layer_paint(&consumer, layer), "0000FF"); + run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"})); + run(&mut publisher, "library.publish", json!({})); + run(&mut publisher, "file.save", json!({})); + assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1); + let accepted = run(&mut consumer, "library.acceptUpdates", json!({})); + assert_eq!(accepted["styles"], 1); + assert_eq!(accepted["components"], 0); + assert_eq!(accepted["variables"], 0); + assert_eq!(style_paint(&consumer, style), "00FF00"); + assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style)); + // Captured before any reapply so a later action can't hide a missed refresh. + let after_accept = layer_paint(&consumer, layer); + assert_eq!(after_accept, "00FF00"); + assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0); + consumer.undo().unwrap(); + assert_eq!(style_paint(&consumer, style), "0000FF"); + assert_eq!(layer_paint(&consumer, layer), "0000FF"); + consumer.redo().unwrap(); + assert_eq!(style_paint(&consumer, style), "00FF00"); + assert_eq!(layer_paint(&consumer, layer), "00FF00"); + run(&mut consumer, "style.apply", json!({"style":style,"id":layer})); + assert_eq!(layer_paint(&consumer, layer), "00FF00"); +} From 76a67e7ee568c269e481d1c1505ae6e43295f4df Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:47:58 +0200 Subject: [PATCH 45/77] Point library style variable bindings at imported library variables Fixes #462 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/library.rs | 19 ++++++++++++- crates/engine/src/library_variables_tests.rs | 30 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 936d798a..df67c571 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -582,11 +582,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String { } /// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than -/// the library's are refreshed). Returns library style id → local id. +/// the library's are refreshed). The variables a written copy binds are imported too and its +/// bindings point at their copies (never at this file's variables of the same ids). Returns library +/// style id → local id. pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet, update: bool) -> BTreeMap { let lid = lib.manifest.id.clone(); let mut out = BTreeMap::new(); let mut hashes = BTreeSet::new(); + // Local ids of the copies written now, with the library style they copy. + let mut written: Vec<(String, &Style)> = Vec::new(); for key in keys { let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue }; let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version); @@ -598,6 +602,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet { *slot = Style { id: r.local.clone(), ..style.clone() }; set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() }); + written.push((r.local.clone(), style)); } r.local } @@ -605,6 +610,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l)); doc.styles.push(Style { id: local.clone(), ..style.clone() }); set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version }); + written.push((local.clone(), style)); local } }; @@ -613,6 +619,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet } out.insert(key.clone(), local); } + let bound: BTreeSet = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect(); + if !bound.is_empty() { + let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update); + for (local, _) in &written { + if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) { + // Bindings to variables the library lacks are dropped (the published value stays). + slot.bound = + std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect(); + } + } + } copy_images(doc, &lib.doc, &hashes); out } diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4bbaad57..4fc778a7 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() { } assert!(s.doc().unwrap().libraries.is_empty()); } + +#[test] +fn library_style_bindings_ignore_unrelated_local_variables() { + let d = dir("style-bound"); + let path = path_str(&d.join("lib.figcraft")); + let mut lib = Session::new(); + ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"})); + let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone(); + let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone(); + ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var})); + let lid = publish_to(&mut lib, &path); + + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Local"})); + let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone(); + assert_eq!(local, var, "the ids must collide for this test"); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40}))); + ex(&mut s, "library.add", json!({"path": path})); + ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r})); + assert_eq!(fill(&s, r), "0000FF"); + healthy(&s); + + // An updated style follows the library's variable, not the local one. + ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, r), "00FFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} From a57976e88f0fdaf7016e0e9e5a011578914087e0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:00:16 +0200 Subject: [PATCH 46/77] Keep frame min/max size in .fig export without an auto layout parent Fixes #457 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 13 +++++------ crates/format/src/fig/export_tests.rs | 31 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 22243355..526ab3d9 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> { } } - /// How a layer sizes and sits in its auto layout parent. + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own + /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { let cl = &n.child_layout; + for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { + if w.is_some() || h.is_some() { + f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); + } + } let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return }; let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill); let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) }; @@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> { if cl.absolute { f.push(("stackPositioning", e("ABSOLUTE"))); } - for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] { - if w.is_some() || h.is_some() { - f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))]))); - } - } if pl.mode == LayoutMode::Grid && let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id))) { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 8d66b72f..67215b0c 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1014,6 +1014,37 @@ fn junk_documents_never_panic() { } } +/// A frame's own min/max size survives whatever its parent is, not only under auto layout. +#[test] +fn frame_size_limits_survive_without_auto_layout_parent() { + fn limits(doc: &Document) -> [Option; 4] { + let (bytes, ew) = export(doc, None).unwrap(); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}"); + let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout; + [cl.min_w, cl.max_w, cl.min_h, cl.max_h] + } + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap(); + let node = doc.node_mut(id).unwrap(); + node.layout = Some(AutoLayout::default()); + node.child_layout.min_w = Some(80.0); + node.child_layout.max_w = Some(150.0); + node.child_layout.min_h = Some(60.0); + node.child_layout.max_h = Some(170.0); + let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)]; + + let mut nested = doc.clone(); + let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap(); + nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default()); + nested.reparent(id, wrapper, None).unwrap(); + assert_eq!(limits(&nested), expected, "auto layout parent"); + nested.node_mut(wrapper).unwrap().layout = None; + assert_eq!(limits(&nested), expected, "plain frame parent"); + assert_eq!(limits(&doc), expected, "page parent"); +} + /// Link each instance's layers to its main's by position, as they are imported (the engine /// links them by path after loading). fn link(doc: &mut Document) { From 6ce65b24f44e2f041b42c7098ffd7b342bffa266 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:12:06 +0000 Subject: [PATCH 47/77] Keep region loops in ring order when adding a point to a reversed edge Fixes #420 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/vectoredit/ops.rs | 46 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 093d0854..4e8c5c25 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) { pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option { let s = net.segments.get(seg).copied()?; let c: CubicBez = net.cubic(seg)?; + // Which way each region loop walks `seg`, read before the split changes its endpoints. + let reversed: Vec> = net + .regions + .iter() + .map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect()) + .collect(); let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 }; let l = c.subsegment(0.0..t); let r = c.subsegment(t..1.0); @@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option bool { + let ends: Vec<(usize, usize)> = n + .loop_walk(lp) + .iter() + .map(|&(i, rev)| { + let s = n.segments[i]; + if rev { (s.b, s.a) } else { (s.a, s.b) } + }) + .collect(); + (0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0) + } + + #[test] + fn split_keeps_region_loop_in_ring_order() { + // Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards. + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false); + n.segments.push(Segment::line(0, 2)); + n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero }); + let p = Point::new(25.0, 10.0); + assert!(n.fill_contains(p)); + for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] { + let mut m = n.clone(); + m.regions[0].loops = vec![lp]; + let mid = split_segment(&mut m, seg, 0.5).unwrap(); + assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops); + assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill"); + if seg == 1 { + assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0)); + } + } + } + #[test] fn inferred_mirroring() { let mut n = open_line(); From 102429e6b56b72a50e22adbdca520d5c2414179a Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 18:13:47 +0700 Subject: [PATCH 48/77] fix(ui): scrubbing a colour picker field is one undo step (#256) The RGB/HSL/HSB fields committed every frame of a scrub, so one drag left about ten undo steps, and the alpha field ignored scrubs. Both now edit live and commit when let go. Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/colorpicker.rs | 37 ++++++++++++++++++++----------- crates/ui-egui/src/field_tests.rs | 37 +++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..5c6f7a0f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -212,11 +212,13 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec { /// Colour models the value row can show. const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; -/// The model dropdown with its fields and the alpha field. Returns a committed colour. -fn model_fields(ui: &mut Ui, c: Color) -> Option { +/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the +/// edit is done (false while a field is being scrubbed). +fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { let model_id = Id::new("cp_model"); let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1); let mut out: Option = None; + let mut done = true; ui.horizontal(|ui| { ui.add_space(16.0); ui.spacing_mut().item_spacing.x = 4.0; @@ -238,7 +240,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { w, 1.0, ) { - Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x), + Some(widgets::FieldEdit::Set(x)) => Some((x, true)), + Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)), None => None, }; match MODELS.get(model).copied().unwrap_or("Hex") { @@ -246,7 +249,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { let [r, g, b, _] = c.to_rgba8(); let vals = [f64::from(r), f64::from(g), f64::from(b)]; for (k, v) in vals.iter().enumerate() { - if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + done = end; let mut rgb = vals; if let Some(slot) = rgb.get_mut(k) { *slot = x.clamp(0.0, 255.0); @@ -266,7 +270,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { }; let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0]; for (k, v) in vals.iter().enumerate() { - if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + done = end; let mut v3 = vals; if let Some(slot) = v3.get_mut(k) { *slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) }; @@ -288,14 +293,20 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { } } } - if MODELS.get(model) != Some(&"CSS") - && let Some(widgets::FieldEdit::Set(v)) = - widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0) - { - out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)); + if MODELS.get(model) != Some(&"CSS") { + let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); + let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + Some(widgets::FieldEdit::Set(v)) => Some((v, true)), + Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), + None => None, + }; + if let Some((v, end)) = edit { + done = end; + out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)); + } } }); - out + out.map(|c| (c, done)) } fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { @@ -744,11 +755,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { done |= d; } // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. - if let Some(c) = model_fields(ui, nc) { + if let Some((c, d)) = model_fields(ui, nc) { nc = c; hsb = Hsb::from_color(nc); changed = true; - done = true; + done |= d; } ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4635d862..e1ff460d 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -2,10 +2,10 @@ //! a scrub and an arrow key move a value, and the Design panel's fields driven through egui //! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on //! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or -//! commit. +//! commit (the colour picker's fields too). use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -216,6 +216,11 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { /// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go. fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { let from = label(u, name); + drag(u, from, dx, modifiers); +} + +/// Press at `from`, move `dx` points sideways in small steps with `modifiers` held, then let go. +fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) { let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers }; u.frame_with(vec![Event::PointerMoved(from), press(true)], modifiers); let steps = 10; @@ -404,3 +409,31 @@ fn mixed_values_take_edits_relative_to_each_layer() { assert_eq!((geom(&u, a).0, geom(&u, b).0), (64.0, 64.0)); assert_eq!(field(&mut u, "X").1, "64"); } + +/// The middle of the label (what scrubs) of the field with id `id`, as last painted. +fn lead(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +#[test] +fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { + let (mut u, a, _) = setup(); + let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8(); + let [r0, .., a0] = rgba(&u); + u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize)); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + // The picker window takes a frame to size itself. + for _ in 0..3 { + u.frame(vec![]); + } + let steps = undo_steps(&u); + let from = lead(&u, Id::new(("cp_f", "r"))); + drag(&mut u, from, -20.0, Modifiers::NONE); + assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); + // The alpha field: a scrub moves it too. + let from = lead(&u, Id::new("cp_a")); + drag(&mut u, from, -30.0, Modifiers::NONE); + let [.., alpha] = rgba(&u); + assert!(alpha < a0, "alpha {a0} → {alpha}"); + assert_eq!(undo_steps(&u), steps + 2, "alpha"); +} From 1593b163fb2c4caa4c811cbfe25763aab93268d2 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 18:15:35 +0700 Subject: [PATCH 49/77] fix(ui): scrubbing a prototype panel number sets it once (#256) Delays, scroll offsets, overlay offsets and the overlay background alpha were set on every frame of a scrub, an undo step each. They now set once, when let go, like the custom size field. Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/field_tests.rs | 26 +++++++++++++++++++- crates/ui-egui/src/proto_panel.rs | 41 +++++++++++++------------------ 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index e1ff460d..f8fa1198 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -2,7 +2,7 @@ //! a scrub and an arrow key move a value, and the Design panel's fields driven through egui //! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on //! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or -//! commit (the colour picker's fields too). +//! commit (the colour picker's and the prototype panel's fields too). use egui::accesskit::Role; use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; @@ -437,3 +437,27 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { assert!(alpha < a0, "alpha {a0} → {alpha}"); assert_eq!(undo_steps(&u), steps + 2, "alpha"); } + +#[test] +fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { + let mut s = Session::new(); + let f1 = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap()); + let f2 = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap()); + let it = json!({"id": f1, "trigger": {"type": "afterDelay", "ms": 500}, "action": {"type": "navigate", "dest": f2}}); + s.execute("proto.interaction.add", &it).unwrap(); + s.execute("select.set", &json!({"ids": [f1]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + crate::proto_panel::open_editor(&u.ctx, Some((f1, 0))); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = undo_steps(&u); + let from = lead(&u, Id::new(("proto_delay", f1, 0usize))); + drag(&mut u, from, 30.0, Modifiers::NONE); + let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger { + figcraft_doc::proto::Trigger::AfterDelay { ms } => ms, + _ => panic!("still after a delay"), + }; + assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 79ae3eb9..1f67ab44 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -700,7 +700,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); let id = Id::new(("proto_mouse_delay", node, k)); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0) && v.is_finite() { @@ -711,13 +711,11 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte Trigger::AfterDelay { ms } => { row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); - if let Some(e) = widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) { - let v = match e { - FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, - }; - if v.is_finite() { - patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); - } + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) + && v.is_finite() + { + patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); } }); } @@ -851,9 +849,10 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: row(ui, 32.0, |ui| { let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_scroll_x"), ("Y", "proto_scroll_y")].into_iter().enumerate() { - let Some(e) = widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) else { continue }; - let v = match e { - FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, + let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) + else { + continue; }; if v.is_finite() { let mut o = offset; @@ -901,7 +900,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: else { continue; }; - let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = e.settled() else { continue }; + let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = e.settled() else { continue }; if v.is_finite() { let mut o = offset.unwrap_or([0.0, 0.0]); if let Some(slot_v) = o.get_mut(i) { @@ -932,18 +931,12 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset }); } let alpha = (bg.a * 100.0).round(); - if let Some(e) = widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) { - let v = match e { - FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, - }; - if v.is_finite() { - let a = v.clamp(0.0, 100.0) as f32 / 100.0; - set_action( - app, - slot, - Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset }, - ); - } + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) + && v.is_finite() + { + let a = v.clamp(0.0, 100.0) as f32 / 100.0; + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset }); } }); } From 778b8c680770dc8a868cfeb8cce66fd06ab61f75 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 18:17:37 +0700 Subject: [PATCH 50/77] fix(ui): scrubbing the Scale panel's W and H scales live (#256) W and H ignored scrubs. They now scale live in one undo step. The factor field applies on release, because it is relative to 1.0 on every frame. Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/field_tests.rs | 14 +++++++++- crates/ui-egui/src/scale_panel.rs | 46 +++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index f8fa1198..492fdc01 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -2,7 +2,7 @@ //! a scrub and an arrow key move a value, and the Design panel's fields driven through egui //! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on //! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or -//! commit (the colour picker's and the prototype panel's fields too). +//! commit (the colour picker's, the prototype panel's and the Scale panel's fields too). use egui::accesskit::Role; use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; @@ -461,3 +461,15 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { }; assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } + +#[test] +fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { + let (mut u, a, _) = setup(); + u.app.run("tool.set", json!({"tool": "scale"})); + u.frame(vec![]); + let steps = undo_steps(&u); + let from = lead(&u, Id::new("scale_w")); + drag(&mut u, from, 20.0, Modifiers::NONE); + let (_, _, w, h) = geom(&u, a); + assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60"); +} diff --git a/crates/ui-egui/src/scale_panel.rs b/crates/ui-egui/src/scale_panel.rs index bdc763d7..6edd3497 100644 --- a/crates/ui-egui/src/scale_panel.rs +++ b/crates/ui-egui/src/scale_panel.rs @@ -21,23 +21,29 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put. let anchor = app.session.tool.scale_anchor; let mut factor: Option = None; + // A scrub of W or H scales live, in one undo step: `Some(done)`. + let mut scrub: Option = None; let round = |v: f64| (v * 100.0).round() / 100.0; widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { - if let Some(FieldEdit::Set(w)) = widgets::number_field(ui, Id::new("scale_w"), Lead::Text("W"), Some(round(b.width())), "", col, 1.0) - && b.width() > 1e-9 - { - factor = Some(w / b.width()); - } - if let Some(FieldEdit::Set(h)) = widgets::number_field(ui, Id::new("scale_h"), Lead::Text("H"), Some(round(b.height())), "", col, 1.0) - && b.height() > 1e-9 - { - factor = Some(h / b.height()); + for (key, lead, side) in [("scale_w", "W", b.width()), ("scale_h", "H", b.height())] { + let (v, s) = match widgets::number_field(ui, Id::new(key), Lead::Text(lead), Some(round(side)), "", col, 1.0) { + Some(FieldEdit::Set(v)) => (v, None), + Some(FieldEdit::Scrub(v, done)) => (v, Some(done)), + None => continue, + }; + if side > 1e-9 { + factor = Some(v / side); + scrub = s; + } } }); widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor"))); crate::right::block(ui, 52.0, 2.0, |ui| { - if let Some(FieldEdit::Set(k)) = widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) { + // Relative to the layers as they are: a scrub applies once, when let go. + if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) = + widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) + { factor = Some(k); } ui.add_space(8.0); @@ -45,8 +51,24 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.run("tool.scaleAnchor", json!({"anchor": figcraft_engine::tools::anchor_name(a)})); } }); - if let Some((k, cx, cy)) = factor.and_then(|k| scale_about(b, anchor, k)) { - app.run("transform.scale", json!({"ids": sel, "factor": k, "cx": cx, "cy": cy})); + let scale = factor.and_then(|k| scale_about(b, anchor, k)).map(|(k, cx, cy)| json!({"ids": sel, "factor": k, "cx": cx, "cy": cy})); + match scrub { + None => { + if let Some(p) = scale { + app.run("transform.scale", p); + } + } + Some(done) => { + if let Some(p) = scale { + let _ = app.session.begin_interaction("Scale"); + if let Err(e) = app.session.execute("transform.scale", &p) { + app.failed("transform.scale", &e); + } + } + if done { + let _ = app.session.commit_interaction(); + } + } } } From 49010614fd79a43833d6b1d4f1d3bc9b87727c3d Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 18:23:40 +0700 Subject: [PATCH 51/77] chore: credit Nael-Nathanael by GitHub profile name Co-Authored-By: Claude Opus 5.5 (1M context) --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index af5d0576..8bca870b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,3 +27,6 @@ display_name = "VanTan" [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.Nael-Nathanael] +sync_github_name = true From d1fcf69b7bbab9f2329e7295956e8a88bb8c0773 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:30 +0200 Subject: [PATCH 52/77] Keep Mixed line height when Enter is pressed without typing (#439) --- crates/ui-egui/src/typography.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 56854f27..45f38b95 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1017,9 +1017,13 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti } /// "Auto", "24", "150%" → the `lineHeight` value. A bare number keeps the current unit when it is -/// a percentage, else means pixels. +/// a percentage, else means pixels. Empty text means Auto, except on a Mixed field (`cur` is +/// `None`), where nothing typed is an untouched field and commits nothing. pub(crate) fn parse_line_height(s: &str, cur: Option) -> Option { let s = s.trim(); + if s.is_empty() && cur.is_none() { + return None; + } if s.is_empty() || s.eq_ignore_ascii_case("auto") { return Some(json!("auto")); } @@ -1200,6 +1204,9 @@ mod tests { assert_eq!(parse_line_height("130", Some(LineHeight::Percent(120.0))), Some(json!("130%"))); assert_eq!(parse_line_height("-5", Some(LineHeight::Pixels(10.0))), Some(json!(0.0))); assert_eq!(parse_line_height("abc", None), None); + // Enter on an untouched Mixed field commits nothing; typing Auto still does. + assert_eq!(parse_line_height("", None), None); + assert_eq!(parse_line_height(" ", None), None); assert_eq!(parse_letter_spacing("2%", None), Some(json!("2%"))); assert_eq!(parse_letter_spacing("1.5px", None), Some(json!(1.5))); assert_eq!(parse_letter_spacing("-1", None), Some(json!("-1%"))); From fdf397d7dc1c7e3d6d92e3aa4e3ed7a7d8e02aed Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:44 +0200 Subject: [PATCH 53/77] Refuse to create a variable collection with a name that is already taken (#416) --- crates/engine/src/cmd/styles.rs | 3 +++ crates/engine/src/variables_editor_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index a5f2b78f..552a44cf 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -112,6 +112,9 @@ pub fn specs() -> Vec { spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { let name = req_string("variable.createCollection", p, "name")?; let doc = s.doc_mut()?; + if doc.variables.collections.iter().any(|c| c.name == name) { + return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists"))); + } let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), diff --git a/crates/engine/src/variables_editor_tests.rs b/crates/engine/src/variables_editor_tests.rs index 815fcb2b..9518911e 100644 --- a/crates/engine/src/variables_editor_tests.rs +++ b/crates/engine/src/variables_editor_tests.rs @@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() { assert_eq!(scope(f, "paddingHorizontal"), Some("GAP")); assert_eq!(scope(f, "visible"), None); } + +#[test] +fn create_collection_refuses_a_taken_name() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let before = s.doc().unwrap().variables.collections.len(); + assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err()); + assert_eq!(s.doc().unwrap().variables.collections.len(), before); + ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"})); + assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1); +} From 0ba8e30fbc50abd8f9c3d2705e93f574191130e1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:51 +0200 Subject: [PATCH 54/77] docs: document perf --instances in the CLI reference (#391) --- docs/cli.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index ab6c1ea8..93d9aef6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -145,7 +145,7 @@ size and SHA-256. The server's key comes from the address or, better, from the lists); it is never printed. The bundle is checked before it is sent and again by the server. See [plugins.md](plugins.md#plugin-directories). -### `perf [--layers N[,N...]]` +### `perf [--layers N[,N...]] [--instances N[,N...]]` Builds synthetic documents of each size (default `1000,10000` layers: screens with an auto layout header, a card group, button instances and a grid of shapes and text) and prints a Markdown table of @@ -154,3 +154,11 @@ and hover, single-layer edits through the engine (fill, move, text in auto layou with all its instances), undo/redo, a drag preview frame, `normalize` after a one-node edit, `document.inspect` and select-all + move. Use a release build for meaningful numbers; `cargo xtask bench` runs it at 1k/10k/50k layers together with the UI frame benchmarks. + +`--layers` picks the document sizes (each value is clamped to 10 to 2,000,000 layers). `--instances` +picks the component workloads (default `2000`): for each count the command builds a synthetic design +system (about 300 components, variables and styles, nested four deep) with that many instances that +carry overrides at every level, in one file and as a library in another. It then prints a second +table with the build time and edits of a deep main component (icon colour, glyph size, an added +layer) and of the 72 button mains, where all copies follow. Zeros are dropped and counts above +100,000 are capped to 100,000; `--instances 0` skips this table. From 4376f659ac85077977f83e79b988faed8c6ca05b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:50 +0200 Subject: [PATCH 55/77] Docs: FIG export guides no longer say variables and style links are omitted (#398) --- docs/cli.md | 8 +++++--- docs/mcp.md | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index ab6c1ea8..54982554 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -48,9 +48,11 @@ page 1 "Screens": 380 nodes, 4 top-level - `` has an image extension: one file with the given `--node`, or the whole page composited. - `--node` takes a layer id (`12` or `0:12`) or a layer name. - `--format fig` (or an `` ending in `.fig`) writes the whole file as `.fig`, experimental: - written from public format notes and checked only by opening it again in FigCraft; other apps - may not open every file. What was left out (shared style links, variables, comments, Dev Mode - data…) is printed as `note:` lines. + written from public format notes, including variables with their modes and bindings and shared + styles with their layer links. Checked by opening it again in FigCraft and, on the example + documents and small test files, in other software; other apps may not open every file. What was + left out (comments, Dev Mode data, some bindings and settings…) is printed as `note:` lines; + `docs/file-format-parity.md` lists what was checked and what is unchecked. - The format comes from `--format`, else ``'s extension, else png. `--scale` (default 1, max 64) applies to raster formats. - SVG/PDF exports with visible descendants more than 256 levels below an exported root fail with diff --git a/docs/mcp.md b/docs/mcp.md index 38d821f4..f4da94b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -764,10 +764,12 @@ skipped. The result's `opened.warnings` list what was approximated or skipped. ending in `.fig`) writes the whole document as a `.fig` file, from the same public format notes the import uses: the layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides -and property values, and prototype interactions. `warnings` lists what was left out (shared style -links, variables, comments, Dev Mode data and the like). Only round trips through FigCraft's own -importer are verified; other apps may not open every file. Without `path` the file comes back as -base64 `data`. Check one by opening it again: +and property values, variable collections with their modes, variables and bindings, shared styles +with their layer links, and prototype interactions. `warnings` lists what was left out (comments, +Dev Mode data, some bindings and settings and the like). Round trips through FigCraft's own +importer are tested, and the example documents and small test files were opened in other software +(`docs/file-format-parity.md` says what was checked); other apps may not open every file. Without +`path` the file comes back as base64 `data`. Check one by opening it again: ```text {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"format":"fig","path":"/tmp/handoff.fig"}}} From 3062c6c4d6410ad3ce47fcdebc83dd10a3e0be5b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:30 +0200 Subject: [PATCH 56/77] Keep the selected guide selected when an earlier guide is removed (#418) --- crates/engine/src/cmd/page.rs | 7 +++++-- crates/engine/src/view_behaviour_tests.rs | 17 +++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd607..8bcd7c40 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -160,8 +160,11 @@ pub fn specs() -> Vec { } guides.remove(k); let ds = s.ds_mut()?; - if ds.guide == Some((owner, k)) { - ds.guide = None; + match ds.guide { + Some((o, i)) if o == owner && i == k => ds.guide = None, + // A later guide of the same owner moves up one place; keep the selection on it. + Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)), + _ => {} } ok() }), diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index c22cd1f5..27bc3771 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() { assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected"); } +#[test] +fn removing_an_earlier_guide_keeps_the_selected_guide() { + let mut s = Session::new(); + for offset in [100, 200, 300] { + ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset})); + } + let page = s.page().unwrap(); + ex(&mut s, "guide.select", json!({"index": 1})); + ex(&mut s, "guide.remove", json!({"index": 0})); + assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index"); + assert!(s.tool_key("ArrowRight", false, false, false).unwrap()); + assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour"); + // A later guide leaves the selection alone. + ex(&mut s, "guide.remove", json!({"index": 1})); + assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0))); +} + #[test] fn guides_snap_and_measure() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 38d821f4..7d9aa0de 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1383,7 +1383,8 @@ siblings and the frame's edges) along those axes, keeping whole pixels in the fr `guide.select {index, frame? | page?}` selects a guide (and deselects the layers; `{}` deselects it), as clicking one does in the app; `guide.remove {}` without an index removes the selected one -(the context menu's Remove guide). While a guide is selected and no layer is, Delete removes it, +(the context menu's Remove guide), and removing another guide keeps the selected one selected. +While a guide is selected and no layer is, Delete removes it, the arrows across it nudge it (Shift: the big nudge) and Escape deselects it. In the app, a guide dragged out of a ruler and dropped on a frame becomes the guide of the top-level frame there, along the frame's axis nearer the ruler's; Alt-dragging a guide drops a copy, and holding Alt while From 18e3070d4c962a7dc41b1980d3b37d4633607758 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 17:36:58 +0700 Subject: [PATCH 57/77] fix(render): glass looks the same wherever a render cuts it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass light falls off with the distance to the edge, read past `depth` (up to ~3 × depth at full splay), but the coverage window around a cut layer was only `depth + 2` wide. With frost and refraction at 0 the backdrop margin is 0 too, so a band or canvas patch edge running through the pane changed the light near it: up to 12 levels between a banded and a whole render. The window now covers the light's reach (where it falls under half a level, clamped at 0 and capped at 512 px) plus the span the normals are smoothed over, and past that reach the light is 0 instead of whatever the window happened to hold. Tests: a cut 50–80 px inside the pane equals the whole render byte for byte (fails on main by 12 levels); light_reach edge cases. Adds an ignored release timing test (glass_bench_tests). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 39 ++++++-- crates/render/src/glass_bench_tests.rs | 123 +++++++++++++++++++++++++ crates/render/src/glass_cache_tests.rs | 71 ++++++++++++++ crates/render/src/lib.rs | 4 + 4 files changed, 231 insertions(+), 6 deletions(-) create mode 100644 crates/render/src/glass_bench_tests.rs create mode 100644 crates/render/src/glass_cache_tests.rs diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 015262ef..7ab72d4d 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -177,19 +177,34 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] { }) } +/// The furthest in (pixels) the rim light is worth half a level or more: where +/// `255 × intensity × e^(−d / band)` falls to 0.5, at most [`MAX_LIGHT_PX`] (0 for no light). +fn light_reach(band: f64, intensity: f64) -> f64 { + let k = 255.0 * intensity / 0.5; + if k > 1.0 && band.is_finite() { (band * k.ln()).min(MAX_LIGHT_PX) } else { 0.0 } +} + +/// The light reaches no further in than this many pixels (a wide light on a deep pane at a high +/// zoom would otherwise need a huge margin of coverage around every cut). +const MAX_LIGHT_PX: f64 = 512.0; + /// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from /// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit. #[allow(clippy::too_many_arguments)] pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option { let g = g.sanitized(); let depth = (g.depth * scale).clamp(1.0, 1024.0); - // Coverage with a margin, so distances aren't cut short where the layer is cropped (past the - // margin the edge is further than `depth` away and nothing bends). - let m = depth.ceil() as u32 + 2; + let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0); + let reach = light_reach(band, g.light_intensity); + let r = (depth * 0.1).clamp(1.0, 8.0) as usize; + // Coverage with a margin, so wherever the layer is cut every distance the pixels read is + // exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are + // smoothed from (2r + 1 further). + let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4; let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?; let dist = inside_distance(&cov); let (cw, ch) = (cov.width() as usize, cov.height() as usize); - let normals = smooth_normals(&dist, cw, ch, (depth * 0.1).clamp(1.0, 8.0) as usize); + let normals = smooth_normals(&dist, cw, ch, r); let mut frosted = back.clone(); fx::blur(&mut frosted, g.frost / 2.0 * scale); let ang = g.light_angle.to_radians(); @@ -197,7 +212,7 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let bend_max = (g.refraction * depth) as f32; let fringe = (g.dispersion * 0.25) as f32; let power = (6.0 - 5.0 * g.splay) as f32; - let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0) as f32; + let (band, reach) = (band as f32, reach as f32); let intensity = g.light_intensity as f32; let depth = depth as f32; let mut out = Pixmap::new(w, h)?; @@ -223,7 +238,8 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 // The rim catches the light where it faces it, and half as much opposite. let facing = -(inward.0 * light.0 + inward.1 * light.1); let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power); - let hl = (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0); + // Past `reach` the light is under half a level: none, wherever the layer is cut. + let hl = if d < reach { (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0) } else { 0.0 }; let i = (y * w as usize + x) * 4; if let Some(p) = dst.get_mut(i..i + 4) { for (o, v) in p.iter_mut().zip(base) { @@ -252,4 +268,15 @@ mod tests { // A pixel on the edge is half a pixel from it. assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]); } + + #[test] + fn light_reach_is_where_the_light_falls_under_half_a_level() { + let r = light_reach(10.0, 1.0); + assert!((255.0 * (-r / 10.0).exp() - 0.5).abs() < 1e-9, "{r}"); + // No light, too faint to show, or a band that isn't a number: it reaches nowhere. + for (band, intensity) in [(10.0, 0.0), (10.0, 0.5 / 255.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0)] { + assert_eq!(light_reach(band, intensity), 0.0, "{band} {intensity}"); + } + assert_eq!(light_reach(1e6, 1.0), MAX_LIGHT_PX); + } } diff --git a/crates/render/src/glass_bench_tests.rs b/crates/render/src/glass_bench_tests.rs new file mode 100644 index 00000000..64c95835 --- /dev/null +++ b/crates/render/src/glass_bench_tests.rs @@ -0,0 +1,123 @@ +//! Glass render timings: a 600 × 360 rounded glass pane over coloured stripes, 2400 × 1600 device +//! pixels. Run in release: +//! `cargo test --release -p figcraft-render --lib glass_render_timings -- --ignored --nocapture`. +//! +//! "export" rows render the whole view with `render_view` (exports, thumbnails, the first canvas +//! frame); "canvas" rows go through `Retained`, which re-renders only what changed, as the canvas +//! does; "single" renders without parallel bands (as on the web). + +use std::sync::Arc; +use std::time::Instant; + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind}; +use figcraft_geom::Affine; + +use crate::{Options, Retained, View, Work, render_view, render_view_single}; + +const RUNS: u32 = 8; + +/// Stripes, a card under the pane and the glass pane: (document, page, pane, card). +fn scene() -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new("glass bench"); + let p = d.first_page().unwrap(); + let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap()); + for i in 0..24u32 { + let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])]; + } + let card = d.create(p, NodeKind::Rectangle, "Card", 420.0, 300.0, 160.0, 120.0).unwrap(); + d.get_mut(card).unwrap().fills = vec![Paint::solid(Color::WHITE)]; + let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.transform = Affine::translate((300.0, 220.0)); + n.corners.radii = [40.0; 4]; + n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })]; + n.effects = vec![Effect { kind: EffectKind::Glass(Glass::default()), visible: true }]; + (d, p, pane, card) +} + +fn view(origin: (f64, f64), scale: f64) -> View { + View { origin, scale, width: 2400, height: 1600 } +} + +/// Mean milliseconds of `RUNS` whole-view renders (`render` of the document and view `step(i)` +/// leaves) after one warm-up. +fn time( + d: &mut Document, + p: NodeId, + render: fn(&Document, NodeId, &View, &Options) -> Option, + mut step: impl FnMut(&mut Document, u32) -> View, +) -> f64 { + let v = step(d, 0); + let _ = render(d, p, &v, &Options::default()); + let t = Instant::now(); + for i in 1..=RUNS { + let v = step(d, i); + assert!(render(d, p, &v, &Options::default()).is_some()); + } + t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS) +} + +/// Mean milliseconds of `RUNS` canvas frames at `scale`, each after `edit(document, i)`. +fn canvas(d: &Document, p: NodeId, scale: f64, edit: impl Fn(&mut Document, u32)) -> f64 { + let mut r = Retained::default(); + let mut doc = Arc::new(d.clone()); + let v = view((0.0, 0.0), scale); + let _ = r.render(&doc, p, &v, &Options::default()); + let t = Instant::now(); + for i in 1..=RUNS { + let mut next = (*doc).clone(); + edit(&mut next, i); + doc = Arc::new(next); + let (_, _, work) = r.render(&doc, p, &v, &Options::default()).unwrap(); + assert!(matches!(work, Work::Partial { .. }), "{work:?}"); + } + t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS) +} + +fn set_glass(d: &mut Document, pane: NodeId, g: Glass) { + d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; +} + +#[test] +#[ignore = "timing: run in release with --ignored --nocapture"] +fn glass_render_timings() { + let (mut d, p, pane, card) = scene(); + let move_card = move |d: &mut Document, i: u32| d.get_mut(card).unwrap().transform = Affine::translate((420.0 + 4.0 * f64::from(i), 300.0)); + let move_pane = move |d: &mut Document, i: u32| d.get_mut(pane).unwrap().transform = Affine::translate((300.0 + 3.0 * f64::from(i), 220.0)); + let rows: Vec<(&str, f64)> = vec![ + ("export: same view again", time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0))), + ( + "export: move the card under the glass", + time(&mut d, p, render_view, |d, i| { + move_card(d, i); + view((0.0, 0.0), 2.0) + }), + ), + ("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))), + ("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))), + ("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)), + ( + "canvas: drag a glass slider (light angle)", + canvas(&d, p, 2.0, |d, i| set_glass(d, pane, Glass { light_angle: -45.0 + f64::from(i), ..Glass::default() })), + ), + ("canvas: drag the pane (zoom 200%)", canvas(&d, p, 2.0, move_pane)), + ("canvas: drag the pane (zoom 137%)", canvas(&d, p, 1.37, move_pane)), + ( + "canvas: resize the pane", + canvas(&d, p, 2.0, |d, i| { + let n = d.get_mut(pane).unwrap(); + n.width = 600.0 + 3.0 * f64::from(i); + }), + ), + ]; + d.get_mut(pane).unwrap().effects.iter_mut().for_each(|e| e.visible = false); + let without = time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0)); + println!("glass render timings ({} build, 2400 × 1600 px, mean of {RUNS}, ms)", if cfg!(debug_assertions) { "debug" } else { "release" }); + println!("| render | ms |\n|---|---:|"); + for (name, ms) in rows { + println!("| {name} | {ms:.1} |"); + } + println!("| export: same view, glass hidden | {without:.1} |"); +} diff --git a/crates/render/src/glass_cache_tests.rs b/crates/render/src/glass_cache_tests.rs new file mode 100644 index 00000000..4844f764 --- /dev/null +++ b/crates/render/src/glass_cache_tests.rs @@ -0,0 +1,71 @@ +//! Glass renders the same however a render is cut: a band or a canvas patch whose edge runs +//! through a pane shows exactly the pixels of the whole render. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind}; +use figcraft_geom::Affine; +use tiny_skia::Pixmap; + +use crate::{Options, View, render_view_single}; + +/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page). +fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId) { + let mut d = Document::new("glass"); + let p = d.first_page().unwrap(); + let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap()); + for i in 0..24u32 { + let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])]; + } + let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.transform = Affine::translate((300.0, 220.0)); + n.corners.radii = [radius; 4]; + n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })]; + n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; + (d, p) +} + +/// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel +/// difference and how many bytes differ. +fn diff_rows(full: &Pixmap, y0: u32, part: &Pixmap) -> (u8, usize) { + let row = full.width() as usize * 4; + let a = &full.data()[y0 as usize * row..][..part.data().len()]; + a.iter().zip(part.data()).fold((0, 0), |(m, n), (x, y)| (m.max(x.abs_diff(*y)), n + usize::from(x != y))) +} + +/// A strong, wide light and nothing that blurs or bends: the light reaches furthest in, and the +/// backdrop effects read nothing past the layer, so a cut through the pane gets no margin. +fn wide_light() -> Glass { + Glass { splay: 1.0, frost: 0.0, refraction: 0.0, light_intensity: 1.0, ..Glass::default() } +} + +/// The largest channel difference and bytes differing between the whole render of `d` and renders +/// cut 50 to 80 px inside the pane's top edge (y = 440 px), as bands and canvas patches cut it: +/// rendered with the backdrop reach as a margin above and below, keeping 400 rows from the cut. +fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> { + let opts = Options::default(); + let full = render_view_single(d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 2400, height: 1600 }, &opts).unwrap(); + let margin = crate::backdrop_reach(crate::max_backdrop_blur(d), 2.0).ceil() as u32 + 1; + [490u32, 505, 520] + .into_iter() + .map(|cut| { + let top = cut - margin; + let v = View { origin: (0.0, f64::from(top) / 2.0), scale: 2.0, width: 2400, height: 400 + 2 * margin }; + let part = render_view_single(d, p, &v, &opts).unwrap(); + let kept = part.clone_rect(tiny_skia::IntRect::from_xywh(0, margin as i32, 2400, 400).unwrap()).unwrap(); + (cut, diff_rows(&full, cut, &kept)) + }) + .collect() +} + +#[test] +fn a_cut_through_the_pane_matches_the_whole_render() { + // Square corners: straight edges on whole pixels raster the same in any cut. + for g in [wide_light(), Glass::default(), Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }] { + let (d, p) = pane_over_stripes(g.clone(), 0.0); + for (cut, diff) in cut_diffs(&d, p) { + assert_eq!(diff, (0, 0), "cut at {cut} px with {g:?}"); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda..7376991b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -438,6 +438,10 @@ mod effects_behaviour_tests; #[cfg(test)] mod gif_tests; #[cfg(test)] +mod glass_bench_tests; +#[cfg(test)] +mod glass_cache_tests; +#[cfg(test)] mod grain_tests; #[cfg(test)] mod group_effects_tests; From bc5b474293a09e15d0105ad56158e987450e7aa0 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 14 Oct 2026 17:43:45 +0700 Subject: [PATCH 58/77] perf(render): sample once where glass doesn't bend Where nothing bends (past `depth`, or with no refraction) the three colour channels sample the backdrop at the same point: one sample instead of three. The rim light's facing and powers are worked out only where the light reaches. Same bytes: an ignored `glass_digests` test prints digests of 30 renders (5 looks, square and rounded, 3 scales), identical before and after this change (and to main). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 29 +++++++++++++++++-------- crates/render/src/glass_cache_tests.rs | 30 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 7ab72d4d..7af74011 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -231,15 +231,26 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let t = (d / depth).min(1.0); let bend = bend_max * (1.0 - t) * (1.0 - t); let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5); - let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k); - let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe)); - let a = rs[3].max(gs[3]).max(bs[3]); - let base = [rs[0].min(a), gs[1].min(a), bs[2].min(a), a]; - // The rim catches the light where it faces it, and half as much opposite. - let facing = -(inward.0 * light.0 + inward.1 * light.1); - let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power); - // Past `reach` the light is under half a level: none, wherever the layer is cut. - let hl = if d < reach { (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0) } else { 0.0 }; + let base = if bend == 0.0 { + // Nothing bends (past `depth`, or no refraction): the three channels' samples are + // at the same point, so one sample. + let s = sample(&frosted, bx, by); + [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]] + } else { + let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k); + let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe)); + let a = rs[3].max(gs[3]).max(bs[3]); + [rs[0].min(a), gs[1].min(a), bs[2].min(a), a] + }; + // The rim catches the light where it faces it, and half as much opposite. Past `reach` + // the light is under half a level: none, wherever the layer is cut. + let hl = if d < reach { + let facing = -(inward.0 * light.0 + inward.1 * light.1); + let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power); + (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0) + } else { + 0.0 + }; let i = (y * w as usize + x) * 4; if let Some(p) = dst.get_mut(i..i + 4) { for (o, v) in p.iter_mut().zip(base) { diff --git a/crates/render/src/glass_cache_tests.rs b/crates/render/src/glass_cache_tests.rs index 4844f764..43eda0b5 100644 --- a/crates/render/src/glass_cache_tests.rs +++ b/crates/render/src/glass_cache_tests.rs @@ -59,6 +59,36 @@ fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> { .collect() } +/// Digests of glass renders over a spread of settings, scales and corners: compare two builds' +/// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`. +#[test] +#[ignore = "prints digests to compare builds"] +fn glass_digests() { + use std::hash::{DefaultHasher, Hash, Hasher}; + let looks = [ + Glass::default(), + wide_light(), + Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }, + Glass { refraction: 1.0, dispersion: 1.0, frost: 10.0, depth: 60.0, light_intensity: 0.0, ..Glass::default() }, + Glass { depth: 1.0, splay: 0.3, light_angle: 120.0, ..Glass::default() }, + ]; + let mut all = DefaultHasher::new(); + for (i, g) in looks.into_iter().enumerate() { + for radius in [0.0, 40.0] { + let (d, p) = pane_over_stripes(g.clone(), radius); + for scale in [1.0, 1.37, 2.0] { + let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 }; + let pix = render_view_single(&d, p, &v, &Options::default()).unwrap(); + let mut h = DefaultHasher::new(); + pix.data().hash(&mut h); + println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish()); + pix.data().hash(&mut all); + } + } + } + println!("all: {:016x}", all.finish()); +} + #[test] fn a_cut_through_the_pane_matches_the_whole_render() { // Square corners: straight edges on whole pixels raster the same in any cut. From 4588832c80441acd96d14fd2b6518d4592e26688 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 22:19:21 +0700 Subject: [PATCH 59/77] test(render): name the glass cut tests for what they check Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/{glass_cache_tests.rs => glass_cut_tests.rs} | 0 crates/render/src/lib.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename crates/render/src/{glass_cache_tests.rs => glass_cut_tests.rs} (100%) diff --git a/crates/render/src/glass_cache_tests.rs b/crates/render/src/glass_cut_tests.rs similarity index 100% rename from crates/render/src/glass_cache_tests.rs rename to crates/render/src/glass_cut_tests.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7376991b..50c6ef53 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -440,7 +440,7 @@ mod gif_tests; #[cfg(test)] mod glass_bench_tests; #[cfg(test)] -mod glass_cache_tests; +mod glass_cut_tests; #[cfg(test)] mod grain_tests; #[cfg(test)] From 7d0f9d7518a0fb7d88fb5ba193471a2170e280a4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:18:10 +0200 Subject: [PATCH 60/77] Colour picker: record only the final colour of a drag in Recent colors (#395) --- crates/ui-egui/src/colorpicker.rs | 65 +++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..a9404c9a 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -154,19 +154,31 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let Err(e) = r { app.toast(e.to_string()); } - if let Some(c) = paint.solid_color() { - app.session.recent_colors.retain(|x| *x != c); - app.session.recent_colors.insert(0, c); - app.session.recent_colors.truncate(16); + // Drag previews are not choices: a drag records only the colour it ends on, in `finish`. + let dragging = app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false); + if !dragging && let Some(c) = paint.solid_color() { + remember_recent(app, c); } } +fn remember_recent(app: &mut FigApp, c: Color) { + app.session.recent_colors.retain(|x| *x != c); + app.session.recent_colors.insert(0, c); + app.session.recent_colors.truncate(16); +} + fn finish(app: &mut FigApp) { if let Some(PickerTarget::PluginField { plugin, control, pending: Some(hex) }) = app.ui.picker.clone() { crate::plugins_ui::set_field(app, &plugin, &control, &hex); app.ui.picker = Some(PickerTarget::PluginField { plugin, control, pending: None }); } if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) { + if let Some(t) = app.ui.picker.clone() + && !matches!(t, PickerTarget::PluginField { .. }) + && let Some(c) = current_paint(app, &t).and_then(|p| p.solid_color()) + { + remember_recent(app, c); + } let _ = app.session.commit_interaction(); } } @@ -1094,3 +1106,48 @@ impl WithStops for Gradient { self } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::layers_drag_tests::Ui; + use figcraft_engine::Session; + + #[test] + fn dragging_records_only_the_final_colour_in_recent_colors() { + let mut u = Ui::new(Session::new()); + let target = PickerTarget::PageBackground; + u.app.ui.picker = Some(target.clone()); + for hex in ["#110000", "#220000", "#330000"] { + let c = Color::from_hex(hex).unwrap(); + write(&mut u.app, &target, &Paint::solid(c), true); + } + assert!(u.app.session.recent_colors.is_empty(), "previews must not be recorded: {:?}", u.app.session.recent_colors); + finish(&mut u.app); + let last = Color::from_hex("#330000").unwrap(); + assert_eq!(u.app.session.recent_colors, vec![last]); + } + + #[test] + fn a_drag_released_with_a_final_write_records_one_colour() { + let mut u = Ui::new(Session::new()); + let target = PickerTarget::PageBackground; + u.app.ui.picker = Some(target.clone()); + // Not a whole 8-bit value: the page stores it rounded, so recording both would add two near-identical swatches. + let c = Color::new(0.3, 0.0, 0.0, 1.0); + write(&mut u.app, &target, &Paint::solid(Color::new(0.1, 0.0, 0.0, 1.0)), true); + write(&mut u.app, &target, &Paint::solid(c), false); + finish(&mut u.app); + assert_eq!(u.app.session.recent_colors.len(), 1, "{:?}", u.app.session.recent_colors); + } + + #[test] + fn a_click_outside_a_drag_still_records_the_colour() { + let mut u = Ui::new(Session::new()); + let target = PickerTarget::PageBackground; + u.app.ui.picker = Some(target.clone()); + let c = Color::from_hex("#445566").unwrap(); + write(&mut u.app, &target, &Paint::solid(c), false); + assert_eq!(u.app.session.recent_colors, vec![c]); + } +} From 85d97a831c29876e97cd6f82157d5038962afd2a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:17:56 +0200 Subject: [PATCH 61/77] Keep flows, prototype settings and guides when duplicating a page (#417) --- crates/engine/src/cmd/page.rs | 21 +++++++++--- crates/engine/src/theme_pages_tests.rs | 46 ++++++++++++++++++++++++++ docs/mcp.md | 3 +- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd607..922e3839 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,5 +1,6 @@ //! Pages. +use figcraft_doc::proto::Flow; use figcraft_doc::*; use serde_json::{Value, json}; @@ -87,13 +88,23 @@ pub fn specs() -> Vec { let doc = s.doc_mut()?; let name = format!("Copy of {}", doc.get(src)?.name); let np = doc.add_page(&name); - if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone() - && let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind + // Each layer and its copy (also those in sections), so flows can follow their start frames. + let mut copies = Vec::new(); + for c in doc.get(src)?.children.clone() { + let copy = doc.duplicate_into(c, np, None)?; + copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy))); + } + // The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides. + if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone() + && let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind { *b = background; - } - for c in doc.get(src)?.children.clone() { - doc.duplicate_into(c, np, None)?; + *f = flows + .into_iter() + .filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl })) + .collect(); + *pr = prototype; + *g = guides; } // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index feaf4c6d..5172bb4b 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } + +#[test] +fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() { + let mut s = Session::new(); + ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})); + let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone(); + ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"})); + ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true})); + ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25})); + ex(&mut s, "select.none", json!({})); + let source = s.page().unwrap(); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let frames = s.doc().unwrap().node(copy).unwrap().children.clone(); + let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()), + _ => panic!("expected a page"), + }; + let (flows, prototype, guides) = state(&s, copy); + let (src_flows, src_prototype, src_guides) = state(&s, source); + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].name, "Checkout"); + assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame"); + assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own"); + assert_eq!(prototype, src_prototype); + assert_eq!(prototype.device, "iPhone 16"); + assert!(prototype.responsive); + assert_eq!(guides, src_guides); + assert_eq!(guides.len(), 1); + assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0)); +} + +#[test] +fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() { + let mut s = Session::new(); + let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + ex(&mut s, "object.wrapInSection", json!({"ids": [start]})); + ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let doc = s.doc().unwrap(); + let section = doc.node(copy).unwrap().children[0]; + let frame = doc.node(section).unwrap().children[0]; + match &doc.node(copy).unwrap().kind { + NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::>(), vec![("Onboarding", frame)]), + _ => panic!("expected a page"), + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 38d821f4..7a4adb5d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -333,7 +333,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: `$$` a dollar sign. Layers are numbered in stacking order, bottom first. Returns `{names}`. - Pages: `page.add {name?}` (a name starting with `-` adds a divider: an empty page shown as a line in the pages list, never opened and holding no layers), `page.list` (each page's `divider`), - `page.rename`, `page.move {id, index}`, `page.duplicate` ("Copy of …", after its source), + `page.rename`, `page.move {id, index}`, `page.duplicate` ("Copy of …", after its source, with its colour, + flows (starting at the copied frames), prototype settings and guides), `page.delete` (the last page that isn't a divider stays). - `component.list`: each component's `folder` (the Assets panel's folders: its page, the frames holding it, then its name's slash parts) and `label` (the name on its tile). From 9fa99f8594f91f33c3bf818154e3d3aace1f3cdd Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:18:44 +0200 Subject: [PATCH 62/77] Keep per-character text fills in SVG export (#429) --- crates/svg/src/lib.rs | 68 +++++++++++++++++++++++++++++++++++++++++ crates/svg/src/tests.rs | 33 ++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 9f0b9f83..a6813017 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -431,8 +431,19 @@ impl W<'_> { }; let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_)); let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) }; + let run_paths = if live_text.is_none() { text_run_paths(n) } else { None }; if let Some(t) = &live_text { self.body.push_str(t); + } else if let Some(runs) = &run_paths { + // Character ranges with their own fills: one path per style, like the canvas. + for (path, fills) in runs { + for p in fills { + if let Some(a) = self.paint_attr(p, w, h) { + let el = format!("", matrix(xf), path_d(path)); + self.body.push_str(&clipped(&paint_clip, el)); + } + } + } } else if let Some((path, rule)) = &outline && closed { @@ -559,6 +570,36 @@ impl W<'_> { if let Some((layout, dy)) = figcraft_text::layout_node(n) { gxf = xf * Affine::translate((0.0, dy)); for line in &layout.lines { + let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); + let run_fills = !line.rtl + && !glyphs.iter().any(|g| g.rtl) + && glyphs.iter().any(|g| layout.styles.get(g.style).is_some_and(|s| s.fills.is_some())); + if run_fills { + // One tspan per run of glyphs in the same style, each in its own fill. + let mut i = 0; + let mut start = line.char_start; + while i < glyphs.len() { + let style = glyphs[i].style; + let mut j = i + 1; + while j < glyphs.len() && glyphs[j].style == style { + j += 1; + } + let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len()); + let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect(); + let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) { + Some(fills) => { + let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into()); + format!(" {a}") + } + None => String::new(), + }; + let _ = write!(spans, "{}", num(glyphs[i].x), num(glyphs[i].y), esc(&text)); + start = end; + i = j; + } + continue; + } let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect(); let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y)); @@ -979,6 +1020,33 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu )) } +/// Text layer `n`'s glyph outlines grouped by style, each with the fills it paints in (its own, else +/// the layer's), in layer space. `None` unless some character range has fills of its own. +fn text_run_paths(n: &Node) -> Option)>> { + let NodeKind::Text(_) = &n.kind else { return None }; + let (layout, dy) = figcraft_text::layout_node(n)?; + if !layout.styles.iter().any(|s| s.fills.is_some()) { + return None; + } + let mut by_style: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &layout.glyphs { + by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()); + } + for d in &layout.decorations { + by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied()); + } + Some( + by_style + .into_iter() + .map(|(k, mut path)| { + path.apply_affine(Affine::translate((0.0, dy))); + let fills = layout.styles.get(k).and_then(|s| s.fills.clone()).unwrap_or_else(|| n.fills.clone()); + (path, fills) + }) + .collect(), + ) +} + #[cfg(test)] mod depth_tests; #[cfg(test)] diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 360b95ca..b15cd999 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -82,3 +82,36 @@ fn nested_frame_paint_is_clipped_in_page_space() { } } } + +/// "ab" in black with a green second character, as outlines and as live text. +fn two_colour_text() -> (Document, figcraft_doc::NodeId) { + use figcraft_doc::{StyleRun, TextData}; + figcraft_text::scan_system_fonts(); + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("ab"); + text.style.font_size = 24.0; + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 1, end: 2, style }); + text.normalize(); + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 100.0, 40.0).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + (d, id) +} + +#[test] +fn text_run_fill_survives_outlined_export() { + let (d, id) = two_colour_text(); + let svg = export_svg(&d, &[id], &SvgOptions { outline_text: true, ..SvgOptions::default() }).unwrap(); + assert!(svg.contains("#000000") && svg.contains("#00FF00"), "{svg}"); + assert_eq!(svg.matches("b") && svg.contains(">a"), "{svg}"); +} From 782c9757f3fec223959e00b647605683418c97e6 Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Thu, 15 Oct 2026 13:02:30 -0300 Subject: [PATCH 63/77] =?UTF-8?q?M3:=20CSS=20colour=20names=20in=20hex=20f?= =?UTF-8?q?ields=20(red=20=E2=86=92=20FF0000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker) now takes any of the 148 CSS named colours. Case, spaces, hyphens and underscores are ignored; hex wins where both could match; names are opaque and keep the colour's opacity. Color::parse_css (the picker's CSS field and command colour params) takes the full list too. --- crates/color/src/lib.rs | 28 ++-- crates/color/src/named.rs | 197 ++++++++++++++++++++++++++ crates/color/src/tests.rs | 17 +++ crates/ui-egui/src/colorpicker.rs | 2 +- crates/ui-egui/src/proto_editor.rs | 2 +- crates/ui-egui/src/proto_panel.rs | 4 +- crates/ui-egui/src/right.rs | 10 +- crates/ui-egui/src/variables_panel.rs | 7 +- docs/ui-parity.md | 4 +- 9 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 crates/color/src/named.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7d0bae9c..d6859a51 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; mod blend; mod hsx; mod icc; +mod named; mod profile; pub use blend::BlendMode; pub use hsx::*; @@ -139,7 +140,13 @@ impl Color { Some(Self::from_rgba8(r, g, b, a)) } - /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names. + /// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name + /// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque). + pub fn from_hex_or_name(s: &str) -> Option { + Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255))) + } + + /// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`. pub fn parse_css(s: &str) -> Option { let t = s.trim().to_ascii_lowercase(); if t.starts_with('#') { @@ -166,21 +173,10 @@ impl Color { let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } - let named = match t.as_str() { - "black" => Color::BLACK, - "white" => Color::WHITE, - "transparent" => Color::TRANSPARENT, - "red" => Color::from_rgb8_const(255, 0, 0), - "green" => Color::from_rgb8_const(0, 128, 0), - "lime" => Color::from_rgb8_const(0, 255, 0), - "blue" => Color::from_rgb8_const(0, 0, 255), - "yellow" => Color::from_rgb8_const(255, 255, 0), - "orange" => Color::from_rgb8_const(255, 165, 0), - "purple" => Color::from_rgb8_const(128, 0, 128), - "gray" | "grey" => Color::from_rgb8_const(128, 128, 128), - _ => return Self::from_hex(&t), - }; - Some(named) + if t == "transparent" { + return Some(Color::TRANSPARENT); + } + Self::from_hex_or_name(&t) } /// CSS `rgba(...)` / hex text for copy-as-CSS. diff --git a/crates/color/src/named.rs b/crates/color/src/named.rs new file mode 100644 index 00000000..882472cc --- /dev/null +++ b/crates/color/src/named.rs @@ -0,0 +1,197 @@ +//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as +//! `FF0000`. + +/// Every CSS named colour except `transparent`, sorted by name for binary search. +const NAMED: [(&str, [u8; 3]); 148] = [ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("aqua", [0, 255, 255]), + ("aquamarine", [127, 255, 212]), + ("azure", [240, 255, 255]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("burlywood", [222, 184, 135]), + ("cadetblue", [95, 158, 160]), + ("chartreuse", [127, 255, 0]), + ("chocolate", [210, 105, 30]), + ("coral", [255, 127, 80]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("crimson", [220, 20, 60]), + ("cyan", [0, 255, 255]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkorange", [255, 140, 0]), + ("darkorchid", [153, 50, 204]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deepskyblue", [0, 191, 255]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("firebrick", [178, 34, 34]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("fuchsia", [255, 0, 255]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("goldenrod", [218, 165, 32]), + ("gray", [128, 128, 128]), + ("green", [0, 128, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [128, 128, 128]), + ("honeydew", [240, 255, 240]), + ("hotpink", [255, 105, 180]), + ("indianred", [205, 92, 92]), + ("indigo", [75, 0, 130]), + ("ivory", [255, 255, 240]), + ("khaki", [240, 230, 140]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lightblue", [173, 216, 230]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightsalmon", [255, 160, 122]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightyellow", [255, 255, 224]), + ("lime", [0, 255, 0]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("maroon", [128, 0, 0]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumpurple", [147, 112, 219]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navy", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olive", [128, 128, 0]), + ("olivedrab", [107, 142, 35]), + ("orange", [255, 165, 0]), + ("orangered", [255, 69, 0]), + ("orchid", [218, 112, 214]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("paleturquoise", [175, 238, 238]), + ("palevioletred", [219, 112, 147]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("plum", [221, 160, 221]), + ("powderblue", [176, 224, 230]), + ("purple", [128, 0, 128]), + ("rebeccapurple", [102, 51, 153]), + ("red", [255, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("royalblue", [65, 105, 225]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("sienna", [160, 82, 45]), + ("silver", [192, 192, 192]), + ("skyblue", [135, 206, 235]), + ("slateblue", [106, 90, 205]), + ("slategray", [112, 128, 144]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("springgreen", [0, 255, 127]), + ("steelblue", [70, 130, 180]), + ("tan", [210, 180, 140]), + ("teal", [0, 128, 128]), + ("thistle", [216, 191, 216]), + ("tomato", [255, 99, 71]), + ("turquoise", [64, 224, 208]), + ("violet", [238, 130, 238]), + ("wheat", [245, 222, 179]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellowgreen", [154, 205, 50]), +]; + +/// Longest name in [`NAMED`]; anything longer can't match. +const LONGEST: usize = 20; + +/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so +/// `Light Blue`, `light-blue` and `lightblue` are the same colour. +pub fn lookup(s: &str) -> Option<[u8; 3]> { + let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::().to_ascii_lowercase(); + if key.is_empty() || key.len() > LONGEST { + return None; + } + NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_is_sorted_and_unique() { + assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0)); + assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST)); + } + + #[test] + fn every_name_is_found() { + for (name, rgb) in NAMED { + assert_eq!(lookup(name), Some(rgb), "{name}"); + } + } + + #[test] + fn lookup_normalises() { + assert_eq!(lookup("Red"), Some([255, 0, 0])); + assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237])); + assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210])); + assert_eq!(lookup(""), None); + assert_eq!(lookup("transparent"), None); + assert_eq!(lookup("reddish"), None); + assert_eq!(lookup("rédé"), None); + assert_eq!(lookup(&"a".repeat(10_000)), None); + } +} diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index facb3415..68a838e0 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -17,6 +17,23 @@ fn css_parsing() { assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]); assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00"); assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE); + assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399"); + assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT); +} + +#[test] +fn colour_fields_take_names() { + assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000"); + assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED"); + assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]); + // Hex wins: every digit string still reads as hex. + assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC"); + assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF"); + // Names are opaque; `transparent` is not a field colour. + assert!(Color::from_hex_or_name("transparent").is_none()); + for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] { + assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}"); + } } #[test] diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..22d3f618 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -284,7 +284,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { } _ => { if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") { - out = Color::from_hex(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a })); + out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a })); } } } diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index 029a332c..76ee3044 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -535,7 +535,7 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option { if let Some(h) = widgets::text_field_inline(&mut child, Id::new(("hex", fills, i)), &color.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) - && let Some(c) = figcraft_color::Color::from_hex(&h) + && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()})); } @@ -1901,7 +1901,7 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }); } if let Some(h) = widgets::text_field(ui, Id::new(("fx_c", i, secondary)), &c.to_hex(), 80.0, "") - && let Some(nc) = figcraft_color::Color::from_hex(&h) + && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { edit(app, secondary, nc.with_alpha(c.a), None); } @@ -2083,7 +2083,7 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr let (sw, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); widgets::paint_swatch(ui.painter(), sw, &Paint::solid(g.color)); if let Some(h) = widgets::text_field(ui, Id::new(("grid_hex", i)), &g.color.to_hex(), 80.0, "") - && let Some(c) = figcraft_color::Color::from_hex(&h) + && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { app.run("node.set", set(&|x| x.color = c.with_alpha(x.color.a))); } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 20137482..5d28089f 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -818,10 +818,11 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C } let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0)); if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0)) - && let Some(nc) = figcraft_color::Color::from_hex(&h) + && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { - // Six digits keep the alpha; eight set it. - let nc = if h.trim().trim_start_matches('#').len() == 8 { nc } else { nc.with_alpha(c.a) }; + // Six digits (or a colour name) keep the alpha; eight digits set it. + let digits = h.trim().trim_start_matches('#'); + let nc = if digits.len() == 8 && digits.chars().all(|ch| ch.is_ascii_hexdigit()) { nc } else { nc.with_alpha(c.a) }; set(app, v, mode, json!(nc.to_css_hex())); } alpha_text(ui, c, body); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cdc94e3..3a9116cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -26,6 +26,7 @@ interaction feel not yet compared. | Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | +| Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Layers and Assets panels @@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | From 44128f7e97117d0da7f82142cb50f16fc07bee8e Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Thu, 15 Oct 2026 23:18:45 +0700 Subject: [PATCH 64/77] fix(render): the rim light fades out at its reach instead of stopping (#256) Where the 512 px cap held the light short of half a level, it dropped to 0 in one pixel: a ring 512 px inside a deep pane (46 levels at depth 300, splay 1, 200%). The falloff now reaches 0 at the reach. Where the cap doesn't bite this moves the light by at most half a level. Cut tests now also cover rounded corners and a look under the cap. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/glass.rs | 12 ++++--- crates/render/src/glass_cut_tests.rs | 52 +++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index 7af74011..70e0d582 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -213,6 +213,10 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let fringe = (g.dispersion * 0.25) as f32; let power = (6.0 - 5.0 * g.splay) as f32; let (band, reach) = (band as f32, reach as f32); + // The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light + // fades out to 0 at `reach` instead of stopping there. + let floor = (-reach / band).exp(); + let fall = 1.0 - floor; let intensity = g.light_intensity as f32; let depth = depth as f32; let mut out = Pixmap::new(w, h)?; @@ -242,12 +246,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32 let a = rs[3].max(gs[3]).max(bs[3]); [rs[0].min(a), gs[1].min(a), bs[2].min(a), a] }; - // The rim catches the light where it faces it, and half as much opposite. Past `reach` - // the light is under half a level: none, wherever the layer is cut. - let hl = if d < reach { + // The rim catches the light where it faces it, and half as much opposite. It fades out + // by `reach` and is none past it, wherever the layer is cut. + let hl = if d < reach && fall > 0.0 { let facing = -(inward.0 * light.0 + inward.1 * light.1); let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power); - (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0) + (intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0) } else { 0.0 }; diff --git a/crates/render/src/glass_cut_tests.rs b/crates/render/src/glass_cut_tests.rs index 43eda0b5..43733c7f 100644 --- a/crates/render/src/glass_cut_tests.rs +++ b/crates/render/src/glass_cut_tests.rs @@ -8,8 +8,8 @@ use tiny_skia::Pixmap; use crate::{Options, View, render_view_single}; -/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page). -fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId) { +/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page, pane). +fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId, NodeId) { let mut d = Document::new("glass"); let p = d.first_page().unwrap(); let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap()); @@ -23,7 +23,7 @@ fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId) { n.corners.radii = [radius; 4]; n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })]; n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; - (d, p) + (d, p, pane) } /// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel @@ -59,6 +59,27 @@ fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> { .collect() } +/// A pane deeper than the light may reach, over black and lit from the left: across its middle row +/// each pixel is the light alone. It fades out smoothly where it stops, with no ring. +#[test] +fn the_light_fades_out_where_it_stops() { + let mut d = Document::new("glass"); + let p = d.first_page().unwrap(); + let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap(); + d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap(); + let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() }; + let n = d.get_mut(pane).unwrap(); + n.fills.clear(); + n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; + let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap(); + let row = &full.data()[700 * 1400 * 4..][..1400 * 4]; + let red: Vec = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect(); + let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap(); + assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels"); + assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]); +} + /// Digests of glass renders over a spread of settings, scales and corners: compare two builds' /// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`. #[test] @@ -75,7 +96,7 @@ fn glass_digests() { let mut all = DefaultHasher::new(); for (i, g) in looks.into_iter().enumerate() { for radius in [0.0, 40.0] { - let (d, p) = pane_over_stripes(g.clone(), radius); + let (d, p, _) = pane_over_stripes(g.clone(), radius); for scale in [1.0, 1.37, 2.0] { let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 }; let pix = render_view_single(&d, p, &v, &Options::default()).unwrap(); @@ -91,11 +112,24 @@ fn glass_digests() { #[test] fn a_cut_through_the_pane_matches_the_whole_render() { - // Square corners: straight edges on whole pixels raster the same in any cut. - for g in [wide_light(), Glass::default(), Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }] { - let (d, p) = pane_over_stripes(g.clone(), 0.0); - for (cut, diff) in cut_diffs(&d, p) { - assert_eq!(diff, (0, 0), "cut at {cut} px with {g:?}"); + // The last look is deep enough that the light is held to `MAX_LIGHT_PX`. + let looks = [ + wide_light(), + Glass::default(), + Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }, + Glass { depth: 300.0, ..wide_light() }, + ]; + // Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a + // cut, and that is the fill, not the glass. + for radius in [0.0, 40.0] { + for g in &looks { + let (mut d, p, pane) = pane_over_stripes(g.clone(), radius); + if radius > 0.0 { + d.get_mut(pane).unwrap().fills.clear(); + } + for (cut, diff) in cut_diffs(&d, p) { + assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}"); + } } } } From 55db465d8e136fbc7f554804ad37b499a642ebc9 Mon Sep 17 00:00:00 2001 From: Ivan Shishkanov Date: Thu, 15 Oct 2026 19:28:46 +0300 Subject: [PATCH 65/77] M7: scrub typography spacing from leading icons (#373) --- ROADMAP.md | 3 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/typography.rs | 74 +++++-- crates/ui-egui/src/typography_scrub_tests.rs | 203 +++++++++++++++++++ crates/ui-egui/src/widgets.rs | 61 +++--- docs/ui-parity.md | 5 +- 6 files changed, 305 insertions(+), 44 deletions(-) create mode 100644 crates/ui-egui/src/typography_scrub_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index ace7702e..4209756d 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-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..18b479db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1225,3 +1225,6 @@ impl FigApp { }) } } + +#[cfg(test)] +mod typography_scrub_tests; diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 56854f27..e5df735f 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -252,18 +252,18 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let lh = uniform(styles, |s| s.line_height); let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into()); let field = SpacingField { field: "lineHeight", id: Id::new("ty_lh"), glyph: LeadGlyph::LineHeight, tip: tr("Line height") }; - if let Some(v) = spacing_field(app, ui, ids, &field, &lh_label, half, editing) - && let Some(v) = parse_line_height(&v, lh) + if let Some(edit) = spacing_field(app, ui, ids, &field, &lh_label, half, editing) + && let Some(v) = parse_line_height(&edit.text(), lh) { - apply_style(app, "lineHeight", v); + commit_spacing(app, ids, range, &field, &edit, v); } let ls = uniform(styles, |s| s.letter_spacing); let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into()); let field = SpacingField { field: "letterSpacing", id: Id::new("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: tr("Letter spacing") }; - if let Some(v) = spacing_field(app, ui, ids, &field, &ls_label, half, editing) - && let Some(v) = parse_letter_spacing(&v, ls) + if let Some(edit) = spacing_field(app, ui, ids, &field, &ls_label, half, editing) + && let Some(v) = parse_letter_spacing(&edit.text(), ls) { - apply_style(app, "letterSpacing", v); + commit_spacing(app, ids, range, &field, &edit, v); } }); } @@ -283,10 +283,12 @@ fn paragraph_rows( let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); row(ui, 32.0, |ui| { let ps_label = ps.map(trim_num).unwrap_or_else(|| tr(MIXED).into()); - if let Some(v) = lead_field(ui, Id::new("ty_ps"), LeadGlyph::ParagraphSpacing, &ps_label, half, tr("Paragraph spacing")) - && let Some(v) = widgets::eval(&v, ps) + let field = + SpacingField { field: "paragraphSpacing", id: Id::new("ty_ps"), glyph: LeadGlyph::ParagraphSpacing, tip: tr("Paragraph spacing") }; + if let Some(edit) = spacing_input(ui, &field, &ps_label, half) + && let Some(v) = widgets::eval(&edit.text(), ps) { - app.set_props(ids, json!({"paragraphSpacing": v.clamp(0.0, 10_000.0)})); + commit_spacing(app, ids, None, &field, &edit, json!(v.clamp(0.0, 10_000.0))); } let items = [(Icon::Hug, tr("Auto width")), (Icon::LayoutV, tr("Auto height")), (Icon::Fixed, tr("Fixed size")), (Icon::Dots, tr("Truncate text"))]; @@ -938,8 +940,7 @@ fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32) { } } -/// A text field with a leading glyph (line height, letter spacing…). Returns the committed text. -/// Line height or letter spacing: which property, its field id, glyph and tooltip. +/// Line height, letter spacing or paragraph spacing: property, field id, glyph and tooltip. struct SpacingField { field: &'static str, id: Id, @@ -947,16 +948,61 @@ struct SpacingField { tip: &'static str, } +enum SpacingEdit { + Typed(String), + Scrub(f64, bool), +} + +impl SpacingEdit { + fn text(&self) -> String { + match self { + Self::Typed(text) => text.clone(), + Self::Scrub(value, _) => trim_num(*value), + } + } +} + +/// Keep typed unit parsing and character-range targeting; only a scrub outside a text-editing +/// session needs its own interaction. The editing session already owns its undo step. +fn commit_spacing(app: &mut FigApp, ids: &[NodeId], range: Option<(NodeId, usize, usize)>, field: &SpacingField, edit: &SpacingEdit, value: Value) { + let props = json!({field.field: value}); + match edit { + SpacingEdit::Scrub(_, done) if figcraft_engine::textedit::selected_range(&app.session).is_none() => { + live(app, field.tip, "node.set", json!({"ids": ids, "props": props}), *done); + } + _ => commit_text(app, ids, range, props), + } +} + +/// The original text editor plus the same leading-icon scrub gesture as ordinary number fields. +/// Auto and Mixed have no numeric starting value and remain unchanged by dragging. +fn spacing_input(ui: &mut Ui, field: &SpacingField, value: &str, width: f32) -> Option { + let rect = Rect::from_min_size(ui.cursor().min, vec2(24.0, FIELD_H)); + let typed = lead_field(ui, field.id, field.glyph, value, width, field.tip); + if ui.data(|d| d.get_temp::(field.id.with("editing")).is_some()) { + return typed.map(SpacingEdit::Typed); + } + let response = ui.interact(rect, field.id.with("lead"), Sense::drag()); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let numeric = value.trim_end_matches('%').parse::().ok(); + match widgets::scrub(ui, field.id, &response, numeric, 1.0) { + Some(FieldEdit::Scrub(v, done)) => Some(SpacingEdit::Scrub(v, done)), + _ => typed.map(SpacingEdit::Typed), + } +} + /// A spacing field that whole layers can bind to a number variable (in pixels): the variable's /// chip when bound, else the editable field with a variable button on hover. While a text range is -/// selected it only takes values. Returns the typed text. -fn spacing_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], f: &SpacingField, value: &str, width: f32, range: bool) -> Option { +/// selected it only takes values. Returns a typed commit or a live scrub. +fn spacing_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], f: &SpacingField, value: &str, width: f32, range: bool) -> Option { let rect = Rect::from_min_size(ui.cursor().min, vec2(width, FIELD_H)); if !range && crate::variable_ui::bound_chip(app, ui, ids, f.field, VariableType::Number, rect) { ui.allocate_rect(rect, Sense::hover()); return None; } - let out = lead_field(ui, f.id, f.glyph, value, width, f.tip); + let out = spacing_input(ui, f, value, width); if !range && ui.data(|d| d.get_temp::(f.id.with("editing")).is_none()) { crate::variable_ui::bind_button(ui, rect, ids, f.field, VariableType::Number); } diff --git a/crates/ui-egui/src/typography_scrub_tests.rs b/crates/ui-egui/src/typography_scrub_tests.rs new file mode 100644 index 00000000..2440f25e --- /dev/null +++ b/crates/ui-egui/src/typography_scrub_tests.rs @@ -0,0 +1,203 @@ +//! Regression for #373: actual pointer gestures on the Typography section's three leading icons. +use egui::{Event, Id, Modifiers, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::{LetterSpacing, LineHeight, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +struct Fields { + app: FigApp, + ctx: egui::Context, + time: f64, + text: NodeId, +} +impl Fields { + fn new() -> Self { + let mut s = Session::new(); + let text = NodeId( + s.execute("text.create", &json!({"text":"Typography","props":{"lineHeight":24,"letterSpacing":0,"paragraphSpacing":12}})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids":[text]})).unwrap(); + let mut f = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text }; + crate::theme::install_fonts(&f.ctx, false, 0); + crate::theme::apply(&f.ctx, crate::theme::Tokens::light()); + for _ in 0..3 { + f.frame(vec![], Modifiers::NONE); + } + f + } + fn frame(&mut self, events: Vec, modifiers: Modifiers) -> egui::CursorIcon { + self.time += 1.0 / 60.0; + let mut events = events; + events.insert(0, Event::ModifiersChanged(modifiers)); + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.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.cursor_icon + } + fn lead(&self, key: &str) -> Pos2 { + let rect = self.ctx.read_response(Id::new(key).with("click")).unwrap().rect; + Pos2::new(rect.min.x + 12.0, rect.center().y) + } + fn drag(&mut self, key: &str, by: f32, modifiers: Modifiers) { + let p = self.lead(key); + self.frame(vec![Event::PointerMoved(p)], modifiers); + self.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers); + for step in 1..=4 { + self.frame(vec![Event::PointerMoved(p + vec2(by * step as f32 / 4.0, 0.0))], modifiers); + } + self.frame(vec![Event::PointerButton { pos: p + vec2(by, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers }], modifiers); + self.frame(vec![], Modifiers::NONE); + } + fn value(&self, key: &str) -> f64 { + let t = self.app.session.doc().unwrap().node(self.text).unwrap().kind.text().unwrap(); + match key { + "ty_lh" => match t.style.line_height { + LineHeight::Pixels(v) | LineHeight::Percent(v) => v, + _ => -1.0, + }, + "ty_ls" => match t.style.letter_spacing { + LetterSpacing::Pixels(v) | LetterSpacing::Percent(v) => v, + }, + _ => t.paragraph_spacing, + } + } +} + +#[test] +fn typography_icon_drag_changes_each_spacing_field_in_one_undo_step() { + for (key, before) in [("ty_lh", 24.0), ("ty_ls", 0.0), ("ty_ps", 12.0)] { + let mut f = Fields::new(); + let undo = f.app.session.undo_depth().0; + f.drag(key, 16.0, Modifiers::NONE); + assert_eq!(f.value(key), before + 16.0, "{key}: dragging the icon must change the value"); + assert_eq!(f.app.session.undo_depth().0, undo + 1, "{key}: one gesture, one undo step"); + f.app.session.undo().unwrap(); + assert_eq!(f.value(key), before); + f.app.session.redo().unwrap(); + assert_eq!(f.value(key), before + 16.0); + } +} + +#[test] +fn typography_icon_scrub_uses_shift_and_alt_rates_and_the_resize_cursor() { + for (modifiers, delta) in [(Modifiers::SHIFT, 160.0), (Modifiers::ALT, 1.6)] { + for (key, start) in [("ty_lh", 24.0), ("ty_ls", 0.0), ("ty_ps", 12.0)] { + let mut f = Fields::new(); + let p = f.lead(key); + assert_eq!(f.frame(vec![Event::PointerMoved(p)], modifiers), egui::CursorIcon::ResizeHorizontal); + f.drag(key, 16.0, modifiers); + assert!((f.value(key) - start - delta).abs() < 1e-8, "{key}: {}", f.value(key)); + } + } +} + +#[test] +fn typography_icon_scrub_preserves_units_and_clamps_only_nonnegative_fields() { + let mut f = Fields::new(); + f.app.run("node.set", json!({"id":f.text,"props":{"lineHeight":"150%","letterSpacing":"2px"}})); + f.frame(vec![], Modifiers::NONE); + f.drag("ty_lh", 16.0, Modifiers::NONE); + f.drag("ty_ls", -16.0, Modifiers::NONE); + let t = f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap(); + assert_eq!(t.style.line_height, LineHeight::Percent(166.0)); + assert_eq!(t.style.letter_spacing, LetterSpacing::Pixels(-14.0)); + f.drag("ty_lh", -200.0, Modifiers::NONE); + f.drag("ty_ps", -200.0, Modifiers::NONE); + assert_eq!(f.value("ty_lh"), 0.0); + assert_eq!(f.value("ty_ps"), 0.0); + assert!(f.app.ui.last_error.is_none(), "{:?}", f.app.ui.last_error); +} + +#[test] +fn typography_icon_scrub_does_not_replace_auto_or_mixed_values() { + let mut f = Fields::new(); + f.app.run("node.set", json!({"id":f.text,"props":{"lineHeight":"auto"}})); + f.frame(vec![], Modifiers::NONE); + let undo = f.app.session.undo_depth().0; + f.drag("ty_lh", 16.0, Modifiers::NONE); + assert_eq!(f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap().style.line_height, LineHeight::Auto); + assert_eq!(f.app.session.undo_depth().0, undo); + let other = f.app.run("text.create", json!({"text":"Other","props":{"lineHeight":36}})).unwrap()["id"].clone(); + f.app.run("select.set", json!({"ids":[f.text,other]})); + f.frame(vec![], Modifiers::NONE); + let before = f.app.session.doc().unwrap().clone(); + let undo = f.app.session.undo_depth().0; + f.drag("ty_lh", 16.0, Modifiers::NONE); + assert_eq!(f.app.session.doc().unwrap(), &before); + assert_eq!(f.app.session.undo_depth().0, undo); +} + +#[test] +fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_text_editing() { + let mut f = Fields::new(); + let undo = f.app.session.undo_depth().0; + figcraft_engine::textedit::begin(&mut f.app.session, f.text, Some(1), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut f.app.session, 4, true).unwrap(); + f.frame(vec![], Modifiers::NONE); + f.drag("ty_ls", 16.0, Modifiers::NONE); + let t = f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap(); + assert_eq!(t.style_at(0).letter_spacing, LetterSpacing::Pixels(0.0)); + assert_eq!(t.style_at(1).letter_spacing, LetterSpacing::Pixels(16.0)); + assert_eq!(t.style_at(4).letter_spacing, LetterSpacing::Pixels(0.0)); + assert!(f.app.session.ds().unwrap().text_edit.is_some()); + assert_eq!(f.app.session.undo_depth().0, undo); + figcraft_engine::textedit::end(&mut f.app.session).unwrap(); + assert_eq!(f.app.session.undo_depth().0, undo + 1); + f.app.session.undo().unwrap(); + assert_eq!(f.value("ty_ls"), 0.0); +} + +#[test] +fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() { + let mut f = Fields::new(); + let p = f.lead("ty_lh") + vec2(36.0, 0.0); + for pressed in [true, false] { + f.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); + } + f.frame(vec![], Modifiers::NONE); + assert!(f.ctx.data(|d| d.get_temp::(Id::new("ty_lh").with("editing"))).is_some(), "click starts typing"); + f.ctx.memory_mut(|m| m.request_focus(Id::new("ty_lh").with("te"))); + f.frame( + vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }], + Modifiers::COMMAND, + ); + f.frame(vec![Event::Text("150%".into())], Modifiers::NONE); + assert_eq!( + f.ctx.data(|d| d.get_temp::(Id::new("ty_lh").with("editing"))).as_deref(), + Some("150%"), + "keyboard selects and replaces the field text" + ); + f.frame( + vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); + assert_eq!(f.app.session.doc().unwrap().node(f.text).unwrap().kind.text().unwrap().style.line_height, LineHeight::Percent(150.0)); + let undo = f.app.session.undo_depth().0; + for pressed in [true, false] { + f.frame( + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); + } + f.ctx.memory_mut(|m| m.request_focus(Id::new("ty_lh").with("te"))); + f.frame(vec![Event::Text("999".into())], Modifiers::NONE); + f.frame( + vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + Modifiers::NONE, + ); + assert_eq!(f.value("ty_lh"), 150.0); + assert_eq!(f.app.session.undo_depth().0, undo); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index db696a1e..2e9899b4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -100,6 +100,38 @@ pub fn scrubbed(from: f64, amount: f64, quantum: f64) -> f64 { if quantum > 0.0 && quantum.is_finite() { stepped(from, (amount / quantum).round(), quantum) } else { from } } +/// The shared label-scrub gesture, including changing modifier rates and raw edge motion. +/// Callers supply the hit area and apply live values until the final `Scrub(_, true)`. +pub(crate) fn scrub(ui: &Ui, id: Id, response: &Response, value: Option, step: f64) -> Option { + let v = value?; + // Where the scrub began and how far it has taken the value (the modifiers can change on + // the way: each stretch counts at its own rate). + let scrub_id = id.with("scrub"); + if response.dragged() || response.drag_stopped() { + let began = response.drag_started(); + let (v0, mut amount): (f64, f64) = ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0)); + let (dx, shift, alt) = ui.input(|i| { + let p = &i.pointer; + // As it begins: all the way from the press (a drag is one only after a few points). + let moved = if began { p.hover_pos().zip(p.press_origin()).map_or(0.0, |(at, from)| at.x - from.x) } else { p.delta().x }; + // With the pointer stopped at the edge of the screen (or of a window that fills + // it) the mouse's own motion goes on, where the platform reports it: the scrub + // does too. Elsewhere the scrub just stops there. + let raw = p.motion().map_or(0.0, |m| m.x / i.pixels_per_point); + let at_edge = moved == 0.0 && p.hover_pos().is_none_or(|at| at.x <= 1.0 || at.x >= i.content_rect().max.x - 1.0); + (f64::from(if at_edge { raw } else { moved }), i.modifiers.shift, i.modifiers.alt) + }); + // The frame the button goes up moves nothing: the scrub ends where the last one left it. + if response.dragged() { + amount += scrub_amount(dx, step, shift, alt); + ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount))); + } + let quantum = if alt { step * 0.1 } else { step }; + return Some(FieldEdit::Scrub(scrubbed(v0, amount, quantum), response.drag_stopped())); + } + None +} + pub(crate) fn fmt_num(v: f64) -> String { if !v.is_finite() { return String::new(); @@ -201,33 +233,8 @@ fn number_field_impl( if lead_resp.hovered() || lead_resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - // Scrub by dragging the label: a step a point, ten with Shift, a tenth with Alt. - if let Some(v) = value { - // Where the scrub began and how far it has taken the value (the modifiers can change on - // the way: each stretch counts at its own rate). - let scrub_id = id.with("scrub"); - if lead_resp.dragged() || lead_resp.drag_stopped() { - let began = lead_resp.drag_started(); - let (v0, mut amount): (f64, f64) = ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0)); - let (dx, shift, alt) = ui.input(|i| { - let p = &i.pointer; - // As it begins: all the way from the press (a drag is one only after a few points). - let moved = if began { p.hover_pos().zip(p.press_origin()).map_or(0.0, |(at, from)| at.x - from.x) } else { p.delta().x }; - // With the pointer stopped at the edge of the screen (or of a window that fills - // it) the mouse's own motion goes on, where the platform reports it: the scrub - // does too. Elsewhere the scrub just stops there. - let raw = p.motion().map_or(0.0, |m| m.x / i.pixels_per_point); - let at_edge = moved == 0.0 && p.hover_pos().is_none_or(|at| at.x <= 1.0 || at.x >= i.content_rect().max.x - 1.0); - (f64::from(if at_edge { raw } else { moved }), i.modifiers.shift, i.modifiers.alt) - }); - // The frame the button goes up moves nothing: the scrub ends where the last one left it. - if lead_resp.dragged() { - amount += scrub_amount(dx, step, shift, alt); - ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount))); - } - let quantum = if alt { step * 0.1 } else { step }; - out = Some(Edit::All(FieldEdit::Scrub(scrubbed(v0, amount, quantum), lead_resp.drag_stopped()))); - } + if let Some(edit) = scrub(ui, id, &lead_resp, value, step) { + out = Some(Edit::All(edit)); } let (key_step, key_big) = key_steps(ui.ctx(), suffix, step); // The keyboard is on the field (also in a window that isn't in front: keys reach one only diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cdc94e3..7044d747 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -63,7 +63,7 @@ interaction feel not yet compared. | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | | Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | -| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | one field widget for every panel (`widgets::number_field`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | +| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | @@ -164,6 +164,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | From 825bb708df4a88a2533d33e78f4618ded8d6f97f Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Thu, 15 Oct 2026 13:51:57 -0300 Subject: [PATCH 66/77] M7: fields select their text on focus A click or Tab into a one-line field selects all of its text, so typing replaces the value, as in Figma: hex and colour fields, inline fields (a fill's hex, variable values, renames), names, the font size field, search boxes and dialog fields (widgets::select_all_on_focus). A drag from the press still selects only what it covers; multi-line fields keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard: egui dropped the focus of the editor created by that click, so it took a second click. --- crates/ui-egui/src/assets_panel.rs | 1 + crates/ui-egui/src/colorpicker.rs | 1 + crates/ui-egui/src/dialogs.rs | 10 +- crates/ui-egui/src/layers.rs | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 3 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/select_all_tests.rs | 136 +++++++++++++++++++++++++ crates/ui-egui/src/shortcuts_panel.rs | 3 +- crates/ui-egui/src/style_picker.rs | 2 + crates/ui-egui/src/typography.rs | 2 + crates/ui-egui/src/variable_ui.rs | 1 + crates/ui-egui/src/variables_panel.rs | 3 +- crates/ui-egui/src/versions.rs | 1 + crates/ui-egui/src/widgets.rs | 58 +++++++++-- docs/ui-parity.md | 4 +- 16 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/select_all_tests.rs diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 5ea19375..80e5c176 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -314,6 +314,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { .hint_text(tr("Search assets")) .desired_width(te_rect.width()), ); + crate::widgets::select_all_on_focus(ui, &r, &filter); if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..7d4be49d 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -1030,6 +1030,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m ui.horizontal(|ui| { ui.add_space(16.0); let r = ui.add(egui::TextEdit::singleline(name).font(regular(11.0)).desired_width(136.0)); + crate::widgets::select_all_on_focus(ui, &r, name); if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) { r.request_focus(); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..2a8c68e3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -253,8 +253,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { .resizable(false) .show(ctx, |ui| { ui.set_width(280.0); - ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0)); - ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0)); + let r = ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0)); + crate::widgets::select_all_on_focus(ui, &r, &find); + let r = ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0)); + crate::widgets::select_all_on_focus(ui, &r, &replace); ui.horizontal(|ui| { if ui.button(tr("Find")).clicked() { action = Some(false); @@ -356,11 +358,13 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { ui.set_width(320.0); ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); ui.add_space(6.0); - ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); + let r = ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); + crate::widgets::select_all_on_focus(ui, &r, &b.find); if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) { b.regex = !b.regex; } let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0)); + crate::widgets::select_all_on_focus(ui, &to, &b.to); ui.horizontal(|ui| { for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] { if ui.button(label).clicked() { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 927e3ae4..c4299c2b 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -579,6 +579,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); + crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..16422869 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,6 +147,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod select_all_tests; +#[cfg(test)] mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a68..f1f30047 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -987,6 +987,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { .id(egui::Id::new("live_report_reason")), ); a11y::text(&resp, tr("Report"), &reason); + crate::widgets::select_all_on_focus(ui, &resp, &reason); if std::mem::take(&mut app.ui.live.focus_report) { resp.request_focus(); } @@ -1148,6 +1149,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { ); // The address may carry the server key: it isn't read out. a11y::text(&resp, tr("Server address"), ""); + crate::widgets::select_all_on_focus(ui, &resp, &form.server); if focus { resp.request_focus(); } @@ -1177,6 +1179,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { widgets::label(ui, tr("File name"), t.text_secondary); let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name"))); a11y::text(&nresp, tr("File name"), &form.name); + crate::widgets::select_all_on_focus(ui, &nresp, &form.name); go |= nresp.lost_focus() && enter(ui) && (keyed || open_server); ui.add_space(4.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7e0552a..8e4ebd2d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -627,6 +627,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), ); r.request_focus(); + crate::widgets::select_all_on_focus(ui, &r, &q); ui.add_space(4.0); let ql = q.to_lowercase(); // Labels match in the UI language and in English. diff --git a/crates/ui-egui/src/select_all_tests.rs b/crates/ui-egui/src/select_all_tests.rs new file mode 100644 index 00000000..f3606c1c --- /dev/null +++ b/crates/ui-egui/src/select_all_tests.rs @@ -0,0 +1,136 @@ +//! A click (or Tab) into a one-line field selects all of its text, so typing replaces the value: +//! the hex fields, inline fields (a fill's hex, renames), search boxes; a drag from the press +//! selects only what it covers. + +use egui::{Event, Id, Key, Modifiers, Pos2, RawInput, Rect, Ui, UiBuilder, pos2, vec2}; + +use crate::widgets; + +const FIELD: Rect = Rect { min: pos2(20.0, 20.0), max: pos2(140.0, 44.0) }; + +fn frame(ctx: &egui::Context, events: Vec, f: &mut impl FnMut(&mut Ui)) { + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), events, ..Default::default() }; + ctx.run_ui(input, |ui| { + let mut child = ui.new_child(UiBuilder::new().max_rect(FIELD)); + f(&mut child); + }) + .textures_delta + .clear(); +} + +fn button(p: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +/// Moves to `at`, presses, (drags to `to`,) releases, then a few quiet frames. +fn click(ctx: &egui::Context, at: Pos2, to: Option, f: &mut impl FnMut(&mut Ui)) { + frame(ctx, vec![], f); + frame(ctx, vec![Event::PointerMoved(at)], f); + frame(ctx, vec![button(at, true)], f); + let end = to.unwrap_or(at); + if let Some(to) = to { + for k in 1..=4 { + frame(ctx, vec![Event::PointerMoved(at + (to - at) * (k as f32 / 4.0))], f); + } + } + frame(ctx, vec![button(end, false)], f); + for _ in 0..3 { + frame(ctx, vec![], f); + } +} + +fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> { + let r = egui::TextEdit::load_state(ctx, te)?.cursor.char_range()?; + let (a, b): (usize, usize) = (r.primary.index.into(), r.secondary.index.into()); + Some((a.min(b), a.max(b))) +} + +#[test] +fn a_click_into_a_hex_field_selects_it_all() { + let ctx = egui::Context::default(); + let id = Id::new("hex"); + let mut f = |ui: &mut Ui| { + widgets::text_field(ui, id, "D9D9D9", 120.0, ""); + }; + click(&ctx, pos2(60.0, 32.0), None, &mut f); + assert!(ctx.memory(|m| m.has_focus(id.with("te")))); + assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); +} + +#[test] +fn a_click_into_an_inline_field_selects_it_all() { + // A fill's hex in the Design panel, a variable's value, a rename. + let ctx = egui::Context::default(); + let id = Id::new("fill_hex"); + let mut f = |ui: &mut Ui| { + widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0)); + }; + click(&ctx, pos2(70.0, 32.0), None, &mut f); + assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); + // Typing replaces it. + frame(&ctx, vec![Event::Text("red".into())], &mut f); + let mut out = None; + let mut g = |ui: &mut Ui| out = widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0)); + frame(&ctx, vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut g); + assert_eq!(out.as_deref(), Some("red")); +} + +#[test] +fn a_click_into_a_search_box_selects_its_query() { + let ctx = egui::Context::default(); + let id = Id::new("search"); + let mut q = String::from("button"); + let mut f = |ui: &mut Ui| { + widgets::text_input(ui, id, &mut q, 120.0, "Search"); + }; + click(&ctx, pos2(60.0, 32.0), None, &mut f); + assert_eq!(selection(&ctx, id), Some((0, 6))); +} + +#[test] +fn tab_into_a_field_selects_it_all() { + let ctx = egui::Context::default(); + let id = Id::new("tabbed"); + let mut q = String::from("Frame 1"); + let mut f = |ui: &mut Ui| { + let r = widgets::text_input(ui, id, &mut q, 120.0, ""); + if !r.has_focus() && ui.input(|i| i.key_pressed(Key::Tab)) { + r.request_focus(); + } + }; + frame(&ctx, vec![], &mut f); + frame(&ctx, vec![Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut f); + for _ in 0..3 { + frame(&ctx, vec![], &mut f); + } + assert_eq!(selection(&ctx, id), Some((0, 7))); +} + +#[test] +fn a_drag_selects_only_what_it_covers() { + let ctx = egui::Context::default(); + let id = Id::new("dragged"); + let mut q = String::from("a long search query"); + let mut f = |ui: &mut Ui| { + widgets::text_input(ui, id, &mut q, 120.0, ""); + }; + click(&ctx, pos2(30.0, 32.0), Some(pos2(70.0, 32.0)), &mut f); + let (a, b) = selection(&ctx, id).unwrap_or_default(); + assert!(b > a, "something is selected: {a}..{b}"); + assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}"); +} + +#[test] +fn a_field_left_mid_edit_selects_all_on_the_next_click() { + let ctx = egui::Context::default(); + let id = Id::new("fill_hex"); + let mut f = |ui: &mut Ui| { + widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0)); + }; + click(&ctx, pos2(70.0, 32.0), None, &mut f); + // The panel goes away mid-edit (another layer selected) and a click lands elsewhere. + click(&ctx, pos2(300.0, 200.0), None, &mut |_: &mut Ui| {}); + click(&ctx, pos2(70.0, 32.0), None, &mut f); + assert!(ctx.memory(|m| m.has_focus(id.with("te")))); + assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); +} diff --git a/crates/ui-egui/src/shortcuts_panel.rs b/crates/ui-egui/src/shortcuts_panel.rs index 05ac20ef..5ff960fd 100644 --- a/crates/ui-egui/src/shortcuts_panel.rs +++ b/crates/ui-egui/src/shortcuts_panel.rs @@ -107,7 +107,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.horizontal(|ui| { crate::dialogs::tab_strip(ui, &t, &Group::ALL, |g| tr(g.label()), &mut app.ui.shortcuts_tab); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text(tr("Search shortcuts")).desired_width(180.0)); + let r = ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text(tr("Search shortcuts")).desired_width(180.0)); + crate::widgets::select_all_on_focus(ui, &r, &app.ui.shortcuts_query); }); }); ui.add_space(10.0); diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 6f0cb2cd..a43e54dd 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -540,6 +540,7 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut } ui.add_space(4.0); let r = ui.add(egui::TextEdit::singleline(&mut p.query).hint_text(tr("Search")).font(regular(11.0)).desired_width(W)); + crate::widgets::select_all_on_focus(ui, &r, &p.query); if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) { r.request_focus(); } @@ -799,6 +800,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { egui::TextEdit::store_state(ui.ctx(), r.id, state); e.focus = false; } + crate::widgets::select_all_on_focus(ui, &r, &e.name); ui.add_space(6.0); caption(ui, tr("Description")); ui.add(egui::TextEdit::multiline(&mut e.description).font(regular(11.0)).desired_width(W).desired_rows(2)); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 56854f27..c95d062d 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -996,6 +996,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti .desired_width(text_rect.width() - 6.0), ); crate::a11y::own_focus(&r); + crate::widgets::select_all_on_focus(ui, &r, &text); // The Enter that started the edit (from the keyboard) doesn't end it. let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); @@ -1089,6 +1090,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) q.clear(); } 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(); } diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 48fecb73..04507c0c 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -397,6 +397,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { }); ui.add_space(4.0); let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tr("Search")).font(regular(11.0)).desired_width(224.0)); + crate::widgets::select_all_on_focus(ui, &r, &query); if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) { r.request_focus(); } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 20137482..4c4923da 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -523,13 +523,14 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let mut child = ui.new_child( UiBuilder::new().max_rect(Rect::from_min_max(pos2(field.min.x + 24.0, field.min.y + 4.0), pos2(field.max.x - 4.0, field.max.y - 4.0))), ); - child.add( + let r = child.add( egui::TextEdit::singleline(&mut st.query) .frame(egui::Frame::NONE) .hint_text(tr("Search")) .font(regular(11.0)) .vertical_align(egui::Align::Center), ); + crate::widgets::select_all_on_focus(ui, &r, &st.query); let note = Rect::from_min_max(pos2(field.max.x + 16.0, sr.min.y + 8.0), pos2(sr.max.x - 16.0, sr.max.y - 8.0)); let mut bar = ui.new_child(UiBuilder::new().max_rect(note).layout(egui::Layout::right_to_left(egui::Align::Center))); let text = match (view.library, coll.extends.as_deref().and_then(|p| view.vars.collection(p))) { diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 12b48448..2d0f9ae2 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -330,6 +330,7 @@ fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(hint).font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w)) }); crate::a11y::text(&title, hint, &f.name); + crate::widgets::select_all_on_focus(ui, &title, &f.name); if std::mem::take(&mut f.focus) { title.request_focus(); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index db696a1e..099e167c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -494,6 +494,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str if started { editing = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(id.with("te"))); + select_all_next(ui, id.with("te")); } let mut out = None; match editing { @@ -527,14 +528,16 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str) -> Response { let t = Tokens::get(ui.ctx()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0)))); - child.add( + let r = child.add( egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(egui::RichText::new(hint).font(regular(11.0)).color(t.text_tertiary)) .desired_width(rect.width() - 16.0), - ) + ); + select_all_on_focus(ui, &r, text); + r } /// A text field that is typed into in place: `text` changes as it is typed (the caller decides @@ -562,6 +565,48 @@ fn keep_focus(r: &Response, started: bool) { } } +/// Selects all of a one-line editor's `text` when it gains the keyboard (a click, Tab or a focus +/// request), so what is typed replaces the value, as in Figma's fields. Call it after adding the +/// editor (`r` is its response). egui puts the caret where the press landed, so the selection is +/// laid again until the button is released; a drag from the press selects what it covers instead. +pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) { + let pending = select_all_id(r.id); + if r.gained_focus() { + ui.data_mut(|d| d.insert_temp(pending, true)); + } + if !ui.data(|d| d.get_temp::(pending)).unwrap_or(false) { + return; + } + // Not focused yet (the click that starts an edit can land before the editor exists): wait. + if !r.has_focus() { + return; + } + let (down, dragged) = ui.input(|i| (i.pointer.any_down(), i.pointer.total_drag_delta().is_some_and(|d| d.length() > 6.0))); + if down && dragged { + ui.data_mut(|d| d.remove::(pending)); + return; + } + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), r.id); + ui.ctx().request_repaint(); + } + if !down { + ui.data_mut(|d| d.remove::(pending)); + } +} + +/// Has [`select_all_on_focus`] select all of editor `te_id` from now on (an edit that starts +/// with the editor already focused, which then gains no focus). +fn select_all_next(ui: &Ui, te_id: Id) { + ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true)); +} + +fn select_all_id(te_id: Id) -> Id { + te_id.with("select_all_on_focus") +} + /// What a dropdown shows before its label. #[derive(Clone, Copy)] pub enum DropLead { @@ -963,11 +1008,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu if started { buf = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(te_id)); - // A rename starts with the whole name selected. - let mut state = egui::TextEdit::load_state(ui.ctx(), te_id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(value.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), te_id); + select_all_next(ui, te_id); } match buf { Some(mut text) => { @@ -976,6 +1017,9 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width()); a11y::own_focus(&r); + keep_focus(&r, started); + // A rename or a hex edit starts with the whole text selected. + select_all_on_focus(ui, &r, &text); // The Enter that started the edit (from the keyboard) doesn't end it. let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cdc94e3..7173bcd7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -26,6 +26,7 @@ interaction feel not yet compared. | Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | +| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Layers and Assets panels @@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | | 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) | From 4f9023d80fdb9b5dfdd8bf6fe10c8e3311c303c0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 19:04:53 +0200 Subject: [PATCH 67/77] Cmd/Ctrl+= and - zoom the canvas only, not the whole interface (fixes #580) --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/shortcut_keys_tests.rs | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..bf72ad78 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -902,6 +902,9 @@ impl FigApp { self.language = Some(self.session.prefs.language.clone()); } if !self.fonts_installed { + // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's + // own keyboard zoom would scale the whole interface on the same keys. + ctx.options_mut(|o| o.zoom_with_keyboard = false); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; } else if figcraft_text::system_fonts_scanned() { diff --git a/crates/ui-egui/src/shortcut_keys_tests.rs b/crates/ui-egui/src/shortcut_keys_tests.rs index 4d1e5e85..42413a4f 100644 --- a/crates/ui-egui/src/shortcut_keys_tests.rs +++ b/crates/ui-egui/src/shortcut_keys_tests.rs @@ -2,7 +2,8 @@ //! headless: arrows pan with nothing selected, the keyboard box selection key and Cmd/Ctrl+arrows //! drawing it out, list indentation while editing text, symbols another keyboard layout types with //! Shift, the presentation keys (Space and N forward, C comments, F full screen), Alt+R for the -//! rotation origin and Enter placing a shape with its tool chosen. +//! rotation origin, Enter placing a shape with its tool chosen and Cmd/Ctrl+= and - zooming the +//! canvas, not the interface. use egui::{Event, Key, Modifiers, ViewportCommand, ViewportId}; use figcraft_doc::NodeId; @@ -211,3 +212,19 @@ fn full_screen_key_and_command_toggle_the_window() { u.app.run("view.fullscreen", json!({"on": "yes"})); u.frame(vec![]); } + +#[test] +fn cmd_plus_and_minus_zoom_the_canvas_not_the_interface() { + let mut u = Ui::new(Session::new()); + u.app.run("view.set", json!({"zoom": 1.0, "cx": 0, "cy": 0})); + press(&mut u, Key::Equals, cmd()); + // egui applies a new zoom factor at the start of the next frame. + u.frame(vec![]); + assert!(view(&u).2 > 1.0, "Cmd/Ctrl+= zooms the canvas in: {}", view(&u).2); + assert_eq!(u.ctx.zoom_factor(), 1.0, "and leaves the interface at its size"); + press(&mut u, Key::Minus, cmd()); + press(&mut u, Key::Minus, cmd()); + u.frame(vec![]); + assert!(view(&u).2 < 1.0, "Cmd/Ctrl+- zooms the canvas out: {}", view(&u).2); + assert_eq!(u.ctx.zoom_factor(), 1.0); +} From 3074db1cbb76ade27b18013028dccac435ca134f Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 18:45:15 +0300 Subject: [PATCH 68/77] M1: drop shadows under a background blur (#255) A layer with a background blur (or glass) drew its blurred backdrop into its own pixels before casting its drop shadows from them, so a translucent fill cast a shadow as dark as an opaque one, and with "show behind transparent areas" the shadow was cast from the backdrop's pixels. The backdrop was also read before the shadows were drawn, so a shadow showing behind was never seen through the blur. When a layer has both, it now casts its shadows from what it paints (with the layer blur, noise and texture it will get), draws them, and only then reads the backdrop, which goes under its content; the content still sees the first backdrop under it while it's drawn. Layers without both draw as before. --- crates/render/src/backdrop_shadow_tests.rs | 79 ++++++++++++++++++++ crates/render/src/draw.rs | 86 +++++++++++++++------- crates/render/src/lib.rs | 2 + 3 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 crates/render/src/backdrop_shadow_tests.rs diff --git a/crates/render/src/backdrop_shadow_tests.rs b/crates/render/src/backdrop_shadow_tests.rs new file mode 100644 index 00000000..9c5fb562 --- /dev/null +++ b/crates/render/src/backdrop_shadow_tests.rs @@ -0,0 +1,79 @@ +//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer +//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its +//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow +//! that shows behind transparent areas is seen, blurred, through the layer. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::*; + +/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80 +/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half) +/// and, when `blur`, a background blur. +fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) { + let mut d = Document::new("shadow behind blur"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() { + let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())]; + } + let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))]; + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() }; + n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]; + if blur { + n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true }); + } + (d, f) +} + +fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { + let i = ((y * p.width() + x) * 4) as usize; + [p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]] +} + +/// The largest channel difference between `a` and `b` over the shadow below the pane +/// (60..180 × 122..160). +fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 { + let mut worst = 0; + for y in 122..160 { + for x in 60..180 { + worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap()); + } + } + worst +} + +#[test] +fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() { + for show_behind in [false, true] { + let (with_blur, f) = scene(show_behind, true); + let (without, _) = scene(show_behind, false); + let got = render_node(&with_blur, f, 1.0).unwrap(); + let want = render_node(&without, f, 1.0).unwrap(); + let diff = max_diff_below_pane(&got, &want); + assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}"); + // A 20% fill casts a faint shadow: the white between the stripes stays light. + let light = px(&got, 120, 140); + assert!(light[0] > 180, "show behind {show_behind}: {light:?}"); + } +} + +#[test] +fn a_shadow_showing_behind_is_seen_through_the_background_blur() { + let (d, f) = scene(true, true); + let got = render_node(&d, f, 1.0).unwrap(); + // Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow + // under it, from y = 80). + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}"); + // Hidden behind the layer, it doesn't. + let (d, f) = scene(false, true); + let got = render_node(&d, f, 1.0).unwrap(); + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b27..af73aeeb 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -423,16 +423,15 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let shape = node_outline(ctx.doc, n); // Background blur and glass: the whole backdrop under the layer (every surface below this // one, composited), through the shape, under the content. - if backdrop::has_effects(n) - && let Some(sh) = &backdrop::outline(ctx.doc, n) - { - let pad = backdrop::pad(n, ctx.scale); - let p = i32::try_from(pad).unwrap_or(0); - if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2) - && let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) - { - layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); - } + let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None }; + let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf)); + // With drop shadows too, the layer casts them from what it paints, not from the backdrop, and + // the backdrop is read again once they lie under it (a shadow showing behind is seen through + // it). Until then, the layer's content sees the backdrop read first under it (#255). + let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); + let first_back = read_backdrop(pix); + if !casts && let Some(b) = &first_back { + layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } let op = n.opacity.clamp(0.0, 1.0); { @@ -448,9 +447,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } }; let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))); - // Backdrops further down see this layer composited onto `pix`. + // Backdrops further down see this layer composited onto `pix` (over its backdrop, when + // that is kept apart for now). let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) }; - draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); + let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under))); + let content_under = over_back.as_ref().unwrap_or(&under); + draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } // A group is no outline of its own: its inner shadows are cast from around what its layers // draw together, over them. @@ -465,24 +467,58 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } } // Layer blur, noise and texture, in list order, over everything else. + if !casts { + layer_effects(ctx, &mut layer, n, id, lxf); + } + // Drop shadows go under the layer, in effect-list order. + { + // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content + // with the layer effects it will get. + let content = casts.then(|| { + let mut c = layer.clone(); + layer_effects(ctx, &mut c, n, id, lxf); + c + }); + let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale); + for e in n.effects.iter().filter(|e| e.visible) { + if let EffectKind::DropShadow(s) = &e.kind + && let Some(sh) = shadow::drop_shadow(&caster, s) + { + composite(pix, (lx, ly), &sh, op, s.blend); + } + } + } + if casts { + // The backdrop with the shadows under it, the content over it, then the layer effects. + if let Some(mut b) = read_backdrop(pix) { + b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + layer = b; + } + layer_effects(ctx, &mut layer, n, id, lxf); + } + composite(pix, (lx, ly), &layer, op, n.blend); +} + +/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on +/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it. +fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option { + let ((lx, ly), (lw, lh)) = (at, size); + let pad = backdrop::pad(n, ctx.scale); + let p = i32::try_from(pad).unwrap_or(0); + let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?; + backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) +} + +/// Node `n`'s layer blur, noise and texture on its `layer`, in list order. +fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) { for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { - EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale), - EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32), - EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32), + EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale), + EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32), + EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32), _ => {} } } - // Drop shadows go under the layer, in effect-list order. - let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale); - for e in n.effects.iter().filter(|e| e.visible) { - if let EffectKind::DropShadow(s) = &e.kind - && let Some(sh) = shadow::drop_shadow(&caster, s) - { - composite(pix, (lx, ly), &sh, op, s.blend); - } - } - composite(pix, (lx, ly), &layer, op, n.blend); } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda..1025d507 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec, kind: &[u8; 4], data: &[u8]) -> Vec { out } +#[cfg(test)] +mod backdrop_shadow_tests; #[cfg(test)] mod backdrop_tests; #[cfg(test)] From f4d53a659b68d03f7bc03e0c438e6197980560f3 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 20:36:47 +0300 Subject: [PATCH 69/77] M1: memory budgets for decoded images and a file's images (#203, #189) --- crates/format/src/image_budget_tests.rs | 93 ++++++++++++++++++++ crates/format/src/lib.rs | 64 +++++++++++--- crates/render/src/image_budget_tests.rs | 111 ++++++++++++++++++++++++ crates/render/src/images.rs | 95 +++++++++++++++----- crates/render/src/lib.rs | 2 + 5 files changed, 331 insertions(+), 34 deletions(-) create mode 100644 crates/format/src/image_budget_tests.rs create mode 100644 crates/render/src/image_budget_tests.rs diff --git a/crates/format/src/image_budget_tests.rs b/crates/format/src/image_budget_tests.rs new file mode 100644 index 00000000..58bb4da1 --- /dev/null +++ b/crates/format/src/image_budget_tests.rs @@ -0,0 +1,93 @@ +//! The images of a native file within a budget (#189): however many members each within the +//! per-member limit a file holds, the images read from it together stay within a byte and a count +//! budget. The document's own images are read first, and the ones past the budget are reported. + +use std::io::Write; + +use figcraft_doc::Document; + +use super::*; + +fn png(k: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A saved document with `n` images, and their hashes and total size. +fn file_with_images(n: u8) -> (Vec, Vec, u64) { + let mut d = Document::new("File"); + let mut hashes = Vec::new(); + let mut total = 0; + for k in 0..n { + let bytes = png(k); + total += bytes.len() as u64; + hashes.push(add_image(&mut d, bytes).unwrap().0); + } + (save_bytes(&d, None).unwrap(), hashes, total) +} + +/// `bytes` with one more member, `name` holding `data`. +fn with_member(bytes: &[u8], name: &str, data: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let names: Vec = z.file_names().map(str::to_string).collect(); + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for n in names { + let member = read_member(&mut z, &n, u64::MAX).unwrap(); + w.start_file(n, stored).unwrap(); + w.write_all(&member).unwrap(); + } + w.start_file(name, stored).unwrap(); + w.write_all(data).unwrap(); + w.finish().unwrap(); + buf.into_inner() +} + +#[test] +fn images_exactly_within_the_budget_all_load() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: total }).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} + +#[test] +fn images_past_the_byte_budget_are_left_out_and_reported() { + let (bytes, hashes, total) = file_with_images(3); + let loaded = load_within(&bytes, ImageBudget { images: 100, bytes: total - 1 }).unwrap(); + let held: u64 = loaded.doc.images.values().map(|i| i.bytes.len() as u64).sum(); + assert!(held < total, "{held} bytes held"); + assert_eq!(loaded.doc.images.len(), 2); + let lost: Vec<&String> = hashes.iter().filter(|h| !loaded.doc.images.contains_key(*h)).collect(); + assert_eq!(lost.len(), 1); + assert!(loaded.issues.iter().any(|i| i.contains(lost[0].as_str()) && i.contains("together")), "{:?}", loaded.issues); +} + +#[test] +fn images_past_the_count_budget_are_left_out_and_reported() { + let (bytes, _, _) = file_with_images(4); + let loaded = load_within(&bytes, ImageBudget { images: 3, bytes: u64::MAX }).unwrap(); + assert_eq!(loaded.doc.images.len(), 3); + assert!(loaded.issues.iter().any(|i| i.contains("more than 3 images")), "{:?}", loaded.issues); +} + +#[test] +fn the_documents_own_images_are_read_before_others() { + let (bytes, hashes, total) = file_with_images(2); + // An image the document doesn't list, named to come first in the archive's order. + let extra = with_member(&bytes, "images/0000", &png(99)); + let loaded = load_within(&extra, ImageBudget { images: 2, bytes: total }).unwrap(); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h)), "{:?}", loaded.issues); + assert!(!loaded.issues.iter().any(|i| hashes.iter().any(|h| i.contains(h.as_str()))), "{:?}", loaded.issues); +} + +#[test] +fn a_file_within_the_shipped_budget_loads_as_before() { + let (bytes, hashes, _) = file_with_images(3); + let loaded = load_bytes_with_versions(&bytes).unwrap(); + assert!(loaded.issues.is_empty(), "{:?}", loaded.issues); + assert!(hashes.iter().all(|h| loaded.doc.images.contains_key(h))); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 56798d14..f88416e2 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -47,6 +47,8 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000; /// Largest version index and total size of the version snapshots read from one file. const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +/// Most images, and image bytes together, read from one file (members past it are left out). +const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 }; const BRANCH_INDEX: &str = "branches/index.json"; /// Most branches a file keeps: making more is refused, extra entries in a file are skipped. pub const MAX_BRANCHES: usize = 100; @@ -453,8 +455,20 @@ pub(crate) fn read_member(z: &mut zip::ZipArchive, name: &str Ok(data) } +/// How many images, and how many bytes of them together, one file may give. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ImageBudget { + pub images: usize, + pub bytes: u64, +} + /// Read a `.figcraft` archive (or bare JSON) with its version history. pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { + load_within(bytes, IMAGE_BUDGET) +} + +/// [`load_bytes_with_versions`] reading at most `budget` of images. +pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result { let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i)); if trimmed == Some(&b'{') { let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?; @@ -464,18 +478,7 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?; let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?; let (mut doc, mut issues, own_images) = parse_json(&text)?; - let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect(); - let mut images = BTreeMap::new(); - let mut unread = Vec::new(); - for name in names { - let hash = name.trim_start_matches("images/").to_string(); - match read_member(&mut z, &name, MAX_MEMBER) { - Ok(data) => { - images.insert(hash, image_data(data)); - } - Err(e) => unread.push((hash, e)), - } - } + let (images, unread) = read_images(&mut z, own_images.as_ref(), budget); report_lost_images(&mut issues, &unread, own_images.as_ref(), &images); match library::read(&mut z, &images, u64::MAX) { Ok(lib) => doc.published = lib.map(Arc::new), @@ -497,6 +500,41 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { Ok(Loaded { doc, issues, versions, branches }) } +/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read +/// with why. The document's own images (`own`) are read first, so a file past the budget loses +/// the images only its history uses before its own. +fn read_images( + z: &mut zip::ZipArchive, + own: Option<&BTreeSet>, + budget: ImageBudget, +) -> (BTreeMap, Vec<(String, FormatError)>) { + let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect(); + hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h))); + let mut images = BTreeMap::new(); + let mut unread = Vec::new(); + let mut left = budget.bytes; + for (i, hash) in hashes.into_iter().enumerate() { + let name = format!("images/{hash}"); + if i >= budget.images { + unread.push((hash, FormatError::Corrupt(format!("the file has more than {} images", budget.images)))); + continue; + } + if z.by_name(&name).is_ok_and(|f| f.size() > left) { + let mb = budget.bytes / (1024 * 1024); + unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB")))); + continue; + } + match read_member(z, &name, left.min(MAX_MEMBER)) { + Ok(data) => { + left = left.saturating_sub(data.len() as u64); + images.insert(hash, image_data(data)); + } + Err(e) => unread.push((hash, e)), + } + } + (images, unread) +} + /// Most lost images reported one by one (the rest as a count). const MAX_IMAGE_ISSUES: usize = 20; @@ -712,6 +750,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form #[cfg(test)] mod branch_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_tests; #[cfg(test)] mod large_tests; diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs new file mode 100644 index 00000000..ae654e09 --- /dev/null +++ b/crates/render/src/image_budget_tests.rs @@ -0,0 +1,111 @@ +//! Decoded images within a memory budget (#203): an image too large is refused from its header, +//! before its pixels are decoded, and the decoded-image caches hold a bounded number of bytes +//! however many images, filters and colour profiles a document uses. + +use std::sync::Arc; + +use figcraft_color::ColorProfile; +use tiny_skia::Pixmap; + +use super::*; + +fn png(w: u32, h: u32) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 40, 40, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A PNG whose header claims `w` × `h` but which holds no pixel data at all. +fn png_header_only(w: u32, h: u32) -> Vec { + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&w.to_be_bytes()); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + out.extend_from_slice(&13u32.to_be_bytes()); + let chunk: Vec = b"IHDR".iter().copied().chain(ihdr).collect(); + out.extend_from_slice(&chunk); + out.extend_from_slice(&crc32(&chunk).to_be_bytes()); + out +} + +fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for b in data { + crc ^= u32::from(*b); + for _ in 0..8 { + crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc +} + +#[test] +fn an_image_within_the_limits_decodes() { + let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 30, 30 * 20 * 4).unwrap(); + assert_eq!((p.width(), p.height()), (30, 20)); +} + +#[test] +fn an_image_wider_or_taller_than_the_limit_is_refused() { + assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none()); + assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none()); +} + +#[test] +fn an_image_whose_pixels_pass_the_byte_budget_is_refused() { + assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 1000, 30 * 20 * 4 - 1).is_none()); +} + +#[test] +fn a_huge_image_is_refused_from_its_header_alone() { + // 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and + // the file has none to decode: only the header is read. + assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none()); + // Past the side limit, the shipped decode refuses it the same way. + assert!(images::decode(&png_header_only(20_000, 10)).is_none()); +} + +fn key(name: &str) -> images::Key { + (name.to_string(), [0; 7], ColorProfile::Srgb) +} + +fn pixmap(side: u32) -> Arc { + Arc::new(Pixmap::new(side, side).unwrap()) +} + +#[test] +fn the_image_cache_keeps_within_its_byte_budget() { + // 10 × 10 pixmaps are 400 bytes; 1000 bytes hold two. + let mut c = images::PixmapCache::default(); + for name in ["a", "b", "c", "d", "e"] { + c.insert(key(name), pixmap(10), 1000); + assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); + assert!(c.get(&key(name)).is_some(), "{name} was just added"); + } +} + +#[test] +fn an_image_larger_than_the_budget_is_held_alone() { + let mut c = images::PixmapCache::default(); + c.insert(key("small"), pixmap(10), 1000); + c.insert(key("big"), pixmap(20), 1000); + assert!(c.get(&key("big")).is_some()); + assert!(c.get(&key("small")).is_none()); + assert_eq!(c.bytes(), 20 * 20 * 4); + // The next image takes its place. + c.insert(key("next"), pixmap(10), 1000); + assert_eq!(c.bytes(), 400); +} + +#[test] +fn filter_and_profile_variants_count_against_one_budget() { + let mut c = images::PixmapCache::default(); + for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() { + for f in 0..4 { + c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000); + assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); + } + } +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index abdb9e1f..4892e508 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -13,11 +13,54 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind}; use figcraft_doc::{Document, ImageData, NodeId}; use tiny_skia::Pixmap; -type Key = (String, [i32; 7], ColorProfile); +pub(crate) type Key = (String, [i32; 7], ColorProfile); -fn cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +/// The largest image side decoded. +const MAX_SIDE: u32 = 16_384; +/// The most bytes one image's decoded pixels take (a 16k × 16k 8-bit RGBA image). +const MAX_DECODED_BYTES: u64 = 1 << 30; +/// The most bytes of pixels each decoded-image cache holds (one image larger than this is held +/// alone). +const MAX_CACHED_BYTES: u64 = 512 << 20; +/// The most entries each decoded-image cache holds. +const MAX_CACHED_IMAGES: usize = 256; + +/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding +/// one past the budget (or past [`MAX_CACHED_IMAGES`]) empties the cache first. +#[derive(Default)] +pub(crate) struct PixmapCache { + map: HashMap>, + bytes: u64, +} + +impl PixmapCache { + pub(crate) fn get(&self, key: &Key) -> Option> { + self.map.get(key).cloned() + } + + /// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone). + pub(crate) fn insert(&mut self, key: Key, p: Arc, max_bytes: u64) { + let size = p.data().len() as u64; + if self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { + self.map.clear(); + self.bytes = 0; + } + if let Some(old) = self.map.insert(key, p) { + self.bytes = self.bytes.saturating_sub(old.data().len() as u64); + } + self.bytes = self.bytes.saturating_add(size); + } + + /// The bytes of pixels held. + #[cfg(test)] + pub(crate) fn bytes(&self) -> u64 { + self.bytes + } +} + +fn cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PixmapCache::default())) } fn fkey(f: &ImageFilters) -> [i32; 7] { @@ -34,7 +77,23 @@ pub fn decode(bytes: &[u8]) -> Option { /// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB → /// `target` conversion is still to do). fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> { - let mut dec = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; + decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES) +} + +/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most +/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded. +pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> { + use image::ImageDecoder; + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(max_side); + limits.max_image_height = Some(max_side); + limits.max_alloc = Some(max_bytes); + reader.limits(limits); + let mut dec = reader.into_decoder().ok()?; + if dec.total_bytes() > max_bytes { + return None; + } let source = crate::image_profile::Source::read(bytes, &mut dec); let img = image::DynamicImage::from_decoder(dec).ok()?; match source.convert(&img, target) { @@ -66,7 +125,7 @@ pub fn profile_name(hash: &str, data: Option<&ImageData>) -> Option { /// Straight RGBA → a premultiplied pixmap (max 16k per side). fn premultiplied(rgba: &image::RgbaImage) -> Option { let (w, h) = rgba.dimensions(); - if w == 0 || h == 0 || w > 16_384 || h > 16_384 { + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { return None; } let mut p = Pixmap::new(w, h)?; @@ -87,17 +146,13 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option { pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option> { let key = (hash.to_string(), fkey(filters), profile); if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(p.clone()); + return Some(p); } let (mut p, srgb) = decode_in(&data?.bytes, profile)?; // Images with their own profile are in `profile` already (sRGB → sRGB is the identity). finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb }); let p = Arc::new(p); - let mut c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 256 { - c.clear(); - } - c.insert(key, p.clone()); + cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone(), MAX_CACHED_BYTES); Some(p) } @@ -166,7 +221,7 @@ pub fn decode_frames(bytes: &[u8]) -> Option> { let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).ok()?; let (w, h) = dec.dimensions(); let frame_bytes = u64::from(w) * u64::from(h) * 4; - if frame_bytes == 0 || w > 16_384 || h > 16_384 { + if frame_bytes == 0 || w > MAX_SIDE || h > MAX_SIDE { return None; } let most = (MAX_ANIMATION_BYTES / frame_bytes).min(MAX_FRAMES as u64) as usize; @@ -304,22 +359,18 @@ pub fn pad(pm: &Pixmap) -> Option { Some(out) } -fn padded_cache() -> &'static Mutex>> { - static C: OnceLock>>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(HashMap::new())) +fn padded_cache() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PixmapCache::default())) } /// `pm` with a transparent 1-pixel border (cached). pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option> { let key = (hash.to_string(), fkey(filters), profile); if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return Some(p.clone()); + return Some(p); } let out = Arc::new(pad(pm)?); - let mut c = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 256 { - c.clear(); - } - c.insert(key, out.clone()); + padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, out.clone(), MAX_CACHED_BYTES); Some(out) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda..904e6dce 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -442,6 +442,8 @@ mod grain_tests; #[cfg(test)] mod group_effects_tests; #[cfg(test)] +mod image_budget_tests; +#[cfg(test)] mod image_profile_tests; #[cfg(test)] mod lod_tests; From a7bcc6d6ffc252bbf0296b5c35aff6065d618a9b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:25:05 +0700 Subject: [PATCH 70/77] #317: compute the backdrop once under a casting layer With drop shadows the backdrop was read twice: before the content and again after the shadows. The first read is only what backdrop effects inside the content see, so it now runs only when the layer has such descendants, and the content is copied for the shadow caster only when it gets a layer blur, noise or texture. The output is unchanged. --- crates/render/src/draw.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index af73aeeb..45b48500 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -429,7 +429,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep // the backdrop is read again once they lie under it (a shadow showing behind is seen through // it). Until then, the layer's content sees the backdrop read first under it (#255). let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); - let first_back = read_backdrop(pix); + // Kept apart, the first read is only what backdrop effects inside the content see: without + // any, the backdrop (blur or glass, the costly part) is computed once, after the shadows. + let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects)); + let first_back = if !casts || nested() { read_backdrop(pix) } else { None }; if !casts && let Some(b) = &first_back { layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } @@ -473,8 +476,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep // Drop shadows go under the layer, in effect-list order. { // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content - // with the layer effects it will get. - let content = casts.then(|| { + // with the layer effects it will get (a copy only when it gets some). + let layer_fx = + n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_))); + let content = (casts && layer_fx).then(|| { let mut c = layer.clone(); layer_effects(ctx, &mut c, n, id, lxf); c From 4a2d19f0db5565260ce08f0b77ed2d5e2b417d76 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:50:55 +0700 Subject: [PATCH 71/77] #326: decode at most the image crate's default 512 MiB per image; a full image cache drops its least recently used images, not all of them --- crates/render/src/image_budget_tests.rs | 14 +++++++++ crates/render/src/images.rs | 39 +++++++++++++++++-------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs index ae654e09..6228efc5 100644 --- a/crates/render/src/image_budget_tests.rs +++ b/crates/render/src/image_budget_tests.rs @@ -86,6 +86,20 @@ fn the_image_cache_keeps_within_its_byte_budget() { } } +#[test] +fn a_full_image_cache_drops_the_least_recently_used_image_only() { + // 1000 bytes hold two 400-byte pixmaps: the third drops the one used longest ago. + let mut c = images::PixmapCache::default(); + c.insert(key("a"), pixmap(10), 1000); + c.insert(key("b"), pixmap(10), 1000); + assert!(c.get(&key("a")).is_some()); + c.insert(key("c"), pixmap(10), 1000); + assert!(c.get(&key("a")).is_some(), "used since b: kept"); + assert!(c.get(&key("b")).is_none(), "used longest ago: dropped"); + assert!(c.get(&key("c")).is_some()); + assert_eq!(c.bytes(), 800); +} + #[test] fn an_image_larger_than_the_budget_is_held_alone() { let mut c = images::PixmapCache::default(); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4892e508..f4f9c5d9 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -17,8 +17,9 @@ pub(crate) type Key = (String, [i32; 7], ColorProfile); /// The largest image side decoded. const MAX_SIDE: u32 = 16_384; -/// The most bytes one image's decoded pixels take (a 16k × 16k 8-bit RGBA image). -const MAX_DECODED_BYTES: u64 = 1 << 30; +/// The most bytes one image's decoding may allocate: the image crate's own default limit (a +/// larger image is refused from its header). +const MAX_DECODED_BYTES: u64 = 512 << 20; /// The most bytes of pixels each decoded-image cache holds (one image larger than this is held /// alone). const MAX_CACHED_BYTES: u64 = 512 << 20; @@ -26,31 +27,45 @@ const MAX_CACHED_BYTES: u64 = 512 << 20; const MAX_CACHED_IMAGES: usize = 256; /// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding -/// one past the budget (or past [`MAX_CACHED_IMAGES`]) empties the cache first. +/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so +/// the images on screen stay while they fit. #[derive(Default)] pub(crate) struct PixmapCache { - map: HashMap>, + /// Each pixmap and when it was last used. + map: HashMap, u64)>, bytes: u64, + tick: u64, } impl PixmapCache { - pub(crate) fn get(&self, key: &Key) -> Option> { - self.map.get(key).cloned() + pub(crate) fn get(&mut self, key: &Key) -> Option> { + self.tick += 1; + let tick = self.tick; + self.map.get_mut(key).map(|(p, used)| { + *used = tick; + p.clone() + }) } /// Add `p` under `key`, keeping the cache within `max_bytes` (or holding `p` alone). pub(crate) fn insert(&mut self, key: Key, p: Arc, max_bytes: u64) { let size = p.data().len() as u64; - if self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { - self.map.clear(); - self.bytes = 0; - } - if let Some(old) = self.map.insert(key, p) { - self.bytes = self.bytes.saturating_sub(old.data().len() as u64); + self.remove(&key); + while self.bytes.saturating_add(size) > max_bytes || self.map.len() >= MAX_CACHED_IMAGES { + let Some(oldest) = self.map.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { break }; + self.remove(&oldest); } + self.tick += 1; + self.map.insert(key, (p, self.tick)); self.bytes = self.bytes.saturating_add(size); } + fn remove(&mut self, key: &Key) { + if let Some((p, _)) = self.map.remove(key) { + self.bytes = self.bytes.saturating_sub(p.data().len() as u64); + } + } + /// The bytes of pixels held. #[cfg(test)] pub(crate) fn bytes(&self) -> u64 { From 97f9c2fc5de430730c24b954736901f2a85a9f69 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 17:37:59 +0300 Subject: [PATCH 72/77] M1: say why when the window can't open (#261) Release builds on Windows have no console, so an eframe::run_native error (no usable graphics adapter, a device the driver refuses) went to an unseen standard error and the app quit silently. The error is now logged and shown in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise update the driver or try another graphics API (OpenGL on Windows), and where to report it. With --control the box is skipped so agents aren't blocked. --- apps/figcraft/src/main.rs | 12 ++++++-- apps/figcraft/src/startup.rs | 44 ++++++++++++++++++++++++++++++ apps/figcraft/src/startup_tests.rs | 35 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 apps/figcraft/src/startup.rs create mode 100644 apps/figcraft/src/startup_tests.rs diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 7b14d8e2..1babfd5b 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -17,6 +17,7 @@ mod gamepad; mod graphics; mod recovery; mod services; +mod startup; #[cfg(all(test, target_os = "macos"))] mod apple_events_tests; @@ -30,6 +31,8 @@ mod recovery_online_tests; mod recovery_tests; #[cfg(test)] mod services_tests; +#[cfg(test)] +mod startup_tests; use std::sync::{Arc, Mutex}; @@ -203,7 +206,7 @@ fn main() -> eframe::Result { let apple_events = apple_events::AppleEvents::install(); #[cfg(target_os = "macos")] let apple_events = &apple_events; - eframe::run_native( + let result = eframe::run_native( "FigCraft", options, Box::new(move |_cc| { @@ -216,5 +219,10 @@ fn main() -> eframe::Result { apple_events: apple_events.connect(&_cc.egui_ctx), })) }), - ) + ); + // Say why instead of quitting silently: release builds on Windows have no console. + if let Err(e) = &result { + startup::failed(e, port.is_some()); + } + result } diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs new file mode 100644 index 00000000..8a50d7a5 --- /dev/null +++ b/apps/figcraft/src/startup.rs @@ -0,0 +1,44 @@ +//! When the window can't open. +//! +//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable +//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees +//! and the app quit without a word (#261). Now it is logged and shown in a message box. + +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// The text shown when the window couldn't open with `error`. `backend_set`: `WGPU_BACKEND` named +/// the graphics API, so the hint is to unset it rather than to try another one. +pub fn message(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered (`graphics.rs`), but wgpu keeps the Direct3D 12 + // adapter it picked even when its device then fails, so naming OpenGL alone can still help. + "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:\n\nset WGPU_BACKEND=gl\nfigcraft.exe" + } else { + "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." + }; + format!("FigCraft couldn't open its window: {error}\n\n{hint}\n\nIf it keeps happening, please report it at {ISSUES} with this message.") +} + +/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would +/// keep the caller waiting, and it reads standard error), show it in a message box. +pub fn failed(error: &eframe::Error, quiet: bool) { + let text = message(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // Standard error even when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{text}"); + } else { + eprintln!("figcraft: {text}"); + } + if quiet { + return; + } + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("FigCraft") + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs new file mode 100644 index 00000000..6818ae77 --- /dev/null +++ b/apps/figcraft/src/startup_tests.rs @@ -0,0 +1,35 @@ +//! The message shown when the window can't open: it carries the error, a next step and where to +//! report it, instead of the app quitting silently (#261). + +use crate::startup::message; + +const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; + +#[test] +fn the_message_carries_the_error_and_where_to_report_it() { + let text = message(ERROR, false); + assert!(text.contains(ERROR), "the error itself: {text}"); + assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); +} + +#[test] +fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { + let text = message(ERROR, false); + assert!(text.contains("graphics driver"), "{text}"); + assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); + assert!(!text.contains("unsetting"), "{text}"); +} + +#[cfg(windows)] +#[test] +fn on_windows_it_shows_how_to_start_with_opengl() { + let text = message(ERROR, false); + assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}"); +} + +#[test] +fn with_wgpu_backend_set_it_suggests_unsetting_it() { + let text = message(ERROR, true); + assert!(text.contains("unsetting"), "{text}"); + assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); +} From 916ee3276c931000fe2b956a8e499bd2a8775b45 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:36:00 +0700 Subject: [PATCH 73/77] #306: say why the window can't open in the interface language The message now comes from the UI crate (dialogs::window_failed) through tr() and trf(), with entries in every catalog, and the app picks the language from the preferences before the event loop starts, so the box reads in it even though no frame was ever drawn. The commands to type stay as written. --- apps/figcraft/src/main.rs | 3 +++ apps/figcraft/src/startup.rs | 21 ++---------------- apps/figcraft/src/startup_tests.rs | 2 +- crates/ui-egui/locales/de.toml | 5 +++++ crates/ui-egui/locales/en.toml | 5 +++++ crates/ui-egui/locales/es.toml | 5 +++++ crates/ui-egui/locales/fr.toml | 5 +++++ crates/ui-egui/locales/ja.toml | 5 +++++ crates/ui-egui/locales/ko.toml | 5 +++++ crates/ui-egui/locales/pt-BR.toml | 5 +++++ crates/ui-egui/locales/zh-Hans.toml | 5 +++++ crates/ui-egui/src/dialogs.rs | 33 +++++++++++++++++++++++++++++ 12 files changed, 79 insertions(+), 20 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 1babfd5b..5debb538 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -169,6 +169,9 @@ fn main() -> eframe::Result { if session.docs.is_empty() && !first_run { let _ = session.execute("file.new", &json!({})); } + // The interface language from the first frame on, and for the message when the window can't + // open (no frame then). + figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); fig.ui.home = false; fig.note_recovered(recovered.copies); diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs index 8a50d7a5..a25821f3 100644 --- a/apps/figcraft/src/startup.rs +++ b/apps/figcraft/src/startup.rs @@ -4,28 +4,11 @@ //! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees //! and the app quit without a word (#261). Now it is logged and shown in a message box. -/// Where a report goes. -const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; - -/// The text shown when the window couldn't open with `error`. `backend_set`: `WGPU_BACKEND` named -/// the graphics API, so the hint is to unset it rather than to try another one. -pub fn message(error: &str, backend_set: bool) -> String { - let hint = if backend_set { - "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." - } else if cfg!(windows) { - // Direct3D 12 and OpenGL are both offered (`graphics.rs`), but wgpu keeps the Direct3D 12 - // adapter it picked even when its device then fails, so naming OpenGL alone can still help. - "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:\n\nset WGPU_BACKEND=gl\nfigcraft.exe" - } else { - "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." - }; - format!("FigCraft couldn't open its window: {error}\n\n{hint}\n\nIf it keeps happening, please report it at {ISSUES} with this message.") -} - /// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would /// keep the caller waiting, and it reads standard error), show it in a message box. pub fn failed(error: &eframe::Error, quiet: bool) { - let text = message(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // In the interface language: the text and its catalog entries are the UI crate's. + let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); // Standard error even when RUST_LOG turned errors off. if log::log_enabled!(log::Level::Error) { log::error!("{text}"); diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs index 6818ae77..0c92bd19 100644 --- a/apps/figcraft/src/startup_tests.rs +++ b/apps/figcraft/src/startup_tests.rs @@ -1,7 +1,7 @@ //! The message shown when the window can't open: it carries the error, a next step and where to //! report it, instead of the app quitting silently (#261). -use crate::startup::message; +use figcraft_ui_egui::dialogs::window_failed as message; const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index affda97d..5987ec70 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -457,6 +457,7 @@ status = "draft — needs native review" "Fade" = "Verblassen" "False" = "Falsch" "Fetching from the server…" = "Wird vom Server abgerufen…" +"FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}" "File" = "Datei" "File name" = "Dateiname" "File published" = "Datei veröffentlicht" @@ -568,6 +569,7 @@ status = "draft — needs native review" "Hue" = "Farbton" "Hug" = "Umschließen" "Hug contents" = "Inhalt umschließen" +"If it keeps happening, please report it at {link} with this message." = "Wenn es weiterhin passiert, melden Sie es bitte mit dieser Meldung unter {link}." "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" @@ -1323,6 +1325,8 @@ status = "draft — needs native review" "Update plugin" = "Plugin aktualisieren" "Updated {components} and {styles}" = "{components} und {styles} aktualisiert" "Updated {name}" = "{name} aktualisiert" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Oft hilft es, den Grafiktreiber zu aktualisieren. Um nur OpenGL zu verwenden, starten Sie FigCraft in der Eingabeaufforderung mit:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Oft hilft es, den Grafiktreiber zu aktualisieren. Mit WGPU_BACKEND (vulkan, gl, metal) lässt sich eine andere Grafik-API ausprobieren." "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." = "Lädt diese Datei auf einen FigCraft-Server hoch und hält diesen Tab mit ihm synchron. Sie erhalten einen Link zum Bearbeiten und einen zum Ansehen." "Uppercase" = "Großbuchstaben" "Use a fixed value" = "Festen Wert verwenden" @@ -1364,6 +1368,7 @@ status = "draft — needs native review" "Visibility" = "Sichtbarkeit" "Visible" = "Sichtbar" "Vivid" = "Lebhaft" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst." "Warm" = "Warm" "Watch" = "Smartwatch" "Wavy" = "Gewellt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 08ac64ba..21df8bfe 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -475,6 +475,7 @@ name = "English" "False" = "False" "Feb" = "Feb" "Fetching from the server…" = "Fetching from the server…" +"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "File" = "File" "File name" = "File name" "File not found" = "File not found" @@ -594,6 +595,7 @@ name = "English" "Hug contents" = "Hug contents" "Icon components on a 24 px grid with keylines and a size preview" = "Icon components on a 24 px grid with keylines and a size preview" "Icon set starter" = "Icon set starter" +"If it keeps happening, please report it at {link} with this message." = "If it keeps happening, please report it at {link} with this message." "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" @@ -1389,6 +1391,8 @@ name = "English" "Update from selection" = "Update from selection" "Updated {components} and {styles}" = "Updated {components} and {styles}" "Updated {name}" = "Updated {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." "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." = "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." "Uppercase" = "Uppercase" "Use a fixed value" = "Use a fixed value" @@ -1431,6 +1435,7 @@ name = "English" "Visibility" = "Visibility" "Visible" = "Visible" "Vivid" = "Vivid" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." "Warm" = "Warm" "Watch" = "Watch" "Wavy" = "Wavy" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3200e891..af5c6a15 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -476,6 +476,7 @@ status = "draft — needs native review" "False" = "Falso" "Feb" = "feb" "Fetching from the server…" = "Obteniendo del servidor…" +"FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}" "File" = "Archivo" "File name" = "Nombre del archivo" "File not found" = "No se encuentra el archivo" @@ -595,6 +596,7 @@ status = "draft — needs native review" "Hug contents" = "Ajustar al contenido" "Icon components on a 24 px grid with keylines and a size preview" = "Componentes de icono en una cuadrícula de 24 px con líneas guía y una vista de tamaños" "Icon set starter" = "Base de conjunto de iconos" +"If it keeps happening, please report it at {link} with this message." = "Si sigue pasando, infórmalo en {link} con este mensaje." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" @@ -1390,6 +1392,8 @@ status = "draft — needs native review" "Update from selection" = "Actualizar desde la selección" "Updated {components} and {styles}" = "Actualizados {components} y {styles}" "Updated {name}" = "{name} actualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Actualizar el controlador gráfico suele ayudar. Para usar solo OpenGL, inicia FigCraft desde el Símbolo del sistema con:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Actualizar el controlador gráfico suele ayudar. WGPU_BACKEND (vulkan, gl, metal) indica otra API gráfica que probar." "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." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." "Uppercase" = "Mayúsculas" "Use a fixed value" = "Usar un valor fijo" @@ -1432,6 +1436,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidad" "Visible" = "Visible" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo." "Warm" = "Cálido" "Watch" = "Reloj" "Wavy" = "Ondulado" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 69798340..1f5544c2 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -457,6 +457,7 @@ status = "draft — needs native review" "Fade" = "Fondu" "False" = "Faux" "Fetching from the server…" = "Récupération depuis le serveur…" +"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}" "File" = "Fichier" "File name" = "Nom du fichier" "File published" = "Fichier publié" @@ -568,6 +569,7 @@ status = "draft — needs native review" "Hue" = "Teinte" "Hug" = "Ajuster" "Hug contents" = "Ajuster au contenu" +"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" @@ -1323,6 +1325,8 @@ status = "draft — needs native review" "Update plugin" = "Mettre à jour le plugin" "Updated {components} and {styles}" = "{components} et {styles} mis à jour" "Updated {name}" = "{name} mis à jour" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Mettre à jour le pilote graphique aide souvent. Pour n’utiliser qu’OpenGL, lancez FigCraft depuis l’invite de commandes avec :" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Mettre à jour le pilote graphique aide souvent. WGPU_BACKEND (vulkan, gl, metal) désigne une autre API graphique à essayer." "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." = "Envoie ce fichier sur un serveur FigCraft et garde cet onglet synchronisé avec lui. Vous obtenez un lien de modification et un lien de consultation." "Uppercase" = "Majuscules" "Use a fixed value" = "Utiliser une valeur fixe" @@ -1364,6 +1368,7 @@ status = "draft — needs native review" "Visibility" = "Visibilité" "Visible" = "Visible" "Vivid" = "Vif" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même." "Warm" = "Chaud" "Watch" = "Montre" "Wavy" = "Ondulé" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fb81b042..9174a65a 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -478,6 +478,7 @@ status = "draft — needs native review" "False" = "false" "Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" +"FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}" "File" = "ファイル" "File name" = "ファイル名" "File not found" = "ファイルが見つからない" @@ -597,6 +598,7 @@ status = "draft — needs native review" "Hug contents" = "内容に合わせる" "Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" "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)" = "オートレイアウトを無視(絶対位置)" @@ -1392,6 +1394,8 @@ status = "draft — needs native review" "Update plugin" = "プラグインを更新" "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)で別のグラフィックス API を試せる。" "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" = "固定値を使用" @@ -1434,6 +1438,7 @@ status = "draft — needs native review" "Visibility" = "表示" "Visible" = "表示" "Vivid" = "ビビッド" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。" "Warm" = "ウォーム" "Watch" = "ウォッチ" "Wavy" = "波線" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index aed3529a..e672efe7 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -458,6 +458,7 @@ status = "draft — needs native review" "Fade" = "페이드" "False" = "거짓" "Fetching from the server…" = "서버에서 가져오는 중…" +"FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}" "File" = "파일" "File name" = "파일 이름" "File published" = "파일 게시됨" @@ -569,6 +570,7 @@ status = "draft — needs native review" "Hue" = "색조" "Hug" = "맞춤" "Hug contents" = "내용에 맞춤" +"If it keeps happening, please report it at {link} with this message." = "계속 발생하면 이 메시지와 함께 {link}에 알려 주세요." "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" @@ -1324,6 +1326,8 @@ status = "draft — needs native review" "Update plugin" = "플러그인 업데이트" "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)로 다른 그래픽 API를 시도할 수 있습니다." "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" = "고정 값 사용" @@ -1365,6 +1369,7 @@ status = "draft — needs native review" "Visibility" = "표시 여부" "Visible" = "표시됨" "Vivid" = "선명하게" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다." "Warm" = "따뜻하게" "Watch" = "시계" "Wavy" = "물결선" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 97500236..f49beb30 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -456,6 +456,7 @@ status = "draft — needs native review" "Fade" = "Esmaecer" "False" = "Falso" "Fetching from the server…" = "Buscando no servidor…" +"FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}" "File" = "Arquivo" "File name" = "Nome do arquivo" "File published" = "Arquivo publicado" @@ -567,6 +568,7 @@ status = "draft — needs native review" "Hue" = "Matiz" "Hug" = "Ajustar" "Hug contents" = "Ajustar ao conteúdo" +"If it keeps happening, please report it at {link} with this message." = "Se continuar acontecendo, informe em {link} com esta mensagem." "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" @@ -1322,6 +1324,8 @@ status = "draft — needs native review" "Update plugin" = "Atualizar plugin" "Updated {components} and {styles}" = "{components} e {styles} atualizados" "Updated {name}" = "{name} atualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Atualizar o driver de vídeo costuma ajudar. Para usar só o OpenGL, abra o FigCraft pelo Prompt de Comando com:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Atualizar o driver de vídeo costuma ajudar. WGPU_BACKEND (vulkan, gl, metal) indica outra API gráfica para tentar." "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." = "Envia este arquivo para um servidor FigCraft e mantém esta aba sincronizada com ele. Você recebe um link para editar e outro para visualizar." "Uppercase" = "Maiúsculas" "Use a fixed value" = "Usar um valor fixo" @@ -1363,6 +1367,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidade" "Visible" = "Visível" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho." "Warm" = "Quente" "Watch" = "Relógio" "Wavy" = "Ondulado" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d1887735..70a05c5f 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -459,6 +459,7 @@ status = "draft — needs native review" "Fade" = "褪色" "False" = "假" "Fetching from the server…" = "正在从服务器获取…" +"FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}" "File" = "文件" "File name" = "文件名" "File published" = "文件已发布" @@ -570,6 +571,7 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "适应" "Hug contents" = "适应内容" +"If it keeps happening, please report it at {link} with this message." = "如果问题持续出现,请附上此消息到 {link} 报告。" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" @@ -1325,6 +1327,8 @@ status = "draft — needs native review" "Update plugin" = "更新插件" "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)指定另一个图形 API 来尝试。" "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" = "使用固定值" @@ -1366,6 +1370,7 @@ status = "draft — needs native review" "Visibility" = "可见性" "Visible" = "可见" "Vivid" = "鲜艳" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。" "Warm" = "暖调" "Watch" = "手表" "Wavy" = "波浪线" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..5f51a610 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,39 @@ use crate::theme::{ACCENT, Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the +/// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND` +/// named the graphics API, so the hint is to unset it rather than to try another one. +pub fn window_failed(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string() + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked + // even when its device then fails, so naming OpenGL alone can still help. + format!( + "{} + +set WGPU_BACKEND=gl +figcraft.exe", + tr("Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:") + ) + } else { + tr("Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try.").to_string() + }; + format!( + "{} + +{hint} + +{}", + trf("FigCraft couldn't open its window: {error}", &[("error", &error)]), + trf("If it keeps happening, please report it at {link} with this message.", &[("link", &ISSUES)]) + ) +} + /// A dialog window, centred. pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) From fe5a22e468c69ef18d0d1e0b08191f4643def5f9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 01:21:06 +0700 Subject: [PATCH 74/77] Colour picker: record recent colours by the write's live flag A text edit holds an interaction too, so treating any open interaction as a drag dropped the colours clicked while editing text. Recent colours dedupe by 8-bit value: the colour picked and the one the document stores differ by a rounding fraction. --- crates/ui-egui/src/colorpicker.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index a9404c9a..f49b2f7b 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -154,15 +154,17 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let Err(e) = r { app.toast(e.to_string()); } - // Drag previews are not choices: a drag records only the colour it ends on, in `finish`. - let dragging = app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false); - if !dragging && let Some(c) = paint.solid_color() { + // Drag previews (`live`) are not choices: a drag records only the colour it ends on. Not "an + // interaction is open": editing text holds one, and its colour clicks are choices. + if !live && let Some(c) = paint.solid_color() { remember_recent(app, c); } } fn remember_recent(app: &mut FigApp, c: Color) { - app.session.recent_colors.retain(|x| *x != c); + // By 8-bit value: a document stores colours rounded to 8 bits, so the colour picked and the one read + // back differ by a fraction and would show as two identical swatches. + app.session.recent_colors.retain(|x| x.to_rgba8() != c.to_rgba8()); app.session.recent_colors.insert(0, c); app.session.recent_colors.truncate(16); } From 9552e530be42174d12aad2278c271484b2d8f663 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 01:30:10 +0700 Subject: [PATCH 75/77] Swedish: command labels that only repeated the command id show in English 468 of the 588 command and menu rows had the command id as their text ("align.bottom" = "align.bottom"), which the app would have shown as is. They are left out, so English shows until they are translated. Swedish isn't in the complete list (that would make every new UI string need a Swedish translation in the same change); the docs say what it covers. --- ROADMAP.md | 2 +- crates/ui-egui/locales/sv.toml | 468 ------------------------------- crates/ui-egui/src/i18n_tests.rs | 2 +- docs/localization-parity.md | 4 +- docs/translating.md | 2 +- 5 files changed, 5 insertions(+), 473 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c577a97d..1feb5054 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`, 100%, native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d29e25c3..f7b2999d 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1588,126 +1588,6 @@ name = "Svenska" [commands] -"align.bottom" = "align.bottom" -"align.hcenter" = "align.hcenter" -"align.left" = "align.left" -"align.right" = "align.right" -"align.top" = "align.top" -"align.vcenter" = "align.vcenter" -"annotation.add" = "annotation.add" -"annotation.addCategory" = "annotation.addCategory" -"annotation.categories" = "annotation.categories" -"annotation.edit" = "annotation.edit" -"annotation.editCategory" = "annotation.editCategory" -"annotation.list" = "annotation.list" -"annotation.remove" = "annotation.remove" -"annotation.removeCategory" = "annotation.removeCategory" -"app.actions" = "app.actions" -"app.preferences" = "app.preferences" -"arrange.back" = "arrange.back" -"arrange.backward" = "arrange.backward" -"arrange.forward" = "arrange.forward" -"arrange.front" = "arrange.front" -"arrange.packH" = "arrange.packH" -"arrange.packV" = "arrange.packV" -"arrange.roundToPixels" = "arrange.roundToPixels" -"arrange.tidy" = "arrange.tidy" -"bool.exclude" = "bool.exclude" -"bool.flatten" = "bool.flatten" -"bool.intersect" = "bool.intersect" -"bool.subtract" = "bool.subtract" -"bool.union" = "bool.union" -"branch.archive" = "branch.archive" -"branch.create" = "branch.create" -"branch.list" = "branch.list" -"branch.merge" = "branch.merge" -"branch.open" = "branch.open" -"branch.review" = "branch.review" -"branch.updateFromMain" = "branch.updateFromMain" -"codeConnect.export" = "codeConnect.export" -"codeConnect.import" = "codeConnect.import" -"codeConnect.remove" = "codeConnect.remove" -"codeConnect.set" = "codeConnect.set" -"collab.cursor" = "collab.cursor" -"collab.cursorChat" = "collab.cursorChat" -"collab.diff" = "collab.diff" -"collab.files" = "collab.files" -"collab.follow" = "collab.follow" -"collab.forgetFile" = "collab.forgetFile" -"collab.host" = "collab.host" -"collab.join" = "collab.join" -"collab.leave" = "collab.leave" -"collab.manageFile" = "collab.manageFile" -"collab.openFile" = "collab.openFile" -"collab.peers" = "collab.peers" -"collab.publish" = "collab.publish" -"collab.recoverFile" = "collab.recoverFile" -"collab.registerLinks" = "collab.registerLinks" -"collab.report" = "collab.report" -"collab.serverInfo" = "collab.serverInfo" -"collab.share" = "collab.share" -"collab.spotlight" = "collab.spotlight" -"collab.stop" = "collab.stop" -"command.batch" = "command.batch" -"comment.add" = "comment.add" -"comment.delete" = "comment.delete" -"comment.edit" = "comment.edit" -"comment.link" = "comment.link" -"comment.list" = "comment.list" -"comment.markRead" = "comment.markRead" -"comment.move" = "comment.move" -"comment.next" = "comment.next" -"comment.open" = "comment.open" -"comment.people" = "comment.people" -"comment.react" = "comment.react" -"comment.reply" = "comment.reply" -"comment.resolve" = "comment.resolve" -"comment.zoom" = "comment.zoom" -"component.addProperty" = "component.addProperty" -"component.addVariant" = "component.addVariant" -"component.bindProperty" = "component.bindProperty" -"component.combineVariants" = "component.combineVariants" -"component.create" = "component.create" -"component.createMultiple" = "component.createMultiple" -"component.deleteProperty" = "component.deleteProperty" -"component.detach" = "component.detach" -"component.editProperty" = "component.editProperty" -"component.exposeInstance" = "component.exposeInstance" -"component.goToMain" = "component.goToMain" -"component.hideWhenPublishing" = "component.hideWhenPublishing" -"component.list" = "component.list" -"component.makeSlot" = "component.makeSlot" -"component.playground" = "component.playground" -"component.properties" = "component.properties" -"component.pushChanges" = "component.pushChanges" -"component.removeSlot" = "component.removeSlot" -"component.resetInstance" = "component.resetInstance" -"component.resetOverride" = "component.resetOverride" -"component.restoreMain" = "component.restoreMain" -"dev.compare" = "dev.compare" -"dev.focus" = "dev.focus" -"dev.resolveChange" = "dev.resolveChange" -"dev.unfocus" = "dev.unfocus" -"devResource.add" = "devResource.add" -"devResource.edit" = "devResource.edit" -"devResource.list" = "devResource.list" -"devResource.remove" = "devResource.remove" -"distribute.horizontal" = "distribute.horizontal" -"distribute.vertical" = "distribute.vertical" -"doc.home" = "doc.home" -"doc.list" = "doc.list" -"doc.nextTab" = "doc.nextTab" -"doc.previousTab" = "doc.previousTab" -"doc.switch" = "doc.switch" -"edit.copy" = "edit.copy" -"edit.copyAsCss" = "edit.copyAsCss" -"edit.copyAsPng" = "edit.copyAsPng" -"edit.copyAsSvg" = "edit.copyAsSvg" -"edit.copyAsText" = "edit.copyAsText" -"edit.copyLink" = "edit.copyLink" -"edit.copyProperties" = "edit.copyProperties" -"edit.cut" = "edit.cut" -"edit.delete" = "edit.delete" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" @@ -1828,354 +1708,6 @@ name = "Svenska" "object.moveToPage" = "Flytta till sida" "object.opacityKeys" = "Nyckelbildrutor för opacitet" "object.rasterize" = "Rastrera" -"object.releaseMask" = "object.releaseMask" -"object.removeFill" = "object.removeFill" -"object.removeFrame" = "object.removeFrame" -"object.removeStroke" = "object.removeStroke" -"object.rename" = "object.rename" -"object.resizeToFit" = "object.resizeToFit" -"object.rotate180" = "object.rotate180" -"object.rotateLeft" = "object.rotateLeft" -"object.rotateRight" = "object.rotateRight" -"object.setThumbnail" = "object.setThumbnail" -"object.swapFillStroke" = "object.swapFillStroke" -"object.toggleLock" = "object.toggleLock" -"object.toggleVisible" = "object.toggleVisible" -"object.ungroup" = "object.ungroup" -"object.wrapInSection" = "object.wrapInSection" -"page.add" = "page.add" -"page.delete" = "page.delete" -"page.duplicate" = "page.duplicate" -"page.list" = "page.list" -"page.move" = "page.move" -"page.rename" = "page.rename" -"page.select" = "page.select" -"page.setBackground" = "page.setBackground" -"paint.documentColors" = "paint.documentColors" -"plugin.close" = "plugin.close" -"plugin.directory.install" = "plugin.directory.install" -"plugin.directory.list" = "plugin.directory.list" -"plugin.directory.publish" = "plugin.directory.publish" -"plugin.directory.unpublish" = "plugin.directory.unpublish" -"plugin.event" = "plugin.event" -"plugin.import" = "plugin.import" -"plugin.list" = "plugin.list" -"plugin.remove" = "plugin.remove" -"plugin.run" = "plugin.run" -"plugin.ui" = "plugin.ui" -"plugins.manage" = "plugins.manage" -"plugins.runLast" = "plugins.runLast" -"pref.highlightHover" = "pref.highlightHover" -"pref.keepTool" = "pref.keepTool" -"pref.language" = "pref.language" -"pref.nudge" = "pref.nudge" -"pref.numberKeysOpacity" = "pref.numberKeysOpacity" -"pref.publishServer" = "pref.publishServer" -"pref.reduceMotion" = "pref.reduceMotion" -"pref.renameDuplicates" = "pref.renameDuplicates" -"pref.showDimensions" = "pref.showDimensions" -"pref.userName" = "pref.userName" -"proto.action.add" = "proto.action.add" -"proto.action.move" = "proto.action.move" -"proto.action.remove" = "proto.action.remove" -"proto.action.set" = "proto.action.set" -"proto.closePreview" = "proto.closePreview" -"proto.conditional.addBlock" = "proto.conditional.addBlock" -"proto.conditional.removeBlock" = "proto.conditional.removeBlock" -"proto.conditional.setCondition" = "proto.conditional.setCondition" -"proto.evaluate" = "proto.evaluate" -"proto.flow.add" = "proto.flow.add" -"proto.flow.remove" = "proto.flow.remove" -"proto.flow.rename" = "proto.flow.rename" -"proto.interaction.add" = "proto.interaction.add" -"proto.interaction.clear" = "proto.interaction.clear" -"proto.interaction.remove" = "proto.interaction.remove" -"proto.interaction.set" = "proto.interaction.set" -"proto.present" = "proto.present" -"proto.presentOptions" = "proto.presentOptions" -"proto.preview" = "proto.preview" -"proto.settings" = "proto.settings" -"section.toggleContents" = "section.toggleContents" -"select.add" = "select.add" -"select.all" = "select.all" -"select.area" = "select.area" -"select.byName" = "select.byName" -"select.children" = "select.children" -"select.inverse" = "select.inverse" -"select.layersAt" = "select.layersAt" -"select.matching" = "select.matching" -"select.next" = "select.next" -"select.none" = "select.none" -"select.parent" = "select.parent" -"select.previous" = "select.previous" -"select.remove" = "select.remove" -"select.sameEffect" = "select.sameEffect" -"select.sameFill" = "select.sameFill" -"select.sameFont" = "select.sameFont" -"select.sameInstance" = "select.sameInstance" -"select.sameProperties" = "select.sameProperties" -"select.sameStroke" = "select.sameStroke" -"select.sameText" = "select.sameText" -"select.set" = "select.set" -"shape.arrow" = "shape.arrow" -"shape.ellipse" = "shape.ellipse" -"shape.line" = "shape.line" -"shape.polygon" = "shape.polygon" -"shape.rectangle" = "shape.rectangle" -"shape.star" = "shape.star" -"shapeBuilder.merge" = "shapeBuilder.merge" -"shapeBuilder.mode" = "shapeBuilder.mode" -"shapeBuilder.regions" = "shapeBuilder.regions" -"shapeBuilder.remove" = "shapeBuilder.remove" -"stroke.add" = "stroke.add" -"stroke.brush" = "stroke.brush" -"stroke.move" = "stroke.move" -"stroke.remove" = "stroke.remove" -"stroke.set" = "stroke.set" -"stroke.widthProfile" = "stroke.widthProfile" -"style.apply" = "style.apply" -"style.bind" = "style.bind" -"style.create" = "style.create" -"style.delete" = "style.delete" -"style.detach" = "style.detach" -"style.duplicate" = "style.duplicate" -"style.list" = "style.list" -"style.move" = "style.move" -"style.renameFolder" = "style.renameFolder" -"style.update" = "style.update" -"template.list" = "template.list" -"text.alignCenter" = "text.alignCenter" -"text.alignJustify" = "text.alignJustify" -"text.alignLeft" = "text.alignLeft" -"text.alignRight" = "text.alignRight" -"text.bold" = "text.bold" -"text.bulletList" = "text.bulletList" -"text.case" = "text.case" -"text.caseLower" = "text.caseLower" -"text.caseOriginal" = "text.caseOriginal" -"text.caseSmallCaps" = "text.caseSmallCaps" -"text.caseTitle" = "text.caseTitle" -"text.caseUpper" = "text.caseUpper" -"text.create" = "text.create" -"text.decoration" = "text.decoration" -"text.edit.begin" = "text.edit.begin" -"text.edit.caret" = "text.edit.caret" -"text.edit.end" = "text.edit.end" -"text.edit.insert" = "text.edit.insert" -"text.edit.key" = "text.edit.key" -"text.edit.preedit" = "text.edit.preedit" -"text.edit.selectLine" = "text.edit.selectLine" -"text.edit.selectWord" = "text.edit.selectWord" -"text.edit.selection" = "text.edit.selection" -"text.endPreview" = "text.endPreview" -"text.fonts" = "text.fonts" -"text.italic" = "text.italic" -"text.lineHeightDown" = "text.lineHeightDown" -"text.lineHeightUp" = "text.lineHeightUp" -"text.link" = "text.link" -"text.listIndent" = "text.listIndent" -"text.listOutdent" = "text.listOutdent" -"text.listStyle" = "text.listStyle" -"text.maxLines" = "text.maxLines" -"text.numberList" = "text.numberList" -"text.onPath" = "text.onPath" -"text.paragraphIndent" = "text.paragraphIndent" -"text.paragraphSpacing" = "text.paragraphSpacing" -"text.pathOptions" = "text.pathOptions" -"text.preview" = "text.preview" -"text.removeFromPath" = "text.removeFromPath" -"text.set" = "text.set" -"text.setFeatures" = "text.setFeatures" -"text.setVariations" = "text.setVariations" -"text.sizeDown" = "text.sizeDown" -"text.sizeUp" = "text.sizeUp" -"text.strikethrough" = "text.strikethrough" -"text.style" = "text.style" -"text.trackingDown" = "text.trackingDown" -"text.trackingUp" = "text.trackingUp" -"text.underline" = "text.underline" -"text.weightDown" = "text.weightDown" -"text.weightUp" = "text.weightUp" -"tool.arrow" = "tool.arrow" -"tool.comment" = "tool.comment" -"tool.ellipse" = "tool.ellipse" -"tool.frame" = "tool.frame" -"tool.hand" = "tool.hand" -"tool.line" = "tool.line" -"tool.move" = "tool.move" -"tool.pen" = "tool.pen" -"tool.pencil" = "tool.pencil" -"tool.pencilBrush" = "tool.pencilBrush" -"tool.place" = "tool.place" -"tool.polygon" = "tool.polygon" -"tool.rectangle" = "tool.rectangle" -"tool.scale" = "tool.scale" -"tool.scaleAnchor" = "tool.scaleAnchor" -"tool.section" = "tool.section" -"tool.set" = "tool.set" -"tool.shapeBuilder" = "tool.shapeBuilder" -"tool.slice" = "tool.slice" -"tool.star" = "tool.star" -"tool.text" = "tool.text" -"transform.move" = "transform.move" -"transform.moveTo" = "transform.moveTo" -"transform.nudge" = "transform.nudge" -"transform.nudgeSize" = "transform.nudgeSize" -"transform.resize" = "transform.resize" -"transform.rotate" = "transform.rotate" -"transform.rotationOrigin" = "transform.rotationOrigin" -"transform.scale" = "transform.scale" -"transform.setRotationOrigin" = "transform.setRotationOrigin" -"variable.addMode" = "variable.addMode" -"variable.bind" = "variable.bind" -"variable.create" = "variable.create" -"variable.createCollection" = "variable.createCollection" -"variable.delete" = "variable.delete" -"variable.deleteCollection" = "variable.deleteCollection" -"variable.duplicate" = "variable.duplicate" -"variable.export" = "variable.export" -"variable.import" = "variable.import" -"variable.list" = "variable.list" -"variable.moveMode" = "variable.moveMode" -"variable.removeMode" = "variable.removeMode" -"variable.renameCollection" = "variable.renameCollection" -"variable.renameMode" = "variable.renameMode" -"variable.resetOverride" = "variable.resetOverride" -"variable.set" = "variable.set" -"variable.setMode" = "variable.setMode" -"variable.update" = "variable.update" -"vector.create" = "vector.create" -"vector.deleteHeal" = "vector.deleteHeal" -"vector.edit.addVertex" = "vector.edit.addVertex" -"vector.edit.begin" = "vector.edit.begin" -"vector.edit.bend" = "vector.edit.bend" -"vector.edit.bounds" = "vector.edit.bounds" -"vector.edit.connect" = "vector.edit.connect" -"vector.edit.cornerRadius" = "vector.edit.cornerRadius" -"vector.edit.cut" = "vector.edit.cut" -"vector.edit.delete" = "vector.edit.delete" -"vector.edit.end" = "vector.edit.end" -"vector.edit.erase" = "vector.edit.erase" -"vector.edit.lasso" = "vector.edit.lasso" -"vector.edit.mirroring" = "vector.edit.mirroring" -"vector.edit.moveHandles" = "vector.edit.moveHandles" -"vector.edit.moveVertices" = "vector.edit.moveVertices" -"vector.edit.nudge" = "vector.edit.nudge" -"vector.edit.paint" = "vector.edit.paint" -"vector.edit.pen" = "vector.edit.pen" -"vector.edit.penEnd" = "vector.edit.penEnd" -"vector.edit.position" = "vector.edit.position" -"vector.edit.resize" = "vector.edit.resize" -"vector.edit.rotate" = "vector.edit.rotate" -"vector.edit.select" = "vector.edit.select" -"vector.edit.selectAll" = "vector.edit.selectAll" -"vector.edit.setHandle" = "vector.edit.setHandle" -"vector.edit.smooth" = "vector.edit.smooth" -"vector.edit.state" = "vector.edit.state" -"vector.edit.strokeCap" = "vector.edit.strokeCap" -"vector.edit.strokeJoin" = "vector.edit.strokeJoin" -"vector.edit.tool" = "vector.edit.tool" -"vector.edit.transform" = "vector.edit.transform" -"vector.edit.widthPoint" = "vector.edit.widthPoint" -"vector.flatten" = "vector.flatten" -"vector.join" = "vector.join" -"vector.offset" = "vector.offset" -"vector.outlineStroke" = "vector.outlineStroke" -"vector.reverse" = "vector.reverse" -"vector.simplify" = "vector.simplify" -"vector.smoothJoin" = "vector.smoothJoin" -"version.autosave" = "version.autosave" -"version.delete" = "version.delete" -"version.duplicate" = "version.duplicate" -"version.exitPreview" = "version.exitPreview" -"version.list" = "version.list" -"version.preview" = "version.preview" -"version.rename" = "version.rename" -"version.restore" = "version.restore" -"view.assets" = "view.assets" -"view.comments" = "view.comments" -"view.contextMenu" = "view.contextMenu" -"view.design" = "view.design" -"view.designPrototype" = "view.designPrototype" -"view.devMode" = "view.devMode" -"view.frameOutlines" = "view.frameOutlines" -"view.fullscreen" = "view.fullscreen" -"view.get" = "view.get" -"view.layers" = "view.layers" -"view.layoutGrids" = "view.layoutGrids" -"view.leftSidebar" = "view.leftSidebar" -"view.maskOutlines" = "view.maskOutlines" -"view.multiplayerCursors" = "view.multiplayerCursors" -"view.nextFrame" = "view.nextFrame" -"view.nextPage" = "view.nextPage" -"view.nextRegion" = "view.nextRegion" -"view.outlines" = "view.outlines" -"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" -"view.outlinesObjectBounds" = "view.outlinesObjectBounds" -"view.outlinesSelection" = "view.outlinesSelection" -"view.pan" = "view.pan" -"view.panelWidths" = "view.panelWidths" -"view.pixelGrid" = "view.pixelGrid" -"view.pixelPreview" = "view.pixelPreview" -"view.prevFrame" = "view.prevFrame" -"view.prevPage" = "view.prevPage" -"view.previousRegion" = "view.previousRegion" -"view.proofSrgb" = "view.proofSrgb" -"view.prototype" = "view.prototype" -"view.rulers" = "view.rulers" -"view.set" = "view.set" -"view.slices" = "view.slices" -"view.snapGeometry" = "view.snapGeometry" -"view.snapObjects" = "view.snapObjects" -"view.snapPixel" = "view.snapPixel" -"view.themeDark" = "view.themeDark" -"view.themeLight" = "view.themeLight" -"view.themeSystem" = "view.themeSystem" -"view.toggleUi" = "view.toggleUi" -"view.zoom100" = "view.zoom100" -"view.zoom200" = "view.zoom200" -"view.zoom50" = "view.zoom50" -"view.zoomAt" = "view.zoomAt" -"view.zoomFit" = "view.zoomFit" -"view.zoomIn" = "view.zoomIn" -"view.zoomOut" = "view.zoomOut" -"view.zoomSelection" = "view.zoomSelection" -"widget.click" = "widget.click" -"widget.insert" = "widget.insert" -"widget.list" = "widget.list" -"window.focus" = "window.focus" -"window.new" = "window.new" [menu] -"bool.exclude" = "bool.exclude" -"bool.flatten" = "bool.flatten" -"bool.intersect" = "bool.intersect" -"bool.subtract" = "bool.subtract" -"bool.union" = "bool.union" -"file.duplicate" = "file.duplicate" -"file.import" = "file.import" -"file.rename" = "file.rename" -"libraries.open" = "libraries.open" -"select.sameEffect" = "select.sameEffect" -"select.sameFill" = "select.sameFill" -"select.sameFont" = "select.sameFont" -"select.sameInstance" = "select.sameInstance" -"select.sameProperties" = "select.sameProperties" -"select.sameStroke" = "select.sameStroke" -"select.sameText" = "select.sameText" -"text.alignCenter" = "text.alignCenter" -"text.alignJustify" = "text.alignJustify" -"text.alignLeft" = "text.alignLeft" -"text.alignRight" = "text.alignRight" -"view.assets" = "view.assets" -"view.design" = "view.design" -"view.layers" = "view.layers" -"view.outlines" = "view.outlines" -"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" -"view.outlinesObjectBounds" = "view.outlinesObjectBounds" -"view.outlinesSelection" = "view.outlinesSelection" -"view.prototype" = "view.prototype" -"view.themeDark" = "view.themeDark" -"view.themeLight" = "view.themeLight" -"view.themeSystem" = "view.themeSystem" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 7d98bdd7..9ee46576 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use super::*; /// Languages that must translate every UI string and command label. -const COMPLETE: &[&str] = &["es", "sv"]; +const COMPLETE: &[&str] = &["es"]; /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0aaf84dd..09f90d86 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -91,8 +91,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`), every UI string, command label and menu wording translated (a -catalog test checks it stays complete); native review welcome. Figma ships none beyond the eight +Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and +menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries diff --git a/docs/translating.md b/docs/translating.md index 9f209491..42f9a8fe 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -15,7 +15,7 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and -Swedish (`sv`), which translates every string. +Swedish (`sv`), which translates every UI string and some command labels. ## The catalogs From 7da4cf5e3144523049a3cae246a1661d747cb5a2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 01:34:36 +0700 Subject: [PATCH 76/77] Export frames to PDF: use the asynchronous save dialog Main's file dialogs answer asynchronously now (they no longer re-enter the event loop on macOS); the menu item takes the path in a callback, and the test draws a frame to let the dialog answer. --- crates/ui-egui/src/menus.rs | 9 +++++---- crates/ui-egui/tests/file_menu_pdf.rs | 21 ++++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8edace2a..13763239 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -160,10 +160,11 @@ pub fn invoke(app: &mut FigApp, id: &str) { /// Every visible top-level frame of the page as one PDF page each, through a save dialog. fn export_frames_pdf(app: &mut FigApp) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"]) else { return }; - if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { - app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); - } + app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| { + if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + } + }); } fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { diff --git a/crates/ui-egui/tests/file_menu_pdf.rs b/crates/ui-egui/tests/file_menu_pdf.rs index 140dbc6a..30b847b7 100644 --- a/crates/ui-egui/tests/file_menu_pdf.rs +++ b/crates/ui-egui/tests/file_menu_pdf.rs @@ -5,14 +5,25 @@ use std::path::PathBuf; use figcraft_engine::Session; -use figcraft_ui_egui::{FigApp, Services, menus}; +use figcraft_ui_egui::{DialogFuture, FigApp, Services, menus}; use serde_json::json; /// Services whose save dialog always returns the given path. struct SaveTo(PathBuf); impl Services for SaveTo { - fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { - Some(self.0.clone()) + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } +} + +/// The menu item, then a frame: the save dialog answers asynchronously and the frame takes its answer. +fn invoke(app: &mut FigApp, id: &str) { + menus::invoke(app, id); + let ctx = egui::Context::default(); + for _ in 0..2 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), ..Default::default() }; + ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); } } @@ -37,7 +48,7 @@ fn frames_pdf_menu_saves_the_pdf() { let path = out_path("one"); let mut app = FigApp::new(Session::new(), Box::new(SaveTo(path.clone()))); frame(&mut app, 0, 0); - menus::invoke(&mut app, "file.exportFramesPdf"); + invoke(&mut app, "file.exportFramesPdf"); assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); let bytes = std::fs::read(&path).expect("the menu writes the PDF where the user chose"); let _ = std::fs::remove_file(&path); @@ -60,7 +71,7 @@ fn frames_pdf_menu_one_page_per_visible_frame() { let hidden = frame(&mut app, 600, 0); let id = hidden["id"].clone(); app.session.execute("node.set", &json!({"id": id, "visible": false})).unwrap(); - menus::invoke(&mut app, "file.exportFramesPdf"); + invoke(&mut app, "file.exportFramesPdf"); assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); let bytes = std::fs::read(&path).expect("the menu writes the PDF where the user chose"); let _ = std::fs::remove_file(&path); From 8ba09001cd1e47542357aac2466d82941821149a Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 16 Oct 2026 02:15:18 +0700 Subject: [PATCH 77/77] M0.9: a control line that isn't JSON ends the connection (#589) The control server answered a line that wasn't JSON with an error and read on. A web page can make the browser send an HTTP request to the loopback port; its request and header lines were skipped that way and the JSON lines of its body ran as commands. The first line that isn't JSON now closes the connection before anything after it is read. Clients that send JSON lines (MCP --attach, scripts) are unaffected. --- apps/figcraft/src/control_server.rs | 8 +++++++- apps/figcraft/src/control_server_tests.rs | 20 ++++++++++++++++++-- docs/control-protocol.md | 3 ++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 995240f5..1c91840f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s } } } - Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}), + Err(e) => { + // A line that isn't JSON ends the connection: a web page can make the browser send + // an HTTP request here, and its header lines must not let the JSON lines of its body + // through as commands. + writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?; + break; + } }; writeln!(out, "{reply}")?; } diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 2e0a7b48..d243fb91 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() { let pending = queue(); let q = pending.clone(); let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))); - client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap(); + client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap(); let mut reader = BufReader::new(client); - assert_eq!(reply(&mut reader)["ok"], false); let start = Instant::now(); let r = loop { if let Some(r) = pending.lock().unwrap().pop() { @@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() { worker.join().unwrap().unwrap(); } +#[test] +fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() { + // What a browser sends when a web page posts to the port: the JSON line in its body never runs. + let (mut client, server) = pair(); + let pending = queue(); + let q = pending.clone(); + let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))); + let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n"; + let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len()); + client.write_all(http.as_bytes()).unwrap(); + let mut reader = BufReader::new(client); + assert_eq!(reply(&mut reader)["ok"], false); + assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed"); + worker.join().unwrap().unwrap(); + assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched"); +} + #[test] fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { let (mut client, server) = pair(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f751c95b..397aed5b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,7 +18,8 @@ reply: {"id": 1, "ok": true, "result": ...} Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is -not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The implementation is in +not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}` and ends the connection (so an +HTTP request a web page makes the browser send here never runs). The implementation is in `crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket). A request line is limited to **32 MiB before the newline**, including whitespace. The reader stops