From c1cd4cf19424a068de38c391d7b107b52d94446d Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:33:45 +0200 Subject: [PATCH] fix(ui): persist keyboard zoom and stabilize scale drags (#574) Fixes #478. Desktop Ctrl/Cmd +/-/0 now update the saved UI scale instead of flashing back, and the Preferences scale slider applies the new layout on release (range 50-250%, matching the preference validation). Addresses the drag jitter in #272. --- ROADMAP.md | 3 ++ crates/ui-egui/src/prefs.rs | 77 +++++++++++++++++++++++++++++++++- docs/book/src/guide/ui-tour.md | 4 ++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 991c5a89..0397020b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## View navigation +- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps + its layout stable during a drag and applies the selected scale on release. + - OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences. - Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 51a406ec..516f1ba2 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -376,8 +376,24 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { { app.ui.dark = t == egui::Theme::Dark; } + // egui's end-of-frame shortcuts only change its context; the saved preference would + // overwrite that change next frame. Consume the desktop shortcuts into the preference. + if ctx.options(|o| o.zoom_with_keyboard) && !crate::keymap::capturing(app) { + use egui::gui_zoom::kb_shortcuts::{ZOOM_IN, ZOOM_IN_SECONDARY, ZOOM_OUT, ZOOM_RESET}; + if ctx.input_mut(|i| i.consume_shortcut(&ZOOM_RESET)) { + app.preferences.ui_scale = 1.0; + } else { + let zoom_in = ctx.input_mut(|i| i.consume_shortcut(&ZOOM_IN) || i.consume_shortcut(&ZOOM_IN_SECONDARY)); + let zoom_out = ctx.input_mut(|i| i.consume_shortcut(&ZOOM_OUT)); + if zoom_in != zoom_out { + app.preferences.ui_scale = (app.preferences.ui_scale + if zoom_in { 0.1 } else { -0.1 }).clamp(0.5, 2.5); + } + } + } + // Scaling while the pointer is held moves the slider under the pointer, feeding a + // different value back into it. Commit the layout change after the drag ends. let z = app.preferences.ui_scale.clamp(0.5, 2.5); - if z.is_finite() && (ctx.zoom_factor() - z).abs() > 1e-3 { + if z.is_finite() && !ctx.input(|i| i.pointer.any_down()) && (ctx.zoom_factor() - z).abs() > 1e-3 { ctx.set_zoom_factor(z); } } @@ -770,7 +786,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }); ui.end_row(); ui.label(app.tr("ui.ui_scale")); - ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0))); + let scale = ui.add( + egui::Slider::new(&mut app.preferences.ui_scale, 0.5..=2.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)), + ); + crate::scenario::publish_rect("prefs:scale", scale.rect); ui.end_row(); ui.label(app.tr("ui.units_of_new_designs")); egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| { @@ -1363,3 +1382,57 @@ mod tests { assert_eq!(app.cam.up, UpAxis::Z); } } + +#[cfg(test)] +mod ui_scale_regression_tests { + use super::*; + use egui::{Event, Modifiers, PointerButton, pos2}; + + #[test] + fn desktop_zoom_shortcuts_update_and_persist_the_scale_preference() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.key", json!({"key":"Minus","cmd":true})); + assert!((h.app.preferences.ui_scale - 0.9).abs() < 1e-4); + assert!((h.ctx.zoom_factor() - 0.9).abs() < 1e-4); + h.frames(12); + assert!((h.ctx.zoom_factor() - 0.9).abs() < 1e-4, "must not flash back to the old preference"); + h.step(&json!({"restart":"close"})).unwrap(); + assert!((h.app.preferences.ui_scale - 0.9).abs() < 1e-4); + h.call("ui.key", json!({"key":"Equals","cmd":true})); + assert!((h.ctx.zoom_factor() - 1.0).abs() < 1e-4); + h.call("ui.key", json!({"key":"Plus","cmd":true})); + assert!((h.ctx.zoom_factor() - 1.1).abs() < 1e-4); + h.call("ui.key", json!({"key":"0","cmd":true})); + assert!((h.ctx.zoom_factor() - 1.0).abs() < 1e-4); + h.call("ui.prefs", json!({"ui_scale":0.5})); + h.call("ui.key", json!({"key":"Minus","cmd":true})); + assert_eq!(h.app.preferences.ui_scale, 0.5); + h.call("ui.prefs", json!({"ui_scale":2.5})); + h.call("ui.key", json!({"key":"Plus","cmd":true})); + assert_eq!(h.app.preferences.ui_scale, 2.5); + } + + #[test] + fn dragging_the_scale_slider_keeps_its_window_still_until_release() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open":"General"})); + let window = crate::scenario::rect_of("prefs:window").unwrap(); + let slider = crate::scenario::rect_of("prefs:scale").unwrap(); + let a = pos2(slider.left() + 20.0, slider.center().y); + let b = pos2(slider.left() + 90.0, slider.center().y); + h.app.synthetic.push(Event::PointerMoved(a)); + h.app.synthetic.push(Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.frames(3); + h.app.synthetic.push(Event::PointerMoved(b)); + h.frames(4); + assert_ne!(h.app.preferences.ui_scale, 1.0, "the slider changes its value during the drag"); + assert_eq!(h.ctx.zoom_factor(), 1.0, "layout must remain stable until release"); + assert_eq!(crate::scenario::rect_of("prefs:window").unwrap(), window); + h.app.synthetic.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.frames(5); + assert!((h.ctx.zoom_factor() - h.app.preferences.ui_scale).abs() < 1e-4); + h.call("ui.prefs", json!({"button":"cancel"})); + assert_eq!(h.app.preferences.ui_scale, 1.0, "Cancel restores the original scale"); + assert_eq!(h.ctx.zoom_factor(), 1.0); + } +} diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index a70a31e9..a9988c0d 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -71,3 +71,7 @@ never sees it. To free it: Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a lightly damped spring transition and a small boundary margin to prevent flicker. + +On desktop, Ctrl/Cmd+Plus (or Equals) enlarges the interface, Ctrl/Cmd+Minus reduces it, +and Ctrl/Cmd+0 restores 100%. These shortcuts update the UI scale preference. The UI scale +slider in Preferences applies the new layout when released, so it stays still while dragging.