diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 499cc7e2b..f6a47bace 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1219,6 +1219,7 @@ Show System Cursor Показувати системний курсор Animation Effects Ефекти анімації Animation effects Ефекти анімації + Interface scale Масштаб інтерфейсу Import... Імпорт... Export... Експорт... Batch Publish... Пакетна публікація... diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c2b388c83..f80b4aa9c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -224,6 +224,8 @@ pub struct CadApp { shown_follows_system: bool, /// Whether egui was last told to animate (`None` until the first frame). shown_animations: Option, + /// The interface scale last handed to egui (`None` until the first frame). + shown_interface_scale: Option, pub frame_ms: f64, pub quit_requested: bool, /// The app may close although drawings have unsaved changes (they were dealt with, or the @@ -271,6 +273,7 @@ impl CadApp { shown_theme: None, shown_follows_system: false, shown_animations: None, + shown_interface_scale: None, frame_ms: 0.0, quit_requested: false, quit_confirmed: false, @@ -561,6 +564,13 @@ impl CadApp { if !animate { animation::unsmooth_scroll(ctx); } + // Applied only when the saved scale changes, so egui's own keyboard zoom keeps working + // within a session (and is never saved). + let scale = self.options.interface_scale; + if self.shown_interface_scale != Some(scale) { + ctx.set_zoom_factor(scale); + self.shown_interface_scale = Some(scale); + } self.drain_control(ctx); self.finish_opening(); self.opening.show(ctx); diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 983b7faf3..aba073118 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -161,6 +161,19 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { } ui.end_row(); + ui.label(crate::tl!("Interface scale")); + let scale = app.options.interface_scale; + let percent = |s: f32| format!("{} %", (s * 100.0).round()); + egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { + for n in [75u16, 100, 125, 150, 175, 200, 250, 300] { + let s = f32::from(n) / 100.0; + if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 { + changes.insert("interfaceScale".into(), json!(s)); + } + } + }); + ui.end_row(); + ui.label(crate::tl!("Model background")); color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes); ui.end_row(); diff --git a/crates/ui-egui/src/options/mod.rs b/crates/ui-egui/src/options/mod.rs index 7e5037ef3..aa1f33859 100644 --- a/crates/ui-egui/src/options/mod.rs +++ b/crates/ui-egui/src/options/mod.rs @@ -21,6 +21,7 @@ //! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default | //! | `selectionColor` | the colour selected objects are highlighted in: `"#rrggbb"`, or `null` for the theme's | //! | `snapMarkerSize` | object snap marker size in points, 5–31 | +//! | `interfaceScale` | the interface's size, 0.5–3 (1: the system's own) | //! //! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state //! rather than in the profile. @@ -52,6 +53,9 @@ const MAX_PATH_CHARS: usize = 1024; /// Object snap marker sizes the Options dialog offers, in points. pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive = 5.0..=31.0; +/// Interface scales the JSON form accepts (1: the system's own size). +pub const INTERFACE_SCALES: std::ops::RangeInclusive = 0.5..=3.0; + /// The interface's own preferences (the drafting ones are engine settings). #[derive(Clone, Copy, Debug, PartialEq)] pub struct UiOptions { @@ -66,6 +70,8 @@ pub struct UiOptions { /// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu /// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu. pub time_sensitive_right_click: bool, + /// The size of every menu, palette, dialog and the command line (1: the system's own). + pub interface_scale: f32, } impl Default for UiOptions { @@ -76,6 +82,7 @@ impl Default for UiOptions { snap_marker_size: theme::SNAP_MARKER, selection_color: None, time_sensitive_right_click: true, + interface_scale: 1.0, } } } @@ -141,6 +148,7 @@ struct UiChanges { savetime: Option, savefilepath: Option, time_sensitive_right_click: Option, + interface_scale: Option, } fn color_param(key: &str, v: &Value) -> Result, String> { @@ -174,6 +182,10 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result { let n = v.as_f64().filter(|n| SNAP_MARKER_SIZES.contains(&(*n as f32)) && n.is_finite()).ok_or("snapMarkerSize must be from 5 to 31")?; c.snap_marker_size = Some(n as f32); } + "interfaceScale" => { + let n = v.as_f64().filter(|n| n.is_finite() && INTERFACE_SCALES.contains(&(*n as f32))).ok_or("interfaceScale must be from 0.5 to 3")?; + c.interface_scale = Some(n as f32); + } "saveVersion" => { let name = v.as_str().map_or_else(|| v.to_string(), str::to_string); c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?); @@ -210,6 +222,7 @@ fn ui_json(app: &CadApp) -> Map { m.insert("paperBackground".into(), color_json(o.paper_background)); m.insert("selectionColor".into(), color_json(o.selection_color)); m.insert("snapMarkerSize".into(), json!(o.snap_marker_size)); + m.insert("interfaceScale".into(), json!(o.interface_scale)); m.insert("saveVersion".into(), json!(app.ui.save_version)); m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime())); m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath())); @@ -272,6 +285,9 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result { if let Some(n) = ui.snap_marker_size { app.options.snap_marker_size = n; } + if let Some(n) = ui.interface_scale { + app.options.interface_scale = n; + } if let Some(v) = ui.save_version { app.ui.save_version = v.year().to_string(); } @@ -314,6 +330,7 @@ impl CadApp { "selectionColor": color_json(self.options.selection_color), "snapMarkerSize": self.options.snap_marker_size, "timeSensitiveRightClick": self.options.time_sensitive_right_click, + "interfaceScale": self.options.interface_scale, }) .to_string() } @@ -329,7 +346,9 @@ impl CadApp { cadcraft_engine::cmd::options::restore(&mut self.session, vars); } let mut c = UiChanges::default(); - for key in ["systemCursor", "modelBackground", "paperBackground", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick"] { + for key in + ["systemCursor", "modelBackground", "paperBackground", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick", "interfaceScale"] + { if let Some(value) = v.get(key) { let _ = parse_ui(key, value, &mut c); } @@ -353,6 +372,9 @@ impl CadApp { if let Some(b) = c.time_sensitive_right_click { self.options.time_sensitive_right_click = b; } + if let Some(n) = c.interface_scale { + self.options.interface_scale = n; + } } } diff --git a/crates/ui-egui/tests/options_followups.rs b/crates/ui-egui/tests/options_followups.rs index d8b8c212d..0f470baeb 100644 --- a/crates/ui-egui/tests/options_followups.rs +++ b/crates/ui-egui/tests/options_followups.rs @@ -107,3 +107,49 @@ fn autosave_save_version_and_right_click_in_options() { assert!(text.contains("Cancel") && text.contains("Snap Overrides"), "the menu opens:\n{text}"); assert!(a.session.prompt_text().contains("next point"), "no Enter: {}", a.session.prompt_text()); } + +#[test] +fn interface_scale_in_options() { + let mut a = app(); + assert_eq!(a.run("options", json!({})).unwrap()["interfaceScale"], json!(1.0)); + + let r = a.run("options", json!({"interfaceScale": 1.5})).unwrap(); + assert_eq!(r["interfaceScale"], json!(1.5)); + let ctx = egui::Context::default(); + frame(&mut a, &ctx, Vec::new(), 0.0); + assert_eq!(ctx.zoom_factor(), 1.5, "applied as egui's zoom factor"); + + // Refused all or nothing. + let before = a.run("options", json!({})).unwrap(); + for bad in [ + json!({"interfaceScale": 0.4, "PICKBOX": 9}), + json!({"interfaceScale": 3.1}), + json!({"interfaceScale": "big", "snapMarkerSize": 20}), + json!({"interfaceScale": null}), + json!({"interfaceScale": f64::NAN}), + ] { + let err = a.run("options", bad.clone()).expect_err(&bad.to_string()); + assert!(err.to_string().contains("interfaceScale must be from 0.5 to 3"), "{bad}: {err}"); + assert_eq!(a.run("options", json!({})).unwrap(), before, "{bad} changed nothing"); + } + + // It survives a restart; an out-of-range saved value is skipped. + let mut b = app(); + b.load_profile(&a.profile_json()); + assert_eq!(b.options.interface_scale, 1.5); + let mut c = app(); + c.load_profile(&a.profile_json().replace("\"interfaceScale\":1.5", "\"interfaceScale\":9.0")); + assert_eq!(c.options.interface_scale, 1.0); + + // Reset brings back the system's own size. + assert_eq!(a.run("options", json!({"reset": true})).unwrap()["interfaceScale"], json!(1.0)); + frame(&mut a, &ctx, Vec::new(), 0.1); + assert_eq!(ctx.zoom_factor(), 1.0); + + // The Display tab shows it. + a.run("options", json!({"interfaceScale": 1.25})).unwrap(); + a.ui.dialog = Some("options:display".into()); + frame(&mut a, &ctx, Vec::new(), 0.2); + let text = frame(&mut a, &ctx, Vec::new(), 0.3); + assert!(text.contains("Interface scale") && text.contains("125 %"), "{text}"); +}