feat(ui): add the interface size preference (#540)

Preferences > Interface size and the `pref.uiScale` command make the whole
interface larger or smaller. Auto keeps the system's display scale; a fixed
size from 75 % to 300 % is absolute (100 % is one screen pixel per point on
every system). The size is held back while the window is too small for it.

Strings are added in en, es and fr; the control protocol, MCP, UI parity and
roadmap docs are updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
leandrodslv
2026-10-18 14:39:57 +02:00
co-authored by Claude Sonnet 5.5
parent d8ff1441b1
commit 2c3ea03c2e
14 changed files with 395 additions and 1 deletions
+50
View File
@@ -55,3 +55,53 @@ fn reduce_motion_is_a_preference_that_persists() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.reduce_motion);
}
#[test]
fn interface_size_is_auto_until_chosen_and_a_chosen_size_is_absolute_and_persists() {
let mut s = Session::new();
assert_eq!(s.prefs.ui_scale, None);
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], "auto");
// A fixed size: 1.0 is one screen pixel per point, so the number is not multiplied by the system's scale.
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
assert_eq!(s.prefs.ui_scale, Some(1.25));
// Reading changes nothing; "auto" goes back to the system's scale.
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], "auto");
assert_eq!(s.prefs.ui_scale, None);
// Nothing to undo: the document didn't change.
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
// Saved with the other preferences.
s.prefs.ui_scale = Some(2.5);
let v = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(v["ui_scale"], 2.5);
let back: crate::Prefs = serde_json::from_value(v).unwrap();
assert_eq!(back.ui_scale, Some(2.5));
}
#[test]
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
let mut s = Session::new();
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
s.prefs.ui_scale = Some(1.5);
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
assert_eq!(s.prefs.ui_scale, Some(1.5), "{junk}");
}
}
#[test]
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_auto_and_keeps_the_rest() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert_eq!(old.ui_scale, None);
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (None, "dark"), "{bad}");
}
// A number out of range is clamped rather than refused.
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (Some(crate::UI_SCALE_MAX), "dark"));
}
+18
View File
@@ -370,6 +370,24 @@ 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?: \"auto\" or a fixed size from 0.75 to 3, 1 being one screen pixel per point whatever scale the system reports, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; omit: just read it)} → {scale: \"auto\" or the size}: how large the whole interface is drawn; auto keeps the system's scale; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
None => {}
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = None,
Some(_) => {
let scale = req_num("pref.uiScale", p, "scale")?;
s.prefs.ui_scale =
Some(crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number or \"auto\""))?);
}
}
Ok(json!({"scale": s.prefs.ui_scale.map_or(json!("auto"), |v| json!(v))}))
}
),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
+22
View File
@@ -603,6 +603,22 @@ impl DocState {
}
}
/// The smallest and largest fixed interface size: 1.0 is one screen pixel per point, whatever scale
/// the system reports.
pub const UI_SCALE_MIN: f64 = 0.75;
pub const UI_SCALE_MAX: f64 = 3.0;
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None` (Auto).
pub fn clamp_ui_scale(scale: f64) -> Option<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))
}
/// App-wide preferences that commands read (nudge amounts, behaviours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -630,6 +646,11 @@ pub struct Prefs {
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
/// How large the whole interface is drawn. `None` is Auto: the scale the system reports. A
/// number is a fixed size, 1.0 being one screen pixel per point, so it ignores the system's
/// scale (kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]). The canvas zoom is separate.
#[serde(deserialize_with = "read_ui_scale")]
pub ui_scale: Option<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,
@@ -698,6 +719,7 @@ impl Default for Prefs {
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
ui_scale: None,
auto_save: false,
invite_links: None,
plugins: Vec::new(),