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 1/5] 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 2/5] 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 3/5] 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 a9975ccc220aec2794643206d1bb9ced435eedc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:13:55 +0700 Subject: [PATCH 4/5] #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 5/5] #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":