Merge pull request #818 from leandrodslv/feat/ui-size

feat(ui): add the interface size preference (#540)
This commit is contained in:
BFlat
2026-10-20 03:01:28 +07:00
committed by GitHub
12 changed files with 507 additions and 2 deletions
+48
View File
@@ -55,3 +55,51 @@ 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_relative_to_the_system_and_persists() {
let mut s = Session::new();
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, 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"], 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 and read back.
s.prefs.ui_scale = 0.9;
let v = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(v["ui_scale"], 0.9);
let back: crate::Prefs = serde_json::from_value(v).unwrap();
assert_eq!(back.ui_scale, 0.9);
}
#[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 = 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, 1.5, "{junk}");
}
}
#[test]
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, 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()), (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()), (crate::UI_SCALE_MAX, "dark"));
}
+17
View File
@@ -370,6 +370,23 @@ pub fn specs() -> Vec<CommandSpec> {
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?: 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 = 1.0,
Some(_) => {
let scale = req_num("pref.uiScale", p, "scale")?;
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}))
}
),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
+23
View File
@@ -604,6 +604,23 @@ impl DocState {
}
}
/// 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`.
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
}
/// 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<f64, D::Error> {
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0))
}
/// App-wide preferences that commands read (nudge amounts, behaviours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -631,6 +648,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, 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: 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,
@@ -701,6 +723,7 @@ impl Default for Prefs {
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
ui_scale: 1.0,
auto_save: false,
start_on_home: false,
invite_links: None,
+5
View File
@@ -656,6 +656,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"
@@ -1066,6 +1067,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"
@@ -1380,6 +1382,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"
@@ -1460,6 +1463,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"
@@ -1731,6 +1735,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)."
+6
View File
@@ -657,6 +657,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"
@@ -1067,6 +1068,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"
@@ -1381,6 +1383,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"
@@ -1461,6 +1464,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"
@@ -1732,6 +1736,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)."
@@ -2046,6 +2051,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Renombrar capas duplicadas"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.startOnHome" = "Empezar en Inicio"
"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"
+6
View File
@@ -653,6 +653,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"
@@ -1060,6 +1061,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"
@@ -1369,6 +1371,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"
@@ -1446,6 +1449,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"
@@ -1714,6 +1718,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)."
@@ -2027,6 +2032,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Renommer les calques dupliqués"
"pref.showDimensions" = "Afficher les dimensions sur les objets"
"pref.startOnHome" = "Démarrer sur l’Accueil"
"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"
+47 -1
View File
@@ -142,8 +142,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()
@@ -275,6 +279,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 +481,46 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value {
p
}
/// 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: 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 = |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| {
for size in crate::ui_scale::SIZES {
if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") {
app.run("pref.uiScale", json!({"scale": size}));
ui.close();
}
}
});
});
crate::widgets::label(
ui,
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 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", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))],
);
crate::widgets::label(ui, &note, 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) {
+19
View File
@@ -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;
@@ -206,6 +207,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;
@@ -516,6 +519,9 @@ pub struct UiState {
pub menu_input: Option<dialogs::MenuInput>,
/// 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 and whether the window held it back. Set by
/// [`FigApp::logic`] for the main window.
pub ui_scale: ui_scale::Plan,
pub shortcuts_panel: bool,
/// The Keyboard shortcuts panel's tab and search text.
pub shortcuts_tab: figcraft_engine::keymap::Group,
@@ -675,6 +681,7 @@ impl Default for UiState {
preferences: false,
menu_input: None,
rename_file: false,
ui_scale: ui_scale::plan(1.0, None, None),
shortcuts_panel: false,
shortcuts_tab: figcraft_engine::keymap::Group::Essential,
shortcuts_query: String::new(),
@@ -1364,6 +1371,16 @@ impl FigApp {
theme::install_fonts(ctx, true, cjk);
}
}
// 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 window_px = ctx.content_rect().size() * ctx.pixels_per_point();
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;
}
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).
@@ -1782,6 +1799,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, "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
+116
View File
@@ -0,0 +1,116 @@
//! 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`. 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);
/// 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`: 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,
/// The window is too small for the size chosen, so a smaller one is drawn.
pub limited: bool,
}
/// 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<f32>, window_px: Option<(f32, f32)>) -> Plan {
let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0);
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,
}
.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).
pub fn percent(scale: f32) -> u32 {
(scale * 100.0).round().clamp(0.0, 100_000.0) as u32
}
/// 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((20000.0, 12000.0));
#[test]
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(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}");
}
}
}
#[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 [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 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, which holds 100 % only.
let min = Some((1600.0, 1000.0));
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(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));
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(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]);
}
}
+211
View File
@@ -0,0 +1,211 @@
//! 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
//! 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 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}");
}
}
#[test]
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, 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 * 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());
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())
};
// 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)));
}
/// 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),
];
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 want in SIZES {
let case = format!("{w}x{h} pt at {native}x, {want}");
let mut app = FigApp::new(Session::new(), Box::new(NoServices));
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();
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);
// 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);
assert!(side < f64::from(content.x) - 100.0, "{case}: the panels ({side} pt) leave no canvas in {content:?}");
}
}
}
}
/// 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!(1.0));
// 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!(1.0));
// 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), "75%");
for _ in 0..4 {
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);
}
+4 -1
View File
@@ -172,7 +172,10 @@ 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` (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
+5
View File
@@ -421,6 +421,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, 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.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file