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":