From 2c3ea03c2ec488a9f2b144cb7dedfb7220a8a7a1 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:44:44 +0200 Subject: [PATCH 01/44] feat(ui): add the interface size preference (#540) Preferences > Interface size and the `pref.uiScale` command make the whole interface larger or smaller. Auto keeps the system's display scale; a fixed size from 75 % to 300 % is absolute (100 % is one screen pixel per point on every system). The size is held back while the window is too small for it. Strings are added in en, es and fr; the control protocol, MCP, UI parity and roadmap docs are updated. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/a11y_keys_tests.rs | 50 +++++++++++ crates/engine/src/cmd/view.rs | 18 ++++ crates/engine/src/lib.rs | 22 +++++ crates/ui-egui/locales/en.toml | 6 ++ crates/ui-egui/locales/es.toml | 7 ++ crates/ui-egui/locales/fr.toml | 7 ++ crates/ui-egui/src/dialogs.rs | 48 ++++++++++ crates/ui-egui/src/lib.rs | 23 +++++ crates/ui-egui/src/ui_scale.rs | 127 +++++++++++++++++++++++++++ crates/ui-egui/src/ui_scale_tests.rs | 75 ++++++++++++++++ docs/control-protocol.md | 4 +- docs/mcp.md | 6 ++ docs/ui-parity.md | 2 + 14 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/ui_scale.rs create mode 100644 crates/ui-egui/src/ui_scale_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..e5ed24d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -319,6 +319,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | +| 2026-10-17 | minor | Interface size (#540): Preferences ▸ Interface size and `pref.uiScale` make the whole interface larger or smaller. Auto keeps the system's display scale (its entry shows the size it draws at); a fixed size from 75 % to 300 % is absolute, so 100 % is one screen pixel per point on every system. A window too small for a fixed size draws a smaller one until it is larger (the choice is kept); the browser keeps its own zoom. Cmd/Ctrl +/-/0 still zoom the canvas only. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 6687886c..72507df5 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -55,3 +55,53 @@ fn reduce_motion_is_a_preference_that_persists() { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.reduce_motion); } + +#[test] +fn interface_size_is_auto_until_chosen_and_a_chosen_size_is_absolute_and_persists() { + let mut s = Session::new(); + assert_eq!(s.prefs.ui_scale, None); + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], "auto"); + // A fixed size: 1.0 is one screen pixel per point, so the number is not multiplied by the system's scale. + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25); + assert_eq!(s.prefs.ui_scale, Some(1.25)); + // Reading changes nothing; "auto" goes back to the system's scale. + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], "auto"); + assert_eq!(s.prefs.ui_scale, None); + // Nothing to undo: the document didn't change. + assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false)); + // Saved with the other preferences. + s.prefs.ui_scale = Some(2.5); + let v = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(v["ui_scale"], 2.5); + let back: crate::Prefs = serde_json::from_value(v).unwrap(); + assert_eq!(back.ui_scale, Some(2.5)); +} + +#[test] +fn interface_size_stops_at_the_ends_and_junk_never_changes_it() { + let mut s = Session::new(); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX); + // The size is a factor (1.25), not a percentage: 125 is far past the end and stops there. + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX); + s.prefs.ui_scale = Some(1.5); + for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] { + assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}"); + assert_eq!(s.prefs.ui_scale, Some(1.5), "{junk}"); + } +} + +#[test] +fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_auto_and_keeps_the_rest() { + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert_eq!(old.ui_scale, None); + for bad in [json!("big"), json!(null), json!([2]), json!(true)] { + let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (None, "dark"), "{bad}"); + } + // A number out of range is clamped rather than refused. + let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (Some(crate::UI_SCALE_MAX), "dark")); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 69b938e9..9b929118 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -370,6 +370,24 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!( + "pref.uiScale", + "Interface size", + "{scale?: \"auto\" or a fixed size from 0.75 to 3, 1 being one screen pixel per point whatever scale the system reports, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; omit: just read it)} → {scale: \"auto\" or the size}: how large the whole interface is drawn; auto keeps the system's scale; the canvas zoom is separate", + false, + |s, p| { + match get(p, "scale") { + None => {} + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = None, + Some(_) => { + let scale = req_num("pref.uiScale", p, "scale")?; + s.prefs.ui_scale = + Some(crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number or \"auto\""))?); + } + } + Ok(json!({"scale": s.prefs.ui_scale.map_or(json!("auto"), |v| json!(v))})) + } + ), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e87..c8010399 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -603,6 +603,22 @@ impl DocState { } } +/// The smallest and largest fixed interface size: 1.0 is one screen pixel per point, whatever scale +/// the system reports. +pub const UI_SCALE_MIN: f64 = 0.75; +pub const UI_SCALE_MAX: f64 = 3.0; + +/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None` (Auto). +pub fn clamp_ui_scale(scale: f64) -> Option { + scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) +} + +/// Reads the saved interface size. Anything but a number reads as Auto and a number out of range is +/// clamped, so a settings file edited by hand never costs the other preferences. +fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale)) +} + /// App-wide preferences that commands read (nudge amounts, behaviours). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -630,6 +646,11 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// How large the whole interface is drawn. `None` is Auto: the scale the system reports. A + /// number is a fixed size, 1.0 being one screen pixel per point, so it ignores the system's + /// scale (kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]). The canvas zoom is separate. + #[serde(deserialize_with = "read_ui_scale")] + pub ui_scale: Option, /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, @@ -698,6 +719,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + ui_scale: None, auto_save: false, invite_links: None, plugins: Vec::new(), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..b16d9550 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -134,7 +134,9 @@ name = "English" "Auto (space between)" = "Auto (space between)" "Auto (space evenly)" = "Auto (space evenly)" "Auto ({mode})" = "Auto ({mode})" +"Auto ({percent}%)" = "Auto ({percent}%)" "Auto columns" = "Auto columns" +"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." "Auto gap" = "Auto gap" "Auto height" = "Auto height" "Auto layout" = "Auto layout" @@ -653,6 +655,7 @@ name = "English" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." +"Interface size" = "Interface size" "Intersect" = "Intersect" "Intersects" = "Intersects" "Invalid color" = "Invalid color" @@ -1371,6 +1374,7 @@ name = "English" "The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" "The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}." "The whole file" = "The whole file" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." "Theme" = "Theme" "There's no destination for that action" = "There's no destination for that action" "Thickness" = "Thickness" @@ -1450,6 +1454,7 @@ name = "English" "Use number keys for opacity" = "Use number keys for opacity" "Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension." "Use the layer's fill" = "Use the layer's fill" +"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size." "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" "Value" = "Value" @@ -1720,6 +1725,7 @@ name = "English" "{n}m" = "{n}m" "{n}w" = "{n}w" "{page}, nothing selected" = "{page}, nothing selected" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (current)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." "{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..61ec8873 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,8 @@ name = "Español" status = "draft — needs native review" [ui] +"Auto ({percent}%)" = "Auto ({percent}%)" +"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto sigue el escalado de pantalla del sistema; los demás tamaños lo ignoran. El zoom del documento es independiente." "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -654,6 +656,7 @@ status = "draft — needs native review" "Interaction" = "Interacción" "Interactions" = "Interacciones" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft." +"Interface size" = "Tamaño de la interfaz" "Intersect" = "Intersecar" "Intersects" = "Intersecciones" "Invalid color" = "Color no válido" @@ -1372,6 +1375,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" "The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}." "The whole file" = "El archivo completo" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%." "Theme" = "Tema" "There's no destination for that action" = "No hay destino para esa acción" "Thickness" = "Grosor" @@ -1451,6 +1455,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Usar teclas numéricas para la opacidad" "Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig." "Use the layer's fill" = "Usar el relleno de la capa" +"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz." "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" "Value" = "Valor" @@ -1721,6 +1726,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem" "{page}, nothing selected" = "{page}, nada seleccionado" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (actual)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual." "{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)." @@ -2032,6 +2038,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" "pref.showDimensions" = "Mostrar dimensiones en los objetos" +"pref.uiScale" = "Tamaño de la interfaz" "pref.userName" = "Tu nombre en los comentarios" "proto.action.add" = "Añadir acción de prototipo" "proto.action.move" = "Reordenar acción de prototipo" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a62..ff88477f 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -134,7 +134,9 @@ status = "draft — needs native review" "Auto (space between)" = "Auto (espace entre)" "Auto (space evenly)" = "Auto (espace égal)" "Auto ({mode})" = "Auto ({mode})" +"Auto ({percent}%)" = "Auto ({percent} %)" "Auto columns" = "Colonnes auto" +"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto suit la mise à l’échelle d’affichage de votre système ; les autres tailles l’ignorent. Le zoom du document est distinct." "Auto gap" = "Espacement auto" "Auto height" = "Hauteur auto" "Auto layout" = "Mise en page auto" @@ -652,6 +654,7 @@ status = "draft — needs native review" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft." +"Interface size" = "Taille de l’interface" "Intersect" = "Intersection" "Intersects" = "Intersections" "Invalid color" = "Couleur non valide" @@ -1366,6 +1369,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un" "The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}." "The whole file" = "Le fichier entier" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %." "Theme" = "Thème" "There's no destination for that action" = "Cette action n’a pas de destination" "Thickness" = "Épaisseur" @@ -1443,6 +1447,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité" "Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig." "Use the layer's fill" = "Utiliser le remplissage du calque" +"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface." "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" "Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" @@ -1710,6 +1715,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem." "{page}, nothing selected" = "{page}, aucune sélection" +"{percent}%" = "{percent} %" "{profile} (current)" = "{profile} (actuel)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel." "{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)." @@ -2021,6 +2027,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" "pref.showDimensions" = "Afficher les dimensions sur les objets" +"pref.uiScale" = "Taille de l’interface" "pref.userName" = "Votre nom dans les commentaires" "proto.action.add" = "Ajouter une action de prototype" "proto.action.move" = "Réordonner l’action de prototype" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 448501ab..7527969e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -273,6 +273,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } }); ui.add_space(8.0); + interface_size(app, ui, prefs.ui_scale); + ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Theme")); for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] { @@ -475,6 +477,52 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { /// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers /// start, a preview of the first new names, then one `object.batchRename`. +/// Preferences ▸ Interface size: Auto (the system's scale) or a fixed size, as a drop-down that +/// takes effect at once, with what each means written out. The browser has its own zoom, so there +/// the row only says so. +fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option) { + let t = Tokens::get(ui.ctx()); + if cfg!(target_arch = "wasm32") { + ui.label(tr("Interface size")); + crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary); + return; + } + let percent = |scale: f32| crate::ui_scale::percent(scale); + let auto = trf("Auto ({percent}%)", &[("percent", &percent(app.ui.ui_native))]); + let fixed = |size: f64| trf("{percent}%", &[("percent", &percent(size as f32))]); + let shown = chosen.map_or_else(|| auto.clone(), fixed); + ui.horizontal(|ui| { + ui.label(tr("Interface size")); + let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); + crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); + crate::right::menu_popup(&resp).show(|ui| { + if crate::right::menu_check(ui, chosen.is_none(), &auto, "") { + app.run("pref.uiScale", json!({"scale": "auto"})); + ui.close(); + } + for size in crate::ui_scale::SIZES { + if crate::right::menu_check(ui, chosen == Some(size), &fixed(size), "") { + app.run("pref.uiScale", json!({"scale": size})); + ui.close(); + } + } + }); + }); + crate::widgets::label( + ui, + tr("Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate."), + t.text_secondary, + ); + // Said in words, not only by the menu's value: the window is too small for the size chosen. + if let Some(size) = chosen.filter(|_| app.ui.ui_scale.limited) { + let note = trf( + "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.", + &[("size", &percent(size as f32)), ("drawn", &percent(app.ui.ui_scale.drawn))], + ); + crate::widgets::label(ui, ¬e, t.text_secondary); + } +} + fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut b) = app.ui.batch_rename.clone() else { return }; let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..f329c521 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,7 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; +pub mod ui_scale; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; @@ -204,6 +205,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod ui_scale_tests; +#[cfg(test)] mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; @@ -459,6 +462,11 @@ pub struct UiState { pub menu_input: Option, /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. pub rename_file: bool, + /// What the interface size drew last frame: the system's scale (what Auto shows) and whether + /// the window held a fixed size back. Set by [`FigApp::logic`] for the main window. + pub ui_scale: ui_scale::Plan, + /// The system's scale of the main window, as of the last frame. + pub ui_native: f32, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -612,6 +620,8 @@ impl Default for UiState { preferences: false, menu_input: None, rename_file: false, + ui_scale: ui_scale::plan(None, None, None), + ui_native: 1.0, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), @@ -1261,6 +1271,17 @@ impl FigApp { theme::install_fonts(ctx, true, cjk); } } + // egui's zoom is for the whole app, so only the main window sets it (a window on another + // screen has its own system scale). The browser keeps its own zoom: Auto there. + if ctx.viewport_id() == egui::ViewportId::ROOT { + let native = ctx.native_pixels_per_point(); + let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); + let fixed = if cfg!(target_arch = "wasm32") { None } else { self.session.prefs.ui_scale }; + let plan = ui_scale::plan(fixed, native, Some((window_px.x, window_px.y))); + ctx.set_zoom_factor(plan.zoom); + self.ui.ui_scale = plan; + self.ui.ui_native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); + } let dark = match self.session.prefs.theme.as_str() { "dark" => true, // The active visuals were set by us last frame (possibly for a manual theme). @@ -1679,6 +1700,8 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["homeSearch"] = json!(self.ui.home_view.file_query); + // Set apart from the literal above, which is at the macro recursion limit. + state["uiScale"] = json!({"choice": self.session.prefs.ui_scale.map_or(json!("auto"), |v| json!(v)), "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs new file mode 100644 index 00000000..91d790cc --- /dev/null +++ b/crates/ui-egui/src/ui_scale.rs @@ -0,0 +1,127 @@ +//! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes +//! egui's zoom factor. +//! +//! egui draws at `pixels_per_point = zoom factor × the system's scale`. Auto leaves the zoom at 1 so +//! the system's scale rules. A fixed size is absolute: 100 % is one screen pixel per point on every +//! system, so the zoom is the size divided by the system's scale (a Retina Mac at 2× needs 0.5 to +//! show 100 %). A fixed size is held back while the window is too small to lay the interface out at +//! it; the saved choice never changes. + +/// The smallest window, in points, the interface is laid out for: the main window's minimum size. +pub const FIT_POINTS: (f32, f32) = (800.0, 500.0); + +/// The zoom factors egui's own settings window allows. +const ZOOM_RANGE: (f32, f32) = (0.1, 10.0); + +/// What to give egui for a saved choice on this screen and window. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plan { + /// For `Context::set_zoom_factor`. + pub zoom: f32, + /// Physical pixels per point once drawn (the zoom times the system's scale). + pub drawn: f32, + /// A fixed size was chosen but the window is too small for it, so a smaller one is drawn. + pub limited: bool, +} + +/// The plan for `choice` (`None`: Auto), the system's scale `native` and the window's size in +/// physical pixels `window_px`. An unknown, zero or not finite value reads as no information: a +/// system scale of 1 and a window of any size. +pub fn plan(choice: Option, native: Option, window_px: Option<(f32, f32)>) -> Plan { + let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); + let Some(want) = choice.map(|c| c as f32).filter(|c| c.is_finite() && *c > 0.0) else { + return Plan { zoom: 1.0, drawn: native, limited: false }; + }; + let fit = + window_px.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1)); + // Never below 1 (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. + let target = match fit { + Some(fit) if want > fit => fit.max(want.min(1.0)), + _ => want, + }; + let zoom = (target / native).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + let drawn = zoom * native; + Plan { zoom, drawn, limited: drawn < want - 0.005 } +} + +/// A size as the percentage Preferences shows (1.25 → 125). +pub fn percent(scale: f32) -> u32 { + (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 +} + +/// The fixed sizes Preferences offers, after Auto. +pub const SIZES: [f64; 7] = [0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; + +#[cfg(test)] +mod tests { + use super::*; + + const BIG: Option<(f32, f32)> = Some((5000.0, 3000.0)); + + #[test] + fn auto_keeps_the_systems_scale_on_every_screen() { + for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + let p = plan(None, Some(native), BIG); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "{native}"); + } + } + + #[test] + fn a_fixed_size_is_the_same_size_whatever_the_systems_scale() { + // Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen. + for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + for size in SIZES { + let p = plan(Some(size), Some(native), BIG); + assert!((p.drawn - size as f32).abs() < 1e-5, "{size} on {native}: drawn {}", p.drawn); + assert!(!p.limited, "{size} on {native}"); + } + } + // 100 % on a 2× screen really is half the Auto size: the zoom is 0.5. + assert_eq!(plan(Some(1.0), Some(2.0), BIG).zoom, 0.5); + } + + #[test] + fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() { + for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] { + for choice in [None, Some(1.5), Some(f64::NAN), Some(f64::INFINITY), Some(-1.0), Some(0.0), Some(1e300)] { + let p = plan(choice, native, Some((f32::NAN, 0.0))); + assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}"); + assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}"); + } + } + // A choice that isn't a usable number is Auto. + assert_eq!(plan(Some(f64::NAN), Some(2.0), BIG), plan(None, Some(2.0), BIG)); + } + + #[test] + fn a_small_window_holds_a_big_size_back_without_going_below_100() { + // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels. + let min = Some((1600.0, 1000.0)); + let p = plan(Some(3.0), Some(2.0), min); + assert_eq!((p.drawn, p.limited), (2.0, true)); + assert!(!plan(Some(2.0), Some(2.0), min).limited); + // The same window on a 1× screen: 800 × 500 pixels hold only 100 %. + let p = plan(Some(2.0), Some(1.0), Some((800.0, 500.0))); + assert_eq!((p.drawn, p.limited), (1.0, true)); + // A window smaller than the minimum (a tiling window manager): never below 100 %, and never + // below a smaller size the person chose. + assert_eq!(plan(Some(2.0), Some(1.0), Some((400.0, 250.0))).drawn, 1.0); + let p = plan(Some(0.75), Some(1.0), Some((400.0, 250.0))); + assert_eq!((p.drawn, p.limited), (0.75, false)); + } + + #[test] + fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() { + // The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too. + let px = Some((1700.0, 1100.0)); + let a = plan(Some(2.5), Some(2.0), px); + let b = plan(Some(2.5), Some(2.0), px); + assert_eq!(a, b); + } + + #[test] + fn percentages_round_like_the_menu_shows_them() { + assert_eq!([percent(0.75), percent(1.0), percent(1.25), percent(2.0), percent(1.7999)], [75, 100, 125, 200, 180]); + assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]); + } +} diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs new file mode 100644 index 00000000..cda3dd1a --- /dev/null +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -0,0 +1,75 @@ +//! The interface size, driven through egui headless on screens of different system scales: Auto +//! leaves the system's scale alone, a fixed size comes out the same on every screen, and the +//! Preferences drop-down tells a screen reader its name and value. +//! +//! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small +//! window is tested on its own in `ui_scale::tests`, and the sizes below stay inside it. + +use egui::accesskit::Role; +use egui::{RawInput, ViewportId, ViewportInfo}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// One frame on a screen whose system scale is `native` (a real window reports it every frame). +fn frame_at(u: &mut Ui, native: f32) -> egui::FullOutput { + let mut raw = RawInput::default(); + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + u.frame_raw(raw) +} + +/// Frames on that screen, enough for a new zoom to take effect. +fn settle(u: &mut Ui, native: f32) { + for _ in 0..4 { + frame_at(u, native); + } +} + +const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0]; + +#[test] +fn auto_leaves_the_systems_scale_and_the_menu_can_say_what_it_is() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + settle(&mut u, native); + assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}"); + assert_eq!(u.ctx.pixels_per_point(), native, "{native}"); + assert_eq!(u.app.ui.ui_native, native, "what Auto shows in the menu: {native}"); + } +} + +#[test] +fn a_fixed_size_draws_the_same_on_every_screen_and_auto_brings_the_systems_scale_back() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + for size in [0.75_f64, 1.0, 1.5] { + assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some()); + settle(&mut u, native); + let drawn = u.ctx.pixels_per_point(); + assert!((drawn - size as f32).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!(!u.app.ui.ui_scale.limited, "{size} on {native}"); + } + assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some()); + settle(&mut u, native); + assert_eq!((u.ctx.zoom_factor(), u.ctx.pixels_per_point()), (1.0, native), "{native}"); + } +} + +#[test] +fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() { + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + let combo = |u: &mut Ui| { + settle(u, 2.0); + let out = frame_at(u, 2.0); + let nodes: Vec<_> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu"); + (n.value().map(str::to_owned), n.is_expanded()) + }; + // Auto says the size it draws at: a 2× screen is 200 %. + assert_eq!(combo(&mut u), (Some("Auto (200%)".to_owned()), Some(false))); + u.app.run("pref.uiScale", json!({"scale": 1.25})); + assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ae9d287..a807fe65 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -169,7 +169,9 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). `canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or -the system's accessibility setting turns the interface's animations off. `live` is the live +the system's accessibility setting turns the interface's animations off. `uiScale` is the interface +size: the `choice` (`"auto"` or a fixed size, `pref.uiScale`), what is `drawn` (physical pixels per +point) and whether the window is too small for the choice (`limited`, so `drawn` is smaller). `live` is the live session of the window's active file as the window shows it (sessions are per file: another tab shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one), `sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f..42a6222d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -417,6 +417,12 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.uiScale {scale?}`: how large the whole interface is drawn. `scale` is `"auto"` (the system's + display scale, the default) or a fixed size from 0.75 to 3 where 1 is one screen pixel per point + whatever scale the system reports, so a 2× screen needs 2 to match Auto. 1.25 means 125 %, not 125 + (outside the range it stops at the nearest end). Omit `scale` to read it. The canvas zoom is separate. + A window too small for a fixed size draws a smaller one until it is larger; the choice is kept. + In the browser the interface always follows the browser's zoom. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..c935e009 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -165,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | +| Interface size (Preferences ▸ Interface size, `pref.uiScale`) | done on desktop | Auto (the system's scale) or a fixed size from 75 % to 300 %, where 100 % is one screen pixel per point on every system; held back while the window is too small for it; the browser keeps its own zoom | | WCAG AA contrast of secondary and accent text | done | | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -176,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | +| 2026-10-17 | minor | Interface size preference (#540): Preferences ▸ Interface size and `pref.uiScale`, Auto or a fixed size from 75 % to 300 %, absolute (100 % is one screen pixel per point whatever the system's scale) | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From a6659dcdfc52d36f265058faeec5ba010574b685 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:55:19 +0200 Subject: [PATCH 02/44] test(ui): sweep every window size, system scale and interface size Draws the interface the way a real window does (a fixed number of physical pixels, its size in points being that divided by the scale drawn now) over 10 window sizes, 6 system scales and every interface size. Guards the rule a fixed size promises: above 100 % the window still holds the smallest layout, the panels leave room for a canvas, and the size settles without flicker. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ui_scale_tests.rs | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index cda3dd1a..74f1f676 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -73,3 +73,85 @@ fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() u.app.run("pref.uiScale", json!({"scale": 1.25})); assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); } + +/// Every window size, system scale and interface size, drawn the way a real window is: the window is +/// a fixed number of physical pixels, and its size in points is that divided by the scale drawn now +/// (egui-winit does the same each frame), so a bigger size shrinks the window in points. +/// +/// What a fixed size promises: whatever it draws at above 100 %, the window still holds the smallest +/// layout the interface is made for (`ui_scale::FIT_POINTS`), and the panels leave room for a canvas. +#[test] +fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window() { + use crate::ui_scale::{FIT_POINTS, SIZES}; + use crate::{FigApp, NoServices}; + use egui::{Pos2, Rect, vec2}; + + // Window sizes in points: the main window's minimum, common laptop and desktop screens up to 5K, + // and a short wide strip. + let windows: [(f32, f32); 10] = [ + (800.0, 500.0), + (1024.0, 640.0), + (1280.0, 720.0), + (1366.0, 768.0), + (1440.0, 900.0), + (1920.0, 1080.0), + (2560.0, 1440.0), + (3840.0, 2160.0), + (5120.0, 2880.0), + (2560.0, 520.0), + ]; + let mut choices: Vec> = vec![None]; + choices.extend(SIZES.iter().map(|s| Some(*s))); + + for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] { + for (w, h) in windows { + let px = vec2(w * native, h * native); + for choice in &choices { + let case = format!("{w}x{h} pt at {native}x, {choice:?}"); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + let scale = choice.map_or(json!("auto"), |c| json!(c)); + assert!(app.run("pref.uiScale", json!({"scale": scale})).is_some(), "{case}"); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut seen = Vec::new(); + for _ in 0..8 { + time += 1.0 / 60.0; + let ppp = ctx.pixels_per_point(); + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, px / ppp)), time: Some(time), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); + out.textures_delta.clear(); + seen.push((ctx.pixels_per_point(), ctx.content_rect().size())); + } + // It settles within a few frames and then stays put: no flicker between two sizes. + let (ppp, content) = seen[seen.len() - 1]; + assert_eq!(seen[seen.len() - 2].0, ppp, "{case}: still changing"); + assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}"); + let plan = app.ui.ui_scale; + assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn); + match choice { + None => assert!((ppp - native).abs() < 1e-3 && !plan.limited, "{case}: Auto must keep the system's scale"), + Some(want) => { + let want = *want as f32; + assert_eq!(plan.limited, ppp < want - 0.005, "{case}: limited flag"); + // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. + if ppp > 1.001 { + assert!( + content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, + "{case}: window is {content:?} points at {ppp}" + ); + } + // It never draws bigger than asked, and only a window too small for it draws smaller. + assert!(ppp <= want + 1e-3, "{case}"); + if !plan.limited { + assert!((ppp - want).abs() < 1e-3, "{case}"); + } + } + } + let panels = &app.inspect()["panels"]; + let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0); + assert!(side < f64::from(content.x) - 100.0, "{case}: the panels ({side} pt) leave no canvas in {content:?}"); + } + } + } +} From 4d9d12ea13c2c01ef3f2fe9358bcb7fa8d856965 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:14:19 +0200 Subject: [PATCH 03/44] fix(ui): let escape close an open menu before its dialog Escape closed the whole Preferences dialog even while a menu was open over it. That held while Preferences had no menu of its own; the Interface size menu is the first, so one Escape threw away the dialog with the menu. Dialog keys now leave Escape to an open menu, and the next Escape closes the dialog as before. Adds a keyboard test of the Interface size menu: Tab reaches it, Enter opens it, the arrows move through the sizes, Enter picks one and closes the menu, and Escape closes only the menu. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/dialogs.rs | 6 ++- crates/ui-egui/src/ui_scale_tests.rs | 63 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7527969e..3fb2c2d4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -143,8 +143,12 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { } /// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's -/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. +/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu +/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone. pub fn keys(app: &mut FigApp, ctx: &Context) { + if egui::Popup::is_any_open(ctx) { + return; + } let u = &mut app.ui; let open = u.color_profile.is_some() || u.menu_input.is_some() diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 74f1f676..189552e7 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -155,3 +155,66 @@ fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window( } } } + +/// The interface size is reachable and changeable without a pointer: Tab reaches the menu, Enter +/// opens it, the arrows and Tab move through its entries, Enter picks one and closes the menu, and +/// Escape closes it alone, without changing anything and without closing Preferences (a second +/// Escape does). +#[test] +fn the_interface_size_menu_works_from_the_keyboard() { + use crate::a11y::focused_name; + use egui::{Event, Key, Modifiers, Popup}; + + // A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle. + let press = |u: &mut Ui, key: Key| { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + u.frame(vec![]); + u.frame(vec![]); + }; + let open = |u: &mut Ui| { + // The Preferences dialog is not modal for the keyboard: Tab crosses the main window first. + let mut tabs = 0; + while focused_name(&u.ctx) != "Interface size" { + assert!(tabs < 140, "Tab never reaches the Interface size menu (focus: {:?})", focused_name(&u.ctx)); + press(u, Key::Tab); + tabs += 1; + } + press(u, Key::Enter); + assert!(Popup::is_any_open(&u.ctx), "Enter opens the menu"); + }; + let chosen = |u: &Ui| u.app.inspect()["uiScale"]["choice"].clone(); + + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + settle(&mut u, 1.0); + assert_eq!(chosen(&u), json!("auto")); + + // Escape closes the menu and nothing else: the size is unchanged and Preferences stays open. + open(&mut u); + press(&mut u, Key::Escape); + u.frame(vec![]); + assert!(!Popup::is_any_open(&u.ctx)); + assert!(u.app.ui.preferences, "the first Escape closes the menu only"); + assert_eq!(chosen(&u), json!("auto")); + + // The arrows enter the menu on Auto (which says the size it draws at) and go down through the + // sizes (75, 100, 125); Enter picks the one that has the keyboard and closes the menu. + open(&mut u); + press(&mut u, Key::ArrowDown); + assert_eq!(focused_name(&u.ctx), "Auto (100%)"); + for _ in 0..3 { + press(&mut u, Key::ArrowDown); + } + assert_eq!(focused_name(&u.ctx), "125%"); + press(&mut u, Key::Enter); + assert_eq!(chosen(&u), json!(1.25)); + assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu"); + assert!(u.app.ui.preferences, "picking a size leaves Preferences open"); + // With no menu open, Escape closes Preferences as before. + press(&mut u, Key::Escape); + assert!(!u.app.ui.preferences); +} From 972e228f41b895d6b7e40dcbac981e65107111a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Valentin=20N=C3=A9meth?= <61153253+attisvalentin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:49 +0200 Subject: [PATCH 04/44] M4: text.rescanFonts and text.loadFont: fonts installed while the app runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system fonts are indexed once per process, so a font installed while FigCraft (or `figcraft-cli mcp`) runs stayed missing until a restart. - text.rescanFonts walks the font folders again and offers the families installed since (loaded families keep their faces) → {added} - text.loadFont {path | data} adds a font file without installing it → {families} - both clear the layout cache so text lays out again in the new face Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 4 +-- crates/engine/src/cmd/text.rs | 30 ++++++++++++++++++ crates/engine/src/tests.rs | 19 ++++++++++++ crates/engine/src/textedit.rs | 3 +- crates/text/src/fontdb.rs | 54 +++++++++++++++++++++++++++++++++ crates/text/src/fontdb_tests.rs | 31 +++++++++++++++++++ crates/text/src/lib.rs | 2 +- docs/mcp.md | 4 ++- 8 files changed, 142 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67..65c526e4 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -363,12 +363,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { } #[cfg(not(target_arch = "wasm32"))] -fn read_path(path: &str) -> Result> { +pub(super) fn read_path(path: &str) -> Result> { crate::file_access::read(path) } #[cfg(target_arch = "wasm32")] -fn read_path(path: &str) -> Result> { +pub(super) fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3b861895..129c8ead 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -474,5 +474,35 @@ pub fn specs() -> Vec { }) } ), + spec!( + "text.rescanFonts", + "Rescan installed fonts", + "{} walks the system font folders again, for fonts installed while the app runs → {added: [families that were unknown]}", + false, + |_, _| Ok(json!({"added": figcraft_text::rescan_system_fonts()})) + ), + spec!( + "text.loadFont", + "Load font file", + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it → {families}", + false, + |_, p| { + let cmd = "text.loadFont"; + let bytes = if let Some(b64) = string(p, "data") { + use base64::Engine as _; + let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); + base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))? + } else if let Some(path) = string(p, "path") { + super::create::read_path(&path)? + } else { + return Err(bad(cmd, "pass `path` or `data` (base64)")); + }; + let families = figcraft_text::load_font_bytes(bytes); + if families.is_empty() { + return Err(bad(cmd, "not a font file")); + } + Ok(json!({"families": families})) + } + ), ] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d8ebfc9e..7cfa320b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -434,6 +434,8 @@ fn every_command_survives_junk_params() { | "file.placeImage" | "image.place" | "file.revert" + // Takes no params and walks the font folders: one walk per junk is slow. + | "text.rescanFonts" ) { continue; } @@ -624,3 +626,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn fonts_load_from_files_and_rescans_report_new_families() { + let mut s = sess(); + assert!(s.execute("text.loadFont", &json!({})).is_err()); + assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); + assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); + assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); + if let Some(f) = figcraft_text::CRAFT_FONTS.first() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); + let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); + assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); + } + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(r["added"].is_array()); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 76b353a0..38d6a199 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -793,7 +793,8 @@ mod tests { json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), ]; - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) { + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { for j in &junk { let (mut s, id) = setup("Hi there\nyou"); begin(&mut s, id, Some(2), false).unwrap(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f9eda2c6..aa5777b5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -670,6 +670,23 @@ impl FontDb { self.faces.len() } + /// Offer the families of a later walk of the font folders: new families become pending, and a + /// family not loaded yet takes the walk's files (styles installed since). Loaded families keep + /// their faces. Returns the display names of the families that were unknown. + fn merge_rescan(&mut self, found: BTreeMap)>) -> Vec { + let mut added = Vec::new(); + for (k, v) in found { + if self.families.contains_key(&k) { + continue; + } + if !self.pending.contains_key(&k) { + added.push(v.0.clone()); + } + self.pending.insert(k, v); + } + added + } + /// Index the system font directories (desktop only): family names are read now, faces loaded /// when first used. Safe to call more than once. pub fn scan_system(&mut self) { @@ -887,6 +904,43 @@ pub fn scan_system_fonts() { }); } +/// Walk the system font folders again, for fonts installed while the app runs (the first scan is +/// once per process). Returns the families that were unknown before. Before the first scan this +/// is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS` set. +pub fn rescan_system_fonts() -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + if SCANNED.get().is_none() { + let before: std::collections::BTreeSet = + raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect(); + scan_system_fonts(); + return family_names().into_iter().filter(|f| !before.contains(f)).collect(); + } + let mut found: BTreeMap)> = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut found); + } + let added = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); + if !added.is_empty() { + crate::clear_layout_cache(); + } + return added; + } + Vec::new() +} + +/// Load font file bytes (every face of a collection) into the global db, for a font that isn't +/// installed. Returns the families of the faces added (none: not a font file). +pub fn load_font_bytes(bytes: Vec) -> Vec { + let faces = db().write().unwrap_or_else(std::sync::PoisonError::into_inner).add_bytes(bytes); + if !faces.is_empty() { + crate::clear_layout_cache(); + } + let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); + families.dedup(); + families +} + /// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 96ce7842..3574fdfb 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -217,3 +217,34 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } + +#[test] +fn a_rescan_offers_fonts_installed_since() { + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let mut db = pending(&["Regular"]); + let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { + fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() + }; + // A new family is reported and offered; a pending one takes the walk's files (a new style). + let added = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); + assert_eq!(added, ["Brand Sans"]); + assert!(db.has_family("brand sans")); + assert_eq!(left(&db), ["Regular", "Bold"]); + // A loaded family keeps its faces and isn't reported again. + db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new())); + db.pending.remove("brand sans"); + assert!(db.merge_rescan(found(&[("Brand Sans", &["Regular"])])).is_empty()); + assert!(!db.pending.contains_key("brand sans")); +} + +#[test] +fn font_bytes_load_into_the_db() { + assert!(load_font_bytes(b"not a font".to_vec()).is_empty()); + assert!(load_font_bytes(Vec::new()).is_empty()); + // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added. + if let Some(f) = CRAFT_FONTS.first() { + let families = load_font_bytes(f.bytes.to_vec()); + assert_eq!(families.len(), 1); + assert!(families.iter().all(|f| has_family(f))); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f6..e107593e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,7 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f..787f0c82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -234,7 +234,9 @@ Or in a project's `.mcp.json`: `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). `text.missingFonts` lists the families the document names that aren't installed - (`{families: [{family, layers}]}`); their text draws in a fallback font. + (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once + per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and + `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From ba4c502ada1809c3a27a7795a312d0303528e0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Valentin=20N=C3=A9meth?= <61153253+attisvalentin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:35:31 +0200 Subject: [PATCH 05/44] M4: text.refit: auto-sizing text measures its box again once its font is there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text box keeps the size it was measured at, so text laid out while its family was missing kept the fallback font's box after the font arrived (a hugging chip clipped its label). - text.refit {ids?} measures auto-sizing text again (whole document by default) → {refitted} - text.rescanFonts and text.loadFont refit the open document's text in the families they add, as one undo step Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 19 +++++--- crates/engine/src/fonts.rs | 77 +++++++++++++++++++++++++++----- crates/engine/src/fonts_tests.rs | 63 ++++++++++++++++++++++++++ docs/mcp.md | 3 ++ 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 129c8ead..fbc41334 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -477,16 +477,20 @@ pub fn specs() -> Vec { spec!( "text.rescanFonts", "Rescan installed fonts", - "{} walks the system font folders again, for fonts installed while the app runs → {added: [families that were unknown]}", - false, - |_, _| Ok(json!({"added": figcraft_text::rescan_system_fonts()})) + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in those families fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + true, + |s, _| { + let added = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&added)); + Ok(json!({"added": added, "refitted": refitted})) + } ), spec!( "text.loadFont", "Load font file", - "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it → {families}", - false, - |_, p| { + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}", + true, + |s, p| { let cmd = "text.loadFont"; let bytes = if let Some(b64) = string(p, "data") { use base64::Engine as _; @@ -501,7 +505,8 @@ pub fn specs() -> Vec { if families.is_empty() { return Err(bad(cmd, "not a font file")); } - Ok(json!({"families": families})) + let refitted = crate::fonts::refit_open(s, None, Some(&families)); + Ok(json!({"families": families, "refitted": refitted})) } ), ] diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 3548124d..42cc8a56 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,13 +1,15 @@ //! Fonts a document names that this machine doesn't have (#752): text layers, their styled //! ranges, text styles and the string variables bound to a font family. Text in such a family -//! is drawn in a fallback font, so the user is told which ones are missing. +//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is +//! there, [`refit`] lets auto-sizing text measure its box in it again. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::params::{get, req_targets}; use crate::{Result, Session, spec}; /// A family the document names that no installed or built-in font provides. @@ -79,6 +81,52 @@ fn string_values(vars: &Variables, id: &str) -> Vec { out } +/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing +/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under +/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the +/// layers whose size changed. +pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + let want: Option> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect()); + let scope: Option> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect()); + let ids: Vec = doc + .nodes() + .filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id))) + .filter(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + let uses = |w: &BTreeSet| { + w.contains(&t.style.font_family.trim().to_lowercase()) + || t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase())) + }; + want.as_ref().is_none_or(uses) + && figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + }) + .map(|n| n.id) + .collect(); + if !ids.is_empty() { + let before = doc.clone(); + doc.mark_dirty(ids.iter().copied()); + crate::normalize(doc, &before); + } + ids +} + +/// [`refit`] in the open document, if there is one. +pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + match s.doc_mut() { + Ok(doc) => refit(doc, under, families), + Err(_) => Vec::new(), + } +} + +fn refit_text(s: &mut Session, p: &Value) -> Result { + let under = match get(p, "ids") { + Some(Value::Array(_)) => Some(req_targets("text.refit", s, p)?), + _ => None, + }; + s.doc()?; + Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) +} + fn missing_fonts(s: &mut Session, _: &Value) -> Result { let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); s.request_ui("dialog.missingFonts", json!({})); @@ -86,13 +134,22 @@ fn missing_fonts(s: &mut Session, _: &Value) -> Result { } pub fn specs() -> Vec { - vec![spec!( - "text.missingFonts", - "Missing fonts", - "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", - false, - missing_fonts - )] + vec![ + spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + ), + spec!( + "text.refit", + "Fit text to fonts", + "{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}", + true, + refit_text + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index a6e2d213..c947773d 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -124,3 +124,66 @@ fn needs_an_open_document() { let mut s = Session::empty(); assert!(s.execute("text.missingFonts", &json!({})).is_err()); } + +/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content. +fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(id).unwrap(); + let want = figcraft_text::auto_size(n).unwrap().0; + n.width = want + dw; + doc.take_dirty(); + want +} + +fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { + s.doc().unwrap().node(id).unwrap().width +} + +fn refitted(r: &Value) -> Vec { + r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +#[test] +fn refit_measures_stale_text_boxes_again() { + let mut s = Session::new(); + let a = text(&mut s, "FigCraft Refit A"); + let b = text(&mut s, "FigCraft Refit B"); + let fixed = text(&mut s, "FigCraft Refit A"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind { + t.auto_resize = figcraft_doc::TextAutoResize::None; + } + s.execute("select.none", &json!({})).unwrap(); + let want_a = stale(&mut s, a, -30.0); + let want_b = stale(&mut s, b, 25.0); + s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0; + s.doc_mut().unwrap().take_dirty(); + // Only the families asked for. + let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()])); + assert_eq!(only_a, vec![a]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, b), want_b + 25.0); + // Only the layers asked for, then everything; a fixed box keeps its size. + stale(&mut s, a, 12.0); + assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]); + assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b)); + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, fixed), 7.0); + // Nothing stale: nothing changes. + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); + assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + assert!(Session::empty().execute("text.refit", &json!({})).is_err()); +} + +#[test] +fn refit_reaches_text_in_instances() { + let mut s = Session::new(); + let t = text(&mut s, "FigCraft Refit C"); + let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap(); + let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap(); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + let want = stale(&mut s, copy, -20.0); + let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap(); + assert_eq!(refitted(&r), vec![copy.0]); + assert_eq!(width(&s, copy), want); +} diff --git a/docs/mcp.md b/docs/mcp.md index 787f0c82..63ff7dd0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -237,6 +237,9 @@ Or in a project's `.mcp.json`: (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). + Text boxes keep the size they were measured at: both refit the open document's auto-sizing text + in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a + file saved while its fonts were missing. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From 33c0f94cc8b5475d38c866a38fcbdc8eb774340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Valentin=20N=C3=A9meth?= <61153253+attisvalentin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:46:20 +0200 Subject: [PATCH 06/44] M4: font review fixes: new styles of loaded families, ids checks, repeat loads - text.rescanFonts offers a loaded family the files it hasn't read (styles installed since) and refits every family that gained faces; `added` still lists only the families that were unknown - text.refit refuses an `ids` that isn't a list instead of refitting the whole document - text.loadFont with a file already loaded returns its families without keeping its bytes or adding its faces again - doc comments on the functions this change touches Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 2 + crates/engine/src/cmd/text.rs | 8 ++-- crates/engine/src/fonts.rs | 7 ++- crates/engine/src/fonts_tests.rs | 8 ++++ crates/engine/src/tests.rs | 2 + crates/engine/src/textedit.rs | 1 + crates/text/src/fontdb.rs | 80 +++++++++++++++++++++++--------- crates/text/src/fontdb_tests.rs | 27 ++++++++--- crates/text/src/lib.rs | 5 +- 9 files changed, 105 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 65c526e4..6ec7d4e5 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -362,11 +362,13 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } +/// The bytes of the file at `path`, within the session's allowed file roots. #[cfg(not(target_arch = "wasm32"))] pub(super) fn read_path(path: &str) -> Result> { crate::file_access::read(path) } +/// The browser has no files to read: always an error. #[cfg(target_arch = "wasm32")] pub(super) fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fbc41334..0f0f003d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -477,12 +477,12 @@ pub fn specs() -> Vec { spec!( "text.rescanFonts", "Rescan installed fonts", - "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in those families fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", true, |s, _| { - let added = figcraft_text::rescan_system_fonts(); - let refitted = crate::fonts::refit_open(s, None, Some(&added)); - Ok(json!({"added": added, "refitted": refitted})) + let rescan = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed)); + Ok(json!({"added": rescan.added, "refitted": refitted})) } ), spec!( diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 42cc8a56..672d4f2c 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -118,10 +118,12 @@ pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Op } } +/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the +/// whole document. fn refit_text(s: &mut Session, p: &Value) -> Result { let under = match get(p, "ids") { - Some(Value::Array(_)) => Some(req_targets("text.refit", s, p)?), - _ => None, + None | Some(Value::Null) => None, + Some(_) => Some(req_targets("text.refit", s, p)?), }; s.doc()?; Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) @@ -133,6 +135,7 @@ fn missing_fonts(s: &mut Session, _: &Value) -> Result { Ok(json!({"families": families})) } +/// The font commands: `text.missingFonts` and `text.refit`. pub fn specs() -> Vec { vec![ spec!( diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index c947773d..463fb904 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -135,14 +135,17 @@ fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { want } +/// The width of layer `id`. fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { s.doc().unwrap().node(id).unwrap().width } +/// The ids a refit reports. fn refitted(r: &Value) -> Vec { r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() } +/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs. #[test] fn refit_measures_stale_text_boxes_again() { let mut s = Session::new(); @@ -172,9 +175,14 @@ fn refit_measures_stale_text_boxes_again() { // Nothing stale: nothing changes. assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + // An `ids` that isn't a list is refused, not taken as the whole document. + stale(&mut s, a, 9.0); + assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err()); + assert_eq!(width(&s, a), want_a + 9.0); assert!(Session::empty().execute("text.refit", &json!({})).is_err()); } +/// Text inside an instance is refitted too. #[test] fn refit_reaches_text_in_instances() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cfa320b..ad3b6aa3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -376,6 +376,7 @@ fn pointer_draw_move_resize() { assert_eq!(s.selection(), vec![r]); } +/// No command panics on junk parameters. #[test] fn every_command_survives_junk_params() { // A junk-parameter sweep: no command may panic (guard turns panics into Internal errors, @@ -627,6 +628,7 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } +/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. #[test] fn fonts_load_from_files_and_rescans_report_new_families() { let mut s = sess(); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 38d6a199..902ba149 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -782,6 +782,7 @@ mod tests { assert!(r.is_null() || r["axes"].is_array()); } + /// No text command panics on junk parameters while a text is being edited. #[test] fn text_commands_survive_junk_while_editing() { let junk = [ diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index aa5777b5..814d3a2f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -444,9 +444,22 @@ pub struct FontDb { families: BTreeMap)>, /// System faces known by name but not loaded (lower-case family → entries). pending: BTreeMap)>, + /// System faces read from disk (file, face index), so a rescan only offers new ones. + loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>, + /// Font files added as bytes (hash and length → families), so loading one again adds nothing. + loaded_bytes: std::collections::HashMap<(u64, usize), Vec>, system_scanned: bool, } +/// What a later walk of the font folders found ([`rescan_system_fonts`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rescan { + /// Families that were unknown before. + pub added: Vec, + /// Every family that gained faces: the new ones and those with styles installed since. + pub changed: Vec, +} + static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); @@ -580,6 +593,7 @@ impl FontDb { }; if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { self.add(face); + self.loaded_files.insert((e.path, e.index)); } } } @@ -670,21 +684,33 @@ impl FontDb { self.faces.len() } - /// Offer the families of a later walk of the font folders: new families become pending, and a - /// family not loaded yet takes the walk's files (styles installed since). Loaded families keep - /// their faces. Returns the display names of the families that were unknown. - fn merge_rescan(&mut self, found: BTreeMap)>) -> Vec { - let mut added = Vec::new(); - for (k, v) in found { + /// Offer the families of a later walk of the font folders: new families become pending, a + /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets + /// the files it hasn't read (styles installed since) as pending, read when it is next used. + fn merge_rescan(&mut self, found: BTreeMap)>) -> Rescan { + let mut out = Rescan::default(); + for (k, (display, entries)) in found { if self.families.contains_key(&k) { + let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default(); + let new: Vec = entries + .into_iter() + .filter(|e| { + !self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index) + }) + .collect(); + if !new.is_empty() { + out.changed.push(display.clone()); + self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new); + } continue; } if !self.pending.contains_key(&k) { - added.push(v.0.clone()); + out.added.push(display.clone()); } - self.pending.insert(k, v); + out.changed.push(display.clone()); + self.pending.insert(k, (display, entries)); } - added + out } /// Index the system font directories (desktop only): family names are read now, faces loaded @@ -905,39 +931,51 @@ pub fn scan_system_fonts() { } /// Walk the system font folders again, for fonts installed while the app runs (the first scan is -/// once per process). Returns the families that were unknown before. Before the first scan this -/// is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS` set. -pub fn rescan_system_fonts() -> Vec { +/// once per process): the families that were unknown before, and every family that gained faces. +/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`. +pub fn rescan_system_fonts() -> Rescan { #[cfg(not(target_arch = "wasm32"))] if !system_fonts_opted_out() { if SCANNED.get().is_none() { let before: std::collections::BTreeSet = raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect(); scan_system_fonts(); - return family_names().into_iter().filter(|f| !before.contains(f)).collect(); + let added: Vec = family_names().into_iter().filter(|f| !before.contains(f)).collect(); + return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); for dir in system_font_dirs() { scan_dir(&dir, 0, &mut found); } - let added = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); - if !added.is_empty() { + let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); + if !rescan.changed.is_empty() { crate::clear_layout_cache(); } - return added; + return rescan; } - Vec::new() + Rescan::default() } /// Load font file bytes (every face of a collection) into the global db, for a font that isn't -/// installed. Returns the families of the faces added (none: not a font file). +/// installed. Returns the families of its faces (none: not a font file); the same file again +/// returns them without adding its faces twice. pub fn load_font_bytes(bytes: Vec) -> Vec { - let faces = db().write().unwrap_or_else(std::sync::PoisonError::into_inner).add_bytes(bytes); - if !faces.is_empty() { - crate::clear_layout_cache(); + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + let key = (h.finish(), bytes.len()); + let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(families) = db.loaded_bytes.get(&key) { + return families.clone(); } + let faces = db.add_bytes(bytes); let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); families.dedup(); + if !families.is_empty() { + db.loaded_bytes.insert(key, families.clone()); + drop(db); + crate::clear_layout_cache(); + } families } diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 3574fdfb..f2456d28 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -218,6 +218,7 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } +/// A rescan offers new families and the styles installed since, and never a file twice. #[test] fn a_rescan_offers_fonts_installed_since() { let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; @@ -225,26 +226,40 @@ fn a_rescan_offers_fonts_installed_since() { let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() }; - // A new family is reported and offered; a pending one takes the walk's files (a new style). - let added = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); - assert_eq!(added, ["Brand Sans"]); + // A new family is added; a pending one takes the walk's files (a new style); both changed. + let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); + assert_eq!(r.added, ["Brand Sans"]); + assert_eq!(r.changed, ["Brand Sans", "CJK UI"]); assert!(db.has_family("brand sans")); assert_eq!(left(&db), ["Regular", "Bold"]); - // A loaded family keeps its faces and isn't reported again. + // A loaded family keeps its faces and gets only the files it hasn't read, once. db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new())); db.pending.remove("brand sans"); - assert!(db.merge_rescan(found(&[("Brand Sans", &["Regular"])])).is_empty()); + db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0)); + let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])])); + assert_eq!(same, Rescan::default()); assert!(!db.pending.contains_key("brand sans")); + let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])); + assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()])); + let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::>()).unwrap_or_default(); + assert_eq!(waiting(&db), ["Black"]); + assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default()); + assert_eq!(waiting(&db), ["Black"]); } +/// Font file bytes add their faces once; junk adds nothing. #[test] fn font_bytes_load_into_the_db() { assert!(load_font_bytes(b"not a font".to_vec()).is_empty()); assert!(load_font_bytes(Vec::new()).is_empty()); - // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added. + // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added, + // and loading the same file again returns it without adding its faces twice. if let Some(f) = CRAFT_FONTS.first() { let families = load_font_bytes(f.bytes.to_vec()); assert_eq!(families.len(), 1); assert!(families.iter().all(|f| has_family(f))); + let faces = db().read().unwrap().face_count(); + assert_eq!(load_font_bytes(f.bytes.to_vec()), families); + assert_eq!(db().read().unwrap().face_count(), faces); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e107593e..667ab731 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,9 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, + has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, + system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; From bc10bb190dde26bfd8ea5f1bbd1f392d36b0cc51 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 18 Oct 2026 20:03:43 +0300 Subject: [PATCH 07/44] M2: individual padding in the Layout panel The Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2x2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (padding_individual_tests). --- ROADMAP.md | 4 +- crates/engine/src/cmd/layout.rs | 28 ++++++ crates/ui-egui/locales/en.toml | 5 + crates/ui-egui/locales/es.toml | 5 + crates/ui-egui/src/icons.rs | 43 ++++++++- crates/ui-egui/src/layout_panel.rs | 92 +++++++++++++++---- crates/ui-egui/src/lib.rs | 2 + .../ui-egui/src/padding_individual_tests.rs | 75 +++++++++++++++ docs/auto-layout-parity.md | 3 +- 9 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/src/padding_individual_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..b00408dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (individual padding in the Layout panel, M2) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-18** · Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5643ac70..b545e52d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,34 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + #[test] + fn each_side_padding_sets_its_side_alone() { + let mut s = Session::new(); + let (f, _, _) = al_frame(&mut s); + // One side at a time: the other three stay 0 ([top, right, bottom, left]). + for (key, v, want) in [ + ("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]), + ("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]), + ("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]), + ("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]), + ] { + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}})); + ex(&mut s, "node.set", json!({"id": f, "props": {key: v}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}"); + } + // The [t, r, b, l] array and the axis pairs agree with the four sides. + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]); + // Negatives clamp to 0; junk is refused. + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0); + assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err()); + } + #[test] fn baseline_alignment_lines_up_text() { let mut s = Session::new(); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..d5d28eeb 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -172,6 +172,7 @@ name = "English" "Both directions" = "Both directions" "Bottom" = "Bottom" "Bottom left radius" = "Bottom left radius" +"Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" "Branch" = "Branch" "Branch archived" = "Branch archived" @@ -632,6 +633,7 @@ name = "English" "Included" = "Included" "Included in layout" = "Included in layout" "Individual corners" = "Individual corners" +"Individual padding" = "Individual padding" "Individual strokes" = "Individual strokes" "Ink" = "Ink" "Inner shadow" = "Inner shadow" @@ -702,6 +704,7 @@ name = "English" "Leaves" = "Leaves" "Left" = "Left" "Left & right" = "Left & right" +"Left padding" = "Left padding" "Left side" = "Left side" "Left side width" = "Left side width" "Letter case" = "Letter case" @@ -1134,6 +1137,7 @@ name = "English" "Review and merge" = "Review and merge" "Ribbon" = "Ribbon" "Right" = "Right" +"Right padding" = "Right padding" "Right side" = "Right side" "Right side width" = "Right side width" "Rotate 90°" = "Rotate 90°" @@ -1402,6 +1406,7 @@ name = "English" "Top" = "Top" "Top & bottom" = "Top & bottom" "Top left radius" = "Top left radius" +"Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" "Triangle arrow" = "Triangle arrow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..85f03521 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -175,6 +175,7 @@ status = "draft — needs native review" "Both directions" = "Ambas direcciones" "Bottom" = "Abajo" "Bottom left radius" = "Radio inferior izquierdo" +"Bottom padding" = "Margen interno inferior" "Bottom right radius" = "Radio inferior derecho" "Branch" = "Rama" "Branch archived" = "Rama archivada" @@ -633,6 +634,7 @@ status = "draft — needs native review" "Included" = "Incluidos" "Included in layout" = "Incluidos en el diseño" "Individual corners" = "Esquinas individuales" +"Individual padding" = "Margen interno individual" "Individual strokes" = "Trazos individuales" "Ink" = "Tinta" "Inner shadow" = "Sombra interior" @@ -703,6 +705,7 @@ status = "draft — needs native review" "Leaves" = "Hojas" "Left" = "Izquierda" "Left & right" = "Izquierda y derecha" +"Left padding" = "Margen interno izquierdo" "Left side" = "Lado izquierdo" "Left side width" = "Ancho del lado izquierdo" "Letter case" = "Mayúsculas y minúsculas" @@ -1135,6 +1138,7 @@ status = "draft — needs native review" "Review and merge" = "Revisar y fusionar" "Ribbon" = "Cinta" "Right" = "Derecha" +"Right padding" = "Margen interno derecho" "Right side" = "Lado derecho" "Right side width" = "Ancho del lado derecho" "Rotate 90°" = "Girar 90°" @@ -1403,6 +1407,7 @@ status = "draft — needs native review" "Top" = "Arriba" "Top & bottom" = "Arriba y abajo" "Top left radius" = "Radio superior izquierdo" +"Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" "Triangle arrow" = "Flecha triangular" diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66e..f4f2f31b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,8 @@ icons! { CornerRadius, Angle, IndependentCorners, + /// Individual padding: a frame with one inward tick per side, each set alone. + IndependentPadding, LinkOn, LinkOff, LayoutH, @@ -139,6 +141,11 @@ icons! { Fixed, PaddingH, PaddingV, + /// Individual padding sides: a frame with the controlled side filled. + PaddingTop, + PaddingRight, + PaddingBottom, + PaddingLeft, Gap, Reset, Detach, @@ -706,6 +713,14 @@ fn draw(p: &P, icon: Icon) { p.corner(x, y, dx, dy, 4.0, 2.0); } } + IndependentPadding => { + // A frame outline with one inward tick per side: each side's padding, set alone. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.line(8.0, 2.5, 8.0, 5.0); + p.line(13.5, 8.0, 11.0, 8.0); + p.line(8.0, 13.5, 8.0, 11.0); + p.line(2.5, 8.0, 5.0, 8.0); + } LinkOn | Link => { p.path(&capsule((4.0, 12.0), (6.5, 9.5), 2.4), true); p.path(&capsule((9.5, 6.5), (12.0, 4.0), 2.4), true); @@ -923,12 +938,32 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - p.rect(2.5, 3.5, 13.5, 12.5, 1.0); - p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.0); + // The horizontal axis: bars across the top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); } PaddingV => { - p.rect(3.5, 2.5, 12.5, 13.5, 1.0); - p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0); + // The vertical axis: bars down the left and right in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingTop => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + } + PaddingRight => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingBottom => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingLeft => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); } Online => { p.circle(8.0, 8.0, 5.5); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 11d30369..72d44b9a 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -4,7 +4,7 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. -use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align, Align2, CornerRadius, Id, Layout, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::*; use serde_json::json; @@ -404,31 +404,91 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; + let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + if open { + // Each side on its own, like Figma's individual padding: a 2x2 grid of sides with + // the toggle at its right, replacing the H/V row while open. Every measure copies + // a plain row: 32px rows, the panel's own inter-row gap, 8px between the fields. + let gap = ui.spacing().item_spacing.y; + let total = 32.0 * 2.0 + gap; + let fields_w = col * 2.0 + 8.0; + let top = ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0); + let right = ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1); + let bottom = ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2); + let left = ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3); + block(ui, total, 0.0, |ui| { + ui.allocate_ui_with_layout(vec2(fields_w, total), Layout::top_down(Align::LEFT), |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + // The horizontal pair (left, right) on the left, the vertical pair on the right. + for (r, pair) in [[&left, &top], [&right, &bottom]].iter().enumerate() { + if r > 0 { + ui.add_space(gap); + } + ui.allocate_ui_with_layout(vec2(fields_w, 32.0), Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (key, name, icon, v, k) in pair.iter() { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + key, + Id::new(("padding", *k)), + Lead::Icon(*icon, name), + Some(*v), + "", + col, + ); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + }); + } + }); + // The toggle keeps the shut row's rect: full-height strip, button 4px + // down, centred in the top 32px exactly like the shut row. + ui.allocate_ui_with_layout(vec2(24.0, total), Layout::top_down(Align::Center), |ui| { + ui.add_space(4.0); + if icons::button(ui, Icon::IndependentPadding, 24.0, true, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), false)); + } + }); + }); + return; + } row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - (pl == pr).then_some(pl), - "", - col, - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field( + // Sides that disagree show both values ("16,54") instead of "Mixed". + let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); + let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; + let e = crate::variable_ui::number_field_or( app, ui, ids, "paddingVertical", Id::new("al_padv"), - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), - "", col, + v_empty, + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + "", ); app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); + let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; + let e = crate::variable_ui::number_field_or( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + (pl == pr).then_some(pl), + col, + h_empty, + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + "", + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); + } }); if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..47c6d960 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod padding_individual_tests; +#[cfg(test)] mod palette_tests; #[cfg(test)] mod panel_remove_tests; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs new file mode 100644 index 00000000..e6397033 --- /dev/null +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -0,0 +1,75 @@ +//! Individual padding in the Design panel, driven headless: the four side fields only +//! appear once the toggle opens, and typing in one sets that side alone. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// An auto layout frame with uniform padding 10, selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let f = + NodeId( + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "props": {"layout": "horizontal", "padding": 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + (Ui::new(s), f) +} + +fn padding(u: &Ui, f: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +#[test] +fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { + let (mut u, f) = scene(); + assert_eq!(padding(&u, f), [10.0; 4]); + // Shut: no per-side fields. + assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + // Click the toggle: the four sides appear. + let t = named_button(&mut u, "Individual padding"); + click(&mut u, t); + let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + // Typing in the top field sets only the top, in one undo step. + let history = u.app.session.undo_depth().0; + click(&mut u, top); + u.frame(vec![Event::Text("8".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [8.0, 10.0, 10.0, 10.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(padding(&u, f), [10.0; 4]); +} diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f..5342135a 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -64,7 +64,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Nested hug/fill settling in one pass (three levels, wrapping fill text, grid rows) | done | | | Absolute position (ignore auto layout) with constraints | done | | | Strokes included in layout (inside strokes), canvas stacking | done | | -| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) | +| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation); the Layout panel's Spacing row opens per-side padding fields (Top/Right/Bottom/Left) behind a toggle | | Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` | | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | @@ -77,6 +77,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (`padding_individual_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | From aec3a22fdfa764b431fa0caa24f9c980456e5cb6 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 18 Oct 2026 20:24:12 +0300 Subject: [PATCH 08/44] M2: shut padding pair fields edit their own axis The closed Spacing row paired each icon with the other axis: the side-bars field wrote top/bottom and the top/bottom-bars field wrote left/right. The H field now binds paddingHorizontal (left+right) and the V field paddingVertical (top+bottom); the PaddingH/PaddingV paintings were swapped to match so every icon is pixel-identical. (shut_pair_fields_edit_their_own_axis). --- crates/ui-egui/src/icons.rs | 14 ++++---- crates/ui-egui/src/layout_panel.rs | 30 ++++++++--------- .../ui-egui/src/padding_individual_tests.rs | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f4f2f31b..d9cb32cc 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -938,17 +938,17 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - // The horizontal axis: bars across the top and bottom in their frame. - p.rect(2.5, 2.5, 13.5, 13.5, 1.0); - p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); - p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); - } - PaddingV => { - // The vertical axis: bars down the left and right in their frame. + // Horizontal padding: bars down the padded left and right sides in their frame. p.rect(2.5, 2.5, 13.5, 13.5, 1.0); p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); } + PaddingV => { + // Vertical padding: bars across the padded top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } PaddingTop => { p.rect(2.5, 2.5, 13.5, 13.5, 1.0); p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 72d44b9a..9f69c6c4 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -456,21 +456,6 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } row(ui, 32.0, |ui| { // Sides that disagree show both values ("16,54") instead of "Mixed". - let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); - let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; - let e = crate::variable_ui::number_field_or( - app, - ui, - ids, - "paddingVertical", - Id::new("al_padv"), - (pt == pb).then_some(pt), - col, - v_empty, - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), - "", - ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; let e = crate::variable_ui::number_field_or( @@ -486,6 +471,21 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { "", ); app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); + let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; + let e = crate::variable_ui::number_field_or( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + (pt == pb).then_some(pt), + col, + v_empty, + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + "", + ); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); } diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index e6397033..8fdb3192 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -73,3 +73,35 @@ fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { u.app.run("edit.undo", json!({})); assert_eq!(padding(&u, f), [10.0; 4]); } + +/// The middle of the shut pair field with accessible name `label`. +fn named_field(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() != Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// The shut pair fields edit their own axis: horizontal touches left and right +/// only, vertical touches top and bottom only. +#[test] +fn shut_pair_fields_edit_their_own_axis() { + let (mut u, f) = scene(); + u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); + assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + let h = named_field(&mut u, "Horizontal padding"); + click(&mut u, h); + u.frame(vec![Event::Text("20".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [10.0, 20.0, 10.0, 20.0]); + let v = named_field(&mut u, "Vertical padding"); + click(&mut u, v); + u.frame(vec![Event::Text("7".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]); +} From 02e8f644fde722ee647228cbacc9c090dab9a09b Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 18 Oct 2026 21:02:22 +0300 Subject: [PATCH 09/44] M2: doc comments for the padding diff's functions --- crates/engine/src/cmd/layout.rs | 1 + crates/ui-egui/src/layout_panel.rs | 2 ++ crates/ui-egui/src/padding_individual_tests.rs | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b545e52d..da1301ec 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,7 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + /// Each side key writes its own padding slot and leaves the other three alone. #[test] fn each_side_padding_sets_its_side_alone() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 9f69c6c4..6c8d6693 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -401,6 +401,8 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } +/// The Spacing row: gap fields, then the H/V padding pair (or the 2x2 per-side +/// grid while the individual-padding toggle is open). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index 8fdb3192..28a207bc 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -23,16 +23,19 @@ fn scene() -> (Ui, NodeId) { (Ui::new(s), f) } +/// The frame's padding as `[top, right, bottom, left]`. fn padding(u: &Ui, f: NodeId) -> [f64; 4] { u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding } +/// A full press-and-release at `p`, then settling one frame. fn click(u: &mut Ui, p: Pos2) { u.button(p, true); u.button(p, false); u.frame(vec![]); } +/// A press-and-release of Enter, committing the focused number field. fn enter(u: &mut Ui) { u.frame(vec![ Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, @@ -51,6 +54,8 @@ fn named_button(u: &mut Ui, label: &str) -> Pos2 { pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) } +/// The side fields stay hidden until the toggle opens, then typing in one sets +/// that side alone in a single undo step. #[test] fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { let (mut u, f) = scene(); From 4a54aa733fe2a755337a58faaae1787d088c2215 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:00:13 +0200 Subject: [PATCH 10/44] M3: .fig imports keep the properties variant layers are bound to Each variant of a component set keeps a copy of the set's property definitions (an id and a parentPropDefId, no name or type), and the variant's layers and its instances' assignments use the copy's id. Those copies were skipped, so imported variants lost every boolean, text and instance swap binding, and instances lost their property values. In Untitled UI (8,074 copies) an input's "Hint text" no longer followed its property. A copy now names the definition it was made from. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/map.rs | 32 +++++-- crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/prop_value_tests.rs | 107 ++++++++++++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 crates/format/src/fig/prop_value_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7df4d9e2..6e4ea86f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -368,6 +368,9 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// The copies of a set's definitions each variant keeps (no name or type of their own; the + /// variant's layers and its instances' assignments use them): copy id → the definition's id. + prop_parents: BTreeMap, /// File nodes by asset `key` and `version`: references can name a collection, variable or /// style as the asset instead of the guid, and the file carries the resources to resolve /// them (the collection node itself says the same `key`). @@ -402,6 +405,7 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); + let mut prop_parents = BTreeMap::new(); let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); // The last change of a GUID wins; a REMOVED change deletes what came before it. for n in changes { @@ -423,10 +427,11 @@ impl<'a> Mapper<'a> { kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone())); } for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() { - if let (Some(did), Some(name), Some(ty)) = - (d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) - { + let Some(did) = d.get("id").and_then(guid) else { continue }; + if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); + } else if let Some(parent) = d.get("parentPropDefId").and_then(guid) { + prop_parents.insert(did, parent); } } } @@ -450,6 +455,7 @@ impl<'a> Mapper<'a> { kids, blobs, prop_defs, + prop_parents, assets, styles: Obj::new(), components: Obj::new(), @@ -651,7 +657,19 @@ impl<'a> Mapper<'a> { } fn is_instance_swap(&self, def: &Kv) -> Option<()> { - matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The definition `def` names: its own, or the set's for a variant's copy of it. + fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { + let mut def = def; + for _ in 0..=MAX_DEPTH { + if let Some(found) = self.prop_defs.get(def) { + return Some(found); + } + def = self.prop_parents.get(def)?; + } + None } /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames @@ -1892,7 +1910,7 @@ impl<'a> Mapper<'a> { // Expanded frames must follow their main frame's source link. A direct slot reference // makes the engine resolve ownership against the instance, where the property is not defined. if !(field == "slot" && inst.is_some()) - && let Some((name, _)) = self.prop_defs.get(&def) + && let Some((name, _)) = self.prop_def(&def) { refs.insert(field.into(), json!(name)); } @@ -1945,7 +1963,7 @@ impl<'a> Mapper<'a> { let assignments = v.list("componentPropAssignments"); let mut props = Obj::new(); for a in assignments { - let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue }; + let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; let val = a.get("value"); let p = match ty { "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), @@ -2031,7 +2049,7 @@ impl<'a> Mapper<'a> { all_props.entry(d).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; - if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { continue; } let var_value = a.get("varValue"); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index fc9293d9..4c103d08 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -26,6 +26,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod prop_value_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod source_identity_tests; diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs new file mode 100644 index 00000000..3e24c330 --- /dev/null +++ b/crates/format/src/fig/prop_value_tests.rs @@ -0,0 +1,107 @@ +//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a +//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own). + +use figcraft_doc::NodeKind; +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn s(text: &str) -> Kv<'static> { + Kv::Str(text.into()) +} + +fn node(local: u32, kind: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))]))); + } + all.append(&mut fields); + o(all) +} + +/// A layer's `field` bound to property definition `def`. +fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) { + let entry = o(vec![ + ("variableField", Kv::Enum(field)), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])), + ]); + ("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))])) +} + +fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)]) +} + +fn message(nodes: Vec>) -> Kv<'static> { + let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + all.extend(nodes); + o(vec![("nodeChanges", Kv::Array(all))]) +} + +fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value { + fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id)) + } + find(v, id).unwrap_or_else(|| panic!("no layer {id}")) +} + +fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap { + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap(); + let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") }; + props +} + +#[test] +fn variant_layers_bind_to_the_set_properties_their_copies_name() { + let msg = message(vec![ + node( + 9, + "FRAME", + "Field", + Some(2), + vec![ + ("isStateGroup", Kv::Bool(true)), + ("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])), + ], + ), + node( + 10, + "SYMBOL", + "State=Default", + Some(9), + vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(71)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + ]); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN"); + assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70"); + assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap(); + assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint")); + assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); +} From c530b4067d6324853cec77c95c8b1a7351540cb7 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:01:33 +0200 Subject: [PATCH 11/44] M3: .fig imports read property values written only as variables A component property assignment carries its value in `value`, and in current files also in the variable form `varValue`. Many assignments leave `value` empty and say it only in `varValue` (booleans as boolValue, instance swaps as symbolIdValue, text as textDataValue), and those were ignored: a hidden layer showed and a swapped icon stayed the default. In Untitled UI that is 1,643 booleans and 185 swaps, so inputs showed their hint text, help icon and leading icon on top of the neighbouring layers. Both forms are read now. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/map.rs | 30 +++++++--- crates/format/src/fig/prop_value_tests.rs | 67 ++++++++++++++++++++++- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 6e4ea86f..22043650 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -646,7 +646,7 @@ impl<'a> Mapper<'a> { targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { - targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + targets.extend(assigned(assignment).and_then(prop_guid)); } } let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); @@ -1929,7 +1929,7 @@ impl<'a> Mapper<'a> { if own_text(v) != Some(t) { o.remove("characterStyleOverrides"); o.remove("styleOverrideTable"); - if let Some(tv) = val.get("textValue") { + if let Some(tv) = prop_text_data(val) { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); char_styles(o, tv, size, &mut |w| self.warn(w)); } @@ -1951,7 +1951,7 @@ impl<'a> Mapper<'a> { // An instance swap property value picks the main. if let Some(i) = outer && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") - && let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid) + && let Some(g) = i.props.get(&def).and_then(|p| prop_guid(p)) { sym = Some(g); } @@ -1964,11 +1964,11 @@ impl<'a> Mapper<'a> { let mut props = Obj::new(); for a in assignments { let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; - let val = a.get("value"); + let val = assigned(a); let p = match ty { "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), "TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})), - "INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), + "INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), _ => None, }; if let Some(p) = p { @@ -2045,7 +2045,7 @@ impl<'a> Mapper<'a> { let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { - if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { + if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { all_props.entry(d).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; @@ -2795,7 +2795,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(Str /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { - v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) + prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str) +} + +/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form. +fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + v.get("textValue").or_else(|| v.get("textDataValue")) +} + +/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form. +fn prop_guid(v: &Kv) -> Option { + v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid)) +} + +/// The value a component property assignment sets: `value`, or the variable form `varValue` holds +/// when `value` is empty, as current files write many booleans and instance swaps. +fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value")) } /// The property a layer's `field` is bound to (`parameterConsumptionMap`). diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs index 3e24c330..577b811a 100644 --- a/crates/format/src/fig/prop_value_tests.rs +++ b/crates/format/src/fig/prop_value_tests.rs @@ -1,5 +1,6 @@ //! Component properties as current `.fig` files write them, on synthetic messages: the copy of a -//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own). +//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and +//! assignments that carry their value only in the variable form (`varValue`). use figcraft_doc::NodeKind; use serde_json::Value; @@ -41,6 +42,15 @@ fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)]) } +/// An assignment of `def` that leaves `value` empty and says it in `varValue` only. +fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> { + o(vec![ + ("defID", g(def)), + ("value", o(vec![])), + ("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])), + ]) +} + fn message(nodes: Vec>) -> Kv<'static> { let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; all.extend(nodes); @@ -105,3 +115,58 @@ fn variant_layers_bind_to_the_set_properties_their_copies_name() { assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint")); assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); } + +#[test] +fn property_values_written_only_as_variables_are_applied() { + let msg = message(vec![ + node( + 10, + "SYMBOL", + "Field", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![ + definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])), + definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])), + definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])), + ]), + )], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]), + node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]), + node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]), + node(40, "SYMBOL", "Mail", Some(2), vec![]), + node(41, "SYMBOL", "Card", Some(2), vec![]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ( + "componentPropAssignments", + Kv::Array(vec![ + variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])), + variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])), + variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])), + ]), + ), + ], + ), + ]); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card"); + assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41"); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); + assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card"); + assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41"); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let props = instance_props(&doc); + assert_eq!(props.get("Hint"), Some(&Value::Bool(false))); + assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into()))); +} From 017517fbb9c1b1dfa96ff23ed9e5f5f8363b23da Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:44:37 +0200 Subject: [PATCH 12/44] #847: an instance's values are kept under the original definition An assignment and the layers that read it can name different copies of one definition (the set's own id on one side, a variant's copy on the other). The values were kept under the id the assignment names and read under the id the layer names, so such a value never reached its layer. Both sides now go through prop_root, the definition the copies were made from (CodeRabbit on #847). In Untitled UI, 143 assignments name a copy that no layer reads while another copy of it is read. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/map.rs | 22 ++++++++++++++----- crates/format/src/fig/prop_value_tests.rs | 26 ++++++++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 22043650..8e467151 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -660,6 +660,18 @@ impl<'a> Mapper<'a> { matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) } + /// The definition a copy of it was made from, followed to the original: the values an instance + /// sets are kept under it, since an assignment and the layers that read it may name different + /// copies of one definition. + fn prop_root(&self, def: &str) -> String { + let mut def = def; + for _ in 0..=MAX_DEPTH { + let Some(parent) = self.prop_parents.get(def) else { break }; + def = parent; + } + def.to_string() + } + /// The definition `def` names: its own, or the set's for a variant's copy of it. fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { let mut def = def; @@ -926,7 +938,7 @@ impl<'a> Mapper<'a> { } self.prop_refs(&mut o, &view, inst); let slot_content = if own == "FRAME" { - prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned())) } else { None }; @@ -1914,7 +1926,7 @@ impl<'a> Mapper<'a> { { refs.insert(field.into(), json!(name)); } - let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; + let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue }; match field { "visible" => { if let Some(b) = val.get("boolValue").and_then(Kv::bool) { @@ -1951,7 +1963,7 @@ impl<'a> Mapper<'a> { // An instance swap property value picks the main. if let Some(i) = outer && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") - && let Some(g) = i.props.get(&def).and_then(|p| prop_guid(p)) + && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p)) { sym = Some(g); } @@ -2046,7 +2058,7 @@ impl<'a> Mapper<'a> { let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { - all_props.entry(d).or_insert(val); + all_props.entry(self.prop_root(&d)).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { @@ -2070,7 +2082,7 @@ impl<'a> Mapper<'a> { self.warn("slot content assignment target is missing or is not slot content"); continue; } - slot_contents.entry(def).or_insert(target); + slot_contents.entry(self.prop_root(&def)).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs index 577b811a..24248d32 100644 --- a/crates/format/src/fig/prop_value_tests.rs +++ b/crates/format/src/fig/prop_value_tests.rs @@ -73,9 +73,10 @@ fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap Kv<'static> { + message(vec![ node( 9, "FRAME", @@ -101,10 +102,15 @@ fn variant_layers_bind_to_the_set_properties_their_copies_name() { Some(2), vec![ ("symbolData", o(vec![("symbolID", g(10))])), - ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(71)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), ], ), - ]); + ]) +} + +#[test] +fn variant_layers_bind_to_the_set_properties_their_copies_name() { + let msg = field_set(71); let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN"); assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70"); @@ -116,6 +122,16 @@ fn variant_layers_bind_to_the_set_properties_their_copies_name() { assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); } +/// An assignment reaches layers that read another copy of its definition: here the instance sets +/// the set's own definition while the variant's layer reads the variant's copy. +#[test] +fn assignments_reach_layers_that_read_another_copy_of_their_definition() { + let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); +} + #[test] fn property_values_written_only_as_variables_are_applied() { let msg = message(vec![ From 4e4bf54bb6b9168b7e61a393b10d8099d14555a1 Mon Sep 17 00:00:00 2001 From: Cody Campbell Date: Sun, 18 Oct 2026 18:53:32 -0400 Subject: [PATCH 13/44] M1: smart-selection drag reordering --- ROADMAP.md | 2 + crates/engine/src/cmd/arrange.rs | 49 +++++ crates/engine/src/lib.rs | 3 + crates/engine/src/selection_reorder.rs | 107 ++++++++++ crates/engine/src/selection_reorder_tests.rs | 152 ++++++++++++++ crates/engine/src/tests.rs | 3 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/src/selection_spacing.rs | 192 ++++++++++++++---- crates/ui-egui/src/selection_spacing_tests.rs | 128 ++++++++++++ docs/gaps.md | 6 +- docs/parity-checklist.md | 4 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 14 files changed, 607 insertions(+), 52 deletions(-) create mode 100644 crates/engine/src/selection_reorder.rs create mode 100644 crates/engine/src/selection_reorder_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286..1c3db7e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-18** · Smart-selection drag reordering (M1): Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. Validation: 1,039 engine unit tests passed (2 ignored), 555 UI unit tests passed (1 ignored); formatting, layering, assets, parity and `cargo xtask wasm` passed. Clippy passes with only the existing `collapsible_match` findings suppressed; strict `cargo xtask ci` stops on seven findings in unchanged UI files. The workspace test run stops on the unchanged text crate’s `tail_tests::hanging_punctuation_outside_the_aligned_edge` (2 lines instead of 1); no text code changed. + - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad6c21d1..3b4d2b26 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -27,6 +27,13 @@ pub fn specs() -> Vec { spacing ), spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy), + spec!( + "arrange.reorder", + "Reorder selection", + "{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)", + true, + reorder + ), spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)), spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)), spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px), @@ -554,6 +561,48 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { ok() } +/// Reorder visual slots without rebuilding or reparenting layers. +fn reorder(s: &mut Session, p: &Value) -> Result { + let cmd = "arrange.reorder"; + let ids = req_targets(cmd, s, p)?; + let source = req_id(cmd, p, "source")?; + let target = req_id(cmd, p, "target")?; + let ds = s.ds()?; + let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone()); + let layout = + crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?; + let positions = layout + .positions(source, target, boolean(p, "swap") == Some(true)) + .ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?; + // Validate every delta before editing; absolute targets always come from the gesture's + // snapshot so crossing several slots and returning to the start does not accumulate edits. + let doc = s.doc()?; + let mut moves = Vec::new(); + for (id, at) in positions { + let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?; + let (dx, dy) = (at.x - now.x0, at.y - now.y0); + if !dx.is_finite() || !dy.is_finite() { + return Err(bad(cmd, "non-finite reorder result")); + } + if dx.abs() > 1e-9 || dy.abs() > 1e-9 { + let node = doc.get(id)?; + let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); + let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return Err(bad(cmd, "non-finite local transform")); + } + moves.push((id, dx, dy)); + } + } + if !moves.is_empty() { + let doc = s.doc_mut()?; + for (id, dx, dy) in moves { + move_world(doc, id, dx, dy)?; + } + } + ok() +} + /// Set the distances between smart-selection tracks without rebuilding/reparenting the layers. fn spacing(s: &mut Session, p: &Value) -> Result { let ids = req_targets("arrange.spacing", s, p)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e87..c3c53c35 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,6 +49,7 @@ pub mod proto_run; pub mod rename; pub mod rotation_origin; pub mod selection_colors; +pub mod selection_reorder; pub mod selection_spacing; pub mod shape_handles; pub mod shapebuilder; @@ -1838,5 +1839,7 @@ mod view_behaviour_tests; #[cfg(test)] mod widgets_tests; +#[cfg(test)] +mod selection_reorder_tests; #[cfg(test)] mod selection_spacing_tests; diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection_reorder.rs new file mode 100644 index 00000000..e538fc8b --- /dev/null +++ b/crates/engine/src/selection_reorder.rs @@ -0,0 +1,107 @@ +//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns +//! and complete top-left-aligned grids have unambiguous insertion slots. +//! Behavior reference: https://help.figma.com/hc/en-us/articles/360040450233 +//! (public documentation only; multi-item marking and irregular grids remain out of scope). +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::{Point, Rect}; + +use crate::selection_spacing::selection_spacing; + +#[derive(Clone, Debug)] +pub struct ReorderLayout { + /// Visual order, independent of selection order and layer stacking. + pub slots: Vec<(NodeId, Rect)>, + columns: usize, + rows: usize, + gap_x: f64, + gap_y: f64, +} + +impl ReorderLayout { + pub fn new(doc: &Document, ids: &[NodeId]) -> Option { + let layout = selection_spacing(doc, ids); + let h = layout.horizontal.as_ref(); + let v = layout.vertical.as_ref(); + let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis { + Some(a) => a.value.filter(|v| *v >= 0.0), + None => Some(0.0), + }; + let (gap_x, gap_y) = (gap(h)?, gap(v)?); + let columns = h.map_or(1, |a| a.tracks.len()); + let rows = v.map_or(1, |a| a.tracks.len()); + if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() { + return None; + } + let mut cells = std::collections::BTreeMap::new(); + for id in ids { + let col = match h { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let row = match v { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let b = doc.world_bounds(*id)?; + if columns > 1 && rows > 1 { + let x = h?.tracks.get(col)?.bounds.x0; + let y = v?.tracks.get(row)?.bounds.y0; + if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 { + return None; + } + } + if cells.insert((row, col), (*id, b)).is_some() { + return None; + } + } + Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y }) + } + + /// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and + /// original gaps are retained, so unequal-sized layers reflow instead of overlapping. + pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option> { + let from = self.slots.iter().position(|(id, _)| *id == source)?; + let to = self.slots.iter().position(|(id, _)| *id == target)?; + if from == to { + return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect()); + } + let mut order = self.slots.clone(); + if swap { + order.swap(from, to); + } else { + let item = order.remove(from); + order.insert(to, item); + } + let mut widths = vec![0.0_f64; self.columns]; + let mut heights = vec![0.0_f64; self.rows]; + for (i, (_, b)) in order.iter().enumerate() { + let w = widths.get_mut(i % self.columns)?; + *w = w.max(b.width()); + let h = heights.get_mut(i / self.columns)?; + *h = h.max(b.height()); + } + let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min); + let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min); + let starts = |sizes: &[f64], mut at: f64, gap: f64| { + sizes + .iter() + .map(|size| { + let start = at; + at += size + gap; + start + }) + .collect::>() + }; + let xs = starts(&widths, x0, self.gap_x); + let ys = starts(&heights, y0, self.gap_y); + order + .iter() + .enumerate() + .map(|(i, (id, b))| { + let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? }; + let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? }; + Some((*id, Point::new(x, y))) + }) + .collect() + } +} diff --git a/crates/engine/src/selection_reorder_tests.rs b/crates/engine/src/selection_reorder_tests.rs new file mode 100644 index 00000000..83ae5b31 --- /dev/null +++ b/crates/engine/src/selection_reorder_tests.rs @@ -0,0 +1,152 @@ +use crate::{Session, selection_reorder::ReorderLayout}; +use figcraft_doc::NodeId; +use serde_json::json; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap()) +} +fn xy(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() { + for vertical in [false, true] { + let mut s = Session::new(); + let mut ids = vec![]; + for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] { + ids.push(rect( + &mut s, + if vertical { 0. } else { at }, + if vertical { at } else { 0. }, + if vertical { 30. } else { size }, + if vertical { size } else { 30. }, + None, + )); + } + let before = s.doc().unwrap().clone(); + // Explicit selection order must not determine visual order. + s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap(); + for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] { + assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) }); + let old = before.node(id).unwrap(); + let new = s.doc().unwrap().node(id).unwrap(); + assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent)); + } + assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + s.redo().unwrap(); + assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) }); + } +} + +#[test] +fn grids_insert_across_rows_and_swap_only_the_two_entries() { + let mut s = Session::new(); + let mut ids = vec![]; + for row in 0..2 { + for col in 0..3 { + ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None)); + } + } + let before = s.doc().unwrap().clone(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[3]), (140., 0.)); + assert_eq!(xy(&s, ids[5]), (140., 60.)); + s.undo().unwrap(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[4]), (0., 0.)); + for id in [ids[1], ids[2], ids[3], ids[5]] { + assert_eq!(s.doc().unwrap().node(id), before.node(id)); + } +} + +#[test] +fn grid_reflow_keeps_gaps_with_different_sizes() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 40., 30., None); + let b = rect(&mut s, 50., 0., 60., 30., None); + let c = rect(&mut s, 0., 50., 40., 50., None); + let d = rect(&mut s, 50., 50., 60., 50., None); + s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap(); + assert_eq!(xy(&s, b), (0., 0.)); + assert_eq!(xy(&s, c), (70., 0.)); + assert_eq!(xy(&s, d), (0., 70.)); + assert_eq!(xy(&s, a), (70., 70.)); +} + +#[test] +fn previews_are_absolute_and_returning_to_start_is_a_noop() { + let mut s = Session::new(); + let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect(); + s.execute("select.set", &json!({"ids":ids})).unwrap(); + let before = s.doc().unwrap().clone(); + let history = s.undo_depth().0; + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[2], ids[1], ids[0]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth().0, history); + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[1], ids[2]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.undo_depth().0, history + 1); + assert_eq!(s.selection(), ids); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); +} + +#[test] +fn invalid_selections_and_targets_do_not_edit_the_document() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 50., 40., None); + let b = rect(&mut s, 70., 0., 50., 40., None); + let c = rect(&mut s, 160., 0., 50., 40., None); + for args in [ + json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps. + json!({"ids":[a,b],"source":c,"target":b}), + json!({"ids":[a,b],"source":a,"target":999999}), + json!({"ids":[a,a,b],"source":a,"target":b}), + json!({"ids":[a,b],"source":[],"target":b}), + ] { + let before = s.doc().unwrap().clone(); + assert!(s.execute("arrange.reorder", &args).is_err()); + assert_eq!(s.doc().unwrap(), &before); + } + for props in [json!({"locked":true}), json!({"visible":false})] { + s.execute("node.set", &json!({"id":b,"props":props})).unwrap(); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none()); + s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap(); + } + let d = rect(&mut s, 0., 70., 50., 40., None); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots"); + let frame = + NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap()); + let x = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let y = rect(&mut s, 70., 0., 50., 40., Some(frame)); + assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none()); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none()); +} + +#[test] +fn reordering_children_of_a_rotated_parent_uses_page_coordinates() { + let mut s = Session::new(); + let frame = NodeId( + s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(), + ); + let a = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let b = rect(&mut s, 70., 0., 50., 40., Some(frame)); + let (pa, pb) = (xy(&s, a), xy(&s, b)); + s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap(); + let (qa, qb) = (xy(&s, a), xy(&s, b)); + assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7); + assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d8ebfc9e..145f4f07 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -418,6 +418,9 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + // Smart-selection reorder targets and modes as junk. + json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), + json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..ffc75fe1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -8,6 +8,7 @@ name = "English" [ui] +"Reorder selection" = "Reorder selection" "% of all commits" = "% of all commits" "(no longer published)" = "(no longer published)" "({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..96662b9d 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Reorder selection" = "Reordenar selección" "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -1741,6 +1742,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ arrastrar" [commands] +"arrange.reorder" = "Reordenar selección" "align.bottom" = "Alinear abajo" "align.hcenter" = "Alinear centros horizontales" "align.left" = "Alinear a la izquierda" diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 5110be1c..c2465404 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2}; use figcraft_doc::{Document, NodeId}; +use figcraft_engine::selection_reorder::ReorderLayout; use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing}; use serde_json::json; @@ -20,6 +21,7 @@ struct LayoutCache { ids: Vec, frame: u64, layout: Arc, + reorder: Option>, } fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { @@ -41,25 +43,58 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { + layout(app, ctx, ids); + ctx.data(|d| d.get_temp::(Id::new("selection_spacing_layout"))).and_then(|c| c.reorder) +} + fn drag_id() -> Id { Id::new("selection_spacing_drag") } +#[derive(Clone)] +enum Action { + Spacing { axis: Axis, value: f64, last_value: Option }, + Reorder { layout: Arc, source: NodeId, target: NodeId, swap: bool }, +} + #[derive(Clone)] struct Drag { ids: Vec, - axis: Axis, + action: Action, start: Pos2, - value: f64, uid: u64, page: NodeId, moved: bool, - last_value: Option, +} + +fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, action: Action) { + let label = match action { + Action::Spacing { .. } => tr("Spacing"), + Action::Reorder { .. } => tr("Reorder selection"), + }; + if app.session.begin_interaction(label).is_ok() + && let Ok(ds) = app.session.ds() + { + let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false }; + app.ui.layers_focus = false; + app.ui.key_box = None; + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + } } pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { @@ -146,7 +181,7 @@ pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool { /// Retire handles when another document, page, tool or editing context takes over. pub fn sync(app: &mut FigApp, ctx: &egui::Context) { if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same || !available(app) { cancel(app, ctx); } @@ -157,39 +192,77 @@ pub fn sync(app: &mut FigApp, ctx: &egui::Context) { pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: bool, space: bool) -> bool { let dragging = ctx.data(|d| d.get_temp::(drag_id())); if let Some(mut drag) = dragging { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same { cancel(app, ctx); return true; } let down = ctx.input(|i| i.pointer.primary_down()); if let Some(p) = pos { - let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); - let distance = if drag.axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; - drag.moved |= distance.abs() >= 3.0; - if drag.moved { - let value = ctx - .input(|i| { - let quantum = if i.modifiers.alt { - 0.1 - } else if i.modifiers.shift { - 10.0 - } else { - 1.0 - }; - ((drag.value + f64::from(distance) / zoom) / quantum).round() * quantum - }) - .clamp(-1e6, 1e6); - let key = if drag.axis.vertical { "vertical" } else { "horizontal" }; - if drag.last_value != Some(value) { - match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { - Ok(_) => drag.last_value = Some(value), - Err(e) => app.toast(e.to_string()), + match &mut drag.action { + Action::Spacing { axis, value: original, last_value } => { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); + let distance = if axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; + drag.moved |= distance.abs() >= 3.0; + if drag.moved { + let value = ctx + .input(|i| { + let quantum = if i.modifiers.alt { + 0.1 + } else if i.modifiers.shift { + 10.0 + } else { + 1.0 + }; + ((*original + f64::from(distance) / zoom) / quantum).round() * quantum + }) + .clamp(-1e6, 1e6); + let key = if axis.vertical { "vertical" } else { "horizontal" }; + if *last_value != Some(value) { + match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { + Ok(_) => *last_value = Some(value), + Err(e) => app.toast(e.to_string()), + } + } } } + Action::Reorder { layout, source, target, swap } if app.canvas.rect.contains(p) => { + drag.moved |= p.distance(drag.start) >= 3.0; + // Hit original slots, never the moving preview: otherwise a stationary + // pointer could alternate between two permutations on successive frames. + let nearest = layout + .slots + .iter() + .min_by(|a, b| { + let center = |b: figcraft_geom::Rect| app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + center(a.1).distance_sq(p).total_cmp(¢er(b.1).distance_sq(p)) + }) + .map(|(id, _)| *id) + .unwrap_or(*source); + let swapping = ctx.input(|i| i.modifiers.command); + if drag.moved && (nearest != *target || swapping != *swap) { + match app + .session + .execute("arrange.reorder", &json!({"ids":drag.ids,"source":source,"target":nearest,"swap":swapping,"fromGesture":true})) + { + Ok(_) => { + *target = nearest; + *swap = swapping; + } + Err(e) => app.toast(e.to_string()), + } + } + } + // A release over a panel or outside the window commits the last canvas + // preview; the release's sentinel coordinates must not select a new slot. + Action::Reorder { .. } => {} } } - ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); + ctx.set_cursor_icon(match &drag.action { + Action::Spacing { axis, .. } if axis.vertical => CursorIcon::ResizeVertical, + Action::Spacing { .. } => CursorIcon::ResizeHorizontal, + Action::Reorder { .. } => CursorIcon::Grabbing, + }); if !down { let _ = app.session.commit_interaction(); ctx.data_mut(|d| d.remove::(drag_id())); @@ -198,11 +271,27 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: } return true; } - if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) { + if !hovered || space || !available(app) { return false; } let Some(p) = pos else { return false }; let ids = app.session.selection(); + if let Some(reorder) = reorder_layout(app, ctx, &ids) { + for (id, b) in &reorder.slots { + let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + if center.distance(p) <= 8.0 { + ctx.set_cursor_icon(CursorIcon::Grab); + if ctx.input(|i| i.pointer.primary_pressed()) { + let source = *id; + start_drag(app, ctx, ids, p, Action::Reorder { layout: reorder, source, target: source, swap: false }); + } + return true; + } + } + } + if ctx.input(|i| i.modifiers.command) { + return false; + } let layout = layout(app, ctx, &ids); for axis in [layout.horizontal.as_ref(), layout.vertical.as_ref()].into_iter().flatten() { for h in handles(app, axis) { @@ -210,20 +299,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: continue; } ctx.set_cursor_icon(if axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); - if ctx.input(|i| i.pointer.primary_pressed()) - && app.session.begin_interaction(tr("Spacing")).is_ok() - && let Ok(ds) = app.session.ds() - { - let drag = - Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; - app.ui.layers_focus = false; - app.ui.key_box = None; - ctx.memory_mut(|m| { - if let Some(f) = m.focused() { - m.surrender_focus(f); - } - }); - ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + if ctx.input(|i| i.pointer.primary_pressed()) { + start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value }); } return true; } @@ -244,8 +321,33 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) { return; } - let axes = if let Some(d) = &drag { - vec![d.axis.clone()] + let reorder = match drag.as_ref().map(|d| &d.action) { + Some(Action::Reorder { layout, .. }) => Some(layout.clone()), + Some(Action::Spacing { .. }) => None, + None => reorder_layout(app, ctx, &ds.selection), + }; + if let Some(layout) = reorder { + for (id, _) in &layout.slots { + let Some(b) = ds.doc.world_bounds(*id) else { continue }; + let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + let hot = hover.is_some_and(|p| center.distance(p) <= 8.0); + let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id); + if active { + painter.circle_filled(center, 4.5, PINK); + } else { + painter.circle_stroke(center, 4.5, Stroke::new(if hot { 2.5 } else { 1.5 }, PINK)); + } + } + } + if let Some(Drag { action: Action::Reorder { layout, target, .. }, moved: true, .. }) = &drag { + if let Some((_, b)) = layout.slots.iter().find(|(id, _)| id == target) { + let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(3.0); + painter.rect_stroke(rect, 0, Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Outside); + } + return; + } + let axes = if let Some(Drag { action: Action::Spacing { axis, .. }, .. }) = &drag { + vec![axis.clone()] } else { let layout = layout(app, ctx, &ds.selection); [layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect() diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 453bd60d..685f7b4e 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -205,3 +205,131 @@ fn stationary_gap_drag_does_not_change_the_document_again() { u.button(p + vec2(20.2 * z, 0.), false); assert_eq!(xy(&u, ids[1]), (200., 100.)); } + +#[test] +fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() { + let (mut u, ids) = scene(true); + let before = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, target - p, true); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[3]), (260., 100.)); + assert_eq!(u.app.session.selection(), ids); + assert_eq!(u.app.session.undo_depth().0, before + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(xy(&u, ids[0]), (100., 100.)); + u.app.run("edit.redo", json!({})); + assert_eq!(xy(&u, ids[0]), (180., 200.)); +} + +#[test] +fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { + for action in [0, 1, 2] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + u.frame(vec![Event::PointerMoved(p)]); + if action == 0 { + u.button(p, true); + } else { + drag(&mut u, p, target - p, false); + assert_eq!(xy(&u, ids[0]), (260., 100.)); + if action == 1 { + key(&mut u, Key::Escape); + } else { + u.frame(vec![Event::PointerMoved(p)]); + } + } + u.button(p, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(u.app.session.selection(), ids); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn command_drag_swaps_and_a_stationary_pointer_keeps_the_preview() { + let (mut u, ids) = scene(true); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + // The modifier can already be down when the handle is pressed. + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::PointerMoved(p)], m); + u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(target)], m); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[4]), (100., 100.)); + assert_eq!(xy(&u, ids[1]), (180., 100.)); + let snapshot = u.app.session.ds().unwrap().doc.clone(); + for _ in 0..5 { + u.frame_with(vec![], m); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &u.app.session.ds().unwrap().doc)); + // Changing modifiers without moving the pointer switches to insertion. + u.frame(vec![]); + assert_eq!(xy(&u, ids[1]), (100., 100.)); + assert_eq!(xy(&u, ids[4]), (100., 200.)); + u.button(target, false); +} + +#[test] +fn switching_documents_during_reordering_rolls_back_its_owner() { + let (mut u, ids) = scene(false); + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.run("file.new", json!({})); + u.app.session.begin_interaction("Unrelated").unwrap(); + u.frame(vec![]); + assert!(u.app.session.ds().unwrap().interaction.is_some()); + u.app.session.active = 0; + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.selection(), ids); +} + +#[test] +fn reorder_release_outside_window_commits_and_undo_while_dragging_cancels() { + for undo in [false, true] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + if undo { + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m }], m); + } + u.frame(vec![Event::PointerGone]); + u.button(Pos2::new(-1., -1.), false); + u.frame(vec![]); + assert_eq!(u.app.session.undo_depth().0, history + usize::from(!undo)); + assert_eq!(xy(&u, ids[0]), if undo { (100., 100.) } else { (260., 100.) }); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() { + let (mut u, ids) = scene(false); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.session.commit_interaction().unwrap(); + let history = u.app.session.undo_depth().0; + let committed = u.app.session.doc().unwrap().clone(); + let middle = u.app.to_screen(205., 130.); + u.frame(vec![Event::PointerMoved(middle)]); + u.button(middle, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &committed); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(xy(&u, ids[0]), (260., 100.)); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..5f2255e6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -174,6 +174,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Layers; Paste over and Paste properties from the keyboard; then (#753) Selection colors for mixed selections and smart-selection spacing fields and canvas handles (no reorder rings). Each was checked in the app (on Windows; the submenus by their author), none side by side with Figma, none on macOS. +- **Landed (2026-10-18):** smart-selection center rings for single-layer insertion and + Command/Ctrl swaps in regular rows, columns and complete top-left-aligned grids; live preview, + cancellation and one undo step. Multiple marked layers and irregular-grid insertion remain. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -444,6 +447,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 775a4fcb..d53c9c52 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,8 @@ # Parity checklist -> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-18 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (563 commands in the engine). **212 / 212 menu items (100.0%)** diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16a5af69..a9a1a507 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -95,7 +95,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | -| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | +| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed; smart-selection rings reorder single layers in regular rows, columns and complete aligned grids, with Command/Ctrl swaps; multi-item marks and irregular-grid insertion remain) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..eab5aafe 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -69,7 +69,7 @@ interaction feel not yet compared. | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | -| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | +| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). Center rings drag a single layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl swaps entries. Preview, target outline, Escape cancellation and one-step undo (`arrange.reorder`, `selection_reorder_tests`, `selection_spacing_tests`). Sizes, gaps and layer hierarchy kept. Mixed gaps and incomplete or differently aligned grids have no rings; multiple marked-item dragging remains. Spacing checked by its author on macOS and in the app on Windows (#402) | | Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From caf0f44d90eb1afd57c06971f7c78d9ed887388c Mon Sep 17 00:00:00 2001 From: AJ Richardson Date: Mon, 19 Oct 2026 16:08:26 +1000 Subject: [PATCH 14/44] #855: The Search button for the pages doesn't work as expected --- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/layers.rs | 57 ++++++++++++++++++-- crates/ui-egui/src/layers_behaviour_tests.rs | 44 +++++++++++++++ crates/ui-egui/src/lib.rs | 5 ++ 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..23879786 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1184,6 +1184,7 @@ name = "English" "Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" +"Search pages" = "Search pages" "Search shortcuts" = "Search shortcuts" "Section" = "Section" "Section color" = "Section color" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..0d5097e1 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1185,6 +1185,7 @@ status = "draft — needs native review" "Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" +"Search pages" = "Buscar páginas" "Search shortcuts" = "Buscar atajos" "Section" = "Sección" "Section color" = "Color de sección" diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80..8159d569 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -184,13 +184,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace")); + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); + let search_id = Id::new("page_search_field"); + let mut focus_search = false; if sresp.clicked() { - app.ui.find = Some((String::new(), String::new())); + app.ui.page_search = !app.ui.page_search; + app.ui.page_filter.clear(); + if app.ui.page_search { + app.ui.pages_open = true; + focus_search = true; + } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); @@ -204,9 +211,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { if !app.ui.pages_open { return; } + if app.ui.page_search { + page_search_field(app, ui, &t, w, search_id, focus_search); + } let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); + let filter = app.ui.page_filter.trim().to_lowercase(); + let visible_pages: Vec<(NodeId, String, bool)> = + pages.iter().filter(|(_, name, divider)| filter.is_empty() || (!divider && name.to_lowercase().contains(&filter))).cloned().collect(); + let filtering = !filter.is_empty(); let cur = ds.page; let sel = ds.selection.clone(); let mut select = None; @@ -220,9 +234,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + for (k, (id, name, divider)) in visible_pages.iter().cloned().enumerate() { + let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if !filtering { + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); let r = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, r, &resp); @@ -311,6 +328,36 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// The Pages panel's inline filter (Escape clears and closes it). +fn page_search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id, focus: bool) { + let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); + let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); + ui.painter().rect_filled(field, CornerRadius::same(5), t.field); + icons::paint(ui.painter(), Rect::from_center_size(pos2(field.min.x + 12.0, field.center().y), vec2(12.0, 12.0)), Icon::Search, t.icon_secondary); + let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); + let r = child.add( + egui::TextEdit::singleline(&mut app.ui.page_filter) + .id(id) + .frame(egui::Frame::NONE) + .font(regular(11.0)) + .hint_text(tr("Search pages")) + .vertical_align(egui::Align::Center) + .desired_width(te_rect.width()), + ); + if focus { + r.request_focus(); + } + crate::widgets::select_all_on_focus(ui, &r, &app.ui.page_filter); + if r.has_focus() { + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.page_search = false; + app.ui.page_filter.clear(); + } +} + /// The pages list's height in a panel with `avail` below the Pages header: the height asked for /// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index d4ef4f8a..17015405 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -199,6 +199,50 @@ fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { panic!("no row for page {page:?}"); } +#[test] +fn pages_search_button_opens_the_filter_and_escape_clears_it() { + let s = Session::new(); + let _ = s.page().unwrap(); + let mut u = Ui::new(s); + + // The search icon in the Pages header opens the page-name field, not Find and Replace. + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search, "the Pages search is open"); + assert!(u.app.ui.find.is_none(), "page search does not open Find and Replace"); + u.frame(vec![Event::Text("COMPON".into())]); + assert_eq!(u.app.ui.page_filter, "COMPON", "typing can start immediately"); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes page search"); + assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter"); +} + +#[test] +fn pages_search_filters_page_names_case_insensitively() { + let mut s = Session::new(); + let _overview = s.page().unwrap(); + let components = id(s.execute("page.add", &json!({"name": "Components"})).unwrap()); + let library = id(s.execute("page.add", &json!({"name": "Library"})).unwrap()); + s.execute("page.select", &json!({"id": library})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + let shapes = u.frame_output(vec![]).shapes; + let text = crate::live_tests::texts(&crate::live_tests::flat(&shapes)); + assert!(text.iter().any(|(label, _, _)| label == "Components"), "the matching page remains visible"); + assert!(!text.iter().any(|(label, _, _)| label == "Library"), "nonmatching pages are filtered out"); + + // Search leaves only Components; the active page starts as Library. + for y in (150..400).step_by(4) { + let p = Pos2::new(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == components { + break; + } + } + assert_eq!(u.app.session.page().unwrap(), components, "matching is case-insensitive and opens the named page"); +} + #[test] fn pages_drag_to_reorder_and_dividers_open_nothing() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..efb0776d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -431,6 +431,9 @@ pub struct UiState { pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, + /// Pages panel search: open, and the case-insensitive name filter. + pub page_search: bool, + pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, pub expanded: BTreeSet, @@ -595,6 +598,8 @@ impl Default for UiState { last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, + page_search: false, + page_filter: String::new(), pages_height: None, expanded: BTreeSet::new(), rename: None, From 3b7b28c03c9feb94e729e3ff95f997ada79bf85a Mon Sep 17 00:00:00 2001 From: AJ Richardson Date: Mon, 19 Oct 2026 16:37:52 +1000 Subject: [PATCH 15/44] improve page search functionality to close with Escape key even when unfocused --- crates/ui-egui/src/layers.rs | 2 +- crates/ui-egui/src/layers_behaviour_tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8159d569..b93b97a3 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -352,7 +352,7 @@ fn page_search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id, if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } - if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if app.ui.page_search && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.page_search = false; app.ui.page_filter.clear(); } diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 17015405..a1f3685a 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -216,6 +216,19 @@ fn pages_search_button_opens_the_filter_and_escape_clears_it() { assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter"); } +#[test] +fn pages_search_escape_closes_even_when_the_field_is_unfocused() { + let mut u = Ui::new(Session::new()); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + u.frame(vec![]); + + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + + assert!(!u.app.ui.page_search, "Escape closes page search without field focus"); + assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter without field focus"); +} + #[test] fn pages_search_filters_page_names_case_insensitively() { let mut s = Session::new(); From be9e72f74d34fdcaaf256d078ee6dcad3691bf40 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Mon, 19 Oct 2026 09:15:34 +0200 Subject: [PATCH 16/44] M3: instances follow the variable modes their main component sets An instance copied its main's variable modes when it was made and kept the copy as modes of its own. Switching it to another variant kept the old variant's modes, and a mode set on the main later never reached it. Instances now take the modes their main sets through the link (`Document::inherited_modes`), closer than any frame around them. Modes set on the instance itself still win and stay through variant switches. Bindings are re-applied for the instances of a main that chose another mode and for an instance that switched mains. Detaching keeps the modes the instance took from its main, and the Variable modes panel shows them as the instance's Auto value. Instances made before this change keep their copy as modes of their own; setting one back to Auto lets it follow its main. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 38 +++++-- crates/engine/src/cmd/component.rs | 14 +++ crates/engine/src/components.rs | 2 + crates/engine/src/instance_modes_tests.rs | 128 ++++++++++++++++++++++ crates/engine/src/lib.rs | 24 +++- crates/ui-egui/src/right.rs | 4 +- 6 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/instance_modes_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 07700e8c..d131b2f0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -949,21 +949,39 @@ impl Document { self.styles.iter().find(|s| s.id == id || s.name == id) } - /// The effective variable modes for a node (walking up its ancestors). + /// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`]. pub fn modes_for(&self, id: NodeId) -> BTreeMap { - let mut out = BTreeMap::new(); - let mut chain = vec![id]; - chain.extend(self.ancestors(id)); - for c in chain.into_iter().rev() { - if let Some(n) = self.node(c) { - for (k, v) in &n.var_modes { - out.insert(k.clone(), v.clone()); - } - } + let mut out = self.inherited_modes(id); + if let Some(n) = self.node(id) { + out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone()))); } out } + /// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the + /// ones its main component sets, which are closer than any container's. + pub fn inherited_modes(&self, id: NodeId) -> BTreeMap { + let mut out = BTreeMap::new(); + for c in self.ancestors(id).into_iter().rev() { + if let Some(n) = self.node(c) { + out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone()))); + } + } + if let Some(n) = self.node(id) { + out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone()))); + } + out + } + + /// The modes an instance's main component sets (none for other layers). + fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator { + let main = match n.kind { + NodeKind::Instance { component, .. } => self.node(component), + _ => None, + }; + main.into_iter().flat_map(|m| &m.var_modes) + } + /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { let mut issues = Vec::new(); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ff7a56df..6840bcf4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result { // Own layers lose their link (slot content stays as it is); nested instances stay // instances of their own mains. let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; let n = doc.get_mut(i)?; n.kind = NodeKind::Frame; n.source = None; @@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": out})) } +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + fn reset_instance(s: &mut Session, p: &Value) -> Result { let cmd = "component.resetInstance"; let ids = req_targets(cmd, s, p)?; diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 56efc5c9..ea67bb07 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -285,6 +285,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.dev_resources.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); doc.insert(root, parent, index)?; sync_instance(doc, id); Ok(id) diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs new file mode 100644 index 00000000..79007622 --- /dev/null +++ b/crates/engine/src/instance_modes_tests.rs @@ -0,0 +1,128 @@ +//! Variable modes on instances: an instance is on the modes its main component sets, under the +//! ones set on the instance itself, so switching variants or changing the main's mode reaches it. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn fill(s: &Session, n: NodeId) -> String { + s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn set_mode(s: &mut Session, n: NodeId, mode: Value) { + run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode})); +} + +/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B. +fn tone(s: &mut Session) { + run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]})); + run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}})); +} + +/// A component filled from `c`, on `mode` when one is given. +fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId { + let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name}))); + run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"})); + if let Some(m) = mode { + set_mode(s, f, json!(m)); + } + id(run(s, "component.create", json!({"ids": [f]}))) +} + +/// A set "Kind" whose variants A and B each set their own Tone mode. +fn kinds(s: &mut Session) -> (NodeId, NodeId) { + tone(s); + let a = swatch(s, "Kind=A", Some("A")); + let b = swatch(s, "Kind=B", Some("B")); + let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]}))); + (set, b) +} + +fn instance(s: &mut Session, component: NodeId) -> NodeId { + id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0}))) +} + +fn switch(s: &mut Session, i: NodeId, kind: &str) { + run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}})); +} + +#[test] +fn switching_variants_takes_the_new_variants_mode() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + assert_eq!(fill(&s, i), "FF0000"); + switch(&mut s, i, "B"); + assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B"); + switch(&mut s, i, "A"); + assert_eq!(fill(&s, i), "FF0000"); + assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own"); +} + +#[test] +fn instances_follow_a_mode_their_main_sets_later() { + let mut s = Session::new(); + tone(&mut s); + let main = swatch(&mut s, "Chip", None); + let i = instance(&mut s, main); + assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default"); + set_mode(&mut s, main, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + set_mode(&mut s, main, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "the main back on Auto"); +} + +#[test] +fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + set_mode(&mut s, i, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + for kind in ["B", "A"] { + switch(&mut s, i, kind); + assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}"); + } + set_mode(&mut s, i, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode"); +} + +#[test] +fn a_main_mode_is_closer_than_the_frame_around_the_instance() { + let mut s = Session::new(); + let (_, b) = kinds(&mut s); + let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100}))); + set_mode(&mut s, frame, json!("A")); + let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame}))); + assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A"); + // Nested: an outer component on mode A holding an instance of B. + let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"}))); + run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame})); + set_mode(&mut s, outer_frame, json!("A")); + let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]}))); + let copy = instance(&mut s, outer); + let nested = s.doc().unwrap().node(copy).unwrap().children[0]; + assert_eq!(fill(&s, nested), "0000FF"); +} + +#[test] +fn detaching_keeps_the_mode_it_took_from_its_main() { + let mut s = Session::new(); + let (set, b) = kinds(&mut s); + let i = instance(&mut s, set); + switch(&mut s, i, "B"); + run(&mut s, "component.detach", json!({"ids": [i]})); + set_mode(&mut s, b, json!("A")); + assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); + assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e87..3145c8a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1460,21 +1460,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool { } /// The layers whose variable bindings an edit may have changed: every bound layer when a variable -/// changed, else the touched layers and everything below those that moved, are new or chose -/// another variable mode (modes are inherited). +/// changed, else the touched layers and everything below those that moved, are new, chose +/// another variable mode or became an instance of another main (modes are inherited, an +/// instance's from its main too), and the instances of a main that chose another mode. fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> { if doc.variables != before.variables { return None; } + let main = |n: &Node| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => Some(component), + _ => None, + }; let mut out = BTreeSet::new(); for &id in touched { let Some(n) = doc.node(id) else { continue }; - let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent); - if inherited { + let Some(b) = before.node(id) else { + out.extend(doc.subtree(id)); + continue; + }; + let modes = b.var_modes != n.var_modes; + if modes || b.parent != n.parent || main(b) != main(n) { out.extend(doc.subtree(id)); } else { out.insert(id); } + if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) { + for i in components::instances_of(doc, id) { + out.extend(doc.subtree(i)); + } + } } Some(out) } @@ -1652,6 +1666,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_modes_tests; +#[cfg(test)] mod instance_resizing_tests; #[cfg(test)] mod interaction_rollback_tests; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 53eaf953..5fd7dfc4 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -961,8 +961,8 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) // The collection's name, then a wider mode picker ("Auto (Light)") across both field columns. let col = widgets::col_width(ui.available_width()); let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round()); - // Auto resolves from the parent of the first selected layer. - let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default(); + // Auto resolves as for the first selected layer: from its parent, or an instance's main. + let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).unwrap_or_default(); let mut group = None; for r in &rows { let c = r.coll; From 5d40b3f42f8b6d9ef8f324ea9a530cbff4bb58de Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 10:53:34 +0200 Subject: [PATCH 17/44] fix(pages): Delete deletes the page picked in the Pages list (#859) Clicking a page row (or the add-page button) gives the Pages list the keyboard: Delete or Backspace with no layers selected now deletes the current page instead of failing with "nothing is selected". No confirmation; a "Page deleted" toast says so and Undo brings it back. The context menu's Delete page shows the same toast. Selecting layers, clicking a layer row or pressing on the canvas gives Delete back to the layers. --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + 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/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.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/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/layers.rs | 13 +++- crates/ui-egui/src/layers_keys_tests.rs | 81 ++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 6 ++ crates/ui-egui/src/selection_spacing.rs | 1 + 21 files changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 98051314..a7e8be38 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -929,6 +929,7 @@ status = "draft — needs native review" "PDF print settings" = "Nastavení tisku PDF" "Padding" = "Vnitřní odsazení" "Page" = "Stránka" +"Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" "Placed on the current page" = "Umístěno na aktuální stránku" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a..e910863b 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -898,6 +898,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-Druckeinstellungen" "Padding" = "Innenabstand" "Page" = "Seite" +"Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" "Placed on the current page" = "Auf der aktuellen Seite platziert" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5..7220519d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -950,6 +950,7 @@ name = "English" "PDF print settings" = "PDF print settings" "Padding" = "Padding" "Page" = "Page" +"Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" "Placed on the current page" = "Placed on the current page" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..63c73131 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -951,6 +951,7 @@ status = "draft — needs native review" "PDF print settings" = "Ajustes de impresión PDF" "Padding" = "Margen interno" "Page" = "Página" +"Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" "Placed on the current page" = "Colocado en la página actual" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d7..6f12dd55 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -952,6 +952,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-tulostusasetukset" "Padding" = "Sisämarginaali" "Page" = "Sivu" +"Page deleted" = "Sivu poistettu" "Pages" = "Sivut" "Pages added to the document" = "Sivut lisätty asiakirjaan" "Pages: {pages}" = "Sivut: {pages}" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a62..4af37b1d 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -947,6 +947,7 @@ status = "draft — needs native review" "PDF print settings" = "Réglages d’impression PDF" "Padding" = "Marge intérieure" "Page" = "Page" +"Page deleted" = "Page supprimée" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 65a9939c..9c5aa4a6 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -933,6 +933,7 @@ status = "draft — needs native review" "PDF print settings" = "Pengaturan cetak PDF" "Padding" = "Padding" "Page" = "Halaman" +"Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" "Placed on the current page" = "Ditempatkan di halaman saat ini" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc4..b5dcaf27 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -938,6 +938,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 印刷設定" "Padding" = "パディング" "Page" = "ページ" +"Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" "Placed on the current page" = "現在のページに配置した" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e191623..70669778 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -892,6 +892,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 인쇄 설정" "Padding" = "패딩" "Page" = "페이지" +"Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" "Placed on the current page" = "현재 페이지에 배치됨" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 8d8cac68..d49e5f03 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -952,6 +952,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-afdrukinstellingen" "Padding" = "Padding" "Page" = "Pagina" +"Page deleted" = "Pagina verwijderd" "Pages" = "Pagina's" "Pages added to the document" = "Pagina's toegevoegd aan het document" "Pages: {pages}" = "Pagina's: {pages}" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db14..00ec38a9 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -891,6 +891,7 @@ status = "draft — needs native review" "PDF print settings" = "Configurações de impressão em PDF" "Padding" = "Espaçamento interno" "Page" = "Página" +"Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" "Placed on the current page" = "Colocado na página atual" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8d371cbe..c72c3edb 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -932,6 +932,7 @@ name = "Русский" "PDF print settings" = "Настройки печати PDF" "Padding" = "Внутренние отступы" "Page" = "Страница" +"Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" "Placed on the current page" = "Размещено на текущей странице" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a..7c317414 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -942,6 +942,7 @@ name = "Svenska" "PDF print settings" = "PDF-utskriftsinställningar" "Padding" = "Utfyllnad" "Page" = "Sida" +"Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" "Placed on the current page" = "Placerat på den aktuella sidan" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a9da62ef..c9339e1f 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -933,6 +933,7 @@ name = "Türkçe" "PDF print settings" = "PDF baskı ayarları" "Padding" = "İç boşluk" "Page" = "Sayfa" +"Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c03..e726ca32 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 打印设置" "Padding" = "内边距" "Page" = "页面" +"Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" "Placed on the current page" = "已放置在当前页面" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d79..94daf657 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -544,6 +544,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; + app.ui.pages_focus = false; app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80..514b8f75 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -199,6 +199,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { + app.ui.pages_focus = true; app.run("page.add", json!({})); } if !app.ui.pages_open { @@ -257,6 +258,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.clicked() && !divider { select = Some(id); + app.ui.pages_focus = true; + app.ui.layers_focus = false; } if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); @@ -274,7 +277,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { ui.close(); } if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); + delete_page(app, id); ui.close(); } }); @@ -311,6 +314,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// Delete `page` (no confirmation: Undo brings it back) and say so. +pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) { + if app.run("page.delete", json!({"id": page})).is_some() { + app.toast(tr("Page deleted")); + } +} + /// The pages list's height in a panel with `avail` below the Pages header: the height asked for /// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { @@ -1064,6 +1074,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if let Some((id, m)) = clicked { app.ui.layers_focus = true; + app.ui.pages_focus = false; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index b78890a8..d0b5f786 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -1,8 +1,8 @@ //! Arrow keys in the layers panel once a row has been clicked: up/down through the rows (Shift //! extends), left/right to collapse, expand, and go to the parent or first child; a press on the -//! canvas gives arrows back to nudging. +//! canvas gives arrows back to nudging. Delete after a click in the Pages list deletes the page. -use egui::{Event, Key, Modifiers, pos2}; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -107,3 +107,80 @@ fn a_canvas_press_gives_arrows_back_to_nudging() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.node(c).unwrap().transform.translation().x, 201.0); } + +/// The middle of `page`'s row in the Pages list, found by clicking down the list until it opens; +/// then back to `back`. +fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { + for y in (90..400).step_by(4) { + let p = pos2(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == page { + u.app.session.execute("page.select", &json!({"id": back})).unwrap(); + u.frame(vec![]); + // Rows are 32 high: aim at the middle, past the hit tolerance at its edge. + return p + vec2(0.0, 12.0); + } + } + panic!("no row for page {page:?}"); +} + +fn pages(u: &Ui) -> usize { + u.app.session.doc().unwrap().pages.len() +} + +#[test] +fn delete_after_clicking_a_page_deletes_it_without_asking() { + // #859: Delete on a page picked in the Pages list said "nothing is selected". + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), second); + key(&mut u, Key::Delete, false); + // Gone at once, no confirmation: a toast says so and Undo brings it back. + assert_eq!(pages(&u), 1); + assert!(!u.app.session.doc().unwrap().pages.contains(&second)); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Page deleted")); + u.app.run("edit.undo", json!({})); + assert_eq!(pages(&u), 2); + // Backspace (the Mac's delete key) does the same. + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + key(&mut u, Key::Backspace, false); + assert_eq!(pages(&u), 1); + // The last page stays. + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 1); +} + +#[test] +fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { + let (mut u, a, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.frame(vec![]); + let row = page_row(&mut u, first, second); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), first); + assert!(u.app.ui.pages_focus); + // Layers selected after the click (Select all, say): Delete deletes them, not the page. + u.app.run("select.set", json!({"ids": [a]})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2); + assert!(u.app.session.doc().unwrap().node(a).is_none()); + // A press on the canvas gives Delete back to it. + let p = pos2(900.0, 700.0); + u.button(p, true); + u.button(p, false); + assert!(!u.app.ui.pages_focus); + u.app.run("select.none", json!({})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..9a1cc3a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,6 +500,10 @@ pub struct UiState { /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, + /// The pages list has the keyboard: Delete with no layers selected deletes the current page (set + /// by a click on a page row or the add-page button, cleared by a click on a layer row or a press + /// on the canvas). + pub pages_focus: bool, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, @@ -634,6 +638,7 @@ impl Default for UiState { layer_filter: String::new(), layer_anchor: None, layers_focus: false, + pages_focus: false, layers_seen_sel: Vec::new(), dark: false, follow_system: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e32f50c8..dfdf290a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -117,6 +117,12 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} + // After a click in the Pages list, with no layers selected, it deletes the current page (#859). + "edit.delete" if app.ui.pages_focus && app.session.selection().is_empty() => { + if let Ok(page) = app.session.page() { + crate::layers::delete_page(app, page); + } + } "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 5110be1c..c05b9ec3 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -217,6 +217,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: let drag = Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; app.ui.layers_focus = false; + app.ui.pages_focus = false; app.ui.key_box = None; ctx.memory_mut(|m| { if let Some(f) = m.focused() { From 139e0ba3084a964ca1e09e0c78c07b105dcb0709 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 10:53:34 +0200 Subject: [PATCH 18/44] chore(contributors): add AganzeFelicite to people.toml --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8b14373e..03b99aa1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true + +[people.AganzeFelicite] +sync_github_name = true From fd02772d224f2e406c64b7df49be36d13d000d91 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:21 +0200 Subject: [PATCH 19/44] refactor(text): one helper for the chars that end a line --- crates/text/src/layout.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fc6e11a..6266dc2b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -157,6 +157,11 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } +/// A char that ends its line: a newline (which also ends the paragraph). +fn is_line_break(c: char) -> bool { + c == '\n' +} + /// Opening punctuation, which hangs outside a line's start. fn opens(c: char) -> bool { matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') @@ -332,7 +337,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text with emoji, or with characters nothing above covers. - let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; fallbacks.extend(emoji.iter().cloned()); drop(dbw); @@ -370,7 +375,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { return f.clone(); } - if c == '\n' || primary.has_char(c) { + if is_line_break(c) || primary.has_char(c) { return primary.clone(); } let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); @@ -382,13 +387,15 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { - *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + !is_line_break(*c) + && !is_line_break(first) + && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) }) && level_at(seg_end) == level { seg_end += 1; } - if first == '\n' { + if is_line_break(first) { items.push(Item { face: face.clone(), coords: no_coords(), @@ -401,7 +408,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) char_start: seg_start, char_end: seg_start + 1, style: st_idx, - ch: '\n', + ch: first, level, }); } else { @@ -546,7 +553,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { vis_end -= 1; } // Letter spacing separates glyphs: none after the line's last visible one. @@ -574,7 +581,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .get(vis_end..*e) .unwrap_or(&[]) .iter() - .filter(|it| it.ch != '\n') + .filter(|it| !is_line_break(it.ch)) .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; @@ -649,7 +656,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) (0..line_items.len()).collect() }; for it in order.iter().filter_map(|k| line_items.get(*k)) { - if it.ch == '\n' { + if is_line_break(it.ch) { continue; } let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; From 855bbb328fde58feee6d8cbc5d5d380c87235cc9 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:34 +0200 Subject: [PATCH 20/44] refactor(text): Line::break_after for carets and selections Carets, clicks and selections asked `para_end && !last_line` to mean "the line ends with a break char". Record that on the line instead, so a break that doesn't end the paragraph can use the same rules. No behaviour change: today only a newline ends a line early. --- crates/text/src/layout.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6266dc2b..7aef4e5e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -82,6 +82,9 @@ pub struct Line { pub x: f64, /// The line ends a paragraph (hard break or end of text). pub para_end: bool, + /// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks + /// the line inside its paragraph. + pub break_after: bool, /// The paragraph runs right to left (its first strong character is right-to-left). pub rtl: bool, } @@ -498,6 +501,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } + let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { lines.push((line_start, items.len(), true, para)); } @@ -694,6 +698,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, + break_after: break_after(*e), rtl: rtl_line, }); y += lh; @@ -1151,11 +1156,11 @@ impl TextLayout { let last_line = li + 1 == self.lines.len(); let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.para_end && !last_line) { + if !(in_line || index == line.char_end && line.break_after && !last_line) { continue; } - if index == line.char_end && line.para_end && !last_line { - // Before the newline: end of this line. + if index == line.char_end && line.break_after && !last_line { + // Before the line break: end of this line. let x = self.line_end_x(line); return (x, li); } @@ -1186,8 +1191,8 @@ impl TextLayout { if !line.rtl && !gl.iter().any(|g| g.rtl) { return None; } - // The caret can't sit after a paragraph's newline on its own line. - let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; + // The caret can't sit after a line's break on its own line. + let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; let next = (line.char_start..=end.max(line.char_start)) .filter(|i| *i != index) .map(|i| (i, self.caret(i).0)) @@ -1254,7 +1259,7 @@ impl TextLayout { } } let last_line = li + 1 == self.lines.len(); - let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; + let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; // Right of a right-to-left line is its start. if line.rtl { line.char_start } else { end } } @@ -1286,7 +1291,7 @@ impl TextLayout { let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. - self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; From 692f45123c5b4b28053bfa90c531f5861f414335 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:47 +0200 Subject: [PATCH 21/44] fix(text): a line separator breaks the line inside its paragraph (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma stores Shift+Enter as U+2028. `.fig` imports keep it, but layout only broke lines at a newline, so imported text ran soft-broken lines together ("Option 1SL: …"), lost the empty line of a separator followed by a newline, and put glyph bullets on one line. A line separator now ends its line without ending the paragraph: no paragraph spacing and no new list marker after it, the next line keeps the item's indent, and it draws no glyph. A trailing one leaves an empty last line, and a line it cuts short isn't stretched when justified. --- crates/text/src/layout.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7aef4e5e..f9139f6f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -160,9 +160,13 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } -/// A char that ends its line: a newline (which also ends the paragraph). +/// The line separator (U+2028): a line break inside a paragraph, as Figma's Shift+Enter stores it +/// (imported text keeps it; typed text has newlines only). +const LINE_SEPARATOR: char = '\u{2028}'; + +/// A char that ends its line: a newline (which also ends the paragraph) or a line separator. fn is_line_break(c: char) -> bool { - c == '\n' + c == '\n' || c == LINE_SEPARATOR } /// Opening punctuation, which hangs outside a line's start. @@ -475,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } + // A line separator (Figma's Shift+Enter) breaks the line inside its paragraph: no + // paragraph spacing and no new list marker after it. + if it.ch == LINE_SEPARATOR { + lines.push((line_start, i + 1, false, para)); + i += 1; + line_start = i; + x = 0.0; + last_break = None; + para_first_line = false; + continue; + } let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; // Hanging punctuation takes no room inside the box. @@ -502,7 +517,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) i += 1; } let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { lines.push((line_start, items.len(), true, para)); } @@ -574,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), + (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } _ => 0.0, }; // Where the visible text ends once justified. From 9e4dcc9204b3d694f6c9a2085524b6d9defa2444 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:58 +0200 Subject: [PATCH 22/44] test(text): line separators in layout, lists and carets (#852) --- crates/text/src/lib.rs | 2 + crates/text/src/line_separator_tests.rs | 82 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 crates/text/src/line_separator_tests.rs diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f6..8160cc90 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -150,6 +150,8 @@ mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] +mod line_separator_tests; +#[cfg(test)] mod list_tests; #[cfg(test)] mod metrics_tests; diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs new file mode 100644 index 00000000..d5ad16b0 --- /dev/null +++ b/crates/text/src/line_separator_tests.rs @@ -0,0 +1,82 @@ +//! A line separator (U+2028: what Figma's Shift+Enter stores, and `.fig` imports keep) breaks the +//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). + +use super::*; +use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData}; + +const EPS: f64 = 1e-6; + +/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().unwrap().default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + t.style.line_height = LineHeight::Pixels(30.0); + t.auto_resize = TextAutoResize::WidthAndHeight; + Some(t) +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +#[test] +fn breaks_the_line_but_not_the_paragraph() { + let Some(mut t) = text("one\u{2028}two\nthree") else { return }; + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]); + assert!(!l.lines[0].para_end && l.lines[0].break_after); + assert!(l.lines[1].para_end && l.lines[1].break_after); + assert!(!l.lines[2].break_after); + // Paragraph spacing after the newline only: 30, then 30 + 10. + assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top); + // The separator draws nothing. + assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3))); +} + +#[test] +fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { + // "Hi,⏎¶Season" in #852: Figma shows an empty line between the two. + let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); + let Some(t) = text("end\u{2028}") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]); + assert!((l.height - 60.0).abs() < EPS, "{}", l.height); +} + +#[test] +fn a_list_item_continues_after_it_without_a_new_marker() { + let Some(mut t) = text("item\u{2028}more\nnext") else { return }; + t.list = ListStyle::Bulleted; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]); + // Markers are the glyphs that cover no char: one per paragraph. + assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2); + // The line after the separator lines up with the item's text, past the bullet. + let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x); + let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap()); + assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}"); +} + +#[test] +fn carets_and_clicks_treat_it_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + for i in 0..=5 { + assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}"); + } + for line in &ln.lines { + for x in [-10.0, 5.0, line.width + 50.0] { + assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); + } + } + // A click right of the first line lands before the separator, not after it. + assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); +} From 7af44512e78083e152f60ab57a03a8fe2adaf2b3 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:46:47 +0200 Subject: [PATCH 23/44] refactor(pages): remember which file the Pages list focus belongs to --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/layers.rs | 7 ++++--- crates/ui-egui/src/layers_keys_tests.rs | 4 ++-- crates/ui-egui/src/lib.rs | 10 +++++----- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/selection_spacing.rs | 2 +- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 94daf657..234b07b2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -544,7 +544,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; - app.ui.pages_focus = false; + app.ui.pages_focus = None; app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 514b8f75..eaba5538 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -199,7 +199,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { - app.ui.pages_focus = true; + app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); app.run("page.add", json!({})); } if !app.ui.pages_open { @@ -209,6 +209,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); let cur = ds.page; + let uid = ds.uid; let sel = ds.selection.clone(); let mut select = None; let mut rename_done: Option<(NodeId, String)> = None; @@ -258,7 +259,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.clicked() && !divider { select = Some(id); - app.ui.pages_focus = true; + app.ui.pages_focus = Some(uid); app.ui.layers_focus = false; } if resp.double_clicked() { @@ -1074,7 +1075,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if let Some((id, m)) = clicked { app.ui.layers_focus = true; - app.ui.pages_focus = false; + app.ui.pages_focus = None; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index d0b5f786..48a6a591 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -169,7 +169,7 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { u.button(row, true); u.button(row, false); assert_eq!(u.app.session.page().unwrap(), first); - assert!(u.app.ui.pages_focus); + assert!(u.app.ui.pages_focus.is_some()); // Layers selected after the click (Select all, say): Delete deletes them, not the page. u.app.run("select.set", json!({"ids": [a]})); key(&mut u, Key::Delete, false); @@ -179,7 +179,7 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { let p = pos2(900.0, 700.0); u.button(p, true); u.button(p, false); - assert!(!u.app.ui.pages_focus); + assert!(u.app.ui.pages_focus.is_none()); u.app.run("select.none", json!({})); key(&mut u, Key::Delete, false); assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a1cc3a8..702a87d6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,10 +500,10 @@ pub struct UiState { /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, - /// The pages list has the keyboard: Delete with no layers selected deletes the current page (set - /// by a click on a page row or the add-page button, cleared by a click on a layer row or a press - /// on the canvas). - pub pages_focus: bool, + /// The file (its `uid`) whose pages list has the keyboard: Delete with no layers selected deletes + /// its current page (set by a click on a page row or the add-page button, cleared by a click on a + /// layer row or a press on the canvas). + pub pages_focus: Option, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, @@ -638,7 +638,7 @@ impl Default for UiState { layer_filter: String::new(), layer_anchor: None, layers_focus: false, - pages_focus: false, + pages_focus: None, layers_seen_sel: Vec::new(), dark: false, follow_system: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dfdf290a..a8961b22 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -118,7 +118,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} // After a click in the Pages list, with no layers selected, it deletes the current page (#859). - "edit.delete" if app.ui.pages_focus && app.session.selection().is_empty() => { + "edit.delete" if app.ui.pages_focus.is_some() && app.session.selection().is_empty() => { if let Ok(page) = app.session.page() { crate::layers::delete_page(app, page); } diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index c05b9ec3..3048ab27 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -217,7 +217,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: let drag = Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; app.ui.layers_focus = false; - app.ui.pages_focus = false; + app.ui.pages_focus = None; app.ui.key_box = None; ctx.memory_mut(|m| { if let Some(f) = m.focused() { From 9f895a37d55a925e8c269836c8cdbd7875e7fe24 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:48:35 +0200 Subject: [PATCH 24/44] fix(pages): Delete deletes no page once the Pages list is off screen The Pages list kept the keyboard after a switch to the Assets tab or to another open file, so Delete with nothing selected deleted a page there. It now deletes a page only while the list that was clicked is on screen (File tab, list expanded, panel and interface shown) and its file is the one open. --- crates/ui-egui/src/layers.rs | 7 +++++ crates/ui-egui/src/layers_keys_tests.rs | 38 +++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 5 ++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index eaba5538..e168da7d 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -315,6 +315,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// The Pages list has the keyboard: a page row (or the add-page button) of the open file was +/// clicked last, and the list is still on screen. +pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool { + let Ok(ds) = app.session.ds() else { return false }; + app.ui.pages_focus == Some(ds.uid) && app.ui.pages_open && !app.ui.left_collapsed && !ds.view.ui_hidden && app.ui.left_tab == LeftTab::File +} + /// Delete `page` (no confirmation: Undo brings it back) and say so. pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) { if app.run("page.delete", json!({"id": page})).is_some() { diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index 48a6a591..71a1242d 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -184,3 +184,41 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { key(&mut u, Key::Delete, false); assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); } + +#[test] +fn delete_deletes_no_page_once_the_pages_list_is_off_screen_or_another_file_is_open() { + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + let click = |u: &mut Ui| { + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); + }; + // The Assets tab, a collapsed Pages list, a collapsed left panel. + let hide: [fn(&mut Ui, bool); 3] = [ + |u, on| u.app.ui.left_tab = if on { crate::LeftTab::Assets } else { crate::LeftTab::File }, + |u, on| u.app.ui.pages_open = !on, + |u, on| u.app.ui.left_collapsed = on, + ]; + for (k, hide) in hide.iter().enumerate() { + click(&mut u); + hide(&mut u, true); + u.frame(vec![]); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "case {k}: the list is off screen"); + hide(&mut u, false); + u.frame(vec![]); + } + // Another file open: its pages are safe. + click(&mut u); + u.app.run("file.new", json!({})); + u.app.run("page.add", json!({})); + u.frame(vec![]); + assert_eq!(pages(&u), 2); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "the other file keeps its pages"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a8961b22..244adcf1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -117,8 +117,9 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} - // After a click in the Pages list, with no layers selected, it deletes the current page (#859). - "edit.delete" if app.ui.pages_focus.is_some() && app.session.selection().is_empty() => { + // After a click in the Pages list, while the list is on screen and its file open, with no + // layers selected, it deletes the current page (#859). + "edit.delete" if crate::layers::pages_have_keyboard(app) && app.session.selection().is_empty() => { if let Ok(page) = app.session.page() { crate::layers::delete_page(app, page); } From e17fccd7abe5419c19869dc3ed70899dd7eb6981 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:50:07 +0200 Subject: [PATCH 25/44] fix(pages): a second Delete after deleting layers keeps the page After a click in the Pages list, layers selected since (Select all, say) took the first Delete, which left nothing selected, so a second Delete deleted the page. Delete that doesn't take the page now ends the Pages list's hold on the keyboard. --- crates/ui-egui/src/layers_keys_tests.rs | 7 +++++++ crates/ui-egui/src/menus.rs | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index 71a1242d..63f8ce74 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -175,7 +175,14 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { key(&mut u, Key::Delete, false); assert_eq!(pages(&u), 2); assert!(u.app.session.doc().unwrap().node(a).is_none()); + // Nothing is selected now, but the list gave up the keyboard: a second Delete keeps the page. + assert!(u.app.ui.pages_focus.is_none()); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "a second Delete keeps the page"); // A press on the canvas gives Delete back to it. + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); let p = pos2(900.0, 700.0); u.button(p, true); u.button(p, false); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 244adcf1..0d83024a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -124,6 +124,12 @@ pub fn invoke(app: &mut FigApp, id: &str) { crate::layers::delete_page(app, page); } } + // Otherwise the list gives up the keyboard: once Delete has taken layers selected since the + // click (Select all, say), a second Delete must not take the page. + "edit.delete" if app.ui.pages_focus.is_some() => { + app.ui.pages_focus = None; + app.run(id, json!({})); + } "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), From 1b66fec3ece5123e21ba6680ff11d0e8182edebb Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:12:54 +0200 Subject: [PATCH 26/44] fix(text): the caret after a line break sits on the next line `caret` put the index right after a line break (a newline, or a line separator) at the end of the line above, so after Enter the caret showed on the previous line until something was typed, and Up/Down counted lines from there. The index before the break stays at the end of its line; the one after it is now on the next line, as after a wrap. Selection highlights already started such lines at their own start (#665); their comment no longer describes the old caret. --- crates/text/src/layout.rs | 13 ++++--------- crates/text/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f9139f6f..014bb1cd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1171,16 +1171,12 @@ impl TextLayout { pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { let last_line = li + 1 == self.lines.len(); + // A caret at a line's end belongs to the next line, whether the line wraps or ends with a + // line break (before the break it is still on this line: past its glyphs). let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); - // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.break_after && !last_line) { + if !in_line { continue; } - if index == line.char_end && line.break_after && !last_line { - // Before the line break: end of this line. - let x = self.line_end_x(line); - return (x, li); - } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end == g.char_start { @@ -1303,8 +1299,7 @@ impl TextLayout { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } - // From the line's own start: `caret(char_start)` after a hard break is the end of the - // line above. + // From the line's own start. let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f5eaab6b..ebf5889c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() { // A break followed by text still doesn't highlight the next line's start. assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none()); } + +#[test] +fn the_caret_after_a_line_break_sits_on_the_next_line() { + if !have_fonts() { + return; + } + // Before the break: the end of its line; after it: the start of the next (after Enter, the + // caret shows on the new line, and Up/Down count from there). + let l = layout(&TextData::new("ab\ncd"), None, None); + assert_eq!(l.caret(2).1, 0); + assert!(l.caret(2).0 > 0.0); + assert_eq!(l.caret(3), (0.0, 1)); + // A trailing break: the caret sits on the empty last line. + let l = layout(&TextData::new("ab\n"), None, None); + assert_eq!(l.caret(3), (0.0, 1)); +} From b930ea5871ab6b7cc11a92c86a7ba787b8461609 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:13:12 +0200 Subject: [PATCH 27/44] test(text): the caret's line on either side of a line separator (#852) --- crates/text/src/line_separator_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index d5ad16b0..a7042483 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -77,6 +77,10 @@ fn carets_and_clicks_treat_it_like_a_newline() { assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); } } + // Before the separator: the end of the first line; after it: the start of the second. + assert_eq!(ls.caret(2).1, 0); + assert!(ls.caret(2).0 > 0.0); + assert_eq!(ls.caret(3), (0.0, 1)); // A click right of the first line lands before the separator, not after it. assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); } From 3e83ae44b73bcff28880131dfb270ed67b478137 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:50:43 +0200 Subject: [PATCH 28/44] fix(pages): a prototype gesture on the canvas ends the Pages list focus --- crates/ui-egui/src/canvas.rs | 2 ++ crates/ui-egui/src/proto_canvas.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 234b07b2..9adaf8ab 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -490,6 +490,8 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: /// then doesn't show. fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if crate::proto_canvas::input(app, ui, resp) { + // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard. + app.ui.pages_focus = None; return true; } let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1187944d..5def6c2a 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -282,6 +282,26 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { mod tests { use super::*; + /// A press on a connection handle takes the keyboard from the Pages list, as a press anywhere + /// else on the canvas does, so a later Delete can't take the page (#859). + #[test] + fn a_press_on_a_connection_handle_ends_the_pages_list_focus() { + let mut s = figcraft_engine::Session::new(); + let a = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); + let a = crate::present_tests::id(&a); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = crate::present_tests::Ui::new(s); + u.app.ui.right_tab = RightTab::Prototype; + u.run("view.zoomSelection", json!({})); + u.frames(2, Vec::new); + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + let doc = u.app.session.doc().unwrap().clone(); + let (_, at) = *handles(&u.app, &doc, &[a]).first().unwrap(); + u.button(at, true); + assert!(u.app.ui.pages_focus.is_none()); + u.button(at, false); + } + #[test] fn curves_leave_the_facing_side() { let a = Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 20.0)); From 6e7b63bc16e5e8a1b321fbb1ec19c6600ec4b8bd Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:50:43 +0200 Subject: [PATCH 29/44] fix(pages): Delete that removes an interaction ends the Pages list focus --- crates/ui-egui/src/proto_editor_tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 2 ++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index c63e62c6..80d49616 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -304,3 +304,26 @@ fn escape_closes_prototype_editor_without_deselecting_layer() { u.key(pos2(100.0, 100.0), egui::Key::Escape); assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); } + +/// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the +/// keyboard: with the layer deselected afterwards, Delete doesn't take the page (#859). +#[test] +fn deleting_an_interaction_ends_the_pages_list_focus() { + let (s, _a, _b, btn) = setup(); + let mut u = Ui::new(s); + let first = u.app.session.page().unwrap(); + u.run("page.add", json!({})); + u.run("page.select", json!({"id": first})); + // As after a click on the page's row (layers_keys_tests clicks the real row). + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0); + assert!(u.app.ui.pages_focus.is_none()); + u.run("select.none", json!({})); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().pages.len(), 2, "the page stays"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 85af66c5..111afa78 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -53,6 +53,8 @@ pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> boo return false; } open_editor(ctx, None); + // Delete went to the interaction, so a later one doesn't go to the Pages list. + app.ui.pages_focus = None; let ids = targets_for(app, node); app.run("proto.interaction.remove", json!({"ids": ids, "index": index})); true From 710c95e527e6b6fa35d28aac043c49472b7ebffc Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:52:09 +0200 Subject: [PATCH 30/44] test(text): a selection across a line separator shows the break (#852) --- crates/text/src/line_separator_tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index a7042483..6891f97b 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -84,3 +84,14 @@ fn carets_and_clicks_treat_it_like_a_newline() { // A click right of the first line lands before the separator, not after it. assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); } + +#[test] +fn a_selection_across_it_shows_the_break_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5)); + assert_eq!((before.len(), across.len()), (1, 2)); + // Reaching past the separator adds the 4 px break mark at the first line's end. + assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width()); + assert_eq!(across, ln.selection_rects(0, 5)); +} From 0108a7c2f5dda45304320469caa0c8e36cac1d65 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Mon, 19 Oct 2026 13:34:40 +0200 Subject: [PATCH 31/44] M3: copied components paste as instances within their file Co-Authored-By: Codex (GPT-6) --- crates/engine/src/cmd/edit.rs | 23 +++- .../engine/src/components_behaviour_tests.rs | 112 ++++++++++++++++++ crates/engine/src/lib.rs | 9 ++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35..52ffc04a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -515,6 +515,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -593,7 +600,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + s.clipboard = + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -623,6 +631,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re } // From another file: its instances follow the mains this file can have. let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -636,6 +645,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); @@ -661,10 +680,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { // else in the frame's centre; otherwise in place on the page (or the viewport centre). let sel = s.selection(); let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; let target = id(p, "parent").or_else(|| { sel.first() .copied() .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 15f660e1..e068c6cf 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -82,6 +82,118 @@ fn text(n: &Node) -> &TextData { n.kind.text().unwrap() } +#[test] +fn copying_a_main_component_pastes_a_linked_instance() { + for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "select.set", json!({"ids": [k.card]})); + ex(&mut s, "edit.copy", json!({})); + if command != "edit.paste" { + ex(&mut s, "select.set", json!({"ids": [k.inst]})); + } + let before = undo_len(&s); + let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card, "{command}"); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + assert_eq!(node(&s, pasted).source, Some(k.card)); + assert_eq!(undo_len(&s), before + 1); + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(pasted).is_none()); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(component_of(&s, pasted), k.card); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated"); + healthy(&s); + } +} + +#[test] +fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + ex(&mut s, "select.set", json!({"ids": [hover]})); + ex(&mut s, "edit.copy", json!({})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), hover); + assert_eq!(node(&s, pasted).name, node(&s, set).name); + let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") }; + assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover"); + assert_eq!(props["Show dot"], true); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + + ex(&mut s, "select.set", json!({"ids": [set]})); + ex(&mut s, "edit.copy", json!({})); + let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap())); + assert_eq!(kids(&s, copied_set).len(), 2); + for variant in kids(&s, copied_set) { + assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. })); + assert!(!kids(&s, set).contains(&variant)); + } + healthy(&s); +} + +#[test] +fn cut_components_and_components_copied_to_another_file_remain_components() { + for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, copy, json!({"ids": [k.card]})); + if undo_cut { + ex(&mut s, "edit.undo", json!({})); + } + if new_file { + ex(&mut s, "file.new", json!({})); + } + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}"); + assert_eq!(kids(&s, pasted).len(), 3); + healthy(&s); + } +} + +#[test] +fn copying_a_component_does_not_link_to_an_id_reused_after_undo() { + let mut s = Session::new(); + let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]}))); + ex(&mut s, "edit.copy", json!({"ids": [main]})); + s.undo().unwrap(); + s.undo().unwrap(); + let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"}))); + ex(&mut s, "component.create", json!({"ids": [other]})); + assert_eq!(other, main, "undo makes the original id available again"); + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert_eq!(node(&s, pasted).name, "Copied main"); + assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0)); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. })); + healthy(&s); +} + +#[test] +fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}})); + let original = s.doc().unwrap().world_transform(k.card); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000}))); + ex(&mut s, "edit.copy", json!({"ids": [k.card]})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card); + for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) { + assert!((actual - expected).abs() < 1e-9); + } + assert_eq!(node(&s, pasted).parent, Some(frame)); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err()); + assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically"); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e87..cf569801 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -735,6 +735,8 @@ pub struct Clipboard { /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, + /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). + pub copied_components: std::collections::BTreeSet, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, @@ -1166,6 +1168,13 @@ impl Session { } ds.clean_selection(); ds.revision += 1; + let (uid, doc) = (ds.uid, ds.doc.clone()); + if self.clipboard.source_doc == uid { + self.clipboard.copied_components.retain(|id| { + doc.node(*id) + .is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. })) + }); + } Ok(true) } From 4cc2230013ed3253e957239c8374dc96e7946097 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:38 +0700 Subject: [PATCH 32/44] #861: one search field for Pages and Layers; Escape closes it only from the field and goes no further; the search button shows when it's on; filter pages in the row loop --- crates/ui-egui/src/layers.rs | 70 ++++++-------------- crates/ui-egui/src/layers_behaviour_tests.rs | 26 ++++++-- 2 files changed, 43 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 25aed36e..cd2652f4 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -185,18 +185,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); - if sresp.hovered() { + if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); let search_id = Id::new("page_search_field"); - let mut focus_search = false; if sresp.clicked() { app.ui.page_search = !app.ui.page_search; app.ui.page_filter.clear(); if app.ui.page_search { app.ui.pages_open = true; - focus_search = true; } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); @@ -213,14 +211,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { return; } if app.ui.page_search { - page_search_field(app, ui, &t, w, search_id, focus_search); + search_field(ui, &t, w, search_id, tr("Search pages"), &mut app.ui.page_filter, &mut app.ui.page_search, sresp.clicked()); } let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); let filter = app.ui.page_filter.trim().to_lowercase(); - let visible_pages: Vec<(NodeId, String, bool)> = - pages.iter().filter(|(_, name, divider)| filter.is_empty() || (!divider && name.to_lowercase().contains(&filter))).cloned().collect(); let filtering = !filter.is_empty(); let cur = ds.page; let uid = ds.uid; @@ -236,7 +232,10 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { - for (k, (id, name, divider)) in visible_pages.iter().cloned().enumerate() { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + if filtering && (divider || !name.to_lowercase().contains(&filter)) { + continue; + } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); if !filtering { @@ -332,36 +331,6 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } -/// The Pages panel's inline filter (Escape clears and closes it). -fn page_search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id, focus: bool) { - let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); - let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); - ui.painter().rect_filled(field, CornerRadius::same(5), t.field); - icons::paint(ui.painter(), Rect::from_center_size(pos2(field.min.x + 12.0, field.center().y), vec2(12.0, 12.0)), Icon::Search, t.icon_secondary); - let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); - let r = child.add( - egui::TextEdit::singleline(&mut app.ui.page_filter) - .id(id) - .frame(egui::Frame::NONE) - .font(regular(11.0)) - .hint_text(tr("Search pages")) - .vertical_align(egui::Align::Center) - .desired_width(te_rect.width()), - ); - if focus { - r.request_focus(); - } - crate::widgets::select_all_on_focus(ui, &r, &app.ui.page_filter); - if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - } - if app.ui.page_search && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.page_search = false; - app.ui.page_filter.clear(); - } -} - /// The Pages list has the keyboard: a page row (or the add-page button) of the open file was /// clicked last, and the list is still on screen. pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool { @@ -688,8 +657,10 @@ fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTre } } -/// The search row under the Layers header (live filter; Escape closes it). -fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { +/// The search row under the Pages or Layers header: a live filter `text`, taking the keyboard when +/// `focus` (just opened). Escape in it clears it and closes the row (`open`), and goes no further +/// (the canvas keeps its selection). +fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut String, open: &mut bool, focus: bool) { let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); ui.painter().rect_filled(field, CornerRadius::same(5), t.field); @@ -697,21 +668,25 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( - egui::TextEdit::singleline(&mut app.ui.layer_filter) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) - .hint_text(tr("Find layers")) + .hint_text(hint) .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); - crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); + // After the field: the click that opened it ends elsewhere, which would take the focus back. + if focus { + r.request_focus(); + } + crate::widgets::select_all_on_focus(ui, &r, text); if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } - if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.layer_search = false; - app.ui.layer_filter.clear(); + if (r.has_focus() || r.lost_focus()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + *open = false; + text.clear(); } } @@ -730,12 +705,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); - if app.ui.layer_search { - ui.memory_mut(|m| m.request_focus(search_id)); - } } if app.ui.layer_search { - search_field(app, ui, &t, w, search_id); + search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked()); } let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index a1f3685a..b963ee82 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -217,16 +217,34 @@ fn pages_search_button_opens_the_filter_and_escape_clears_it() { } #[test] -fn pages_search_escape_closes_even_when_the_field_is_unfocused() { - let mut u = Ui::new(Session::new()); +fn pages_search_leaves_escape_to_the_canvas_when_the_field_is_unfocused() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); u.app.ui.page_search = true; u.app.ui.page_filter = "COMPON".into(); u.frame(vec![]); u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); - assert!(!u.app.ui.page_search, "Escape closes page search without field focus"); - assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter without field focus"); + assert!(u.app.session.selection().is_empty(), "Escape deselects on the canvas"); + assert!(u.app.ui.page_search, "the page search stays open"); + assert_eq!(u.app.ui.page_filter, "COMPON", "and keeps its filter"); +} + +#[test] +fn escape_in_the_pages_search_does_not_reach_the_canvas() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search); + u.frame(vec![Event::Text("x".into())]); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes the page search"); + assert_eq!(u.app.session.selection(), vec![r], "and leaves the selection alone"); } #[test] From 203ec13e03bd70ce696f9afabab48dd49172ce45 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:40 +0700 Subject: [PATCH 33/44] #821: text.loadFont reads at most MAX_FONT_FILE (the font scan's 256 MiB cap) --- crates/engine/src/cmd/create.rs | 6 ++---- crates/engine/src/cmd/text.rs | 2 +- crates/text/src/fontdb.rs | 8 +++++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6ec7d4e5..3a9f6d67 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -362,15 +362,13 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } -/// The bytes of the file at `path`, within the session's allowed file roots. #[cfg(not(target_arch = "wasm32"))] -pub(super) fn read_path(path: &str) -> Result> { +fn read_path(path: &str) -> Result> { crate::file_access::read(path) } -/// The browser has no files to read: always an error. #[cfg(target_arch = "wasm32")] -pub(super) fn read_path(path: &str) -> Result> { +fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0f0f003d..6e1f5c3d 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -497,7 +497,7 @@ pub fn specs() -> Vec { let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))? } else if let Some(path) = string(p, "path") { - super::create::read_path(&path)? + super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)? } else { return Err(bad(cmd, "pass `path` or `data` (base64)")); }; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 814d3a2f..604f3eee 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -819,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { + continue; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; for (index, family, style) in faces_in(&bytes) { out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style }); } } } +/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). +pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; + /// The parts of a font file the scan reads: headers, table directories and `name` tables, at their /// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan). fn index_bytes(path: &std::path::Path, len: usize) -> Option> { From 7126deaed001c6e03c25046a44c80c75e8233ec5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:40 +0700 Subject: [PATCH 34/44] #821: es and fi labels for text.rescanFonts, text.loadFont, text.refit --- crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/locales/fi.toml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b4c7c335..1af2f09f 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2139,6 +2139,7 @@ status = "draft — needs native review" "text.listIndent" = "Aumentar sangría de la lista" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" +"text.loadFont" = "Cargar archivo de fuente" "text.maxLines" = "Máximo de líneas" "text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" @@ -2147,7 +2148,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Espacio entre párrafos" "text.pathOptions" = "Opciones de texto en trazado" "text.preview" = "Previsualizar cambio de texto" +"text.refit" = "Ajustar texto a las fuentes" "text.removeFromPath" = "Quitar texto del trazado" +"text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index db5d2579..2dff384b 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -2141,6 +2141,7 @@ status = "draft — needs native review" "text.listIndent" = "Sisennä luettelo" "text.listOutdent" = "Vähennä luettelon sisennystä" "text.listStyle" = "Luettelotyyli" +"text.loadFont" = "Lataa fonttitiedosto" "text.maxLines" = "Rivien enimmäismäärä" "text.missingFonts" = "Puuttuvat fontit" "text.numberList" = "Numeroitu luettelo" @@ -2149,7 +2150,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Kappaleväli" "text.pathOptions" = "Tekstipolun asetukset" "text.preview" = "Esikatsele tekstimuutosta" +"text.refit" = "Sovita teksti fontteihin" "text.removeFromPath" = "Poista teksti polulta" +"text.rescanFonts" = "Etsi asennetut fontit uudelleen" "text.set" = "Aseta teksti" "text.setFeatures" = "OpenType-ominaisuudet" "text.setVariations" = "Muuttuvan fontin akselit" From 6325a323eecb26ddf2fdf738e34fbb9230a21b74 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:41 +0700 Subject: [PATCH 35/44] #821: move the loadFont/rescanFonts test into fonts_tests --- crates/engine/src/fonts_tests.rs | 18 ++++++++++++++++++ crates/engine/src/tests.rs | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 463fb904..0fe6dcb3 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -195,3 +195,21 @@ fn refit_reaches_text_in_instances() { assert_eq!(refitted(&r), vec![copy.0]); assert_eq!(width(&s, copy), want); } + +/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. +#[test] +fn fonts_load_from_files_and_rescans_report_new_families() { + let mut s = Session::new(); + assert!(s.execute("text.loadFont", &json!({})).is_err()); + assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); + assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); + assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); + if let Some(f) = figcraft_text::CRAFT_FONTS.first() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); + let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); + assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); + } + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(r["added"].is_array()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad3b6aa3..d6e2c9e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -627,21 +627,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. -#[test] -fn fonts_load_from_files_and_rescans_report_new_families() { - let mut s = sess(); - assert!(s.execute("text.loadFont", &json!({})).is_err()); - assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); - assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); - assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); - if let Some(f) = figcraft_text::CRAFT_FONTS.first() { - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); - let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); - assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); - } - let r = s.execute("text.rescanFonts", &json!({})).unwrap(); - assert!(r["added"].is_array()); -} From 9fddc4b058aa4440f8e629ea8530cef2efbe0504 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:00 +0700 Subject: [PATCH 36/44] #854: drop the progress-doc edits (ROADMAP, gaps, parity docs, generated checklist); the maintainer adds them --- ROADMAP.md | 2 -- docs/gaps.md | 6 +----- docs/parity-checklist.md | 4 ++-- docs/target-app-parity.md | 5 ++--- docs/ui-parity.md | 5 ++--- 5 files changed, 7 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1c3db7e4..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,8 +131,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-18** · Smart-selection drag reordering (M1): Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. Validation: 1,039 engine unit tests passed (2 ignored), 555 UI unit tests passed (1 ignored); formatting, layering, assets, parity and `cargo xtask wasm` passed. Clippy passes with only the existing `collapsible_match` findings suppressed; strict `cargo xtask ci` stops on seven findings in unchanged UI files. The workspace test run stops on the unchanged text crate’s `tail_tests::hanging_punctuation_outside_the_aligned_edge` (2 lines instead of 1); no text code changed. - - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/docs/gaps.md b/docs/gaps.md index 5f2255e6..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -174,9 +174,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Layers; Paste over and Paste properties from the keyboard; then (#753) Selection colors for mixed selections and smart-selection spacing fields and canvas handles (no reorder rings). Each was checked in the app (on Windows; the submenus by their author), none side by side with Figma, none on macOS. -- **Landed (2026-10-18):** smart-selection center rings for single-layer insertion and - Command/Ctrl swaps in regular rows, columns and complete top-left-aligned grids; live preview, - cancellation and one undo step. Multiple marked layers and irregular-grid insertion remain. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -447,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d53c9c52..775a4fcb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,8 @@ # Parity checklist -> **Generated:** 2026-10-18 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (563 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). **212 / 212 menu items (100.0%)** diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a9a1a507..16a5af69 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -95,7 +95,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | -| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed; smart-selection rings reorder single layers in regular rows, columns and complete aligned grids, with Command/Ctrl swaps; multi-item marks and irregular-grid insertion remain) | 3–5 | +| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | @@ -638,7 +638,6 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index eab5aafe..766284f1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -69,7 +69,7 @@ interaction feel not yet compared. | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | -| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). Center rings drag a single layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl swaps entries. Preview, target outline, Escape cancellation and one-step undo (`arrange.reorder`, `selection_reorder_tests`, `selection_spacing_tests`). Sizes, gaps and layer hierarchy kept. Mixed gaps and incomplete or differently aligned grids have no rings; multiple marked-item dragging remains. Spacing checked by its author on macOS and in the app on Windows (#402) | +| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | | Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | @@ -172,7 +172,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From cfbb5a8f504f3c0150b0ee404621a1ac20da38cd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:01 +0700 Subject: [PATCH 37/44] #854: no help-centre URL in selection_reorder.rs --- crates/engine/src/selection_reorder.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection_reorder.rs index e538fc8b..d54109c7 100644 --- a/crates/engine/src/selection_reorder.rs +++ b/crates/engine/src/selection_reorder.rs @@ -1,7 +1,6 @@ //! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns //! and complete top-left-aligned grids have unambiguous insertion slots. -//! Behavior reference: https://help.figma.com/hc/en-us/articles/360040450233 -//! (public documentation only; multi-item marking and irregular grids remain out of scope). +//! Marking several items and irregular grids are out of scope. use figcraft_doc::{Document, NodeId}; use figcraft_geom::{Point, Rect}; From 109b90c0aac1a9c881e126c2389da07858efa4d0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:02 +0700 Subject: [PATCH 38/44] #854: locale entries in sorted position --- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 132fda0b..0544eccc 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -8,7 +8,6 @@ name = "English" [ui] -"Reorder selection" = "Reorder selection" "% of all commits" = "% of all commits" "(no longer published)" = "(no longer published)" "({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" } @@ -1101,6 +1100,7 @@ name = "English" "Rename on the server…" = "Rename on the server…" "Rename page" = "Rename page" "Rename to" = "Rename to" +"Reorder selection" = "Reorder selection" "Replace all" = "Replace all" "Replace mixed export settings" = "Replace mixed export settings" "Replace with" = "Replace with" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index dc8bc40b..862f0570 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,6 @@ name = "Español" status = "draft — needs native review" [ui] -"Reorder selection" = "Reordenar selección" "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -1102,6 +1101,7 @@ status = "draft — needs native review" "Rename on the server…" = "Cambiar el nombre en el servidor…" "Rename page" = "Cambiar nombre de página" "Rename to" = "Nuevo nombre" +"Reorder selection" = "Reordenar selección" "Replace all" = "Reemplazar todo" "Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos" "Replace with" = "Reemplazar con" @@ -1743,7 +1743,6 @@ status = "draft — needs native review" "⌥ drag" = "⌥ arrastrar" [commands] -"arrange.reorder" = "Reordenar selección" "align.bottom" = "Alinear abajo" "align.hcenter" = "Alinear centros horizontales" "align.left" = "Alinear a la izquierda" @@ -1766,6 +1765,7 @@ status = "draft — needs native review" "arrange.front" = "Traer al frente" "arrange.packH" = "Agrupar en horizontal" "arrange.packV" = "Agrupar en vertical" +"arrange.reorder" = "Reordenar selección" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" From bb793470f1c30d3df6cfcf44148af37acaa7cd7d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:03 +0700 Subject: [PATCH 39/44] #854: no reorder ring on a layer under 16 px on screen, and its hit radius is at most a quarter of the layer, so a small layer still drags the selection --- crates/ui-egui/src/selection_spacing.rs | 17 ++++++++---- crates/ui-egui/src/selection_spacing_tests.rs | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 01e32d30..3a39955a 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -61,6 +61,15 @@ fn drag_id() -> Id { Id::new("selection_spacing_drag") } +/// The reorder ring of a layer with world bounds `b`: its centre on screen and hit radius. The +/// radius is 8 px, at most a quarter of the layer's smaller side on screen, so most of a small +/// layer still drags the selection; a layer under 16 px on screen has no ring. +fn ring(app: &FigApp, b: figcraft_geom::Rect) -> Option<(Pos2, f32)> { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom); + let side = ((b.x1 - b.x0).min(b.y1 - b.y0) * zoom) as f32; + (side >= 16.0).then(|| (app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5), (side / 4.0).min(8.0))) +} + #[derive(Clone)] enum Action { Spacing { axis: Axis, value: f64, last_value: Option }, @@ -279,8 +288,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: let ids = app.session.selection(); if let Some(reorder) = reorder_layout(app, ctx, &ids) { for (id, b) in &reorder.slots { - let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); - if center.distance(p) <= 8.0 { + if ring(app, *b).is_some_and(|(center, r)| center.distance(p) <= r) { ctx.set_cursor_icon(CursorIcon::Grab); if ctx.input(|i| i.pointer.primary_pressed()) { let source = *id; @@ -329,9 +337,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { }; if let Some(layout) = reorder { for (id, _) in &layout.slots { - let Some(b) = ds.doc.world_bounds(*id) else { continue }; - let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); - let hot = hover.is_some_and(|p| center.distance(p) <= 8.0); + let Some((center, r)) = ds.doc.world_bounds(*id).and_then(|b| ring(app, b)) else { continue }; + let hot = hover.is_some_and(|p| center.distance(p) <= r); let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id); if active { painter.circle_filled(center, 4.5, PINK); diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 685f7b4e..2ee44742 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -333,3 +333,29 @@ fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() { assert_eq!(u.app.session.undo_depth().0, history); assert_eq!(xy(&u, ids[0]), (260., 100.)); } + +#[test] +fn small_layers_on_screen_have_no_reorder_ring_and_drag_the_selection_from_their_middle() { + let mut u = Ui::new(Session::new()); + let z = u.app.session.ds().unwrap().view.zoom; + // 12 px squares 36 px apart on screen: a regular row, too small for rings. + let ids: Vec = (0..3) + .map(|col| { + let p = json!({"x": (100. + f64::from(col) * 36.) / z, "y": 100. / z, "width": 12. / z, "height": 12. / z}); + NodeId(u.app.session.execute("shape.rectangle", &p).unwrap()["id"].as_u64().unwrap()) + }) + .collect(); + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + let before: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + let p = u.app.to_screen(before[0].0 + 6. / z, before[0].1 + 6. / z); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, vec2(0., 60.), true); + let after: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + assert!(after[0].1 > before[0].1, "the selection moved: {before:?} -> {after:?}"); + assert!( + before.iter().zip(&after).all(|(b, a)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1 - (after[0].1 - before[0].1)).abs() < 1e-6), + "all together, in order" + ); + assert_eq!(u.app.session.selection(), ids); +} From 99a8ef0e26555f80c5ee8c859c8589feba972068 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:55:26 +0700 Subject: [PATCH 40/44] #832: drop the progress-doc edits (ROADMAP header, log and revision rows, auto-layout-parity row); the maintainer adds them --- ROADMAP.md | 4 +--- docs/auto-layout-parity.md | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b00408dc..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (individual padding in the Layout panel, M2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,7 +131,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-18** · Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -317,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 5342135a..ea2cc54f 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -64,7 +64,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Nested hug/fill settling in one pass (three levels, wrapping fill text, grid rows) | done | | | Absolute position (ignore auto layout) with constraints | done | | | Strokes included in layout (inside strokes), canvas stacking | done | | -| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation); the Layout panel's Spacing row opens per-side padding fields (Top/Right/Bottom/Left) behind a toggle | +| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) | | Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` | | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | @@ -77,7 +77,6 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (`padding_individual_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | From 742e911652b41cc1bed3428a422533ee0709adfd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:58:26 +0700 Subject: [PATCH 41/44] #832: the per-side fields are two plain rows like Individual corners (toggle in the first); shut H/V fields read Mixed when their sides disagree; no comment naming the incumbent --- crates/ui-egui/src/layout_panel.rs | 143 +++++++----------- .../ui-egui/src/padding_individual_tests.rs | 6 +- 2 files changed, 60 insertions(+), 89 deletions(-) diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 6c8d6693..b5ff1d45 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -4,7 +4,7 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. -use egui::{Align, Align2, CornerRadius, Id, Layout, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::*; use serde_json::json; @@ -401,97 +401,66 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } -/// The Spacing row: gap fields, then the H/V padding pair (or the 2x2 per-side -/// grid while the individual-padding toggle is open). +/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field +/// per side (like Individual corners). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); - if open { - // Each side on its own, like Figma's individual padding: a 2x2 grid of sides with - // the toggle at its right, replacing the H/V row while open. Every measure copies - // a plain row: 32px rows, the panel's own inter-row gap, 8px between the fields. - let gap = ui.spacing().item_spacing.y; - let total = 32.0 * 2.0 + gap; - let fields_w = col * 2.0 + 8.0; - let top = ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0); - let right = ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1); - let bottom = ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2); - let left = ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3); - block(ui, total, 0.0, |ui| { - ui.allocate_ui_with_layout(vec2(fields_w, total), Layout::top_down(Align::LEFT), |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - // The horizontal pair (left, right) on the left, the vertical pair on the right. - for (r, pair) in [[&left, &top], [&right, &bottom]].iter().enumerate() { - if r > 0 { - ui.add_space(gap); - } - ui.allocate_ui_with_layout(vec2(fields_w, 32.0), Layout::left_to_right(Align::Center), |ui| { - ui.spacing_mut().item_spacing.x = 8.0; - for (key, name, icon, v, k) in pair.iter() { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - key, - Id::new(("padding", *k)), - Lead::Icon(*icon, name), - Some(*v), - "", - col, - ); - app.field(ids, key, e, |v| json!(v.max(0.0))); - } - }); - } - }); - // The toggle keeps the shut row's rect: full-height strip, button 4px - // down, centred in the top 32px exactly like the shut row. - ui.allocate_ui_with_layout(vec2(24.0, total), Layout::top_down(Align::Center), |ui| { - ui.add_space(4.0); - if icons::button(ui, Icon::IndependentPadding, 24.0, true, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), false)); - } - }); - }); - return; - } - row(ui, 32.0, |ui| { - // Sides that disagree show both values ("16,54") instead of "Mixed". - let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); - let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; - let e = crate::variable_ui::number_field_or( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - (pl == pr).then_some(pl), - col, - h_empty, - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - "", - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); - let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; - let e = crate::variable_ui::number_field_or( - app, - ui, - ids, - "paddingVertical", - Id::new("al_padv"), - (pt == pb).then_some(pt), - col, - v_empty, - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), - "", - ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); - if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); + let toggle = |ui: &mut Ui| { + if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); } - }); + }; + if open { + // `l` is the selection's common layout, so its sides are common too. `k` indexes + // [top, right, bottom, left]. + let sides = [ + ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3), + ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0), + ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1), + ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2), + ]; + for (r, pair) in sides.chunks(2).enumerate() { + row(ui, 32.0, |ui| { + for (key, name, icon, v, k) in pair { + let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + if r == 0 { + toggle(ui); + } + }); + } + } else { + row(ui, 32.0, |ui| { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + (pl == pr).then_some(pl), + "", + col, + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + (pt == pb).then_some(pt), + "", + col, + ); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + toggle(ui); + }); + } if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. let vertical = l.mode == LayoutMode::Vertical; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index 28a207bc..bc95886e 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -90,13 +90,15 @@ fn named_field(u: &mut Ui, label: &str) -> Pos2 { pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) } -/// The shut pair fields edit their own axis: horizontal touches left and right -/// only, vertical touches top and bottom only. +/// The shut pair fields read Mixed when their sides disagree and edit their own axis: horizontal +/// touches left and right only, vertical touches top and bottom only. #[test] fn shut_pair_fields_edit_their_own_axis() { let (mut u, f) = scene(); u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + let shown = crate::live_tests::texts(&crate::live_tests::flat(&u.frame_output(vec![]).shapes)); + assert!(shown.iter().any(|(t, _, _)| t == "Mixed"), "sides that disagree read Mixed: {shown:?}"); let h = named_field(&mut u, "Horizontal padding"); click(&mut u, h); u.frame(vec![Event::Text("20".into())]); From f2ede63dd931502f81f1f72f7d92e5795ac2507f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:00:09 +0700 Subject: [PATCH 42/44] #867: comments describe the line separator without naming another product --- crates/text/src/layout.rs | 6 +++--- crates/text/src/line_separator_tests.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 014bb1cd..26ae3d33 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -160,8 +160,8 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } -/// The line separator (U+2028): a line break inside a paragraph, as Figma's Shift+Enter stores it -/// (imported text keeps it; typed text has newlines only). +/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft +/// break (imported text keeps it; typed text has newlines only). const LINE_SEPARATOR: char = '\u{2028}'; /// A char that ends its line: a newline (which also ends the paragraph) or a line separator. @@ -479,7 +479,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } - // A line separator (Figma's Shift+Enter) breaks the line inside its paragraph: no + // A line separator (a soft break) breaks the line inside its paragraph: no // paragraph spacing and no new list marker after it. if it.ch == LINE_SEPARATOR { lines.push((line_start, i + 1, false, para)); diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index 6891f97b..13f91786 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -1,4 +1,4 @@ -//! A line separator (U+2028: what Figma's Shift+Enter stores, and `.fig` imports keep) breaks the +//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the //! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). use super::*; @@ -41,7 +41,7 @@ fn breaks_the_line_but_not_the_paragraph() { #[test] fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { - // "Hi,⏎¶Season" in #852: Figma shows an empty line between the two. + // "Hi,⏎¶Season" in #852: an empty line between the two. let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; let l = layout(&t, None, None); assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); From a9975ccc220aec2794643206d1bb9ced435eedc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:13:55 +0700 Subject: [PATCH 43/44] #818: drop the progress-doc edits (ROADMAP revision row, ui-parity rows); the maintainer adds them --- ROADMAP.md | 1 - docs/ui-parity.md | 2 -- 2 files changed, 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e5ed24d6..8a587286 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -319,7 +319,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | -| 2026-10-17 | minor | Interface size (#540): Preferences ▸ Interface size and `pref.uiScale` make the whole interface larger or smaller. Auto keeps the system's display scale (its entry shows the size it draws at); a fixed size from 75 % to 300 % is absolute, so 100 % is one screen pixel per point on every system. A window too small for a fixed size draws a smaller one until it is larger (the choice is kept); the browser keeps its own zoom. Cmd/Ctrl +/-/0 still zoom the canvas only. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c935e009..766284f1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -165,7 +165,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | -| Interface size (Preferences ▸ Interface size, `pref.uiScale`) | done on desktop | Auto (the system's scale) or a fixed size from 75 % to 300 %, where 100 % is one screen pixel per point on every system; held back while the window is too small for it; the browser keeps its own zoom | | WCAG AA contrast of secondary and accent text | done | | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -177,7 +176,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | -| 2026-10-17 | minor | Interface size preference (#540): Preferences ▸ Interface size and `pref.uiScale`, Auto or a fixed size from 75 % to 300 %, absolute (100 % is one screen pixel per point whatever the system's scale) | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From 20e4028afa2a5f7c922ba591e62d1a7a1dac9be1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:23:14 +0700 Subject: [PATCH 44/44] #818: interface sizes are relative to the system's display scale (100% is native, Auto goes; 75-200% in Preferences, pref.uiScale takes 0.75-3, "auto" reads as 1); interface_size moved above batch_rename_dialog's doc comment; tests and docs follow --- crates/engine/src/a11y_keys_tests.rs | 34 +++++----- crates/engine/src/cmd/view.rs | 9 ++- crates/engine/src/lib.rs | 25 +++---- crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 3 +- crates/ui-egui/locales/fr.toml | 3 +- crates/ui-egui/src/dialogs.rs | 28 ++++---- crates/ui-egui/src/lib.rs | 22 +++---- crates/ui-egui/src/ui_scale.rs | 99 +++++++++++++--------------- crates/ui-egui/src/ui_scale_tests.rs | 69 +++++++++---------- docs/control-protocol.md | 5 +- docs/mcp.md | 11 ++-- 12 files changed, 138 insertions(+), 173 deletions(-) diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 72507df5..e4892bca 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -57,25 +57,23 @@ fn reduce_motion_is_a_preference_that_persists() { } #[test] -fn interface_size_is_auto_until_chosen_and_a_chosen_size_is_absolute_and_persists() { +fn interface_size_is_relative_to_the_system_and_persists() { let mut s = Session::new(); - assert_eq!(s.prefs.ui_scale, None); - assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], "auto"); - // A fixed size: 1.0 is one screen pixel per point, so the number is not multiplied by the system's scale. + assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default"); + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0); assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25); - assert_eq!(s.prefs.ui_scale, Some(1.25)); - // Reading changes nothing; "auto" goes back to the system's scale. + assert_eq!(s.prefs.ui_scale, 1.25); + // Reading changes nothing; "auto" (older scripts) is the system's own size. assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25); - assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], "auto"); - assert_eq!(s.prefs.ui_scale, None); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0); // Nothing to undo: the document didn't change. assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false)); - // Saved with the other preferences. - s.prefs.ui_scale = Some(2.5); + // Saved with the other preferences and read back. + s.prefs.ui_scale = 0.9; let v = serde_json::to_value(&s.prefs).unwrap(); - assert_eq!(v["ui_scale"], 2.5); + assert_eq!(v["ui_scale"], 0.9); let back: crate::Prefs = serde_json::from_value(v).unwrap(); - assert_eq!(back.ui_scale, Some(2.5)); + assert_eq!(back.ui_scale, 0.9); } #[test] @@ -86,22 +84,22 @@ fn interface_size_stops_at_the_ends_and_junk_never_changes_it() { assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX); // The size is a factor (1.25), not a percentage: 125 is far past the end and stops there. assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX); - s.prefs.ui_scale = Some(1.5); + s.prefs.ui_scale = 1.5; for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] { assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}"); - assert_eq!(s.prefs.ui_scale, Some(1.5), "{junk}"); + assert_eq!(s.prefs.ui_scale, 1.5, "{junk}"); } } #[test] -fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_auto_and_keeps_the_rest() { +fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); - assert_eq!(old.ui_scale, None); + assert_eq!(old.ui_scale, 1.0); for bad in [json!("big"), json!(null), json!([2]), json!(true)] { let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap(); - assert_eq!((p.ui_scale, p.theme.as_str()), (None, "dark"), "{bad}"); + assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}"); } // A number out of range is clamped rather than refused. let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); - assert_eq!((p.ui_scale, p.theme.as_str()), (Some(crate::UI_SCALE_MAX), "dark")); + assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9b929118..eb573c57 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -373,19 +373,18 @@ pub fn specs() -> Vec { spec!( "pref.uiScale", "Interface size", - "{scale?: \"auto\" or a fixed size from 0.75 to 3, 1 being one screen pixel per point whatever scale the system reports, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; omit: just read it)} → {scale: \"auto\" or the size}: how large the whole interface is drawn; auto keeps the system's scale; the canvas zoom is separate", + "{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate", false, |s, p| { match get(p, "scale") { None => {} - Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = None, + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0, Some(_) => { let scale = req_num("pref.uiScale", p, "scale")?; - s.prefs.ui_scale = - Some(crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number or \"auto\""))?); + s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?; } } - Ok(json!({"scale": s.prefs.ui_scale.map_or(json!("auto"), |v| json!(v))})) + Ok(json!({"scale": s.prefs.ui_scale})) } ), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c8010399..c70a145f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -603,20 +603,21 @@ impl DocState { } } -/// The smallest and largest fixed interface size: 1.0 is one screen pixel per point, whatever scale -/// the system reports. +/// The smallest and largest interface size, relative to the system's display scale (1.0 is the +/// system's own size). pub const UI_SCALE_MIN: f64 = 0.75; pub const UI_SCALE_MAX: f64 = 3.0; -/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None` (Auto). +/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`. pub fn clamp_ui_scale(scale: f64) -> Option { scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) } -/// Reads the saved interface size. Anything but a number reads as Auto and a number out of range is -/// clamped, so a settings file edited by hand never costs the other preferences. -fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { - Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale)) +/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a +/// number out of range is clamped, so a settings file edited by hand never costs the other +/// preferences. +fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0)) } /// App-wide preferences that commands read (nudge amounts, behaviours). @@ -646,11 +647,11 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, - /// How large the whole interface is drawn. `None` is Auto: the scale the system reports. A - /// number is a fixed size, 1.0 being one screen pixel per point, so it ignores the system's - /// scale (kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]). The canvas zoom is separate. + /// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the + /// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and + /// [`UI_SCALE_MAX`]). The canvas zoom is separate. #[serde(deserialize_with = "read_ui_scale")] - pub ui_scale: Option, + pub ui_scale: f64, /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, @@ -719,7 +720,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, - ui_scale: None, + ui_scale: 1.0, auto_save: false, invite_links: None, plugins: Vec::new(), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b16d9550..16e74b5e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -134,9 +134,7 @@ name = "English" "Auto (space between)" = "Auto (space between)" "Auto (space evenly)" = "Auto (space evenly)" "Auto ({mode})" = "Auto ({mode})" -"Auto ({percent}%)" = "Auto ({percent}%)" "Auto columns" = "Auto columns" -"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." "Auto gap" = "Auto gap" "Auto height" = "Auto height" "Auto layout" = "Auto layout" @@ -1064,6 +1062,7 @@ name = "English" "Reduce motion" = "Reduce motion" "Refraction" = "Refraction" "Regular expression" = "Regular expression" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 61ec8873..2e7bcff2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,8 +8,6 @@ name = "Español" status = "draft — needs native review" [ui] -"Auto ({percent}%)" = "Auto ({percent}%)" -"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto sigue el escalado de pantalla del sistema; los demás tamaños lo ignoran. El zoom del documento es independiente." "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -1065,6 +1063,7 @@ status = "draft — needs native review" "Reduce motion" = "Reducir movimiento" "Refraction" = "Refracción" "Regular expression" = "Expresión regular" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente." "Remove" = "Quitar" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index ff88477f..c3dd0553 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -134,9 +134,7 @@ status = "draft — needs native review" "Auto (space between)" = "Auto (espace entre)" "Auto (space evenly)" = "Auto (espace égal)" "Auto ({mode})" = "Auto ({mode})" -"Auto ({percent}%)" = "Auto ({percent} %)" "Auto columns" = "Colonnes auto" -"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto suit la mise à l’échelle d’affichage de votre système ; les autres tailles l’ignorent. Le zoom du document est distinct." "Auto gap" = "Espacement auto" "Auto height" = "Hauteur auto" "Auto layout" = "Mise en page auto" @@ -1061,6 +1059,7 @@ status = "draft — needs native review" "Reduce motion" = "Réduire les animations" "Refraction" = "Réfraction" "Regular expression" = "Expression régulière" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct." "Remove" = "Retirer" "Remove all" = "Tout retirer" "Remove annotation" = "Retirer l’annotation" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3fb2c2d4..c69f5616 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -479,33 +479,25 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { p } -/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers -/// start, a preview of the first new names, then one `object.batchRename`. -/// Preferences ▸ Interface size: Auto (the system's scale) or a fixed size, as a drop-down that -/// takes effect at once, with what each means written out. The browser has its own zoom, so there +/// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the +/// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there /// the row only says so. -fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option) { +fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { let t = Tokens::get(ui.ctx()); if cfg!(target_arch = "wasm32") { ui.label(tr("Interface size")); crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary); return; } - let percent = |scale: f32| crate::ui_scale::percent(scale); - let auto = trf("Auto ({percent}%)", &[("percent", &percent(app.ui.ui_native))]); - let fixed = |size: f64| trf("{percent}%", &[("percent", &percent(size as f32))]); - let shown = chosen.map_or_else(|| auto.clone(), fixed); + let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]); + let shown = percent(chosen); ui.horizontal(|ui| { ui.label(tr("Interface size")); let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { - if crate::right::menu_check(ui, chosen.is_none(), &auto, "") { - app.run("pref.uiScale", json!({"scale": "auto"})); - ui.close(); - } for size in crate::ui_scale::SIZES { - if crate::right::menu_check(ui, chosen == Some(size), &fixed(size), "") { + if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { app.run("pref.uiScale", json!({"scale": size})); ui.close(); } @@ -514,19 +506,21 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option) { }); crate::widgets::label( ui, - tr("Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate."), + tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."), t.text_secondary, ); // Said in words, not only by the menu's value: the window is too small for the size chosen. - if let Some(size) = chosen.filter(|_| app.ui.ui_scale.limited) { + if app.ui.ui_scale.limited { let note = trf( "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.", - &[("size", &percent(size as f32)), ("drawn", &percent(app.ui.ui_scale.drawn))], + &[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))], ); crate::widgets::label(ui, ¬e, t.text_secondary); } } +/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers +/// start, a preview of the first new names, then one `object.batchRename`. fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut b) = app.ui.batch_rename.clone() else { return }; let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f329c521..7f7dcca6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -462,11 +462,9 @@ pub struct UiState { pub menu_input: Option, /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. pub rename_file: bool, - /// What the interface size drew last frame: the system's scale (what Auto shows) and whether - /// the window held a fixed size back. Set by [`FigApp::logic`] for the main window. + /// What the interface size drew last frame and whether the window held it back. Set by + /// [`FigApp::logic`] for the main window. pub ui_scale: ui_scale::Plan, - /// The system's scale of the main window, as of the last frame. - pub ui_native: f32, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -620,8 +618,7 @@ impl Default for UiState { preferences: false, menu_input: None, rename_file: false, - ui_scale: ui_scale::plan(None, None, None), - ui_native: 1.0, + ui_scale: ui_scale::plan(1.0, None, None), shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), @@ -1271,16 +1268,15 @@ impl FigApp { theme::install_fonts(ctx, true, cjk); } } - // egui's zoom is for the whole app, so only the main window sets it (a window on another - // screen has its own system scale). The browser keeps its own zoom: Auto there. + // egui's zoom is for the whole app and multiplies each window's own system scale, so the + // size is the same relative to every screen; the main window, whose size can hold a size + // back, sets it. The browser keeps its own zoom. if ctx.viewport_id() == egui::ViewportId::ROOT { - let native = ctx.native_pixels_per_point(); let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); - let fixed = if cfg!(target_arch = "wasm32") { None } else { self.session.prefs.ui_scale }; - let plan = ui_scale::plan(fixed, native, Some((window_px.x, window_px.y))); + let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; + let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); ctx.set_zoom_factor(plan.zoom); self.ui.ui_scale = plan; - self.ui.ui_native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); } let dark = match self.session.prefs.theme.as_str() { "dark" => true, @@ -1701,7 +1697,7 @@ impl FigApp { }); state["homeSearch"] = json!(self.ui.home_view.file_query); // Set apart from the literal above, which is at the macro recursion limit. - state["uiScale"] = json!({"choice": self.session.prefs.ui_scale.map_or(json!("auto"), |v| json!(v)), "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); + 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()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs index 91d790cc..6b275a07 100644 --- a/crates/ui-egui/src/ui_scale.rs +++ b/crates/ui-egui/src/ui_scale.rs @@ -1,11 +1,10 @@ //! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes //! egui's zoom factor. //! -//! egui draws at `pixels_per_point = zoom factor × the system's scale`. Auto leaves the zoom at 1 so -//! the system's scale rules. A fixed size is absolute: 100 % is one screen pixel per point on every -//! system, so the zoom is the size divided by the system's scale (a Retina Mac at 2× needs 0.5 to -//! show 100 %). A fixed size is held back while the window is too small to lay the interface out at -//! it; the saved choice never changes. +//! egui draws at `pixels_per_point = zoom factor × the system's scale`. The size is relative to the +//! system's scale, so it is the zoom itself: 100 % is the system's own size on every screen (native +//! on a Retina Mac too), 125 % a quarter larger. A size above 100 % is held back while the window is +//! too small to lay the interface out at it; the saved choice never changes. /// The smallest window, in points, the interface is laid out for: the main window's minimum size. pub const FIT_POINTS: (f32, f32) = (800.0, 500.0); @@ -16,32 +15,31 @@ const ZOOM_RANGE: (f32, f32) = (0.1, 10.0); /// What to give egui for a saved choice on this screen and window. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Plan { - /// For `Context::set_zoom_factor`. + /// For `Context::set_zoom_factor`: the size drawn, relative to the system's scale. pub zoom: f32, /// Physical pixels per point once drawn (the zoom times the system's scale). pub drawn: f32, - /// A fixed size was chosen but the window is too small for it, so a smaller one is drawn. + /// The window is too small for the size chosen, so a smaller one is drawn. pub limited: bool, } -/// The plan for `choice` (`None`: Auto), the system's scale `native` and the window's size in -/// physical pixels `window_px`. An unknown, zero or not finite value reads as no information: a -/// system scale of 1 and a window of any size. -pub fn plan(choice: Option, native: Option, window_px: Option<(f32, f32)>) -> Plan { +/// The plan for `choice` (relative to the system's scale), the system's scale `native` and the +/// window's size in physical pixels `window_px`. An unknown, zero or not finite value reads as no +/// information: a size of 1, a system scale of 1 and a window of any size. +pub fn plan(choice: f64, native: Option, window_px: Option<(f32, f32)>) -> Plan { let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); - let Some(want) = choice.map(|c| c as f32).filter(|c| c.is_finite() && *c > 0.0) else { - return Plan { zoom: 1.0, drawn: native, limited: false }; - }; - let fit = - window_px.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1)); - // Never below 1 (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. - let target = match fit { + let want = Some(choice as f32).filter(|c| c.is_finite() && *c > 0.0).unwrap_or(1.0); + // The largest zoom at which the window still holds the smallest layout. + let fit = window_px + .filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) + .map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1) / native); + // Never below 100 % (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. + let zoom = match fit { Some(fit) if want > fit => fit.max(want.min(1.0)), _ => want, - }; - let zoom = (target / native).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); - let drawn = zoom * native; - Plan { zoom, drawn, limited: drawn < want - 0.005 } + } + .clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + Plan { zoom, drawn: zoom * native, limited: zoom < want - 0.005 } } /// A size as the percentage Preferences shows (1.25 → 125). @@ -49,79 +47,70 @@ pub fn percent(scale: f32) -> u32 { (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 } -/// The fixed sizes Preferences offers, after Auto. -pub const SIZES: [f64; 7] = [0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; +/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3). +pub const SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; #[cfg(test)] mod tests { use super::*; - const BIG: Option<(f32, f32)> = Some((5000.0, 3000.0)); + const BIG: Option<(f32, f32)> = Some((20000.0, 12000.0)); #[test] - fn auto_keeps_the_systems_scale_on_every_screen() { - for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { - let p = plan(None, Some(native), BIG); - assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "{native}"); - } - } - - #[test] - fn a_fixed_size_is_the_same_size_whatever_the_systems_scale() { + fn a_size_is_relative_to_the_systems_scale_on_every_screen() { // Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen. for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + let p = plan(1.0, Some(native), BIG); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "100 % is the system's own size on {native}"); for size in SIZES { - let p = plan(Some(size), Some(native), BIG); - assert!((p.drawn - size as f32).abs() < 1e-5, "{size} on {native}: drawn {}", p.drawn); + let p = plan(size, Some(native), BIG); + assert!((p.zoom - size as f32).abs() < 1e-5, "{size} on {native}: zoom {}", p.zoom); + assert!((p.drawn - size as f32 * native).abs() < 1e-4, "{size} on {native}: drawn {}", p.drawn); assert!(!p.limited, "{size} on {native}"); } } - // 100 % on a 2× screen really is half the Auto size: the zoom is 0.5. - assert_eq!(plan(Some(1.0), Some(2.0), BIG).zoom, 0.5); } #[test] fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() { for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] { - for choice in [None, Some(1.5), Some(f64::NAN), Some(f64::INFINITY), Some(-1.0), Some(0.0), Some(1e300)] { + for choice in [1.0, 1.5, f64::NAN, f64::INFINITY, -1.0, 0.0, 1e300] { let p = plan(choice, native, Some((f32::NAN, 0.0))); assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}"); assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}"); } } - // A choice that isn't a usable number is Auto. - assert_eq!(plan(Some(f64::NAN), Some(2.0), BIG), plan(None, Some(2.0), BIG)); + // A choice that isn't a usable number is the system's own size. + assert_eq!(plan(f64::NAN, Some(2.0), BIG), plan(1.0, Some(2.0), BIG)); } #[test] fn a_small_window_holds_a_big_size_back_without_going_below_100() { - // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels. + // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels, which holds 100 % only. let min = Some((1600.0, 1000.0)); - let p = plan(Some(3.0), Some(2.0), min); - assert_eq!((p.drawn, p.limited), (2.0, true)); - assert!(!plan(Some(2.0), Some(2.0), min).limited); - // The same window on a 1× screen: 800 × 500 pixels hold only 100 %. - let p = plan(Some(2.0), Some(1.0), Some((800.0, 500.0))); - assert_eq!((p.drawn, p.limited), (1.0, true)); + let p = plan(1.5, Some(2.0), min); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, 2.0, true)); + assert!(!plan(1.0, Some(2.0), min).limited); + // A larger window on a 1× screen: 1200 × 750 pixels hold 150 %. + let p = plan(2.0, Some(1.0), Some((1200.0, 750.0))); + assert_eq!((p.zoom, p.limited), (1.5, true)); // A window smaller than the minimum (a tiling window manager): never below 100 %, and never // below a smaller size the person chose. - assert_eq!(plan(Some(2.0), Some(1.0), Some((400.0, 250.0))).drawn, 1.0); - let p = plan(Some(0.75), Some(1.0), Some((400.0, 250.0))); - assert_eq!((p.drawn, p.limited), (0.75, false)); + assert_eq!(plan(2.0, Some(1.0), Some((400.0, 250.0))).zoom, 1.0); + let p = plan(0.75, Some(1.0), Some((400.0, 250.0))); + assert_eq!((p.zoom, p.limited), (0.75, false)); } #[test] fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() { // The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too. let px = Some((1700.0, 1100.0)); - let a = plan(Some(2.5), Some(2.0), px); - let b = plan(Some(2.5), Some(2.0), px); - assert_eq!(a, b); + assert_eq!(plan(2.5, Some(2.0), px), plan(2.5, Some(2.0), px)); } #[test] fn percentages_round_like_the_menu_shows_them() { - assert_eq!([percent(0.75), percent(1.0), percent(1.25), percent(2.0), percent(1.7999)], [75, 100, 125, 200, 180]); + assert_eq!([percent(0.75), percent(0.9), percent(1.1), percent(1.25), percent(2.0), percent(1.7999)], [75, 90, 110, 125, 200, 180]); assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]); } } diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 189552e7..018c963a 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -1,5 +1,5 @@ -//! The interface size, driven through egui headless on screens of different system scales: Auto -//! leaves the system's scale alone, a fixed size comes out the same on every screen, and the +//! The interface size, driven through egui headless on screens of different system scales: 100 % +//! leaves the system's scale alone, another size is relative to it on every screen, and the //! Preferences drop-down tells a screen reader its name and value. //! //! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small @@ -29,25 +29,25 @@ fn settle(u: &mut Ui, native: f32) { const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0]; #[test] -fn auto_leaves_the_systems_scale_and_the_menu_can_say_what_it_is() { +fn by_default_the_interface_is_the_systems_own_size() { for native in SCREENS { let mut u = Ui::new(Session::new()); settle(&mut u, native); assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}"); assert_eq!(u.ctx.pixels_per_point(), native, "{native}"); - assert_eq!(u.app.ui.ui_native, native, "what Auto shows in the menu: {native}"); } } #[test] -fn a_fixed_size_draws_the_same_on_every_screen_and_auto_brings_the_systems_scale_back() { +fn a_size_is_relative_to_the_systems_scale_on_every_screen_and_auto_is_100() { for native in SCREENS { let mut u = Ui::new(Session::new()); - for size in [0.75_f64, 1.0, 1.5] { + for size in [0.75_f64, 0.9, 1.25, 1.5] { assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some()); settle(&mut u, native); let drawn = u.ctx.pixels_per_point(); - assert!((drawn - size as f32).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!((drawn - size as f32 * native).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!((u.ctx.zoom_factor() - size as f32).abs() < 1e-5, "{size} on {native}"); assert!(!u.app.ui.ui_scale.limited, "{size} on {native}"); } assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some()); @@ -68,8 +68,8 @@ fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu"); (n.value().map(str::to_owned), n.is_expanded()) }; - // Auto says the size it draws at: a 2× screen is 200 %. - assert_eq!(combo(&mut u), (Some("Auto (200%)".to_owned()), Some(false))); + // 100 % is the system's own size, whatever it is (2× here). + assert_eq!(combo(&mut u), (Some("100%".to_owned()), Some(false))); u.app.run("pref.uiScale", json!({"scale": 1.25})); assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); } @@ -100,17 +100,14 @@ fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window( (5120.0, 2880.0), (2560.0, 520.0), ]; - let mut choices: Vec> = vec![None]; - choices.extend(SIZES.iter().map(|s| Some(*s))); for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] { for (w, h) in windows { let px = vec2(w * native, h * native); - for choice in &choices { - let case = format!("{w}x{h} pt at {native}x, {choice:?}"); + for want in SIZES { + let case = format!("{w}x{h} pt at {native}x, {want}"); let mut app = FigApp::new(Session::new(), Box::new(NoServices)); - let scale = choice.map_or(json!("auto"), |c| json!(c)); - assert!(app.run("pref.uiScale", json!({"scale": scale})).is_some(), "{case}"); + assert!(app.run("pref.uiScale", json!({"scale": want})).is_some(), "{case}"); let ctx = egui::Context::default(); let mut time = 0.0; let mut seen = Vec::new(); @@ -129,24 +126,18 @@ fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window( assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}"); let plan = app.ui.ui_scale; assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn); - match choice { - None => assert!((ppp - native).abs() < 1e-3 && !plan.limited, "{case}: Auto must keep the system's scale"), - Some(want) => { - let want = *want as f32; - assert_eq!(plan.limited, ppp < want - 0.005, "{case}: limited flag"); - // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. - if ppp > 1.001 { - assert!( - content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, - "{case}: window is {content:?} points at {ppp}" - ); - } - // It never draws bigger than asked, and only a window too small for it draws smaller. - assert!(ppp <= want + 1e-3, "{case}"); - if !plan.limited { - assert!((ppp - want).abs() < 1e-3, "{case}"); - } - } + // The size drawn, relative to the system's scale. + let zoom = ppp / native; + let want = want as f32; + assert_eq!(plan.limited, zoom < want - 0.005, "{case}: limited flag"); + // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. + if zoom > 1.001 { + assert!(content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, "{case}: window is {content:?} points at {ppp}"); + } + // It never draws bigger than asked, and only a window too small for it draws smaller. + assert!(zoom <= want + 1e-3, "{case}"); + if !plan.limited { + assert!((zoom - want).abs() < 1e-3, "{case}"); } let panels = &app.inspect()["panels"]; let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0); @@ -191,7 +182,7 @@ fn the_interface_size_menu_works_from_the_keyboard() { u.app.ui.preferences = true; u.ctx.enable_accesskit(); settle(&mut u, 1.0); - assert_eq!(chosen(&u), json!("auto")); + assert_eq!(chosen(&u), json!(1.0)); // Escape closes the menu and nothing else: the size is unchanged and Preferences stays open. open(&mut u); @@ -199,14 +190,14 @@ fn the_interface_size_menu_works_from_the_keyboard() { u.frame(vec![]); assert!(!Popup::is_any_open(&u.ctx)); assert!(u.app.ui.preferences, "the first Escape closes the menu only"); - assert_eq!(chosen(&u), json!("auto")); + assert_eq!(chosen(&u), json!(1.0)); - // The arrows enter the menu on Auto (which says the size it draws at) and go down through the - // sizes (75, 100, 125); Enter picks the one that has the keyboard and closes the menu. + // The arrows enter the menu on its first size and go down through the sizes (75, 90, 100, 110, + // 125); Enter picks the one that has the keyboard and closes the menu. open(&mut u); press(&mut u, Key::ArrowDown); - assert_eq!(focused_name(&u.ctx), "Auto (100%)"); - for _ in 0..3 { + assert_eq!(focused_name(&u.ctx), "75%"); + for _ in 0..4 { press(&mut u, Key::ArrowDown); } assert_eq!(focused_name(&u.ctx), "125%"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c54fbd7e..08c08b08 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -173,8 +173,9 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or the system's accessibility setting turns the interface's animations off. `uiScale` is the interface -size: the `choice` (`"auto"` or a fixed size, `pref.uiScale`), what is `drawn` (physical pixels per -point) and whether the window is too small for the choice (`limited`, so `drawn` is smaller). `live` is the live +size: the `choice` (relative to the system's display scale, `pref.uiScale`), the `zoom` drawn +(relative too), what that is in physical pixels per point (`drawn`) and whether the window is too +small for the choice (`limited`, so `zoom` is smaller). `live` is the live session of the window's active file as the window shows it (sessions are per file: another tab shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one), `sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the diff --git a/docs/mcp.md b/docs/mcp.md index 42a6222d..46c0fd0f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -417,12 +417,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. -- `pref.uiScale {scale?}`: how large the whole interface is drawn. `scale` is `"auto"` (the system's - display scale, the default) or a fixed size from 0.75 to 3 where 1 is one screen pixel per point - whatever scale the system reports, so a 2× screen needs 2 to match Auto. 1.25 means 125 %, not 125 - (outside the range it stops at the nearest end). Omit `scale` to read it. The canvas zoom is separate. - A window too small for a fixed size draws a smaller one until it is larger; the choice is kept. - In the browser the interface always follows the browser's zoom. +- `pref.uiScale {scale?}`: how large the whole interface is drawn, relative to the system's display + scale: 1 (the default) is the system's own size on every screen, 1.25 means 125 %, not 125; from + 0.75 to 3 (outside the range it stops at the nearest end; `"auto"` is 1). Omit `scale` to read it. + The canvas zoom is separate. A window too small for a size above 100 % draws a smaller one until + it is larger; the choice is kept. In the browser the interface always follows the browser's zoom. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system":