#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

This commit is contained in:
bflatastic
2026-10-20 00:23:14 +07:00
parent a9975ccc22
commit 20e4028afa
12 changed files with 138 additions and 173 deletions
+16 -18
View File
@@ -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"));
}
+4 -5
View File
@@ -373,19 +373,18 @@ pub fn specs() -> Vec<CommandSpec> {
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)),
+13 -12
View File
@@ -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<f64> {
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<Option<f64>, 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<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).
@@ -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<f64>,
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(),
+1 -2
View File
@@ -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"
+1 -2
View File
@@ -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"
+1 -2
View File
@@ -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"
+11 -17
View File
@@ -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<f64>) {
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<f64>) {
});
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, &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) {
let Some(mut b) = app.ui.batch_rename.clone() else { return };
let t = Tokens::get(ctx);
+9 -13
View File
@@ -462,11 +462,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: 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
+44 -55
View File
@@ -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<f64>, native: Option<f32>, 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<f32>, 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]);
}
}
+30 -39
View File
@@ -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<Option<f64>> = 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%");
+3 -2
View File
@@ -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
+5 -6
View File
@@ -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":