From 779a5c982e2888d6487cfb3db5a984ad232cf5b6 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Mon, 2 Nov 2026 11:29:02 +0100 Subject: [PATCH 01/10] i18n(sv): translate 21 new strings (#1870) Brings the Swedish catalog up to date with upstream main (f9b56936). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/locales/sv.toml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 748518fff..1c9cf0ee2 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -13,7 +13,10 @@ name = "Svenska" "({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 year" = "1 år" "1…9, 0" = "1…9, 0" +"30 days" = "30 dagar" +"90 days" = "90 dagar" "@{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 resurser, {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: ange overrides för deras värden per läge och lägg till egna variabler" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts." @@ -477,6 +480,7 @@ name = "Svenska" "Drop to import" = "Släpp för att importera" "Drop to open" = "Släpp för att öppna" "Drop shadow" = "Slagskugga" +"Drop to use this image" = "Släpp för att använda bilden" "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" @@ -652,6 +656,7 @@ name = "Svenska" "Help page" = "Hjälpsida" "Here: {names}" = "Här: {names}" "Hidden" = "Dold" +"Hide UI" = "Dölj gränssnitt" "Hide contents" = "Dölj innehåll" "Hide from publishing" = "Dölj vid publicering" "Hide section contents" = "Dölj avsnittets innehåll" @@ -832,6 +837,9 @@ name = "Svenska" "Make slot" = "Gör till plats" "New collection" = "Ny samling" "Nobody shares a file there now." = "Ingen delar någon fil där just nu." +"Past 30 days" = "Senaste 30 dagarna" +"Past 90 days" = "Senaste 90 dagarna" +"Past year" = "Senaste året" "Pasted design layers" = "Inklistrade designlager" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designlager inklistrade; urklippet innehöll inte bilderna för: {layers}" "Remove these results from the file" = "Ta bort de här resultaten från filen" @@ -977,6 +985,7 @@ name = "Svenska" "Not read yet" = "Inte läst än" "Not shared" = "Inte delad" "Note {n}" = "Anteckning {n}" +"Nothing counted yet" = "Inget har räknats ännu" "Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen." "Nothing changed since this was marked ready for dev." = "Inget har ändrats sedan detta markerades som klart för utveckling." "Nothing here yet. Set a frame's status to list it." = "Inget här än. Ange en frames status för att lista den." @@ -985,6 +994,9 @@ name = "Svenska" "Nov" = "nov" "Now" = "Nu" "Nudge (Shift: big nudge)" = "Flytta stegvis (Shift: stort steg)" +"Rotate a degree (Shift: 15)" = "Rotera en grad (Shift: 15)" +"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓" +"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓" "Nudge amount" = "Steglängd" "Number" = "Nummer" "Number ↑" = "Nummer ↑" @@ -1179,6 +1191,7 @@ name = "Svenska" "Remove" = "Ta bort" "Remove all" = "Ta bort alla" "Remove annotation" = "Ta bort anteckning" +"Remove all auto layout" = "Ta bort all auto layout" "Remove auto layout" = "Ta bort auto layout" "Remove column" = "Ta bort kolumn" "Remove dev resource" = "Ta bort utvecklarresurs" @@ -1309,8 +1322,11 @@ name = "Svenska" "Search files and templates" = "Sök efter filer och mallar" "Search results" = "Sökresultat" "Search fonts" = "Sök typsnitt" +"This server doesn't count insertions" = "Den här servern räknar inte infogningar" +"This server doesn't count styles and variables" = "Den här servern räknar inte stilar och variabler" "Someone who left" = "Någon som har lämnat" "Take the results off the board; they stay in the file" = "Ta bort resultaten från tavlan; de finns kvar i filen" +"Upload from computer" = "Ladda upp från datorn" "Use fonts installed on this computer…" = "Använd typsnitt som är installerade på den här datorn…" "Search layers" = "Sök lager" "Search pages" = "Sök sidor" @@ -1373,6 +1389,7 @@ name = "Svenska" "Show device frame" = "Visa enhetsram" "Show dimensions on objects" = "Visa dimensioner på objekt" "Show hotspot hints" = "Visa tips om aktiva punkter" +"The bars are hidden. {shortcut} shows them again" = "Fälten är dolda. {shortcut} visar dem igen" "Show in Explorer" = "Visa i Utforskaren" "Show in Finder" = "Visa i Finder" "Show in folder" = "Visa i mapp" @@ -1864,6 +1881,7 @@ name = "Svenska" "video triggers are not supported and were skipped" = "videoutlösare stöds inte och hoppades över" "yesterday" = "i går" "{count} in it now · Changed {date}" = "{count} i den nu · Ändrad {date}" +"{insertions} · {detachments}" = "{insertions} · {detachments}" "{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" @@ -1902,6 +1920,7 @@ name = "Svenska" "{n} detached" = { one = "{n} bortkopplad", other = "{n} bortkopplade" } "{n} files" = { one = "{n} fil", other = "{n} filer" } "{n} hours ago" = { one = "för {n} timme sedan", other = "för {n} timmar sedan" } +"{n} inserted" = { one = "{n} infogad", other = "{n} infogade" } "{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" } @@ -1932,6 +1951,7 @@ name = "Svenska" "{n}w" = "{n} v" "{page}, nothing selected" = "{page}, inget markerat" "{percent}%" = "{percent} %" +"{period}: {counts}" = "{period}: {counts}" "{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)." @@ -1939,6 +1959,7 @@ name = "Svenska" "{server} · {shared} · Opened {date}" = "{server} · {shared} · Öppnad {date}" "{tool} hold {key}" = "{tool} håll ned {key}" "{used} · {detached}" = "{used} · {detached}" +"{used} · {inserted}" = "{used} · {inserted}" "{value} isn't a colour" = "{value} är inte en färg" "{when} · edited" = "{when} · ändrad" "← ↑ → ↓" = "← ↑ → ↓" From ebe60a4924a09bcc1ee3e5b5ec3a12d400cee9c6 Mon Sep 17 00:00:00 2001 From: Jonas <124611557+MrChocolate12@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:29:12 +0100 Subject: [PATCH 02/10] Complete German translation (192 missing entries) (#1863) I completed all missing translations and checked them in de.toml. Co-authored-by: Brandon Thomas --- crates/ui-egui/locales/de.toml | 192 +++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index e6d13bbea..b088b2d32 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1781,6 +1781,198 @@ status = "draft — needs native review" "⌘ click" = "⌘ Klick" "⌘ scroll" = "⌘ Scrollen" "⌥ drag" = "⌥ Ziehen" +"1 year" = "1 Jahr" +"30 days" = "30 Tage" +"90 days" = "90 Tage" +"A responsive product page: hero, features, figures, a quote and a footer" = "Eine responsive Produktseite: Hero-Bereich, Funktionen, Kennzahlen, ein Zitat und eine Fußzeile" +"A title, an agenda, a big figure, a chart and a closing slide" = "Ein Titel, eine Agenda, eine große Kennzahl, ein Diagramm und eine Abschlussfolie" +"A to Z" = "A bis Z" +"All the fonts this file uses are installed" = "Alle Schriften dieser Datei sind installiert" +"Allow and download" = "Erlauben und herunterladen" +"Allow downloading fonts?" = "Herunterladen von Schriften erlauben?" +"Alphabetical" = "Alphabetisch" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ein Analytics-Bildschirm auf einem Raster: Kennzahlen, aus Vektoren gezeichnete Diagramme und eine Tabelle" +"Android Vector Drawable" = "Android Vector Drawable" +"Apps" = "Apps" +"Apr" = "Apr." +"Aug" = "Aug." +"Best effort" = "Bestmöglich" +"Board grid" = "Boardraster" +"Bottom padding" = "Innenabstand unten" +"Bound to {name}. Click to detach the variable." = "An {name} gebunden. Klicken Sie, um die Variable zu lösen." +"Check for Updates" = "Nach Updates suchen" +"Check for updates…" = "Nach Updates suchen…" +"Checking the fonts installed on this computer…" = "Auf diesem Computer installierte Schriften werden geprüft…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Farb- und Textstile, Variablen für Hell und Dunkel, Buttons, Eingabefelder und Karten" +"Compatibility" = "Kompatibilität" +"Convert to frame" = "In Rahmen umwandeln" +"Convert to section" = "In Abschnitt umwandeln" +"Dart constants for Flutter…" = "Dart-Konstanten für Flutter…" +"Calculated from other variables; it follows them" = "Aus anderen Variablen berechnet; folgt ihnen" +"Copied comments for AI" = "Kommentare für KI kopiert" +"Copy effect" = "Effekt kopieren" +"Copy fill" = "Füllung kopieren" +"Copy for AI" = "Für KI kopieren" +"Copy stroke" = "Kontur kopieren" +"Couldn't download {name}" = "{name} konnte nicht heruntergeladen werden" +"Created" = "Erstellt" +"Crop image" = "Bild zuschneiden" +"Dashboard" = "Dashboard" +"Date created" = "Erstellungsdatum" +"Dec" = "Dez." +"Design system starter" = "Designsystem-Starter" +"Detach calculation" = "Berechnung lösen" +"Don't show this again" = "Nicht mehr anzeigen" +"Download" = "Herunterladen" +"Downloading… {percent}%" = "Wird heruntergeladen… {percent} %" +"Draw a frame" = "Rahmen zeichnen" +"Drop to use this image" = "Loslassen, um dieses Bild zu verwenden" +"Edited {when}" = "Bearbeitet {when}" +"Exact only" = "Nur exakt" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Rahmen und Ebenen als PNG, JPG, SVG oder PDF exportieren" +"Exported {name}; {n} kinds of content were changed or left out" = { one = "{name} exportiert; {n} Inhaltsart wurde geändert oder ausgelassen", other = "{name} exportiert; {n} Inhaltsarten wurden geändert oder ausgelassen" } +"Exported {n} files; some content was changed or left out" = { one = "{n} Datei exportiert; einige Inhalte wurden geändert oder ausgelassen", other = "{n} Dateien exportiert; einige Inhalte wurden geändert oder ausgelassen" } +"Feb" = "Feb." +"FigCraft stopped because of an internal error." = "FigCraft wurde wegen eines internen Fehlers beendet." +"File not found" = "Datei nicht gefunden" +"Format" = "Format" +"Four screens of a trail app with components and a prototype flow" = "Vier Bildschirme einer Wander-App mit Komponenten und einem Prototyp-Ablauf" +"Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." = "Kostenlose Schriften stammen von Google Fonts und Noto und werden über jsDelivr und GitHub geladen. Jede Datei wird anhand des Schriftenindex geprüft, bevor sie in den Schriftenordner installiert wird, den die Craft-Apps gemeinsam nutzen. Nichts wird heruntergeladen, bevor Sie es erlauben." +"Getting started" = "Erste Schritte" +"Graph paper" = "Rasterpapier" +"Greyscale components and two example screens to sketch a flow with" = "Komponenten in Graustufen und zwei Beispielbildschirme, um einen Ablauf zu skizzieren" +"Hide UI" = "Oberfläche ausblenden" +"Hide these tips (Getting started in the Help menu shows them again)" = "Diese Tipps ausblenden („Erste Schritte“ im Hilfemenü zeigt sie wieder an)" +"Hide when publishing" = "Beim Veröffentlichen ausblenden" +"Icon components on a 24 px grid with keylines and a size preview" = "Icon-Komponenten auf einem 24-px-Raster mit Keylines und einer Größenvorschau" +"Icon set starter" = "Icon-Set-Starter" +"Ignore unsupported" = "Nicht unterstützte ignorieren" +"Individual padding" = "Einzelne Innenabstände" +"Interface size" = "Größe der Oberfläche" +"Jan" = "Jan." +"Joining live session…" = "Live-Sitzung wird beigetreten…" +"Jul" = "Jul." +"Jun" = "Jun." +"Kits" = "Kits" +"Landing page" = "Landingpage" +"Last modified" = "Zuletzt geändert" +"Leaving the prototype" = "Prototyp wird verlassen" +"Left padding" = "Innenabstand links" +"Link frames on the Prototype tab, then present the flow" = "Verknüpfen Sie Rahmen im Tab „Prototyp“ und präsentieren Sie dann den Ablauf" +"Make a component" = "Komponente erstellen" +"Past 30 days" = "Letzte 30 Tage" +"Past 90 days" = "Letzte 90 Tage" +"Past year" = "Letztes Jahr" +"Mar" = "Mär." +"May" = "Mai" +"Missing fonts" = "Fehlende Schriften" +"Mobile app" = "Mobile App" +"Newest first" = "Neueste zuerst" +"No comments to copy" = "Keine Kommentare zum Kopieren" +"No files or templates match your search" = "Keine Dateien oder Vorlagen passen zu Ihrer Suche" +"No other pages" = "Keine weiteren Seiten" +"No templates match your search" = "Keine Vorlagen passen zu Ihrer Suche" +"Not now" = "Nicht jetzt" +"Nothing counted yet" = "Noch nichts gezählt" +"Nov" = "Nov." +"Rotate a degree (Shift: 15)" = "Um ein Grad drehen (Umschalt: 15)" +"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓" +"Ctrl+Alt+← ↑ → ↓" = "Strg+Alt+← ↑ → ↓" +"Oct" = "Okt." +"Oldest first" = "Älteste zuerst" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Eine Ankündigung in fünf gängigen Größen: Quadrat, Hochformat, Story, Link und Banner" +"Order" = "Reihenfolge" +"Other" = "Sonstige" +"Paste effect" = "Effekt einfügen" +"Paste fill" = "Füllung einfügen" +"Paste stroke" = "Kontur einfügen" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Wählen Sie das Rahmen-Werkzeug und ziehen Sie auf der Arbeitsfläche oder wählen Sie eine Gerätegröße" +"Plugin" = "Plugin" +"Plugins & widgets" = "Plugins & Widgets" +"Presentation slides" = "Präsentationsfolien" +"Playground" = "Testumgebung" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativ zur Anzeigeskalierung Ihres Systems (100 % ist die Größe des Systems). Der Dokumentzoom ist davon unabhängig." +"Reload the page to get back the unsaved files the browser had room for." = "Laden Sie die Seite neu, um die ungespeicherten Dateien zurückzuerhalten, für die der Browser Platz hatte." +"Reload the page to get your unsaved files back." = "Laden Sie die Seite neu, um Ihre ungespeicherten Dateien zurückzuerhalten." +"Reload the page to start again." = "Laden Sie die Seite neu, um von vorn zu beginnen." +"Remove all auto layout" = "Gesamtes Auto-Layout entfernen" +"Remove from recents" = "Aus „Zuletzt verwendet“ entfernen" +"Reorder selection" = "Auswahl neu anordnen" +"Reset grid" = "Raster zurücksetzen" +"Right padding" = "Innenabstand rechts" +"Search templates" = "Vorlagen suchen" +"See all templates" = "Alle Vorlagen ansehen" +"Select layers and they space themselves as their content changes" = "Wählen Sie Ebenen aus, und ihre Abstände passen sich an, wenn sich der Inhalt ändert" +"Search files and templates" = "Dateien und Vorlagen suchen" +"Search results" = "Suchergebnisse" +"This server doesn't count insertions" = "Dieser Server zählt keine Einfügungen" +"This server doesn't count styles and variables" = "Dieser Server zählt keine Stile und Variablen" +"Upload from computer" = "Vom Computer hochladen" +"Search pages" = "Seiten suchen" +"Sep" = "Sep." +"Server key" = "Serverschlüssel" +"Set corner smoothing to 60%" = "Eckenglättung auf 60 % setzen" +"Share with a code" = "Mit einem Code teilen" +"Show all templates" = "Alle Vorlagen anzeigen" +"The bars are hidden. {shortcut} shows them again" = "Die Leisten sind ausgeblendet. {shortcut} blendet sie wieder ein" +"Show in Explorer" = "Im Explorer anzeigen" +"Show in Finder" = "Im Finder anzeigen" +"Show in folder" = "Im Ordner anzeigen" +"Social and slides" = "Social Media und Folien" +"Social post sizes" = "Größen für Social-Media-Beiträge" +"Sort by" = "Sortieren nach" +"Sort by {column}" = "Nach {column} sortieren" +"Start from a template" = "Mit einer Vorlage beginnen" +"Start sharing with the Share button, then pass on the code it gives" = "Starten Sie das Teilen mit der Schaltfläche „Teilen“ und geben Sie dann den angezeigten Code weiter" +"Strong line every" = "Starke Linie alle" +"Text on a path" = "Text auf Pfad" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Das Fenster ist zu klein für {size} %, daher wird die Oberfläche mit {drawn} % dargestellt. Vergrößern Sie das Fenster, um {size} % zu verwenden." +"This file is too large to join here (up to {mb} MB)" = "Diese Datei ist zu groß, um hier beizutreten (bis zu {mb} MB)" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Diese Datei verwendet Schriften, die auf diesem Computer nicht installiert sind. Ihr Text wird in einer Ersatzschrift dargestellt, bis Sie sie installieren." +"This link opens a website outside the prototype:" = "Dieser Link öffnet eine Website außerhalb des Prototyps:" +"This link opens your mail app to write a message:" = "Dieser Link öffnet Ihre Mail-App, um eine Nachricht zu schreiben:" +"This server doesn't count detached instances" = "Dieser Server zählt keine gelösten Instanzen" +"Top padding" = "Innenabstand oben" +"Try another search, or browse templates." = "Versuchen Sie eine andere Suche oder stöbern Sie in den Vorlagen." +"Try another word, or look at every kind." = "Versuchen Sie ein anderes Wort oder sehen Sie sich alle Arten an." +"Turn a layer into a component, reuse it, and change every copy at once" = "Wandeln Sie eine Ebene in eine Komponente um, verwenden Sie sie wieder und ändern Sie alle Kopien auf einmal" +"Update check is not supported in the web browser." = "Die Update-Suche wird im Webbrowser nicht unterstützt." +"Use your browser's zoom to change the interface size." = "Ändern Sie die Größe der Oberfläche mit dem Zoom Ihres Browsers." +"Uses variables: each layer shows them in its own mode" = "Verwendet Variablen: Jede Ebene zeigt sie in ihrem eigenen Modus" +"Websites" = "Websites" +"What you try here changes this preview and the code below, not the file" = "Was Sie hier ausprobieren, ändert diese Vorschau und den Code darunter, nicht die Datei" +"Widget" = "Widget" +"Wireframe kit" = "Wireframe-Kit" +"Your unsaved files were too large for the browser to keep." = "Ihre ungespeicherten Dateien waren zu groß, als dass der Browser sie behalten konnte." +"Z to A" = "Z bis A" +"connector labels without a saved snapshot are centred on the line, without their background" = "Verbinder-Beschriftungen ohne gespeicherten Schnappschuss werden mittig auf der Linie und ohne Hintergrund dargestellt" +"elbowed and curved connectors without a saved path follow FigCraft's own routing" = "Abgewinkelte und gebogene Verbinder ohne gespeicherten Pfad folgen dem eigenen Routing von FigCraft" +"instance swap properties whose default component was past the layer limit were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente über dem Ebenenlimit lag, wurden übersprungen" +"instances whose component was past the layer limit were imported as frames" = "Instanzen, deren Komponente über dem Ebenenlimit lag, wurden als Rahmen importiert" +"invalid vector region fill paint list; layer fill used" = "Ungültige Füllungsliste der Vektorregion; Füllung der Ebene verwendet" +"invalid vector region paints were skipped" = "Ungültige Füllungen der Vektorregion wurden übersprungen" +"just now" = "gerade eben" +"linked vector region fill styles keep resolved appearance; live style links were left out" = "Verknüpfte Füllstile von Vektorregionen behalten ihr aufgelöstes Aussehen; Live-Stilverknüpfungen wurden ausgelassen" +"note or shape generated child has invalid bounds; child skipped" = "Vom Notiz- oder Formgenerator erzeugtes Kindobjekt hat ungültige Grenzen; Kindobjekt übersprungen" +"note or shape generated child is missing its style or bounds; child skipped" = "Dem vom Notiz- oder Formgenerator erzeugten Kindobjekt fehlen Stil oder Grenzen; Kindobjekt übersprungen" +"note or shape generated children exceed the layer or nesting limit; remaining children skipped" = "Vom Notiz- oder Formgenerator erzeugte Kindobjekte überschreiten das Ebenen- oder Verschachtelungslimit; übrige Kindobjekte übersprungen" +"note or shape generated layout is missing or unsupported; using its outer frame" = "Das vom Notiz- oder Formgenerator erzeugte Layout fehlt oder wird nicht unterstützt; äußerer Rahmen verwendet" +"only in a text style or variable" = "nur in einem Textstil oder einer Variable" +"shape generated outline is missing or damaged; using its rectangle" = "Die vom Formgenerator erzeugte Kontur fehlt oder ist beschädigt; Rechteck verwendet" +"slot layers without a matching slot property were imported as frames" = "Slot-Ebenen ohne passende Slot-Eigenschaft wurden als Rahmen importiert" +"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "An Variablen gebundene Füllungen von Vektorregionen behalten ihr aufgelöstes Aussehen; Live-Bindungen wurden ausgelassen" +"vector region paint data exceeded import limits and was skipped" = "Füllungsdaten der Vektorregion überschritten die Importgrenzen und wurden übersprungen" +"vector region references a missing fill style; layer fill used" = "Vektorregion verweist auf einen fehlenden Füllstil; Füllung der Ebene verwendet" +"yesterday" = "gestern" +"{insertions} · {detachments}" = "{insertions} · {detachments}" +"{month} {day}, {year}" = "{day}. {month} {year}" +"{n} detached" = { one = "{n} gelöst", other = "{n} gelöst" } +"{n} inserted" = { one = "{n} eingefügt", other = "{n} eingefügt" } +"{n} text layers" = { one = "{n} Textebene", other = "{n} Textebenen" } +"{percent}%" = "{percent} %" +"{period}: {counts}" = "{period}: {counts}" +"{used} · {detached}" = "{used} · {detached}" +"{used} · {inserted}" = "{used} · {inserted}" [commands] "align.bottom" = "Unten ausrichten" From 5d387bb3b482f749c279af960eec1e9069f63927 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:29:23 -0500 Subject: [PATCH 03/10] M2: fill container children share the free space by their content area, border-box fashion (#1824) (#1861) * M2: fill container children share the free space by their content area, border-box fashion (#1824) Co-Authored-By: Claude Fable 5.1 * Layout: rustfmt's line for the frozen-share closure Co-Authored-By: Claude Fable 5.1 * M2: one doc comment on distribute (review) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/layout/src/lib.rs | 43 +++++++++++++++++++++++-------- crates/layout/src/nested_tests.rs | 42 ++++++++++++++++++++++++++++++ docs/auto-layout-parity.md | 4 ++- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f13339c6f..56f4bf625 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -327,6 +327,10 @@ struct Item { max_main: Option, min_cross: Option, max_cross: Option, + /// The item's own insets along the main axis (an auto layout frame's padding and included + /// inside stroke): fill children share the free space by their content area, border-box + /// fashion, so these come on top of each one's share. + inset_main: f64, /// Baseline alignment: distance from the item's top to its first baseline. baseline: Option, /// Baseline alignment: offset from the line's top (computed per line). @@ -414,6 +418,10 @@ fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) }; let (min_main, max_main, min_cross, max_cross) = if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) }; + let inset_main = node.layout.as_ref().map_or(0.0, |al| { + let (pt, pr, pb, pl) = insets(node, al); + if horizontal { pl + pr } else { pt + pb } + }); items.push(Item { id: *child, main: if horizontal { w } else { h }, @@ -425,6 +433,7 @@ fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, max_main, min_cross, max_cross, + inset_main, baseline: None, base_off: 0.0, }); @@ -596,19 +605,31 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } } -/// Share `free` main-axis space among the fill items `open` like CSS flexbox: each takes an equal -/// share within its min/max; when clamping adds more than it removes, the items held at their min -/// keep it (otherwise those held at their max do) and the rest share what is left again. +/// Share `free` among the fill items `open` by their content areas: each one's own insets (an +/// auto layout frame's padding and included inside stroke) come off the top and go back on its +/// share, so a child with more padding or a thicker inside stroke takes a little more and the +/// content areas come out the same, as in the CSS border-box model ("Figma distributes space +/// amongst fill container children by the children's content area instead of by their size"). +/// Min and max sizes still clamp each item's outer size; the clamped ones are frozen and the rest +/// share what is left, as before. fn distribute(items: &mut [Item], mut open: Vec, free: f64) { let mut free = free.max(0.0); while !open.is_empty() { - let share = free / open.len() as f64; - let sized: Vec<(usize, f64)> = - open.iter().filter_map(|i| items.get(*i).map(|it| (*i, clamp_size(share, it.min_main, it.max_main)))).collect(); - let violation: f64 = sized.iter().map(|(_, v)| v - share).sum(); - let frozen = |v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > share + 1e-9) || (violation < 0.0 && v < share - 1e-9); - for (i, v) in &sized { - if frozen(*v) + let insets: f64 = open.iter().filter_map(|i| items.get(*i)).map(|it| it.inset_main).sum(); + let content_share = ((free - insets) / open.len() as f64).max(0.0); + let sized: Vec<(usize, f64, f64)> = open + .iter() + .filter_map(|i| { + items.get(*i).map(|it| { + let want = content_share + it.inset_main; + (*i, want, clamp_size(want, it.min_main, it.max_main)) + }) + }) + .collect(); + let violation: f64 = sized.iter().map(|(_, want, v)| v - want).sum(); + let frozen = |want: f64, v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > want + 1e-9) || (violation < 0.0 && v < want - 1e-9); + for (i, want, v) in &sized { + if frozen(*want, *v) && let Some(it) = items.get_mut(*i) { it.main = *v; @@ -616,7 +637,7 @@ fn distribute(items: &mut [Item], mut open: Vec, free: f64) { } } free = free.max(0.0); - open = sized.into_iter().filter(|(_, v)| !frozen(*v)).map(|(i, _)| i).collect(); + open = sized.into_iter().filter(|(_, want, v)| !frozen(*want, *v)).map(|(i, _, _)| i).collect(); } } diff --git a/crates/layout/src/nested_tests.rs b/crates/layout/src/nested_tests.rs index 4b77b3b94..2a51e282e 100644 --- a/crates/layout/src/nested_tests.rs +++ b/crates/layout/src/nested_tests.rs @@ -287,6 +287,48 @@ fn padding_and_included_strokes() { assert_eq!(pos(&d, c), (15.0, 15.0, 70.0, 20.0)); } +/// "Figma distributes space amongst fill container children by the children's content area +/// instead of by their size, matching the CSS border-box model. A layer with a thicker stroke will +/// take up slightly more of the available width or height, so that its inner content area matches +/// its sibling's." (Use auto layout with CSS Flexbox in mind) +#[test] +fn fill_children_share_by_content_area() { + let mut d = Document::new("t"); + let p = page(&d); + let f = al(&mut d, p, LayoutMode::Horizontal, (200.0, 50.0), (Sizing::Fixed, Sizing::Fixed)); + layout_mut(&mut d, f).gap = 0.0; + let a = al(&mut d, f, LayoutMode::Horizontal, (10.0, 50.0), (Sizing::Fill, Sizing::Fixed)); + let b = al(&mut d, f, LayoutMode::Horizontal, (10.0, 50.0), (Sizing::Fill, Sizing::Fixed)); + layout_mut(&mut d, b).padding = [0.0, 20.0, 0.0, 20.0]; + layout_frame(&mut d, f, &NoMeasure); + // 200 less b's 40 of padding leaves 160 of content, 80 each: a is 80 wide, b 120. + assert_eq!((size(&d, a).0, size(&d, b).0), (80.0, 120.0)); + assert_eq!(pos(&d, b).0, 80.0); + // A 10 px inside stroke counted in b's layout instead: 90 and 110. + layout_mut(&mut d, b).padding = [0.0; 4]; + { + let n = d.get_mut(b).unwrap(); + n.strokes = vec![figcraft_doc::color::Paint::solid(figcraft_doc::color::Color::BLACK)]; + n.stroke.weight = 10.0; + n.stroke.align = StrokeAlign::Inside; + } + layout_mut(&mut d, b).strokes_included = true; + layout_frame(&mut d, f, &NoMeasure); + assert_eq!((size(&d, a).0, size(&d, b).0), (90.0, 110.0)); + // A stroke not counted in the layout is no inset: an even split, as before. + layout_mut(&mut d, b).strokes_included = false; + layout_frame(&mut d, f, &NoMeasure); + assert_eq!((size(&d, a).0, size(&d, b).0), (100.0, 100.0)); + // Equal insets: still even. A max on a freezes it there and b takes the rest, insets kept. + layout_mut(&mut d, a).padding = [0.0, 20.0, 0.0, 20.0]; + layout_mut(&mut d, b).padding = [0.0, 20.0, 0.0, 20.0]; + layout_frame(&mut d, f, &NoMeasure); + assert_eq!((size(&d, a).0, size(&d, b).0), (100.0, 100.0)); + d.get_mut(a).unwrap().child_layout.max_w = Some(60.0); + layout_frame(&mut d, f, &NoMeasure); + assert_eq!((size(&d, a).0, size(&d, b).0), (60.0, 140.0)); +} + #[test] fn grid_hug_tracks_and_wrapping_text_rows() { use figcraft_doc::GridTrack; diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index f481fc7ab..61462a2c1 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -38,6 +38,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Hug: the frame sizes to its children plus padding (the 40 px text + 10 px padding = 60 px example) | pass | `sizing_rules` | | Fixed: the frame keeps its size whatever its children need | pass | `sizing_rules` | | Fill: children share the space left after padding, gaps and fixed siblings; across the flow they stretch | pass | `sizing_rules` | +| Fill children share by their content area (border-box): a child with more padding or a thicker included inside stroke takes a little more, so the content areas come out the same | **fixed** | "Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model. A layer with a thicker stroke will take up slightly more of the available width or height, so that its inner content area matches its sibling's" (Use auto layout with CSS Flexbox in mind); the split went by outer size before. Min and max still clamp the outer size; a child's own insets are its padding and its included inside stroke (`nested_tests::fill_children_share_by_content_area`; #1824) | | Fill on a child turns its hugging parent Fixed on that axis | pass | `al_behaviour_tests::fill_on_a_child_turns_the_hugging_parent_fixed` | | Hug only on auto layout frames (and text); Fill only in an auto layout flow, never on a top-level frame | pass | `al_behaviour_tests::hug_and_fill_only_where_they_apply` | | Min/max width and height bound Hug, Fill and Fixed sizes, on frames and children | pass | `sizing_rules`; a fill child at its max leaves the rest to the other fill children (silent on redistribution: CSS flexbox) | @@ -97,6 +98,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` | | 2026-11-01 | patch | M2: a text layer keeps a max height or a number of max lines, not both ("Text layers cannot have both a max height and a set number of max lines", Guide to auto layout): `node.set {maxHeight}` on a text sets its max lines to Auto, and `node.set {maxLines}` or `text.maxLines` with a number removes its max height. Found by a documentation audit of this page. Test: `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both`. Numbers unchanged | | 2026-11-01 | minor | M2: Remove all auto layout ("To remove auto layout from a frame and all of its nested objects at once, right-click on the frame and go to More layout options > Remove all auto layout", Toggle on auto layout in designs): `layout.removeAll {ids?}` strips the frames and every frame nested in them in one undo step, leaving instances and their layers alone; in the canvas context menu beside Add auto layout and in the Actions palette. Found by a documentation audit of this page. Test: `al_nesting_tests::remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it`. Numbers unchanged | | 2026-10-30 | minor | M8, #1083: grid placement finds free cells on a bitmap, a word at a time, with the same placements; crafted grids of wide children no longer stall a layout pass. Numbers unchanged. | From 33589610334929e5d8a7059e8f2332952e76283a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:29:33 -0500 Subject: [PATCH 04/10] M4: Backspace or Delete at a list item's start keeps its indentation level (#1862) * M4: Backspace or Delete at a list item's start keeps its indentation level Co-Authored-By: Claude Fable 5.1 * Lists: the level is kept by Backspace and Delete alone, and the tests read the text's own glyph Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/doc/src/text.rs | 18 +++++++++-- crates/engine/src/canvas/textedit.rs | 45 +++++++++++++++++++--------- crates/engine/src/text_tail_tests.rs | 22 ++++++++++++++ crates/text/src/behaviour_tests.rs | 32 +++++++++++++++++++- crates/text/src/layout.rs | 12 ++++---- docs/ui-parity.md | 5 ++-- 6 files changed, 110 insertions(+), 24 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0ba0aeeca..8a7253e9e 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -251,7 +251,10 @@ pub const MAX_LIST_INDENT: u8 = 8; #[serde(default)] pub struct ParaList { pub list: ListStyle, - /// Nesting level (0 = outermost; plain paragraphs are always 0). + /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by + /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level + /// it had and starts where that level's item text does ("delete the counter, but keep the + /// same level of indentation"). pub indent: u8, /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` /// (items that go on counting ignore it; other paragraphs are always 0). @@ -798,11 +801,22 @@ impl TextData { /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { + self.set_para_lists_with(start, end, false, f); + } + + /// Take the list items among the paragraphs chars `start..end` touch out of their list at the + /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but + /// keep the same level of indentation" (Create bulleted and numbered lists). + pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { + self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); + } + + fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { let (a, b) = self.para_span(start, end); self.fill_paragraphs(); for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { f(pl); - if pl.list == ListStyle::None { + if pl.list == ListStyle::None && !keep_levels { pl.indent = 0; } if pl.list != ListStyle::Numbered { diff --git a/crates/engine/src/canvas/textedit.rs b/crates/engine/src/canvas/textedit.rs index d7a70e5c7..368a48c90 100644 --- a/crates/engine/src/canvas/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -303,9 +303,21 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result Result { let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; let t = text_of(s, te.id)?; @@ -315,19 +327,24 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result { let item = t.para_list(t.para_of(a)).list != ListStyle::None; let (pa, pb) = t.para_span(a, b); let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None); - let deeper = match key { - "Tab" if any_item => !shift, - "Backspace" if item && at_start => false, - "Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false, + let mv = match key { + "Tab" if any_item && !shift => ListMove::Deeper, + "Tab" if any_item => ListMove::Out, + "Backspace" | "Delete" if item && at_start => ListMove::Unlist, + "Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => ListMove::Out, _ => return Ok(false), }; edit(s, |t, _| { - t.set_para_lists(a, b, |pl| match pl.list { - ListStyle::None => {} - _ if deeper => pl.indent = pl.indent.saturating_add(1), - _ if pl.indent > 0 => pl.indent -= 1, - _ => pl.list = ListStyle::None, - }) + if mv == ListMove::Unlist { + t.unlist_keeping_levels(a, b); + } else { + t.set_para_lists(a, b, |pl| match (pl.list, mv) { + (ListStyle::None, _) | (_, ListMove::Unlist) => {} + (_, ListMove::Deeper) => pl.indent = pl.indent.saturating_add(1), + (_, ListMove::Out) if pl.indent > 0 => pl.indent -= 1, + (_, ListMove::Out) => pl.list = ListStyle::None, + }); + } })?; Ok(true) } @@ -411,7 +428,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R let t = text_of(s, te.id)?; let chars: Vec = t.characters.chars().collect(); let n = chars.len(); - if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? { + if matches!(key, "Tab" | "Backspace" | "Delete" | "Enter") && !word && !line && list_key(s, key, shift)? { return Ok(()); } match key { diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index a94852093..8e399ba3a 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -129,6 +129,28 @@ fn list_keys_at_list_items() { "silent: Backspace at an item's start removes its bullet", ), ("a\nbc", vec![(B, 0), (B, 0)], 4, "Backspace", false, "a\nb", vec![(B, 0), (B, 0)], "Backspace inside an item deletes text"), + ( + "a\nb", + vec![(B, 0), (B, 1)], + 2, + "Backspace", + false, + "a\nb", + vec![(B, 0), (P, 1)], + "Backspace at a nested item's start removes its bullet and keeps its level", + ), + ("a\nb", vec![(N, 0), (N, 2)], 2, "Delete", false, "a\nb", vec![(N, 0), (P, 2)], "Delete at an item's start does the same"), + ("a\nbc", vec![(B, 0), (B, 1)], 3, "Delete", false, "a\nb", vec![(B, 0), (B, 1)], "Delete inside an item deletes text"), + ( + "a\nb", + vec![(B, 0), (P, 1)], + 2, + "Tab", + false, + "a\n\tb", + vec![(B, 0), (P, 1)], + "a plain paragraph at a level isn't an item: Tab types a tab", + ), ("a\nb", vec![(P, 0), (P, 0)], 3, "Tab", false, "a\nb\t", vec![(P, 0), (P, 0)], "outside a list Tab types a tab"), ("a", vec![(B, 8)], 1, "Tab", false, "a", vec![(B, 8)], "silent: nesting stops at 8 levels"), ]; diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index fe63e3866..439a81fcf 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -9,7 +9,7 @@ use super::*; use crate::breaks::{break_between, grapheme_starts}; -use figcraft_doc::{LeadingTrim, LetterSpacing, LineHeight, TextAlignH, TextAutoResize, TextData, TextDecoration}; +use figcraft_doc::{LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextData, TextDecoration}; /// The default face once system fonts are indexed (a Latin face where the system has one, so CJK /// and emoji come from fallback faces), or None (skip) on a machine without fonts. @@ -100,6 +100,36 @@ fn letter_spacing_percent_and_pixels() { } } +/// "Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the +/// same level of indentation" (Create bulleted and numbered lists): a plain paragraph at a list +/// level starts where that level's item text starts, even once no list is left; a plain +/// paragraph at level 0 starts at the box as always. +#[test] +fn a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does() { + if face().is_none() { + return; + } + let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0 }; + let mut t = text("a\nb\nc", TextAutoResize::WidthAndHeight); + t.paragraphs = vec![level(ListStyle::Bulleted, 1), level(ListStyle::None, 1), level(ListStyle::None, 0)]; + let l = layout(&t, None, None); + // Where a line's text starts: its first character's glyph (the indent is in the glyphs); a + // list marker shares that character and sits in the gutter to its left, so the text is the + // rightmost of them. + let text_x = |l: &TextLayout, li: usize| { + l.glyphs.iter().filter(|g| g.char_start == l.lines[li].char_start).map(|g| g.x).fold(f64::NAN, |a, b| if a.is_nan() { b } else { a.max(b) }) + }; + let (x0, x1, x2) = (text_x(&l, 0), text_x(&l, 1), text_x(&l, 2)); + assert!((x1 - x0).abs() < EPS, "the plain paragraph keeps the item's text start: {x1} vs {x0}"); + assert!(x0 > 20.0, "a nested item's text starts two gutters in: {x0}"); + assert!(x2.abs() < EPS, "a level-0 plain paragraph starts at the box: {x2}"); + // No list left at all: the level still indents, by the same gutters. + let mut alone = text("b", TextAutoResize::WidthAndHeight); + alone.paragraphs = vec![level(ListStyle::None, 1)]; + let a = layout(&alone, None, None); + assert!((text_x(&a, 0) - x1).abs() < EPS, "{} vs {x1}", text_x(&a, 0)); +} + #[test] fn paragraph_spacing_and_indent() { if face().is_none() { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7f5872fdf..51dd6d06b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -951,10 +951,12 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). let line_indent_of = |p: usize, first: bool, ci: usize| { let pl = list_of(p); + let level = step * (f64::from(pl.indent) + 1.0) - hang_list; match pl.list { - ListStyle::None if first => indent_at(ci), - ListStyle::None => 0.0, - _ => step * (f64::from(pl.indent) + 1.0) - hang_list, + // A plain paragraph that kept its level on leaving a list starts where that level's + // item text does; paragraph indent goes on its first line as on any plain paragraph. + ListStyle::None => (if pl.indent > 0 { level } else { 0.0 }) + if first { indent_at(ci) } else { 0.0 }, + _ => level, } }; // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge @@ -1237,9 +1239,9 @@ fn roman(mut n: usize) -> String { } /// The width of one list level's gutter: room for the widest number plus half an em, at least one -/// and a half ems (0 without lists). +/// and a half ems (0 without lists, or plain paragraphs that kept a list level). fn list_step(lists: &[ParaList], markers: &[String], style: Option<&TextStyle>, face: Option<&Arc>) -> f64 { - if lists.iter().all(|l| l.list == ListStyle::None) { + if lists.iter().all(|l| l.list == ListStyle::None && l.indent == 0) { return 0.0; } let size = style.map(|s| sanitize_size(s.font_size)).unwrap_or(12.0); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3963e4624..b4f3d9023 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-11-01 · **Change:** minor (the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -89,7 +89,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | -| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | @@ -176,6 +176,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | | 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged | From e9eb7ce7f43b298b44233b55eb0dccdf303addb2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:29:59 -0500 Subject: [PATCH 05/10] M4: max lines on auto-width text keep it auto width, hugging that many lines (#1864) * M4: max lines on auto-width text keep it auto width, hugging that many lines Co-Authored-By: Claude Fable 5.1 * M4: an auto-width text with max lines hugs the ellipsis on its last line, and the editing test expects auto width Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/doc/src/text.rs | 8 +++--- crates/engine/src/canvas/textedit/tests.rs | 2 +- crates/engine/src/cmd/text.rs | 4 +-- crates/engine/src/text_behaviour_tests.rs | 30 +++++++++++++++++++++- crates/text/src/layout.rs | 2 ++ docs/ui-parity.md | 5 ++-- 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8a7253e9e..0c41d3c9a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -609,12 +609,14 @@ impl TextData { } /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height - /// with a line limit (the box hugs up to `max_lines` lines). + /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is + /// available only if truncate text is enabled, and: Text resizing is set to auto height or + /// auto width"). pub fn truncates(&self) -> bool { match self.auto_resize { TextAutoResize::Truncate => true, - TextAutoResize::Height => self.max_lines > 0, - TextAutoResize::WidthAndHeight | TextAutoResize::None => false, + TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, + TextAutoResize::None => false, } } diff --git a/crates/engine/src/canvas/textedit/tests.rs b/crates/engine/src/canvas/textedit/tests.rs index fecca5808..81756901d 100644 --- a/crates/engine/src/canvas/textedit/tests.rs +++ b/crates/engine/src/canvas/textedit/tests.rs @@ -242,7 +242,7 @@ fn paragraph_and_feature_commands_on_nodes() { s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); let t = data(&s, id); assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); - assert_eq!(t.auto_resize, TextAutoResize::Truncate); + assert_eq!(t.auto_resize, TextAutoResize::WidthAndHeight, "max lines keep an auto-width text auto width"); assert_eq!(t.style.decoration, TextDecoration::Strikethrough); assert_eq!(t.style.case, TextCase::SmallCaps); assert_eq!(t.list, ListStyle::Bulleted); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b507afd6b..446b01d29 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -319,13 +319,13 @@ pub fn specs() -> Vec { spec!( "text.maxLines", "Max lines", - "{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}", + "{lines (0 = no limit; truncates: auto-height and auto-width text hug up to that many lines, fixed-size text becomes a truncating box), ids?}", true, |s, p| { let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32; let r = para(s, p, |t| { t.max_lines = v; - if v > 0 && t.auto_resize != TextAutoResize::Height { + if v > 0 && !matches!(t.auto_resize, TextAutoResize::Height | TextAutoResize::WidthAndHeight) { t.auto_resize = TextAutoResize::Truncate; } })?; diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 9ae7dd62c..ab62136c4 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -365,12 +365,40 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() { s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap(); s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap(); assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0)); - // Other text still becomes a truncating box with max lines. + // Fixed-size text still becomes a truncating box with max lines. let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100})); s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap(); assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate); } +#[test] +fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() { + // "Max lines is available only if truncate text is enabled, and: Text resizing is set to + // auto height or auto width" (Explore text properties): an auto-width text with max lines + // stays auto width, growing with its longest line and hugging up to that many lines. + use figcraft_doc::TextAutoResize; + // The synthetic font: every character 8 px wide at 16 px, so the widths are the same + // everywhere and never depend on what the system has installed. + let font = test_mono_font(); + let mut s = Session::new(); + let t = text(&mut s, "one\ntwo\nthree", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24})); + assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight); + assert_eq!(size(&s, t), (40.0, 72.0), "three lines tall, as wide as 'three'"); + s.execute("text.maxLines", &json!({"ids": [t], "lines": 2})).unwrap(); + assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight, "max lines keep auto width"); + assert_eq!(size(&s, t), (32.0, 48.0), "two lines tall, as wide as the widest line it shows with its ellipsis (two…), not the hidden third"); + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(2)); + // Still auto width: a longer line widens the box. + s.execute("text.edit.begin", &json!({"id": t, "caret": 3})).unwrap(); + s.execute("text.edit.insert", &json!({"text": " and a longer line"})).unwrap(); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(size(&s, t), (168.0, 48.0), "the box grew with its longest line (21 characters)"); + assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight); + // No limit: all of its lines again. + s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap(); + assert_eq!(size(&s, t).1, 72.0); +} + /// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of /// 1000 units wide), loaded into the font database so text measures the same on every machine. fn test_mono_font() -> &'static str { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 51dd6d06b..5d3d3c27f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -798,6 +798,8 @@ fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) { } } add_ellipsis(out, wrap); + // The ellipsis can make the last line the widest: an auto-width box hugs it too. + out.width = out.width.max(out.lines.last().map_or(0.0, |l| l.width)); // Carets and selections reach every char the line shows, borrowed ones included. if let Some(l) = out.lines.last() { let shown = diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b4f3d9023..aa1c4510e 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-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -89,7 +89,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | -| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | @@ -177,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | +| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | | 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged | From c44cfc94af9c1efa47f3359932245480cfdfaed8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:30:50 -0500 Subject: [PATCH 06/10] M4: superscript and subscript are synthesised where the font has no glyphs for them (#1828) (#1868) * M4: superscript and subscript are synthesised where the font has no glyphs for them (#1828) Co-Authored-By: Claude Fable 5.1 * Text: a zero OS/2 script offset counts as unset, and the native-glyph check sees the case-mapped characters Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/engine/src/text_behaviour_tests.rs | 40 ++++++++++ crates/text/src/fontdb.rs | 18 +++++ crates/text/src/layout.rs | 92 +++++++++++++++-------- crates/text/src/tests.rs | 43 +++++++++++ docs/ui-parity.md | 4 +- 5 files changed, 163 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index ab62136c4..bed6b3d6b 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -399,6 +399,46 @@ fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() { assert_eq!(size(&s, t).1, 72.0); } +#[test] +fn superscript_and_subscript_are_synthesised_without_glyphs_for_them() { + // "If it isn't provided, Figma draws a synthesized version by using the font's standard + // version of the character, shrinking it, and positioning it accordingly" (Explore text + // properties). The synthetic font has no sups or subs feature and no OS/2 metrics, so the + // usual 0.65 em at 0.35 em up (superscript) or 0.14 em down (subscript) apply. + let font = test_mono_font(); + let mut s = Session::new(); + let t = text(&mut s, "x2", json!({"fontFamily": font, "fontSize": 40})); + let glyphs = |s: &Session| -> Vec<(f64, f64, f64)> { + let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap(); + l.glyphs.iter().map(|g| (g.size, g.y, g.advance)).collect() + }; + let plain = glyphs(&s); + assert_eq!(plain.len(), 2); + assert_eq!((plain[0].0, plain[0].2), (40.0, 20.0)); + assert_eq!(size(&s, t).0, 40.0); + s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": true}})).unwrap(); + let sup = glyphs(&s); + for (g, p) in sup.iter().zip(&plain) { + assert!((g.0 - 26.0).abs() < 1e-6 && (g.2 - 13.0).abs() < 1e-6, "shrunk to 0.65 em, the advance with it: {g:?}"); + assert!((p.1 - g.1 - 14.0).abs() < 1e-6, "raised 0.35 em: {g:?} against {p:?}"); + } + assert!((size(&s, t).0 - 26.0).abs() < 1e-6, "the auto-width box hugs the smaller glyphs: {:?}", size(&s, t)); + s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": true}})).unwrap(); + let sub = glyphs(&s); + for (g, p) in sub.iter().zip(&plain) { + assert!((g.0 - 26.0).abs() < 1e-6 && (g.1 - p.1 - 5.6).abs() < 1e-6, "shrunk, and lowered 0.14 em: {g:?} against {p:?}"); + } + // Capitalised, the same: the synthesis follows the characters shaping saw. + s.execute("node.set", &json!({"id": t, "props": {"textCase": "upper"}})).unwrap(); + s.execute("text.setFeatures", &json!({"ids": [t], "features": {"subs": null, "sups": true}})).unwrap(); + for (g, p) in glyphs(&s).iter().zip(&plain) { + assert!((g.0 - 26.0).abs() < 1e-6 && (p.1 - g.1 - 14.0).abs() < 1e-6, "upper case, superscript: {g:?} against {p:?}"); + } + s.execute("node.set", &json!({"id": t, "props": {"textCase": "original"}})).unwrap(); + s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": false}})).unwrap(); + assert_eq!(glyphs(&s), plain, "off again"); +} + /// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of /// 1000 units wide), loaded into the font database so text measures the same on every machine. fn test_mono_font() -> &'static str { diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c6aac948..d0004fcd9 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -102,6 +102,11 @@ pub struct Face { pub underline_pos: f64, pub underline_thickness: f64, pub strike_pos: f64, + /// Synthesised superscript and subscript (the OS/2 table's values, or the usual ones without + /// it): the glyph size as a fraction of the em, and the baseline shift up in font units + /// (negative for a subscript, which sits below). + pub superscript: (f64, f64), + pub subscript: (f64, f64), pub shaper: harfrust::ShaperData, /// OpenType GSUB feature tags the face provides (e.g. `smcp`). gsub_features: Vec<[u8; 4]>, @@ -179,6 +184,17 @@ impl Face { let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3); + // Superscript and subscript sizes and offsets: the OS/2 table's, else the usual 0.65 em, + // 0.35 em up and 0.14 em down (what common fonts carry); a zero counts as unset, as some + // fonts leave these fields. + let os2 = f.os2().ok(); + let script = |size: Option, offset: Option, up: bool, usual_offset: f64| -> (f64, f64) { + let size = size.map(f64::from).filter(|v| *v > 0.0).map_or(0.65, |v| v / upem); + let offset = offset.map(f64::from).filter(|v| *v != 0.0).unwrap_or(usual_offset * upem); + (size, if up { offset } else { -offset }) + }; + let superscript = script(os2.as_ref().map(|t| t.y_superscript_y_size()), os2.as_ref().map(|t| t.y_superscript_y_offset()), true, 0.35); + let subscript = script(os2.as_ref().map(|t| t.y_subscript_y_size()), os2.as_ref().map(|t| t.y_subscript_y_offset()), false, 0.14); let weight = { let w = attrs.weight.value(); if w.is_finite() && w > 0.0 { w.round() as u16 } else { figcraft_doc::style_weight(&style) } @@ -200,6 +216,8 @@ impl Face { underline_pos: under.0, underline_thickness: under.1.max(upem * 0.02), strike_pos: strike, + superscript, + subscript, shaper, gsub_features, track, diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 5d3d3c27f..7305dac15 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1418,39 +1418,65 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe // Variable fonts: the axis values the style asks for (named instance, implied weight, custom). let values = face.axis_values(style); let coords = face.coords(&values); - let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new(); - if let Some(hb) = face.hb() { - let instance = (!coords.is_empty()).then(|| { - let vars = face - .axes - .iter() - .zip(&values) - .filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 })); - ShaperInstance::from_variations(&hb, vars) - }); - let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build(); - let mut buf = UnicodeBuffer::new(); - for (u, src, _) in &mapped { - buf.add(*u, *src as u32); - } - let rtl = seg.level % 2 == 1; - buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); - buf.guess_segment_properties(); - let gb = shaper.shape(buf, ShapeOptions::new().features(&feats)); - for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { - raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); - } - // Right-to-left glyphs come out in visual order; items are kept in logical order (lines - // put runs back in visual order). - if rtl { - raw.reverse(); - } - } else { - for (u, src, _) in &mapped { - let gid = face.glyph_for(*u); - raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0)); + let shape = |feats: &[Feature]| -> Vec<(u32, u32, i32, i32, i32)> { + let mut raw = Vec::new(); + if let Some(hb) = face.hb() { + let instance = (!coords.is_empty()).then(|| { + let vars = face + .axes + .iter() + .zip(&values) + .filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 })); + ShaperInstance::from_variations(&hb, vars) + }); + let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + for (u, src, _) in &mapped { + buf.add(*u, *src as u32); + } + let rtl = seg.level % 2 == 1; + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + buf.guess_segment_properties(); + let gb = shaper.shape(buf, ShapeOptions::new().features(feats)); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset)); + } + // Right-to-left glyphs come out in visual order; items are kept in logical order + // (lines put runs back in visual order). + if rtl { + raw.reverse(); + } + } else { + for (u, src, _) in &mapped { + let gid = face.glyph_for(*u); + raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0)); + } } + raw + }; + let mut raw = shape(&feats); + // Superscript or subscript (`sups` / `subs` on the node): the face's own glyphs where it has + // the feature and every letter or digit here got one; otherwise synthesised from the standard + // glyphs, shrunk and shifted ("Figma draws a synthesized version by using the font's standard + // version of the character, shrinking it, and positioning it accordingly"; a run mixing + // available and unavailable glyphs is synthesised whole, "for consistency"). + let script = [(b"sups", true), (b"subs", false)].into_iter().find(|(tag, _)| feats.iter().any(|f| f.tag == Tag::new(tag) && f.value != 0)); + // The glyph a cluster's character has on its own, after the case mapping shaping saw. + let plain_gid = |cl: usize| mapped.iter().find(|(_, src, _)| *src == cl).map(|(u, _, _)| face.glyph_for(*u)); + let unchanged = |raw: &[(u32, u32, i32, i32, i32)]| { + raw.iter().any(|&(gid, cl, ..)| { + let cl = cl as usize; + chars.get(cl).is_some_and(|c| c.is_alphanumeric()) && plain_gid(cl) == Some(gid) + }) + }; + let synth = script.filter(|(tag, _)| !face.has_feature(tag) || unchanged(&raw)); + if let Some((tag, _)) = synth + && face.has_feature(tag) + { + let plain: Vec = feats.iter().filter(|f| f.tag != Tag::new(tag)).cloned().collect(); + raw = shape(&plain); } + let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript }); let n = raw.len(); for gi in 0..n { let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue }; @@ -1459,7 +1485,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1); let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl); let ch = chars.get(cl).copied().unwrap_or(' '); - let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 }; + let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale; let spacing = if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 }; // The glyph's own advance and what shaping added to it (kerning) each land on the grid. let own = face.advance_at(gid, &coords); @@ -1472,7 +1498,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe adv, spacing, dx: f64::from(xo) * k * scale, - dy: f64::from(yo) * k * scale, + dy: f64::from(yo) * k * scale + script_shift * k, char_start: cl, char_end: cend, style: seg.st_idx, diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e643ccaee..6d341419c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -157,6 +157,49 @@ fn small_caps_native_or_synthesised() { assert_eq!(sc.glyphs.iter().find(|g| g.char_start == 0).unwrap().gid, face.glyph_for('A')); } +#[test] +fn superscript_native_or_synthesised_and_never_mixed() { + // "Figma tries to use any superscript/subscript glyphs provided by the font. If it isn't + // provided, Figma draws a synthesized version by using the font's standard version of the + // character, shrinking it, and positioning it accordingly." and a layer mixing both "will all + // update to the synthesized form for consistency" (Explore text properties). + let Some(face) = inter() else { return }; + let mut t = TextData::new("x2"); + t.style.font_size = 20.0; + let plain = layout(&t, None, None); + t.features.insert("sups".into(), true); + let sup = layout(&t, None, None); + let at = |l: &TextLayout, i: usize| l.glyphs.iter().find(|g| g.char_start == i).cloned().unwrap(); + let native = |i: usize| at(&sup, i).gid != at(&plain, i).gid && (at(&sup, i).size - 20.0).abs() < 1e-9; + let synthesised = |i: usize| at(&sup, i).gid == at(&plain, i).gid && at(&sup, i).size < 20.0 && at(&sup, i).y < at(&plain, i).y; + assert!((native(0) && native(1)) || (synthesised(0) && synthesised(1)), "both glyphs the same way: {:?} {:?}", at(&sup, 0), at(&sup, 1)); + if !face.has_feature(b"sups") { + assert!(synthesised(0) && synthesised(1), "no sups in the face: synthesised"); + } + t.features.insert("sups".into(), false); + t.features.insert("subs".into(), true); + let sub = layout(&t, None, None); + let g = at(&sub, 1); + let p = at(&plain, 1); + assert!((g.gid != p.gid && (g.size - 20.0).abs() < 1e-9) || (g.size < 20.0 && g.y > p.y), "subscript: native, or shrunk and lowered: {g:?}"); + // Capitalised, the same holds: the check against the face's own glyphs sees the characters + // shaping saw (an X, not an x), so a face with a superscript 2 and no superscript X + // synthesises both rather than mixing them. + t.features.clear(); + t.style.case = figcraft_doc::TextCase::Upper; + let plain_up = layout(&t, None, None); + t.features.insert("sups".into(), true); + let up = layout(&t, None, None); + let native_up = |i: usize| at(&up, i).gid != at(&plain_up, i).gid && (at(&up, i).size - 20.0).abs() < 1e-9; + let synth_up = |i: usize| at(&up, i).gid == at(&plain_up, i).gid && at(&up, i).size < 20.0 && at(&up, i).y < at(&plain_up, i).y; + assert!( + (native_up(0) && native_up(1)) || (synth_up(0) && synth_up(1)), + "upper case: both glyphs the same way: {:?} {:?}", + at(&up, 0), + at(&up, 1) + ); +} + #[test] fn title_case_across_runs() { let Some(face) = inter() else { return }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index aa1c4510e..d936c9408 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-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -90,6 +90,7 @@ interaction feel not yet compared. | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | @@ -178,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look |---|---|---| | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | | 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` | +| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | | 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged | From 9f09b7163cfda16241b6693b01510b8cafd83a0a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:31:00 -0500 Subject: [PATCH 07/10] M2: a double-click on an auto layout edge sets Hug contents, with Alt Fill container (#1838) (#1867) * M2: a double-click on an auto layout edge sets Hug contents, with Alt Fill container (#1838) Co-Authored-By: Claude Fable 5.1 * Auto layout edges: a text child fills with Alt too, and still enters on a plain double-click Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/engine/src/canvas/tools/click.rs | 31 +++++++++++ crates/engine/src/double_click_tests.rs | 74 +++++++++++++++++++++++++ crates/engine/src/keymap.rs | 2 + crates/ui-egui/locales/en.toml | 5 ++ docs/auto-layout-parity.md | 4 +- 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs index 0e07b331d..a48b215bc 100644 --- a/crates/engine/src/canvas/tools/click.rs +++ b/crates/engine/src/canvas/tools/click.rs @@ -22,6 +22,29 @@ impl Session { } } + /// A double-click on an edge handle of the one selected layer auto layout sizes: Hug contents + /// on that axis for an auto layout frame, Fill container with Alt for a child of one (a + /// vertical edge sizes the width, a horizontal one the height). Whether it applied: on any + /// other layer, or off the edges, the double-click goes on as before. + fn edge_double_click(&mut self, doc: &Document, sel: &[NodeId], ev: &PointerEvent) -> Result { + let [id] = sel[..] else { return Ok(false) }; + let Some(super::Handle::Edge(edge)) = super::handles::handle_at(doc, sel, ev.p(), ev.zoom) else { return Ok(false) }; + let Some(n) = doc.node(id) else { return Ok(false) }; + let applies = if ev.alt { + !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) + } else { + n.layout.is_some() + }; + if !applies { + return Ok(false); + } + // Edges run top, right, bottom, left: the odd ones are vertical and size the width. + let mut props = serde_json::Map::new(); + props.insert(if edge % 2 == 1 { "sizingH" } else { "sizingV" }.to_string(), json!(if ev.alt { "fill" } else { "hug" })); + self.execute("node.set", &json!({"id": id, "props": props}))?; + Ok(true) + } + pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -61,6 +84,14 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } + // An edge of an auto layout object's bounding box: Hug contents on that axis, with Alt + // Fill container ("Tip: Double-click the vertical or horizontal edge of an auto layout + // object's bounding box to set it to Hug contents." and "Hold ⌥ Option or Alt and + // double-click the vertical or horizontal edge of an auto layout object's bounding box to + // set it to Fill container.", Guide to auto layout). + if edits && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd) && self.edge_double_click(&doc, &sel, ev)? { + return Ok(()); + } let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); // A double-click anywhere on the one selected shape edits its points, like Enter: also // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index 83ea82fdd..15fbf905c 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -128,3 +128,77 @@ fn a_press_drags_only_past_the_threshold_in_screen_pixels() { assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}"); } } + +/// A double-click with Alt held throughout, as the canvas sends it. +fn alt_double_click(s: &mut Session, x: f64, y: f64, zoom: f64) { + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] { + let mut ev = PointerEvent::at(kind, x, y, zoom); + ev.alt = true; + s.pointer(&ev).unwrap(); + } +} + +/// "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to +/// set it to Hug contents." and "Hold ⌥ Option or Alt and double-click the vertical or horizontal +/// edge of an auto layout object's bounding box to set it to Fill container." (Guide to auto +/// layout): a vertical edge sizes the width, a horizontal one the height; a layer auto layout +/// doesn't size keeps the double-click's old meaning. +#[test] +fn a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills() { + use figcraft_doc::Sizing; + let mut s = Session::new(); + let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0}})); + let c = + id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))["id"]) + .unwrap(); + let sizing = |s: &Session, id: NodeId| { + let cl = &s.doc().unwrap().node(id).unwrap().child_layout; + (cl.sizing_h, cl.sizing_v) + }; + let size = |s: &Session, id: NodeId| { + let n = s.doc().unwrap().node(id).unwrap(); + (n.width, n.height) + }; + ex(&mut s, "select.set", json!({"ids": [f]})); + assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0))); + // The frame's right edge: Hug contents horizontally, and the frame shrinks onto its child. + double_click(&mut s, 400.0, 50.0, 0.0, 1.0); + assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Fixed)); + assert_eq!(size(&s, f), (100.0, 100.0)); + assert_eq!(s.selection(), [f], "still selected, not entered"); + s.undo().unwrap(); + assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)), "one undo step"); + // Its bottom edge: Hug contents vertically. + double_click(&mut s, 200.0, 100.0, 0.0, 1.0); + assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug)); + assert_eq!(size(&s, f), (400.0, 40.0)); + // Alt on the child's right edge: Fill container horizontally, and the child takes the width. + ex(&mut s, "select.set", json!({"ids": [c]})); + alt_double_click(&mut s, 100.0, 20.0, 1.0); + assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed)); + assert_eq!(size(&s, c), (400.0, 40.0)); + // Without Alt the child, no auto layout frame itself, keeps the old meaning (a frame's + // double-click: nothing to enter here), and a plain rectangle's edge still edits its points. + double_click(&mut s, 400.0, 20.0, 0.0, 1.0); + assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed)); + // A text child of the frame: Alt+double-click on its edge sets Fill like any child's, and a + // plain double-click on its edge still enters the text. + let x = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"id": x, "props": {"textAutoResize": "none", "width": 60, "height": 20}})); + ex(&mut s, "select.set", json!({"ids": [x]})); + let b = s.doc().unwrap().world_bounds(x).unwrap(); + alt_double_click(&mut s, b.x1, (b.y0 + b.y1) / 2.0, 1.0); + assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed), "a text child fills too"); + let b = s.doc().unwrap().world_bounds(x).unwrap(); + // On the edge handle, a pixel in (the hit test's reach): the text, not the frame edge it shares. + double_click(&mut s, b.x1 - 1.0, (b.y0 + b.y1) / 2.0, 0.0, 1.0); + assert!(s.ds().unwrap().text_edit.as_ref().is_some_and(|te| te.id == x), "a text's edge still enters it without Alt"); + assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed)); + crate::textedit::end(&mut s).unwrap(); + let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 100, "height": 100}))["id"]).unwrap(); + ex(&mut s, "select.set", json!({"ids": [r]})); + double_click(&mut s, 99.0, 350.0, 0.0, 1.0); + assert_eq!(sizing(&s, r), (Sizing::Fixed, Sizing::Fixed)); + assert!(s.ds().unwrap().vector_edit.is_some(), "a shape's double-click still edits its points"); +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index a0f0bc4b6..fb7713e66 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -364,6 +364,8 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[ (Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"), (Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"), (Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"), + (Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"), + (Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"), (Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"), (Prototype, "Presenting: previous frame", "←", "←"), (Prototype, "Presenting: restart", "R", "R"), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c44635a2a..034e3381a 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -407,6 +407,11 @@ name = "English" "Decoration style" = "Decoration style" "Decoration" = "Decoration" "Deep select" = "Deep select" +"Set hug contents" = "Set hug contents" +"Set fill container" = "Set fill container" +"Double-click an edge of an auto layout frame" = "Double-click an edge of an auto layout frame" +"⌥ double-click an edge of an auto layout child" = "⌥ double-click an edge of an auto layout child" +"Alt+double-click an edge of an auto layout child" = "Alt+double-click an edge of an auto layout child" "Default" = "Default" "Default content" = "Default content" "Default value (the top-left variant)" = "Default value (the top-left variant)" diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 61462a2c1..3ddd0072a 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -41,6 +41,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Fill children share by their content area (border-box): a child with more padding or a thicker included inside stroke takes a little more, so the content areas come out the same | **fixed** | "Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model. A layer with a thicker stroke will take up slightly more of the available width or height, so that its inner content area matches its sibling's" (Use auto layout with CSS Flexbox in mind); the split went by outer size before. Min and max still clamp the outer size; a child's own insets are its padding and its included inside stroke (`nested_tests::fill_children_share_by_content_area`; #1824) | | Fill on a child turns its hugging parent Fixed on that axis | pass | `al_behaviour_tests::fill_on_a_child_turns_the_hugging_parent_fixed` | | Hug only on auto layout frames (and text); Fill only in an auto layout flow, never on a top-level frame | pass | `al_behaviour_tests::hug_and_fill_only_where_they_apply` | +| Double-click an edge of an auto layout frame's box: Hug contents on that axis; Alt+double-click an edge of a child's box: Fill container | **fixed** | "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents."; "Hold ⌥ Option or Alt and double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Fill container." A vertical edge sizes the width, a horizontal one the height; any other layer keeps the double-click's old meaning (#1838). `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills` | | Min/max width and height bound Hug, Fill and Fixed sizes, on frames and children | pass | `sizing_rules`; a fill child at its max leaves the rest to the other fill children (silent on redistribution: CSS flexbox) | | A hugging text wider than its max width wraps at the max | pass | `sizing_rules` | | A text has a max height or a number of max lines, not both: a max height sets max lines to Auto, a number of max lines removes the max height | **fixed** | both coexisted; `node.set {maxHeight}` and `{maxLines}` and `text.maxLines` keep one of the two (a frame's max height is untouched); `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both` | @@ -99,6 +100,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| | 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` | +| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. | | 2026-11-01 | patch | M2: a text layer keeps a max height or a number of max lines, not both ("Text layers cannot have both a max height and a set number of max lines", Guide to auto layout): `node.set {maxHeight}` on a text sets its max lines to Auto, and `node.set {maxLines}` or `text.maxLines` with a number removes its max height. Found by a documentation audit of this page. Test: `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both`. Numbers unchanged | | 2026-11-01 | minor | M2: Remove all auto layout ("To remove auto layout from a frame and all of its nested objects at once, right-click on the frame and go to More layout options > Remove all auto layout", Toggle on auto layout in designs): `layout.removeAll {ids?}` strips the frames and every frame nested in them in one undo step, leaving instances and their layers alone; in the canvas context menu beside Add auto layout and in the Actions palette. Found by a documentation audit of this page. Test: `al_nesting_tests::remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it`. Numbers unchanged | | 2026-10-30 | minor | M8, #1083: grid placement finds free cells on a bitmap, a word at a time, with the same placements; crafted grids of wide children no longer stall a layout pass. Numbers unchanged. | From f9d507f7b0bccf4a8818be9520c670207d77cc5e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:31:11 -0500 Subject: [PATCH 08/10] M2: a child resized to the full available space of its auto layout frame becomes Fill container (#1859) * M2: a child resized to the full available space of its auto layout frame becomes Fill container Co-Authored-By: Claude Fable 5.1 * Auto layout: a child becomes Fill for the full space only across the flow or alone along it Co-Authored-By: Claude Fable 5.1 * Auto layout: the full available space is what layout leaves (padding and an included inside stroke), judged after a new path's size Co-Authored-By: Claude Fable 5.1 * M2: a rotated child resized to the frame's room stays Fixed (review) CodeRabbit on #1859: a rotated layer's local size isn't the room layout gives it, so Fill would squash its geometry. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/engine/src/al_behaviour_tests.rs | 60 ++++++++++++++++++++++++ crates/engine/src/cmd/arrange.rs | 13 +++--- crates/engine/src/cmd/props.rs | 61 +++++++++++++++++++++---- crates/layout/src/lib.rs | 5 +- docs/auto-layout-parity.md | 4 +- 5 files changed, 126 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index d36b35b8f..514a39fdc 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -72,6 +72,52 @@ fn fill_on_a_child_turns_the_hugging_parent_fixed() { assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug)); } +#[test] +fn a_child_resized_to_the_full_available_space_becomes_fill() { + // Documented: "Child objects of an auto layout frame will also be set to Fill container if + // they are manually resized to the full available space of the parent frame." + let mut s = Session::new(); + let f = al_frame(&mut s, json!({"padding": 10, "gap": 10})); // 200 × 100, fixed + let a = rect(&mut s, f, 50.0, 20.0, "#FF0000"); + // Along the flow, alone in the frame: its width less the padding (200 - 20). + s.execute("transform.resize", &json!({"ids": [a], "width": 180})).unwrap(); + assert_eq!(sizing(&s, a), (Sizing::Fill, Sizing::Fixed)); + assert_eq!(size(&s, a), (180.0, 20.0)); + s.undo().unwrap(); + assert_eq!(sizing(&s, a), (Sizing::Fixed, Sizing::Fixed), "undone as part of the edit"); + // Across the flow, beside a sibling too: the frame's height less its padding. + let b = rect(&mut s, f, 50.0, 20.0, "#0000FF"); + set(&mut s, b, json!({"height": 80})); + assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fill)); + assert_eq!(size(&s, b), (50.0, 80.0)); + // Short of it: fixed, as before. + set(&mut s, b, json!({"height": 70})); + assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed)); + // Along the flow beside a sibling, what is left is not "the full available space": a width + // that happens to match it (180 - 10 - 50) stays fixed. + s.execute("transform.resize", &json!({"ids": [b], "width": 120})).unwrap(); + assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed)); + // A hugging frame has no full space to offer: the child stays fixed. + let g = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "padding": 10})); + let c = rect(&mut s, g, 50.0, 20.0, "#00FF00"); + set(&mut s, c, json!({"width": 180})); + assert_eq!(sizing(&s, c), (Sizing::Fixed, Sizing::Fixed)); + // An inside stroke the layout includes takes room like the padding: the full space is what + // layout leaves the child (200 - 2 × 10 - 2 × 4), not the frame less its padding alone. + let h = al_frame(&mut s, json!({"padding": 10, "strokesIncluded": true, "stroke": "#000000", "strokeWeight": 4, "strokeAlign": "inside"})); + let d = rect(&mut s, h, 50.0, 20.0, "#FF0000"); + set(&mut s, d, json!({"width": 180})); + assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed), "wider than the room the stroke leaves"); + assert_eq!(size(&s, d), (180.0, 20.0), "and kept as typed"); + set(&mut s, d, json!({"width": 172})); + assert_eq!(sizing(&s, d), (Sizing::Fill, Sizing::Fixed)); + assert_eq!(size(&s, d), (172.0, 20.0)); + // Excluded from the layout, the stroke takes no room: the frame less its padding again. + set(&mut s, h, json!({"strokesIncluded": false})); + set(&mut s, d, json!({"width": 172})); + assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed)); +} + #[test] fn hug_and_fill_only_where_they_apply() { // Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout, @@ -232,6 +278,20 @@ fn a_size_given_with_a_new_path_fixes_that_axis() { set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30})); assert_eq!(sizing(&s, v).0, Sizing::Fixed); assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v)); + // The axis is judged by the size the path is given, not the one before it: the frame's full + // height (100) given with a path fills; a smaller height given with the next path is fixed, + // though the layer filled the frame until then. + set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100})); + assert_eq!(sizing(&s, v).1, Sizing::Fill); + assert!((size(&s, v).1 - 100.0).abs() < 1e-6, "{:?}", size(&s, v)); + set(&mut s, v, json!({"height": 40, "path": "M0 0 L20 5"})); + assert_eq!(sizing(&s, v).1, Sizing::Fixed); + assert!((size(&s, v).1 - 40.0).abs() < 1e-6, "{:?}", size(&s, v)); + // Rotated, the layer's local size isn't the room layout gives it: a height matching the + // frame's stays fixed rather than have Fill squash the path. + set(&mut s, v, json!({"rotation": 90})); + set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100})); + assert_eq!(sizing(&s, v).1, Sizing::Fixed); } /// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 19572c959..b90cf1d1f 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -569,12 +569,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(i)?; let after = n.transform * Point::new(w * ax, h * ay); n.transform = Affine::translate(before - after) * n.transform; - if num(p, "width").is_some() { - n.child_layout.sizing_h = Sizing::Fixed; - } - if num(p, "height").is_some() { - n.child_layout.sizing_v = Sizing::Fixed; - } if let NodeKind::Text(t) = &mut n.kind { if num(p, "height").is_some() { t.fix_size(num(p, "width").is_some(), true); @@ -582,6 +576,13 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { t.auto_resize = TextAutoResize::Height; } } + // A side given is fixed, or fills its auto layout parent when it is the full space there. + if num(p, "width").is_some() { + super::props::fix_axis(doc, i, true)?; + } + if num(p, "height").is_some() { + super::props::fix_axis(doc, i, false)?; + } super::props::restore_worlds(doc, &kept)?; } super::styles::detach_edited(doc, &before); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index fbf80fc1b..01f83bc85 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -437,17 +437,55 @@ fn layout_keys_beside_none(cmd: &str, props: &Map) -> Result<()> } /// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing -/// into W or H. -fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { +/// into W or H; sized to the full space its auto layout parent has for it on that axis, the layer +/// fills instead: "Child objects of an auto layout frame will also be set to Fill container if +/// they are manually resized to the full available space of the parent frame" (Guide to auto +/// layout). +pub(crate) fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { + let sizing = if fills_available_space(doc, id, is_w) { Sizing::Fill } else { Sizing::Fixed }; let n = doc.get_mut(id)?; if is_w { - n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_h = sizing; } else { - n.child_layout.sizing_v = Sizing::Fixed; + n.child_layout.sizing_v = sizing; } Ok(()) } +/// Whether `id`'s width (`is_w`) or height is the full space its auto layout parent has for it on +/// that axis: the frame's size less its padding and an inside stroke included in the layout (the +/// insets layout itself takes). Across the flow that is every child's room; +/// along the flow only a lone child has it (beside siblings what is left depends on the gaps, the +/// alignment and overflow, so a size that happens to match it is not "the full available +/// space"). Never in a hugging or wrapping frame or a grid, or for a layer that ignores the layout. +fn fills_available_space(doc: &Document, id: NodeId, is_w: bool) -> bool { + let Some(n) = doc.node(id) else { return false }; + let Some(parent) = n.parent.and_then(|p| doc.node(p)) else { return false }; + let Some(l) = &parent.layout else { return false }; + if n.child_layout.absolute || l.wrap || l.mode == LayoutMode::Grid { + return false; + } + // A rotated layer's local size isn't the size layout gives it room for (its rotated bounds + // are): it stays Fixed rather than have Fill squash its geometry. + let [_, b, c, ..] = n.transform.as_coeffs(); + if b.abs() > 1e-9 || c.abs() > 1e-9 { + return false; + } + let hugs = if is_w { parent.child_layout.sizing_h } else { parent.child_layout.sizing_v } == Sizing::Hug; + if hugs { + return false; + } + if (l.mode == LayoutMode::Horizontal) == is_w + && parent.children.iter().filter(|c| **c != id).filter_map(|c| doc.node(*c)).any(|c| c.visible && !c.child_layout.absolute) + { + return false; + } + let (pt, pr, pb, pl) = figcraft_layout::insets(parent, l); + let available = if is_w { parent.width - pl - pr } else { parent.height - pt - pb }; + let size = if is_w { n.width } else { n.height }; + available > 0.0 && (size - available).abs() < 0.01 +} + /// The `geometry` properties of `apply`; `false` when `k` is not one of them. fn apply_geometry( cmd: &str, @@ -459,10 +497,17 @@ fn apply_geometry( path_offset: &mut (f64, f64), ) -> Result { match k { - "path" => *path_offset = set_path(cmd, doc, id, v, props)?, - // A new path takes the size asked for as its scale (`set_path`); the size still fixes - // its auto layout axis. - "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, + "path" => { + *path_offset = set_path(cmd, doc, id, v, props)?; + // A size given beside the path is its scale (`set_path`), and still fixes or fills + // its auto layout axis, judged once the path has given the layer that size. + for (long, short, is_w) in [("width", "w", true), ("height", "h", false)] { + if props.contains_key(long) || props.contains_key(short) { + fix_axis(doc, id, is_w)?; + } + } + } + "width" | "height" | "w" | "h" if props.contains_key("path") => {} "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 56f4bf625..19de84afb 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -216,8 +216,9 @@ fn coherent_own_sizing(doc: &mut Document, id: NodeId) -> bool { /// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the frame's own /// inside stroke (per side) when strokes are included in the layout; center and outside strokes -/// never take up space. -fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) { +/// never take up space. The frame's size less these is its children's room, the "full available +/// space" a child resized to it fills. +pub fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) { let included = al.strokes_included && f.has_visible_stroke() && f.stroke.align == StrokeAlign::Inside; let stroke = if included { f.stroke.sides.unwrap_or([f.stroke.weight; 4]) } else { [0.0; 4] }; let [pt, pr, pb, pl] = al.padding.map(|p| finite_or_zero(p).max(0.0)); diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 3ddd0072a..3b5466b2c 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -40,6 +40,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Fill: children share the space left after padding, gaps and fixed siblings; across the flow they stretch | pass | `sizing_rules` | | Fill children share by their content area (border-box): a child with more padding or a thicker included inside stroke takes a little more, so the content areas come out the same | **fixed** | "Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model. A layer with a thicker stroke will take up slightly more of the available width or height, so that its inner content area matches its sibling's" (Use auto layout with CSS Flexbox in mind); the split went by outer size before. Min and max still clamp the outer size; a child's own insets are its padding and its included inside stroke (`nested_tests::fill_children_share_by_content_area`; #1824) | | Fill on a child turns its hugging parent Fixed on that axis | pass | `al_behaviour_tests::fill_on_a_child_turns_the_hugging_parent_fixed` | +| A child resized to the full available space becomes Fill container on that axis | **fixed** | "Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame": a width or height given by a resize, the W/H fields or a canvas handle that equals the frame's size less its padding sets Fill there: across the flow for any child, along the flow for a lone child (beside siblings what is left depends on the gaps, the alignment and overflow, so it is left alone); not in a hugging or wrapping frame or a grid. It stayed Fixed before (`al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill`) | | Hug only on auto layout frames (and text); Fill only in an auto layout flow, never on a top-level frame | pass | `al_behaviour_tests::hug_and_fill_only_where_they_apply` | | Double-click an edge of an auto layout frame's box: Hug contents on that axis; Alt+double-click an edge of a child's box: Fill container | **fixed** | "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents."; "Hold ⌥ Option or Alt and double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Fill container." A vertical edge sizes the width, a horizontal one the height; any other layer keeps the double-click's old meaning (#1838). `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills` | | Min/max width and height bound Hug, Fill and Fixed sizes, on frames and children | pass | `sizing_rules`; a fill child at its max leaves the rest to the other fill children (silent on redistribution: CSS flexbox) | @@ -101,6 +102,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver |---|---|---| | 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` | | 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. | +| 2026-11-02 | patch | M2: a child of an auto layout frame resized to the full space available to it on an axis (the frame's size less its padding: across the flow for any child, along the flow for a lone child) becomes Fill container on that axis, whether by `transform.resize`, the W/H fields or a canvas handle ("Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame", Guide to auto layout). Any other size keeps the axis Fixed as before; beside siblings along the flow, in a hugging or wrapping frame, in a grid and for a layer that ignores the layout nothing changes. Test: `al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill` | | 2026-11-01 | patch | M2: a text layer keeps a max height or a number of max lines, not both ("Text layers cannot have both a max height and a set number of max lines", Guide to auto layout): `node.set {maxHeight}` on a text sets its max lines to Auto, and `node.set {maxLines}` or `text.maxLines` with a number removes its max height. Found by a documentation audit of this page. Test: `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both`. Numbers unchanged | | 2026-11-01 | minor | M2: Remove all auto layout ("To remove auto layout from a frame and all of its nested objects at once, right-click on the frame and go to More layout options > Remove all auto layout", Toggle on auto layout in designs): `layout.removeAll {ids?}` strips the frames and every frame nested in them in one undo step, leaving instances and their layers alone; in the canvas context menu beside Add auto layout and in the Actions palette. Found by a documentation audit of this page. Test: `al_nesting_tests::remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it`. Numbers unchanged | | 2026-10-30 | minor | M8, #1083: grid placement finds free cells on a bitmap, a word at a time, with the same placements; crafted grids of wide children no longer stall a layout pass. Numbers unchanged. | From 1f0acd2bbd31148898ee514dbdc63598c183d2a4 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:31:21 -0500 Subject: [PATCH 09/10] M4: Alt+Enter while editing text opens the link at the caret (text.openLink) (#1860) Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/text.rs | 26 ++++++++++ crates/engine/src/keymap.rs | 1 + crates/engine/src/lib.rs | 2 + crates/engine/src/shortcut_parity_tests.rs | 3 +- crates/engine/src/text_link_tests.rs | 42 +++++++++++++++ crates/ui-egui/src/app/commands.rs | 7 +++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/text_link_tests.rs | 60 ++++++++++++++++++++++ docs/ui-parity.md | 9 ++-- 9 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/text_link_tests.rs create mode 100644 crates/ui-egui/src/text_link_tests.rs diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 446b01d29..f72f215ee 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -521,6 +521,32 @@ pub fn specs() -> Vec { textedit::end(s)?; ok() }), + spec!( + "text.openLink", + "Open link", + "{} while editing: the link at the caret (the selection's start) goes to the UI to open, as Alt+Enter does (\"press Option Return / Alt Enter to activate a focused link\"); only a web or mail link opens → {url} ({url: null}: no link there)", + false, + |s, _| { + let Some(te) = s.ds()?.text_edit.clone() else { return Err(bad("text.openLink", "no text is being edited")) }; + let doc = s.doc()?; + let t = doc.node(te.id).and_then(|n| n.kind.text()).ok_or_else(|| bad("text.openLink", format!("{} is not text", te.id)))?; + let n = t.char_count(); + let at = te.caret.min(te.anchor); + // The caret sits between two chars: the link of the one after it, else before it. + let link = (at < n) + .then(|| t.style_at(at).link.clone()) + .flatten() + .or_else(|| (at > 0 && at <= n).then(|| t.style_at(at - 1).link.clone()).flatten()); + match link { + Some(url) if crate::doc_urls::openable(&url) => { + s.request_ui("open.url", json!({"url": url})); + Ok(json!({"url": url})) + } + Some(url) => Err(bad("text.openLink", format!("`{url}` is not a web or mail link"))), + None => Ok(json!({"url": Value::Null})), + } + } + ), spec!( "text.fonts", "List fonts", diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index fb7713e66..4f55fa4ad 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -247,6 +247,7 @@ pub static KEYS: &[Binding] = &[ // Text (on selected text layers, and on the selected characters while editing) k(Text, "text.bold", &["Cmd+B"]).both(), k(Text, "text.italic", &["Cmd+I"]).both(), + k(Text, "text.openLink", &["Alt+Enter"]).both(), k(Text, "text.underline", &["Cmd+U"]).both(), k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(), k(Text, "text.link", &["Shift+Cmd+U", "Cmd+K"]).text(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0da532b25..e919444e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -565,6 +565,8 @@ mod text_behaviour_tests; #[cfg(test)] mod text_fill_tests; #[cfg(test)] +mod text_link_tests; +#[cfg(test)] mod text_metrics_local_tests; #[cfg(test)] mod text_motion_tests; diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index 0b9b573af..31ce9d18e 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -181,6 +181,7 @@ static ROWS: &[Row] = &[ pin(Zoom, "view.nextFrame", &["N", "End"], &[]), // Text doc(Text, "Bold", &["Cmd+B"], &[], Bound("text.bold"), ""), + doc(Text, "Activate a focused link", &["Alt+Enter"], &[], Bound("text.openLink"), "while editing: the link at the caret; web and mail links only"), doc(Text, "Italic", &["Cmd+I"], &[], Bound("text.italic"), ""), doc(Text, "Underline", &["Cmd+U"], &[], Bound("text.underline"), ""), doc(Text, "Strikethrough", &["Shift+Cmd+X"], &[], Bound("text.strikethrough"), ""), @@ -412,7 +413,7 @@ fn counts_match_the_docs() { let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); let pinned = ROWS.len() - documented.len(); // docs/ui-parity.md quotes these. - assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (133, 132, 1, 0, 2, 57)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57)); } #[test] diff --git a/crates/engine/src/text_link_tests.rs b/crates/engine/src/text_link_tests.rs new file mode 100644 index 000000000..df0748996 --- /dev/null +++ b/crates/engine/src/text_link_tests.rs @@ -0,0 +1,42 @@ +//! Alt+Enter while editing text activates the link at the caret ("When editing text, press +//! ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma): `text.openLink` +//! hands a web or mail link at the caret to the UI, refuses other schemes, and does nothing where +//! there is no link. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +#[test] +fn the_link_at_the_caret_goes_to_the_ui_and_only_a_web_or_mail_link() { + let mut s = Session::new(); + let t = id_of(&s.execute("text.create", &json!({"text": "See the docs now", "x": 0, "y": 0})).unwrap()["id"]).unwrap(); + s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap(); + assert!(s.execute("text.openLink", &json!({})).is_err(), "nothing to activate while no text is edited"); + s.execute("text.edit.begin", &json!({"id": t, "caret": 6})).unwrap(); + let r = s.execute("text.openLink", &json!({})).unwrap(); + assert_eq!(r["url"], "https://example.com/docs"); + let req = s.ui_requests.iter().find(|r| r.kind == "open.url").expect("a UI request to open it"); + assert_eq!(req.data["url"], "https://example.com/docs"); + assert!(s.ds().unwrap().text_edit.is_some(), "still editing"); + // Right after the link's last char the caret still counts as on it; before its start it doesn't. + s.execute("text.edit.caret", &json!({"index": 12})).unwrap(); + assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs"); + s.execute("text.edit.caret", &json!({"index": 2})).unwrap(); + assert!(s.execute("text.openLink", &json!({})).unwrap()["url"].is_null(), "no link there"); + // A selection: its start decides. + s.execute("text.edit.caret", &json!({"index": 14})).unwrap(); + s.execute("text.edit.caret", &json!({"index": 8, "extend": true})).unwrap(); + assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs"); + // Mail links open; anything that isn't a web or mail link is refused and nothing is requested. + s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "mailto:hello@example.com"})).unwrap(); + s.execute("text.edit.caret", &json!({"index": 6})).unwrap(); + assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "mailto:hello@example.com"); + let before = s.ui_requests.len(); + for bad in ["javascript:alert(1)", "file:///etc/passwd", "example.com/docs"] { + s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": bad})).unwrap(); + assert!(s.execute("text.openLink", &json!({})).is_err(), "{bad} is refused"); + } + assert_eq!(s.ui_requests.len(), before, "nothing requested for a refused link"); +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 0e34f437a..93762e579 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -228,6 +228,13 @@ impl FigApp { } "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), + // A link in text activated while editing (`text.openLink`): the engine checked it + // is a web or mail link. + "open.url" => { + if let Some(url) = r.data["url"].as_str() { + self.services.open_url(url); + } + } "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ecb64d22..e9969c106 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -243,6 +243,8 @@ mod table_paste_tests; #[cfg(test)] mod text_enter_tests; #[cfg(test)] +mod text_link_tests; +#[cfg(test)] mod titlebar_tests; #[cfg(test)] mod ui_params_tests; diff --git a/crates/ui-egui/src/text_link_tests.rs b/crates/ui-egui/src/text_link_tests.rs new file mode 100644 index 000000000..5f902e7a9 --- /dev/null +++ b/crates/ui-egui/src/text_link_tests.rs @@ -0,0 +1,60 @@ +//! Alt+Enter while editing text opens the link at the caret through the platform ("When editing +//! text, press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma), +//! driven through egui headless with a recording opener; editing goes on. + +use std::sync::{Arc, Mutex}; + +use egui::{Event, Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::Services; +use crate::layers_drag_tests::Ui; + +/// Records every URL the app asks the platform to open. +#[derive(Default, Clone)] +struct Opened(Arc>>); + +impl Services for Opened { + fn open_url(&mut self, url: &str) { + if let Ok(mut v) = self.0.lock() { + v.push(url.to_string()); + } + } +} + +fn alt_enter(u: &mut Ui) { + let modifiers = Modifiers::ALT; + u.frame_with( + vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + u.frame(vec![]); +} + +#[test] +fn alt_enter_opens_the_link_at_the_caret_while_editing() { + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "See the docs now", "x": 20, "y": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap(); + s.execute("text.edit.begin", &json!({"id": t, "caret": 2})).unwrap(); + let opened = Opened::default(); + let mut u = Ui::new(s); + u.app.services = Box::new(opened.clone()); + u.frame(vec![]); + alt_enter(&mut u); + assert!(opened.0.lock().unwrap().is_empty(), "no link at the caret: nothing opens"); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "still editing"); + u.app.session.execute("text.edit.caret", &json!({"index": 6})).unwrap(); + alt_enter(&mut u); + assert_eq!(*opened.0.lock().unwrap(), vec!["https://example.com/docs".to_string()]); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "still editing"); + // Not a web or mail link: nothing opens. + u.app.session.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "file:///etc/passwd"})).unwrap(); + alt_enter(&mut u); + assert_eq!(opened.0.lock().unwrap().len(), 1); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d936c9408..7a416f2d6 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-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -89,7 +89,7 @@ interaction feel not yet compared. | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | -| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | +| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | @@ -129,8 +129,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | | Count | Notes | |---|---|---| -| Documented shortcuts (rows) | 133 | each with macOS and Windows/Linux keys | -| Bound as documented | 132 (110 before this suite) | 111 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Documented shortcuts (rows) | 134 | each with macOS and Windows/Linux keys | +| Bound as documented | 133 (110 before this suite) | 112 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | | Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there | | Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two | | Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build | @@ -180,6 +180,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | | 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` | | 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. | +| 2026-11-02 | patch | Shortcuts: Alt+Enter while editing text opens the link at the caret ("When editing text, press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma) through the new `text.openLink` command, which hands a web or mail link to the platform's opener and refuses any other scheme as the prototype player does; editing goes on. The key went to the editor as a plain Enter before. A documented row: 134 documented, 133 bound. Tests: engine `text_link_tests`, `text_link_tests` (UI) | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | | 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged | From e65ce42000ea2bd3c31d14f6c7e47d14bc6ab164 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 05:31:32 -0500 Subject: [PATCH 10/10] M7: Minimize collapses the navigation bar and both sidebars, the right one back while something is selected (#1835) (#1865) * M7: Minimize collapses the navigation bar and both sidebars, the right one back while something is selected (#1835) Co-Authored-By: Claude Fable 5.1 * M7: a narrow window ignores Minimize, and a rename started while minimized waits without repaints Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/view.rs | 7 ++ crates/engine/src/keymap.rs | 2 +- crates/engine/src/shortcut_parity_tests.rs | 2 +- crates/engine/src/view_state.rs | 4 + crates/ui-egui/locales/ar.toml | 1 - crates/ui-egui/locales/bn.toml | 1 - crates/ui-egui/locales/bs.toml | 1 - crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/locales/fi.toml | 1 - crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/it.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ka.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/nl.toml | 1 - crates/ui-egui/locales/pl.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/th.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/vi.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/app.rs | 22 ++++-- crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/app/frame.rs | 16 ++++ crates/ui-egui/src/idle_tests.rs | 17 +++++ crates/ui-egui/src/panel_width_tests.rs | 85 +++++++++++++++++++++- crates/ui-egui/src/panels/layers.rs | 6 +- crates/ui-egui/src/shortcuts_tests.rs | 6 +- docs/ui-parity.md | 5 +- 35 files changed, 161 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 1fc2ef22a..60a5c26f1 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -321,6 +321,13 @@ pub fn specs() -> Vec { spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")), spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")), spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)), + spec!( + "view.minimize", + "Minimize the interface", + "{on?} collapses the navigation bar and both sidebars and keeps the toolbar (Shift+Cmd/Ctrl+\\, the Minimize button by the file name); the right sidebar shows while something is selected", + false, + |s, p| flag(s, p, |v| &mut v.ui_minimized) + ), spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| { s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")})); ok() diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 4f55fa4ad..cec22d62f 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -225,7 +225,7 @@ pub static KEYS: &[Binding] = &[ k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"), k(View, "view.designPrototype", &["Shift+E"]), k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"), - k(View, "view.leftSidebar", &["Shift+Cmd+\\"]), + k(View, "view.minimize", &["Shift+Cmd+\\"]), k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(), k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(), k(View, "view.contextMenu", &["Shift+F10", "Menu"]).label("Context menu of the selection (the focused layer row or the canvas)"), diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index 31ce9d18e..46eb14447 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -145,7 +145,7 @@ static ROWS: &[Row] = &[ doc(View, "Outline mode", &["Shift+Cmd+O"], &[], Bound("view.outlines"), ""), doc(View, "Pixel preview", &["Ctrl+P"], &["Ctrl+Alt+P"], Bound("view.pixelPreview"), ""), doc(View, "Others' cursors in a live session", &["Alt+Cmd+\\"], &["Ctrl+Alt+\\"], Bound("view.multiplayerCursors"), "new command"), - doc(View, "Hide the side panels (minimize the interface)", &["Shift+Cmd+\\"], &[], Bound("view.leftSidebar"), ""), + doc(View, "Minimize the interface: the navigation bar and both sidebars", &["Shift+Cmd+\\"], &[], Bound("view.minimize"), "the toolbar stays; the right sidebar shows while something is selected"), doc(View, "Layers panel", &["Alt+1"], &["Alt+1", "Ctrl+1"], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; both bound there"), doc(View, "Interface scale: make larger", &["Alt+Shift+Cmd+="], &[], Bound("pref.uiScaleUp"), "Accessibility at Figma, Adjust the scale of the Figma UI"), doc(View, "Interface scale: make smaller", &["Alt+Shift+Cmd+-"], &[], Bound("pref.uiScaleDown"), "Accessibility at Figma"), diff --git a/crates/engine/src/view_state.rs b/crates/engine/src/view_state.rs index 54102d6b7..fd70b4b11 100644 --- a/crates/engine/src/view_state.rs +++ b/crates/engine/src/view_state.rs @@ -38,6 +38,9 @@ pub struct ViewState { pub snap_objects: bool, pub snap_pixel: bool, pub ui_hidden: bool, + /// Minimized interface (Shift+Cmd/Ctrl+\): the navigation bar and both sidebars are collapsed + /// and the toolbar stays; the right sidebar shows while something is selected. + pub ui_minimized: bool, } impl Default for ViewState { @@ -67,6 +70,7 @@ impl Default for ViewState { snap_objects: true, snap_pixel: true, ui_hidden: false, + ui_minimized: false, } } } diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 86a21e234..a3dcf0844 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -288,7 +288,6 @@ status = "draft — needs native review" "Code" = "الكود" "Code syntax" = "صياغة الكود" "Collapse" = "طي" -"Collapse panel" = "طي اللوحة" "Collection" = "مجموعة" "Collections" = "المجموعات" "Color" = "اللون" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 9f2e6421a..32d66721f 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -275,7 +275,6 @@ status = "draft — needs native review" "Code" = "কোড" "Code syntax" = "কোড সিনট্যাক্স" "Collapse" = "সংকুচিত করুন" -"Collapse panel" = "প্যানেল সংকুচিত করুন" "Collection" = "সংগ্রহ" "Collections" = "সংগ্রহসমূহ" "Color" = "রঙ" diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index 67e0a14cd..72f0268bd 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -288,7 +288,6 @@ status = "draft — needs native review" "Code" = "Kod" "Code syntax" = "Sintaksa koda" "Collapse" = "Skupi" -"Collapse panel" = "Skupi panel" "Collection" = "Kolekcija" "Collections" = "Kolekcije" "Color" = "Boja" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e77610771..16de8065a 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -265,7 +265,6 @@ status = "draft — needs native review" "Code" = "Kód" "Code syntax" = "Syntaxe kódu" "Collapse" = "Sbalit" -"Collapse panel" = "Sbalit panel" "Collection" = "Kolekce" "Collections" = "Kolekce" "Color" = "Barva" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b088b2d32..344e70912 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -263,7 +263,6 @@ status = "draft — needs native review" "Code" = "Code" "Code syntax" = "Code-Syntax" "Collapse" = "Einklappen" -"Collapse panel" = "Bereich einklappen" "Collection" = "Sammlung" "Collections" = "Sammlungen" "Color" = "Farbe" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 034e3381a..a23c281ba 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -293,7 +293,6 @@ name = "English" "Code" = "Code" "Code syntax" = "Code syntax" "Collapse" = "Collapse" -"Collapse panel" = "Collapse panel" "Collection" = "Collection" "Collections" = "Collections" "Color" = "Color" @@ -536,6 +535,7 @@ name = "English" "Exclusion" = "Exclusion" "Exit focus view (Esc)" = "Exit focus view (Esc)" "Expand" = "Expand" +"Expand the interface" = "Expand the interface" "Export" = "Export" "Export all" = "Export all" "Export frames and layers as PNG, JPG, SVG or PDF" = "Export frames and layers as PNG, JPG, SVG or PDF" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3b1f7296e..b9e004dd0 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -293,7 +293,6 @@ status = "draft — needs native review" "Code" = "Código" "Code syntax" = "Sintaxis de código" "Collapse" = "Contraer" -"Collapse panel" = "Contraer panel" "Collection" = "Colección" "Collections" = "Colecciones" "Color" = "Color" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index f181ba8a5..0c74f9de7 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -281,7 +281,6 @@ status = "draft — needs native review" "Code or invite link" = "Koodi tai kutsulinkki" "Code syntax" = "Koodin syntaksi" "Collapse" = "Tiivistä muunnokset" -"Collapse panel" = "Supista paneeli" "Collection" = "Kokoelma" "Collections" = "Kokoelmat" "Color" = "Väri" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 42a35007b..b30abe731 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -269,7 +269,6 @@ status = "draft — needs native review" "Code" = "Code" "Code syntax" = "Syntaxe du code" "Collapse" = "Réduire" -"Collapse panel" = "Réduire le panneau" "Collection" = "Collection" "Collections" = "Collections" "Color" = "Couleur" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a3457d820..da08f159d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -281,7 +281,6 @@ status = "draft — needs native review" "Code" = "Kode" "Code syntax" = "Sintaks kode" "Collapse" = "Ciutkan" -"Collapse panel" = "Ciutkan panel" "Collection" = "Koleksi" "Collections" = "Koleksi" "Color" = "Warna" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index ce54d15db..3d2877e7e 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -259,7 +259,6 @@ status = "draft — needs native review" "Code" = "Codice" "Code syntax" = "Sintassi del codice" "Collapse" = "Comprimi" -"Collapse panel" = "Comprimi pannello" "Collection" = "Collezione" "Collections" = "Collezioni" "Color" = "Colore" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 8e2334f4b..8cf2da2d5 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -270,7 +270,6 @@ status = "draft — needs native review" "Code" = "コード" "Code syntax" = "コード構文" "Collapse" = "折りたたむ" -"Collapse panel" = "パネルを折りたたむ" "Collection" = "コレクション" "Collections" = "コレクション" "Color" = "カラー" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 90dffe678..f634a2e7e 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -279,7 +279,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Code" = "კოდი" "Code syntax" = "კოდის სინტაქსი" "Collapse" = "ჩაკეცვა" -"Collapse panel" = "პანელის ჩაკეცვა" "Collection" = "კოლექცია" "Collections" = "კოლექციები" "Color" = "ფერი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 691c1cbc7..d52310997 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -260,7 +260,6 @@ status = "draft — needs native review" "Code" = "코드" "Code syntax" = "코드 구문" "Collapse" = "접기" -"Collapse panel" = "패널 접기" "Collection" = "컬렉션" "Collections" = "컬렉션" "Color" = "색상" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 027b0b3a2..070ca4bb4 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -271,7 +271,6 @@ status = "draft — needs native review" "Code or invite link" = "Code of uitnodigingslink" "Code syntax" = "Code-syntaxis" "Collapse" = "Inklappen" -"Collapse panel" = "Paneel samenvouwen" "Collection" = "Verzameling" "Collections" = "Verzamelingen" "Color" = "Kleur" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 28406332a..05d9b60f8 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -275,7 +275,6 @@ name = "Polski" "Code" = "Kod" "Code syntax" = "Składnia kodu" "Collapse" = "Zwiń" -"Collapse panel" = "Zwiń panel" "Collection" = "Kolekcja" "Collections" = "Kolekcje" "Color" = "Kolor" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 7924ed0f8..1b00ce28c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -258,7 +258,6 @@ status = "draft — needs native review" "Code" = "Código" "Code syntax" = "Sintaxe de código" "Collapse" = "Recolher" -"Collapse panel" = "Recolher painel" "Collection" = "Coleção" "Collections" = "Coleções" "Color" = "Cor" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 90e6c607b..a9a155818 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -272,7 +272,6 @@ name = "Русский" "Code" = "Код" "Code syntax" = "Синтаксис кода" "Collapse" = "Свернуть" -"Collapse panel" = "Свернуть панель" "Collection" = "Коллекция" "Collections" = "Коллекции" "Color" = "Цвет" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 1c9cf0ee2..9384ba152 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -293,7 +293,6 @@ name = "Svenska" "Code" = "Kod" "Code syntax" = "Kodsyntax" "Collapse" = "Komprimera" -"Collapse panel" = "Komprimera panel" "Collection" = "Samling" "Collections" = "Samlingar" "Color" = "Färg" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index d3268bdf9..45de7b858 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -273,7 +273,6 @@ status = "draft — needs native review" "Code" = "โค้ด" "Code syntax" = "ไวยากรณ์โค้ด" "Collapse" = "ยุบ" -"Collapse panel" = "ยุบแผง" "Collection" = "คอลเลกชัน" "Collections" = "คอลเลกชัน" "Color" = "สี" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 6c80e6864..db05d1c93 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -269,7 +269,6 @@ name = "Türkçe" "Code" = "Kod" "Code syntax" = "Kod söz dizimi" "Collapse" = "Daralt" -"Collapse panel" = "Paneli daralt" "Collection" = "Koleksiyon" "Collections" = "Koleksiyonlar" "Color" = "Renk" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 8149ed30b..8b58fc67e 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -256,7 +256,6 @@ status = "draft — needs native review" "Code or invite link" = "Mã hoặc liên kết mời" "Code syntax" = "Cú pháp mã" "Collapse" = "Thu gọn" -"Collapse panel" = "Thu gọn bảng" "Collection" = "Bộ sưu tập" "Collections" = "Bộ sưu tập" "Color" = "Màu" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 2fed25ce2..a6ecb8ab3 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -261,7 +261,6 @@ status = "draft — needs native review" "Code" = "代码" "Code syntax" = "代码语法" "Collapse" = "折叠" -"Collapse panel" = "折叠面板" "Collection" = "集合" "Collections" = "集合" "Color" = "颜色" diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c625f342a..e13acce68 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -274,12 +274,21 @@ impl FigApp { return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); + // Minimized (Shift+Cmd/Ctrl+\, the Minimize button by the file name): "collapses the + // navigation bar, left sidebar, and right sidebar"; "If you select an object while the + // panels are minimized, the left sidebar remains minimized while the right sidebar expands + // [...] Once all objects are deselected, the right panel minimizes again" (Explore the + // navigation bar and left sidebar). The toolbar stays; Hide UI takes everything. let narrow = !hidden && ui.available_width() < NARROW_WIDTH; + // A narrow window has no sidebars beside the canvas to take: its panels open from their + // buttons as before, and the interface is minimized again once the window is wide. + let minimized = !hidden && !narrow && self.session.ds().is_ok_and(|d| d.view.ui_minimized); + let right_minimized = minimized && self.session.selection().is_empty(); let mobile = self.ui.mobile_panel.filter(|_| narrow); self.ui.narrow = narrow; // A narrow window shows the right panel when its button asks for it, a board's too. - let right_open = (self.right_panel_shown(hidden) && !narrow) || mobile == Some(MobilePanel::Right); - if !hidden && (!narrow || mobile == Some(MobilePanel::Left)) { + let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right); + if !hidden && !minimized && (!narrow || mobile == Some(MobilePanel::Left)) { egui::Panel::left("left_rail") .exact_size(layers::RAIL_WIDTH) .resizable(false) @@ -290,7 +299,7 @@ impl FigApp { }); } // The panels are as wide as asked, less in a window too small for them and the canvas. - let left_open = !hidden && if narrow { mobile == Some(MobilePanel::Left) } else { !self.ui.left_collapsed }; + let left_open = !hidden && !minimized && if narrow { mobile == Some(MobilePanel::Left) } else { !self.ui.left_collapsed }; let (left_w, right_w) = if narrow { let w = (ui.available_width() - MOBILE_STRIP).max(0.0); (if left_open { w } else { 0.0 }, w) @@ -353,6 +362,8 @@ impl FigApp { if narrow { let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 }; self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0)); + } else if minimized { + self.expand_button(&ctx, egui::pos2(ctx.content_rect().min.x + 8.0, rows.min + 8.0)); } // The panels' edges, over the canvas and the panels (drawn after them: they take the press). for (name, x, shown, grows) in edges { @@ -385,8 +396,9 @@ impl FigApp { updates_ui::show(self, &ctx); self.toast_ui(&ctx); // A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field - // next frame. - if self.ui.rename.is_some_and(|r| r.start) && !hidden && !self.ui.left_collapsed && self.ui.left_tab == LeftTab::File { + // next frame; while the panel isn't drawn (the interface hidden or minimized, the panel + // closed in a narrow window) the rename waits for it without asking for frames. + if self.ui.rename.is_some_and(|r| r.start) && left_open && self.ui.left_tab == LeftTab::File { ctx.request_repaint(); } a11y::end(self, &ctx); diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 72435673c..f63ab334a 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -102,6 +102,7 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["homeSearch"] = json!(self.ui.home_view.file_query); + state["panels"]["minimized"] = json!(self.session.ds().is_ok_and(|d| d.view.ui_minimized)); // Set apart from the literal above, which is at the macro recursion limit. state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 128cd0294..49040b7d9 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -247,6 +247,22 @@ impl FigApp { }); } + /// The way back from a minimized interface by mouse: a small Panels button at the top left of + /// the canvas, where the navigation bar was (the shortcut works too). + pub(super) fn expand_button(&mut self, ctx: &egui::Context, at: egui::Pos2) { + use a11y::Tip as _; + let t = theme::Tokens::get(ctx); + egui::Area::new(egui::Id::unique("expand_interface")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { + let (r, resp) = ui.allocate_exact_size(egui::vec2(32.0, 32.0), egui::Sense::click()); + let resp = resp.tip(i18n::tr("Expand the interface")); + ui.painter().rect(r, 8.0, t.panel, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), r.shrink(8.0), icons::Icon::Panels, t.icon); + if resp.clicked() { + self.run("view.minimize", serde_json::json!({})); + } + }); + } + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request /// that frame and the frame that hides it, expire and remove it at the deadline, and place it /// at the bottom centre. diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index e2054bc84..ae34ded78 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -513,3 +513,20 @@ fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { assert_eq!(passes, 2, "egui reran the frame"); assert_eq!(app.frame_stats.frames, frames + 1, "one frame was drawn, not one per pass"); } + +#[test] +fn a_rename_started_while_minimized_waits_for_the_layers_panel_quietly() { + let (mut h, _) = document(); + let r = h.run("shape.rectangle", json!({"x": 500, "y": 500, "width": 40, "height": 40})); + h.run("view.minimize", json!({"on": true})); + h.settle(); + // What Cmd/Ctrl+R leaves: the field opens when the layers panel next draws, which a minimized + // interface doesn't do; no frame is asked for meanwhile. + h.app.ui.rename = Some(crate::panels::layers::Rename { id: id(&r), start: true }); + h.assert_idle("a rename started while the interface is minimized"); + h.run("view.minimize", json!({"on": false})); + for _ in 0..3 { + h.frame(vec![]); + } + assert!(h.app.ui.rename.is_some_and(|r| !r.start), "the field opened once the interface expanded"); +} diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index 6f3eacf7e..357b13669 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -160,7 +160,7 @@ fn the_command_sets_and_resets_the_widths() { u.app.run("view.panelWidths", json!({"left": 320, "right": 400})); u.frame(vec![]); assert_eq!(widths(&u), (320.0, 400.0)); - assert_eq!(u.app.inspect()["panels"], json!({"left": 320.0, "right": 400.0, "leftCollapsed": false})); + assert_eq!(u.app.inspect()["panels"], json!({"left": 320.0, "right": 400.0, "leftCollapsed": false, "minimized": false})); u.app.run("view.panelWidths", json!({"left": 5000})); assert_eq!(widths(&u), (*LEFT_PANEL.end(), 400.0), "within its limits; the other panel as it was"); for junk in [json!({"left": "wide"}), json!({"left": null, "right": null}), json!({})] { @@ -210,6 +210,58 @@ fn minimize_ui_hides_the_panels_and_keeps_their_widths() { assert_eq!(edges(&u), (56.0, shown.1)); } +/// The middle of the accessible control with `role` and `label`, if one shows. +fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == role && n.label() == Some(label))?; + let b = n.bounds()?; + Some(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) +} + +/// "You can click Minimize next to the file name to collapse the navigation bar, left sidebar, +/// and right sidebar" (Explore the navigation bar and left sidebar; Shift+Cmd/Ctrl+\ too): the +/// rail and both sidebars go, the toolbar and the widths stay. "If you select an object while +/// the panels are minimized, the left sidebar remains minimized while the right sidebar expands +/// [...] Once all objects are deselected, the right panel minimizes again." The Expand button at +/// the top left puts everything back (#1835). +#[test] +fn minimize_collapses_the_rail_and_both_sidebars_and_the_right_one_follows_the_selection() { + use egui::accesskit::Role; + let (mut u, _) = ui(); + u.app.run("view.panelWidths", json!({"left": 320, "right": 400})); + u.app.run("select.set", json!({"ids": []})); + u.frame(vec![]); + let shown = edges(&u); + let minimize = control(&mut u, Role::Button, "Minimize").expect("the Minimize button by the file name"); + u.button(minimize, true); + u.button(minimize, false); + u.frame(vec![]); + assert_eq!(edges(&u), (0.0, 1440.0), "no rail and no sidebars: the canvas has the window"); + assert!(control(&mut u, Role::Toolbar, "Tools").is_some(), "the toolbar stays"); + assert_eq!(widths(&u), (320.0, 400.0), "the widths are kept"); + // A selection brings the right sidebar, the left stays away; deselecting takes it again. + u.app.run("select.all", json!({})); + u.frame(vec![]); + assert!(edges(&u).0 == 0.0 && edges(&u).1 < 1440.0, "the right sidebar shows for the selection: {:?}", edges(&u)); + u.app.run("select.set", json!({"ids": []})); + u.frame(vec![]); + assert_eq!(edges(&u), (0.0, 1440.0), "and goes once nothing is selected"); + // Expand puts the interface back as it was. + let expand = control(&mut u, Role::Button, "Expand the interface").expect("the Expand button at the top left"); + u.button(expand, true); + u.button(expand, false); + u.frame(vec![]); + assert_eq!((edges(&u), widths(&u)), (shown, (320.0, 400.0)), "back as it was"); + assert!(control(&mut u, Role::Button, "Expand the interface").is_none()); + // Hide UI hides the toolbar as well, as before. + u.app.run("view.toggleUi", json!({})); + assert!(control(&mut u, Role::Toolbar, "Tools").is_none(), "Hide UI takes the toolbar too"); + u.app.run("view.toggleUi", json!({})); +} + #[test] fn the_smallest_window_keeps_its_canvas_whatever_the_widths() { for asked in [(240.0, 240.0), (480.0, 480.0), (480.0, 240.0), (200.0, 480.0)] { @@ -231,6 +283,37 @@ fn the_smallest_window_keeps_its_canvas_whatever_the_widths() { } } +/// Minimize takes nothing from a phone-sized window, which has no sidebars beside the canvas: its +/// panels open from their buttons as before, and the interface is minimized again once the window +/// is wide. +#[test] +fn a_narrow_window_keeps_its_panel_buttons_while_minimized() { + use crate::MobilePanel; + let ctx = egui::Context::default(); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + let mut k = 0.0; + let mut canvas_width = |app: &mut FigApp, size: egui::Vec2| { + for _ in 0..4 { + k += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), time: Some(k), ..Default::default() }; + ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + } + app.canvas.rect.width() + }; + let phone = vec2(390.0, 844.0); + app.run("view.minimize", json!({"on": true})); + assert!(canvas_width(&mut app, phone) >= 380.0, "closed panels leave the whole width"); + app.ui.mobile_panel = Some(MobilePanel::Left); + let w = canvas_width(&mut app, phone); + assert!((1.0..=60.0).contains(&w), "the Layers button opens the left panel while minimized: {w}"); + app.ui.mobile_panel = None; + let wide = canvas_width(&mut app, vec2(1440.0, 900.0)); + assert!(wide >= 1439.0, "wide again, the interface is minimized: the canvas has the window ({wide})"); + app.run("view.minimize", json!({"on": false})); + let wide = canvas_width(&mut app, vec2(1440.0, 900.0)); + assert!(wide > 600.0 && wide < 1100.0, "expanded, both panels are beside the canvas again ({wide})"); +} + /// A phone-sized window: the canvas has the whole width, and each side panel opens alone over it, /// leaving the strip with the panel buttons. A wide window ignores the choice. #[test] diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index feeed46e6..164ec7e4a 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -159,13 +159,15 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); } let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0)); - let hresp = ui.interact(hr, ui.scope_id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); + // Minimize, by the file name: "click Minimize next to the file name to collapse the + // navigation bar, left sidebar, and right sidebar" (Explore the navigation bar and left sidebar). + let hresp = ui.interact(hr, ui.scope_id().with("hideui"), Sense::click()).tip(tr("Minimize")); if hresp.hovered() { ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon); if hresp.clicked() { - app.ui.close_left_panel(); + app.run("view.minimize", json!({})); } ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border)); match app.ui.left_tab { diff --git a/crates/ui-egui/src/shortcuts_tests.rs b/crates/ui-egui/src/shortcuts_tests.rs index 5c1e06fab..4b223b211 100644 --- a/crates/ui-egui/src/shortcuts_tests.rs +++ b/crates/ui-egui/src/shortcuts_tests.rs @@ -124,8 +124,12 @@ fn panel_keys_and_cmd_k() { press(&mut u, Key::D, Modifiers::SHIFT); assert_eq!(u.app.ui.right_tab, RightTab::Design); let collapsed = u.app.ui.left_collapsed; + let minimized = |u: &Ui| u.app.session.ds().unwrap().view.ui_minimized; press(&mut u, Key::Backslash, Modifiers::SHIFT | Modifiers::COMMAND); - assert_eq!(u.app.ui.left_collapsed, !collapsed, "Shift+Cmd+\\ toggles the left sidebar"); + assert!(minimized(&u), "Shift+Cmd+\\ minimizes the interface"); + assert_eq!(u.app.ui.left_collapsed, collapsed, "without collapsing the left sidebar for good"); + press(&mut u, Key::Backslash, Modifiers::SHIFT | Modifiers::COMMAND); + assert!(!minimized(&u), "and back"); press(&mut u, Key::K, Modifiers::COMMAND); assert!(u.app.ui.palette.is_some(), "Cmd+K opens Actions on the canvas"); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7a416f2d6..1d3c01373 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-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -17,7 +17,7 @@ interaction feel not yet compared. |---|---|---| | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | -| Minimize UI (Cmd+\\) | done | | +| Minimize UI (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Design panel's sections on one grid | done | every section lays its rows out on the same grid: the panel is 240 pt, its content 16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with 8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex, so they share the panel when it is dragged wider); rows 24 pt in a 32 pt track, 10 pt captions over them; section titles 11 pt semibold and a row's values 11 pt, their buttons 24 pt and 4 pt apart. The panel's edge is drawn inside it: a frame stroke counts as margin and took 1 pt off each side, putting every right margin 1–2 pt out (checked in the app on Windows over the control channel) | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other; a command that needs parameters only through its menu item, and none twice under one label, #1574), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | @@ -181,6 +181,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` | | 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. | | 2026-11-02 | patch | Shortcuts: Alt+Enter while editing text opens the link at the caret ("When editing text, press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma) through the new `text.openLink` command, which hands a web or mail link to the platform's opener and refuses any other scheme as the prototype player does; editing goes on. The key went to the editor as a plain Enter before. A documented row: 134 documented, 133 bound. Tests: engine `text_link_tests`, `text_link_tests` (UI) | +| 2026-11-02 | patch | M7, #1835: Minimize (`view.minimize`, Shift+Cmd/Ctrl+\\, the Minimize button by the file name) collapses the navigation bar, the left sidebar and the right sidebar and keeps the toolbar and the panel widths ("click Minimize next to the file name to collapse the navigation bar, left sidebar, and right sidebar"; "This collapses the navigation bar, left sidebar, and right sidebar for an expanded view", Explore the navigation bar and left sidebar); the right sidebar shows while something is selected and goes once nothing is; an Expand button at the top left of the canvas puts everything back. The shortcut and the button collapsed the left sidebar alone before (`view.leftSidebar` stays as a command without a key). Tests: `panel_width_tests`, `shortcuts_tests`. Numbers unchanged (the row is bound to the new command) | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | | 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | | 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged |