mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 16:29:34 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
d8ff1441b1
commit
2c3ea03c2e
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user