//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the //! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new //! designs, autosave, display precision, timeline layout, the agent cursor), Navigation (mouse presets, zoom //! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search, //! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics //! (visual style, grid, shadow, perspective, anti-aliasing, colour vision), Sketch and Selection. //! Changes apply at once; Cancel puts back what was there when the dialog opened, OK keeps them, //! Apply keeps them and stays open, and Restore Defaults resets the page shown. Everything is //! kept between runs, except anti-aliasing, which needs a restart. use egui::{Modifiers, Pos2, RichText}; use serde::{Deserialize, Serialize}; use serde_json::json; use solidcraft_engine::geom::Vec3; /// The key the apps keep the preferences under in eframe's storage. pub const STORAGE_KEY: &str = "solidcraft.prefs"; /// The key earlier releases used. Compatibility shim (docs/compatibility.md): read when the /// current key is missing, never written. pub const LEGACY_STORAGE_KEY: &str = "solvecraft.prefs"; /// The stored preferences, from the current key or else the legacy one. pub fn stored(get: impl Fn(&str) -> Option) -> Option { get(STORAGE_KEY).or_else(|| get(LEGACY_STORAGE_KEY)) } use crate::SolveApp; use crate::theme::Tokens; use crate::viewport::NavMode; pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"]; pub const OPENSCAD_NAV: &str = "openscad"; pub const TOUCHPAD_NAV: &str = "touchpad"; /// A mouse button that can drag the camera. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavButton { Left, Middle, Right, } /// Modifier keys a binding needs held (others may be held too). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct NavKeys { pub shift: bool, pub ctrl: bool, pub alt: bool, } impl NavKeys { const NONE: NavKeys = NavKeys { shift: false, ctrl: false, alt: false }; const SHIFT: NavKeys = NavKeys { shift: true, ctrl: false, alt: false }; const CTRL: NavKeys = NavKeys { shift: false, ctrl: true, alt: false }; const SHIFT_ALT: NavKeys = NavKeys { shift: true, ctrl: false, alt: true }; /// All of these keys are held (Ctrl is Cmd on macOS). fn held(self, m: Modifiers) -> bool { (!self.shift || m.shift) && (!self.ctrl || m.ctrl || m.command) && (!self.alt || m.alt) } } /// One camera binding: dragging `button` with `keys` held does `mode`. #[derive(Clone, Copy, Debug)] pub struct NavBinding { pub button: NavButton, pub keys: NavKeys, pub mode: NavMode, } const fn bind(button: NavButton, keys: NavKeys, mode: NavMode) -> NavBinding { NavBinding { button, keys, mode } } /// A mouse mapping: its bindings, most specific first (the first one whose button is dragged /// with all its keys held wins). pub struct NavPreset { pub id: &'static str, pub label: &'static str, /// What the mouse does, for the Preferences page. pub help: &'static str, /// Choosing the preset turns the wheel the other way. pub reverse_zoom: bool, pub bindings: &'static [NavBinding], } impl NavPreset { /// What dragging `button` with `mods` held does, if anything. pub fn mode(&self, button: NavButton, mods: Modifiers) -> Option { self.bindings.iter().find(|b| b.button == button && b.keys.held(mods)).map(|b| b.mode) } } use NavButton::{Left, Middle, Right}; use NavMode::{Orbit, Pan, Zoom}; /// The mouse mappings. The first is the default. pub const NAV_PRESETS: [NavPreset; 8] = [ NavPreset { id: "fusion", label: "Fusion", help: "Middle drag pans, Shift+middle orbits, right drag orbits", reverse_zoom: false, bindings: &[bind(Middle, NavKeys::SHIFT, Orbit), bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], }, NavPreset { id: "solidworks", label: "SolidWorks", help: "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits", reverse_zoom: true, bindings: &[ bind(Middle, NavKeys::CTRL, Pan), bind(Middle, NavKeys::SHIFT, Zoom), bind(Middle, NavKeys::NONE, Orbit), bind(Right, NavKeys::NONE, Orbit), ], }, NavPreset { id: "inventor", label: "Inventor", help: "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", reverse_zoom: false, bindings: &[ bind(Middle, NavKeys::SHIFT, Orbit), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit), ], }, NavPreset { id: "alias", label: "Alias", help: "Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms; middle drag also pans and right drag orbits", reverse_zoom: false, bindings: &[ bind(Left, NavKeys::SHIFT_ALT, Orbit), bind(Middle, NavKeys::SHIFT_ALT, Pan), bind(Right, NavKeys::SHIFT_ALT, Zoom), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit), ], }, NavPreset { id: "blender", label: "Blender", help: "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone", reverse_zoom: false, bindings: &[bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Orbit)], }, NavPreset { id: "tinkercad", label: "Tinkercad", help: "Right drag orbits, middle or Shift+right drag pans", reverse_zoom: false, bindings: &[bind(Right, NavKeys::SHIFT, Pan), bind(Right, NavKeys::NONE, Orbit), bind(Middle, NavKeys::NONE, Pan)], }, NavPreset { id: OPENSCAD_NAV, label: "OpenSCAD", help: "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms", reverse_zoom: false, bindings: &[ bind(Left, NavKeys::NONE, Orbit), bind(Right, NavKeys::SHIFT, Zoom), bind(Middle, NavKeys::SHIFT, Zoom), bind(Right, NavKeys::NONE, Pan), bind(Middle, NavKeys::NONE, Pan), ], }, NavPreset { id: TOUCHPAD_NAV, label: "Touchpad", help: "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.", reverse_zoom: false, bindings: &[bind(Middle, NavKeys::NONE, Pan), bind(Right, NavKeys::NONE, Orbit)], }, ]; impl NavPreset { /// Button-free motion has exact modifiers, unlike the additive drag bindings. pub fn motion_mode(&self, mods: Modifiers) -> Option { if self.id != TOUCHPAD_NAV || mods.ctrl || mods.command || mods.mac_cmd { return None; } match (mods.shift, mods.alt) { (true, false) => Some(Pan), (false, true) => Some(Orbit), _ => None, } } fn translated_label(&self) -> &str { if self.id == TOUCHPAD_NAV { crate::i18n::tr("nav.touchpad.label") } else { crate::i18n::tr_label(self.label) } } } impl Prefs { /// Touchpad scroll zooms without overwriting the user's preference for other presets. pub fn trackpad_pans(&self) -> bool { self.nav != TOUCHPAD_NAV && self.trackpad_pan } } /// The preset with this id (the default for an unknown one). pub fn nav_preset(id: &str) -> &'static NavPreset { NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0]) } /// The dialog's pages, in order. pub const PAGES: [&str; 8] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection", "AI"]; pub const PAGE_AI: usize = 7; pub const PAGE_SHORTCUTS: usize = 2; pub const PAGE_DESIGN: usize = 3; pub const PAGE_GRAPHICS: usize = 4; pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")]; pub const MSAA: [u32; 4] = [1, 2, 4, 8]; /// Supersampling factors (#1288): off, 1.5× and 2× the viewport's pixels each way. pub const SUPERSAMPLING: [f32; 3] = [1.0, 1.5, 2.0]; /// Original view navigators, independent of the mouse navigation preset. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NavigatorStyle { /// Also what a navigator this version doesn't know reads as (`lenient`). #[default] Axes, Cube, } impl NavigatorStyle { pub const ALL: [Self; 2] = [Self::Axes, Self::Cube]; pub fn label(self) -> &'static str { match self { Self::Axes => "Signed axes", Self::Cube => "Cube", } } } /// What an orbit turns around (Preferences: "Orbit around"). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrbitAround { /// The world origin: the view turns about it and keeps it still on screen. Origin, /// The view's centre: what Pan moves and Fit frames (the default, as it always was). #[default] ViewCentre, /// The model point under the cursor when the drag began. Cursor, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Prefs { /// Units of new designs. pub default_units: String, /// Mouse mapping: `fusion`, `solidworks` or `inventor`. pub nav: String, /// View orientation widget: original signed axes (default) or compact cube. pub navigator_style: NavigatorStyle, /// Show the radial ring and enable hold/flick gestures for viewport context menus. pub radial_context_menu: bool, /// The wheel zooms out when turned forward. pub zoom_reverse: bool, /// What an orbit turns around. pub orbit_around: OrbitAround, /// The cursor preference as releases before the origin option saved it (`true` = the point /// under the cursor). [`migrate`] folds it into `orbit_around` when saved preferences are /// read, so an existing choice survives the upgrade; this version only writes `false`. pub orbit_cursor: bool, /// Whether a connected 3D mouse moves the view. pub space_mouse_enabled: bool, /// Speed multiplier for 3D mouse navigation. pub space_mouse_speed: f32, /// Anti-aliasing samples (applies on restart). pub msaa: u32, /// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or /// `ultra` (#1287). Only the display plan the viewport draws; never the design or exports. pub display_quality: String, /// Supersampling (#1288): the view drawn at this many times its pixels each way and filtered /// down (1, 1.5 or 2; 1 is off). pub supersampling: f32, /// Supersample only while the view holds still (full speed while orbiting). pub supersampling_adaptive: bool, /// Show the frame rate in the view's corner (the view then redraws every frame). pub show_frame_rate: bool, /// Show world X/Y/Z component dimensions beside the total distance in Measure. pub measure_axis_distances: bool, /// Decimals shown for lengths and angles. pub length_decimals: u8, pub angle_decimals: u8, /// `dark`, `light` or `system` (follow the operating system). pub theme: String, /// Interface language (a registered locale ID). pub language: String, /// Interface scale (1 = 100 %). pub ui_scale: f32, /// Interface font: an installed font family (or a font file), or empty for the built-in one /// (#1290). Menus, dialogs and labels use it; glyphs it lacks fall back as before. pub ui_font: String, /// Put the feature history above the parts browser in the left sidebar instead of along the /// bottom of the viewport. pub timeline_left: bool, /// Height of the feature-history section when it shares the left sidebar with the browser. pub timeline_left_height: f32, /// Whether the browser below the left feature history is collapsed. pub timeline_left_browser_collapsed: bool, /// The wheel zooms toward the point under the cursor (else toward the view's centre). pub zoom_to_cursor: bool, /// Spread discrete wheel input across frames (off: apply each event immediately). pub smooth_wheel_zoom: bool, /// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms. pub trackpad_pan: bool, /// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast. pub look_at_anim: bool, pub look_at_speed: f32, /// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3). pub up_axis: String, /// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`). pub offer_stand_up: bool, /// What a middle-button double-click in the view does: `home`, `fit` or `off`. pub middle_double_click: String, /// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`. pub color_vision: crate::theme::ColorVision, /// Check for updates at start and once a day (docs/updates.md). pub check_updates: bool, /// When the last update check ran, and the versions skipped or put off (`update::Memo`). pub update: crate::update::Memo, /// Save as mesh: the last format (`stl` or `3mf`), whether it was sent to a slicer, which one /// (an installed slicer's id or a program path), and the programs the user added (newest first). pub save_mesh_format: String, /// Hidden edges in the current-view DXF export (false: omit, true: dashed). pub view_dxf_dashed: bool, /// Model-space chord tolerance for current-view DXF curves, in millimetres. pub view_dxf_tolerance: f64, pub save_mesh_send: bool, pub save_mesh_slicer: String, pub custom_slicers: Vec, /// AI features (docs/agent-chat.md): the master switch. Off: no AI button, shortcut, menu /// entry or panel, and nothing is looked for or started. pub ai_features: bool, /// The AI button at the toolbar row's right end (the shortcut and View menu entry stay). pub ai_toolbar_button: bool, /// Agents the user added (the installed ones are found by themselves). API keys are never /// here: they live in the system keychain. pub agents: Vec, /// The agent a new conversation starts with (an agent's id; empty: the first one available). pub agent_default: String, /// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent. pub ai_permissions: crate::agent_chat::agents::Mode, pub agent_permissions: std::collections::BTreeMap, /// The model last chosen in the chat, per agent: put back when the agent offers it again. pub agent_models: std::collections::BTreeMap, /// "Always allow" answers: per agent, the tools it may use without asking. pub agent_always_allow: std::collections::BTreeMap>, /// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip. pub agent_chat_side: String, pub agent_chat_width: f32, pub agent_chat_minimized: bool, /// Changes the agent may make for one message. pub agent_chat_turn_limit: u32, } /// What a middle-button double-click in the view does. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MiddleDoubleClick { /// Go Home (the iso view, framing what is shown). Home, /// Frame what is shown, keeping the orientation. Fit, Off, } impl MiddleDoubleClick { pub const ALL: [(MiddleDoubleClick, &'static str, &'static str); 3] = [(MiddleDoubleClick::Home, "home", "Home"), (MiddleDoubleClick::Fit, "fit", "Fit"), (MiddleDoubleClick::Off, "off", "Off")]; /// The preference's setting (Home for an unknown one). pub fn of(app: &SolveApp) -> MiddleDoubleClick { Self::parse(&app.preferences.middle_double_click).unwrap_or(MiddleDoubleClick::Home) } pub fn parse(id: &str) -> Option { Self::ALL.iter().find(|(_, i, _)| *i == id).map(|(m, _, _)| *m) } pub fn label(self) -> &'static str { Self::ALL.iter().find(|(m, _, _)| *m == self).map_or("Home", |(_, _, l)| l) } } impl Default for Prefs { fn default() -> Self { Prefs { default_units: "mm".into(), nav: "fusion".into(), navigator_style: NavigatorStyle::default(), radial_context_menu: false, zoom_reverse: false, orbit_around: OrbitAround::default(), orbit_cursor: false, space_mouse_enabled: true, space_mouse_speed: 1.0, msaa: 4, display_quality: "normal".into(), supersampling: 1.0, supersampling_adaptive: true, show_frame_rate: false, measure_axis_distances: true, length_decimals: 2, angle_decimals: 1, theme: "dark".into(), language: "en".into(), ui_scale: 1.0, ui_font: String::new(), timeline_left: false, timeline_left_height: 360.0, timeline_left_browser_collapsed: false, zoom_to_cursor: true, smooth_wheel_zoom: true, trackpad_pan: true, look_at_anim: true, look_at_speed: 1.0, up_axis: "z".into(), offer_stand_up: true, middle_double_click: "home".into(), color_vision: crate::theme::ColorVision::Off, check_updates: true, update: crate::update::Memo::default(), save_mesh_format: "stl".into(), view_dxf_dashed: false, view_dxf_tolerance: 0.01, save_mesh_send: false, save_mesh_slicer: String::new(), custom_slicers: Vec::new(), ai_features: true, ai_toolbar_button: true, agents: Vec::new(), agent_default: String::new(), ai_permissions: crate::agent_chat::agents::Mode::Edits, agent_permissions: Default::default(), agent_models: Default::default(), agent_always_allow: Default::default(), agent_chat_side: "right".into(), agent_chat_width: 380.0, agent_chat_minimized: false, agent_chat_turn_limit: crate::agent_chat::gate::DEFAULT_TURN_LIMIT, } } } /// The preferences window. #[derive(Default)] pub struct PrefsWindow { pub open: bool, /// The page shown (index into [`PAGES`]). pub section: usize, /// Everything as it was when the dialog opened (or at the last Apply), for Cancel. snapshot: Option, /// The up axis preference the view follows now (applied when it changes, and at startup). applied_up: Option, /// The search text of the interface font list. font_filter: String, } /// Open the dialog at a page. pub fn open_at(app: &mut SolveApp, page: usize) { app.prefs_window.open = true; app.prefs_window.section = page.min(PAGES.len() - 1); } /// Every setting the dialog changes, for Cancel and Restore Defaults. #[derive(Clone)] struct Snapshot { prefs: Prefs, dark: bool, grid: bool, shadow: bool, perspective: bool, style: u8, pick_bodies: bool, agent: crate::agent_cursor::Settings, autosave: f64, auto_project: bool, keys: std::collections::BTreeMap, } impl Snapshot { fn take(app: &SolveApp) -> Snapshot { Snapshot { prefs: app.preferences.clone(), dark: app.ui.dark, grid: app.ui.show_grid, shadow: app.ui.ground_shadow, perspective: app.ui.perspective, style: app.ui.visual_style, pick_bodies: app.ui.pick_bodies, agent: app.ui.agent_cursor, autosave: app.autosave_minutes, auto_project: app.session.auto_project, keys: app.keymap.custom.clone(), } } fn restore(self, app: &mut SolveApp) { let msaa = app.preferences.msaa; // Update bookkeeping isn't a setting: a check that finished while the dialog was open stays. let update = std::mem::take(&mut app.preferences.update); app.preferences = self.prefs; app.preferences.update = update; app.ui.dark = self.dark; app.ui.show_grid = self.grid; app.ui.ground_shadow = self.shadow; app.ui.perspective = self.perspective; app.ui.visual_style = self.style; app.ui.pick_bodies = self.pick_bodies; app.ui.agent_cursor = self.agent; app.autosave_minutes = self.autosave; set_autosave(app); app.session.auto_project = self.auto_project; app.keymap.custom = self.keys; if app.preferences.msaa != msaa { save_graphics(app); } } } /// The interface font (#1290): the built-in font first, then the installed families, with a /// search box. The choice applies at once, like the other preferences. fn ui_font_row(app: &mut SolveApp, ui: &mut egui::Ui) { let builtin = app.tr_label("Built-in").to_string(); let current = if app.preferences.ui_font.trim().is_empty() { builtin.clone() } else { app.preferences.ui_font.clone() }; let families = crate::sketch_text::families(ui.ctx()); let mut chosen: Option = None; let combo = egui::ComboBox::from_id_salt("pref_ui_font") .width(220.0) .height(340.0) .selected_text(current) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show_ui(ui, |ui| { let search = egui::TextEdit::singleline(&mut app.prefs_window.font_filter).hint_text(crate::i18n::tr("sketch_text.font_search")); ui.add(search.desired_width(f32::INFINITY)); let filter = app.prefs_window.font_filter.trim().to_lowercase(); egui::ScrollArea::vertical().max_height(280.0).min_scrolled_height(280.0).auto_shrink([false, false]).show(ui, |ui| { if builtin.to_lowercase().contains(&filter) && ui.selectable_label(app.preferences.ui_font.trim().is_empty(), &builtin).clicked() { chosen = Some(String::new()); } match &families { Some(list) => { for family in list.iter().filter(|f| f.to_lowercase().contains(&filter)) { let item = ui.selectable_label(app.preferences.ui_font == *family, family); crate::scenario::publish_handle(&format!("prefs:font:{family}"), item.rect.center()); if item.clicked() { chosen = Some(family.clone()); } } } None => { ui.label(crate::i18n::tr_label("Listing the installed fonts…")); } } }); }); crate::scenario::publish_handle("prefs:ui_font", combo.response.rect.center()); if let Some(name) = chosen { if name.is_empty() || crate::fonts::ui_font(&name).is_some() { app.preferences.ui_font = name; } else { app.set_status(format!("The font `{name}` could not be read"), true); } app.prefs_window.font_filter.clear(); ui.close(); } } /// Restore Defaults for one page. fn defaults(app: &mut SolveApp, page: usize) { let d = Prefs::default(); let p = &mut app.preferences; match page { 0 => { p.theme = d.theme; p.language = d.language; p.ui_scale = d.ui_scale; p.ui_font = d.ui_font; p.timeline_left = d.timeline_left; p.timeline_left_height = d.timeline_left_height; p.timeline_left_browser_collapsed = d.timeline_left_browser_collapsed; p.default_units = d.default_units; p.length_decimals = d.length_decimals; p.angle_decimals = d.angle_decimals; p.check_updates = d.check_updates; app.ui.dark = true; app.ui.agent_cursor = Default::default(); app.autosave_minutes = 5.0; set_autosave(app); } PAGE_AI => { // The agents the user added and their "always allow" answers stay. p.ai_features = d.ai_features; p.ai_toolbar_button = d.ai_toolbar_button; p.ai_permissions = d.ai_permissions; p.agent_permissions = d.agent_permissions; p.agent_chat_side = d.agent_chat_side; p.agent_chat_width = d.agent_chat_width; p.agent_chat_minimized = d.agent_chat_minimized; p.agent_chat_turn_limit = d.agent_chat_turn_limit; } 1 => { p.nav = d.nav; p.navigator_style = d.navigator_style; p.radial_context_menu = d.radial_context_menu; p.zoom_reverse = d.zoom_reverse; p.zoom_to_cursor = d.zoom_to_cursor; p.smooth_wheel_zoom = d.smooth_wheel_zoom; p.trackpad_pan = d.trackpad_pan; p.orbit_around = d.orbit_around; p.orbit_cursor = d.orbit_cursor; p.space_mouse_enabled = d.space_mouse_enabled; p.space_mouse_speed = d.space_mouse_speed; p.look_at_anim = d.look_at_anim; p.look_at_speed = d.look_at_speed; p.middle_double_click = d.middle_double_click; } PAGE_SHORTCUTS => crate::keymap::reset(app, None), PAGE_DESIGN => { p.up_axis = d.up_axis; p.offer_stand_up = d.offer_stand_up; } PAGE_GRAPHICS => { let msaa = p.msaa != d.msaa; p.msaa = d.msaa; p.display_quality = d.display_quality; p.supersampling = d.supersampling; p.supersampling_adaptive = d.supersampling_adaptive; p.show_frame_rate = d.show_frame_rate; p.measure_axis_distances = d.measure_axis_distances; p.color_vision = d.color_vision; app.ui.visual_style = 0; app.ui.show_grid = true; app.ui.ground_shadow = true; app.ui.perspective = false; if msaa { save_graphics(app); } } 5 => app.session.auto_project = true, _ => app.ui.pick_bodies = false, } } /// Settings that follow the preferences every frame: the theme when it follows the system, and /// the interface scale. pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { // The viewport's display quality (an unknown saved value draws at Normal). solidcraft_engine::doc::display_quality::set_current( solidcraft_engine::doc::display_quality::DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default(), ); // The view's up axis follows the preference (set in the dialog, by ui.prefs or loaded). if app.prefs_window.applied_up.as_deref() != Some(app.preferences.up_axis.as_str()) { up_axis_changed(app); } if app.preferences.theme == "system" && let Some(t) = ctx.system_theme() { 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.input(|i| i.pointer.any_down()) && (ctx.zoom_factor() - z).abs() > 1e-3 { ctx.set_zoom_factor(z); } } /// A new, empty design takes the preferred units (not an undo step, not a change to save). /// Callers: a launch with no file, New on a design that is already empty, `file.new`, and the /// blank design left when the last tab closes. A design that already has features is unchanged. pub fn apply_new_design(app: &mut SolveApp) { if app.session.doc.features.is_empty() && app.session.doc.units != app.preferences.default_units { let saved = !app.session.is_dirty(); if app.session.execute("document.units", &json!({ "units": app.preferences.default_units })).is_ok() { app.session.undo.pop(); if saved { app.session.mark_saved(); } } } } /// The autosave interval follows `app.autosave_minutes` (0: off). pub fn set_autosave(app: &mut SolveApp) { let m = app.autosave_minutes; if let Some(a) = app.autosave.as_mut() { a.interval = if m > 0.0 { std::time::Duration::from_secs_f64(m * 60.0) } else { std::time::Duration::from_secs(u64::MAX / 4) }; } } /// Which navigation dragging `button` with `mods` held does under the preset. pub fn nav_mode(app: &SolveApp, button: NavButton, mods: Modifiers) -> Option { nav_preset(&app.preferences.nav).mode(button, mods) } /// +1, or −1 when the wheel zooms the other way. pub fn zoom_sign(app: &SolveApp) -> f64 { if app.preferences.zoom_reverse { -1.0 } else { 1.0 } } /// The model point under a screen position. Over empty space: the centre of the visible bodies /// and sketches (what Fit frames), or the view's centre when nothing is visible. pub(crate) fn pivot_under(app: &SolveApp, proj: &crate::viewport::Proj, at: Pos2) -> Vec3 { use crate::viewport::Hit; for h in crate::viewport::pick(app, proj, at) { match h { Hit::Face { point, .. } | Hit::Vertex { point, .. } => return point, Hit::Edge { mid, .. } => return mid, _ => {} } } let b = crate::viewport::view_bounds(app); if b.is_empty() { app.cam.target } else { b.center() } } /// Units per millimetre and the unit's name. pub fn unit_scale(units: &str) -> (f64, &'static str) { match units { "cm" => (0.1, "cm"), "m" => (0.001, "m"), "in" => (1.0 / 25.4, "in"), "ft" => (1.0 / 304.8, "ft"), _ => (1.0, "mm"), } } /// Inch labels retain a meaningful fourth digit without promoting floating-point noise. pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize { let base = usize::from(decimals.min(8)); if units != "in" { return base; } let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5; base.max(if needs_four { 4 } else { 3 }) } pub fn length_number(mm: f64, units: &str, decimals: u8) -> String { let value = mm * unit_scale(units).0; format!("{:.*}", length_precision(value, units, decimals), value) } /// A plain number typed into a field, with a decimal point or a decimal comma (#1347): "2.5" /// and "2,5" are both 2.5, as a numeric keypad types either depending on the locale. Spaces are /// ignored and "−" is a minus, as in egui's own parser. A comma beside a point ("1.234,5") or a /// second comma is not guessed at: the text is not a number. pub fn parse_number(text: &str) -> Option { let t: String = text.chars().filter(|c| !c.is_whitespace()).map(|c| if c == '\u{2212}' { '-' } else { c }).collect(); let t = if !t.contains('.') && t.matches(',').count() == 1 { t.replacen(',', ".", 1) } else { t }; t.parse::().ok().filter(|v| v.is_finite()) } /// Edit a millimetre value through a control expressed in the current design units. /// Keep the stored value untouched unless the user actually changes the field. pub fn length_drag<'a>(value: &'a mut f64, units: &str, decimals: u8) -> egui::DragValue<'a> { let (scale, unit) = unit_scale(units); egui::DragValue::new(value) .speed(0.5) .suffix(format!(" {unit}")) .custom_formatter(move |mm, _| length_number(mm, unit, decimals)) .custom_parser(move |text| parse_length_mm(text, scale)) } /// A length typed into a field, in millimetres: a bare number is in the design's units (`scale` /// units per millimetre); an explicit unit ("10 mm", "0.5 in", "2 cm + 3 mm") is honoured. pub fn parse_length_mm(text: &str, scale: f64) -> Option { use solidcraft_engine::doc::expr; if !(scale.is_finite() && scale > 0.0) { return None; } let defaults = expr::Defaults { len: 1.0 / scale, ..expr::Defaults::default() }; let no_params = |n: &str| Err(solidcraft_engine::doc::DocError::Expr(format!("unknown parameter `{n}`"))); let value = expr::eval_with_defaults(text.trim(), &no_params, defaults).ok()?.to_kind_in(expr::Kind::Length, defaults).ok()?; value.is_finite().then_some(value) } /// A length in millimetres in `units`, with `decimals` (for display). pub fn format_length(mm: f64, units: &str, decimals: u8) -> String { let (_, u) = unit_scale(units); format!("{} {u}", length_number(mm, units, decimals)) } /// A length, area (`power` 2) or volume (3) given in millimetres, shown in the design's units /// with the length precision. pub fn show_mm(app: &SolveApp, v: f64, power: i32) -> String { let (k, u) = unit_scale(&app.session.doc.units); let sup = match power { 2 => "²", 3 => "³", _ => "", }; let value = v * k.powi(power); format!("{:.*} {u}{sup}", length_precision(value, &app.session.doc.units, app.preferences.length_decimals), value) } /// Numbers in millimetres, in the design's units without the unit name. pub fn show_mm_bare(app: &SolveApp, v: f64) -> String { length_number(v, &app.session.doc.units, app.preferences.length_decimals) } /// Write the anti-aliasing setting where the host reads it at start (the window's samples can't /// change while it is open). fn save_graphics(app: &SolveApp) { if let Some(path) = &app.services.graphics_path { // Other settings in the file (the graphics `backend`) stay as they are. let mut graphics = std::fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .filter(serde_json::Value::is_object) .unwrap_or_else(|| json!({})); graphics["msaa"] = json!(app.preferences.msaa); let _ = std::fs::write(path, graphics.to_string()); } } /// At start, with the graphics device known: the saved anti-aliasing (`requested` samples, as /// the host read it) is more than the device supports. The window would not have opened with it, /// so the renderer fell back; put the setting back to its default (or the largest supported /// count below it) and tell the user what changed. pub fn check_msaa(app: &mut SolveApp, requested: u32) { let supported = &app.msaa_supported; if requested <= 1 || supported.is_empty() || supported.contains(&requested) { return; } let default = Prefs::default().msaa; let reset = supported.iter().copied().filter(|&n| n <= default).max().unwrap_or(1); let supported = supported.clone(); app.preferences.msaa = reset; save_graphics(app); app.settings_reset.push(SettingReset::Msaa { requested, reset, supported }); } /// A setting put back at start because it would keep SolidCraft from starting. #[derive(Clone, Debug, PartialEq)] pub enum SettingReset { /// Anti-aliasing samples the graphics device can't render with. Msaa { requested: u32, reset: u32, supported: Vec }, } impl SettingReset { /// The line the notice shows, in the current interface language. pub fn message(&self) -> String { match self { SettingReset::Msaa { requested, reset, supported } => { let setting = format!("{} › {}", crate::i18n::tr("ui.graphics"), crate::i18n::tr("ui.anti_aliasing")); let supported = supported.iter().map(|n| format!("{n}")).collect::>().join("×, ") + "×"; crate::i18n::format( "settings.msaa_reset", &[("setting", &setting), ("requested", &requested.to_string()), ("reset", &reset.to_string()), ("supported", &supported)], ) } } } } /// Settings put back at start (`SolveApp::settings_reset`), until dismissed. pub fn show_reset_notice(app: &mut SolveApp, ctx: &egui::Context) { if app.settings_reset.is_empty() { return; } let mut ok = false; let mut prefs = false; crate::frame::window(ctx, app.tr("settings.reset_title"), crate::frame::Width::Normal) .id(egui::Id::new("sc_settings_reset")) .collapsible(false) .resizable(false) .show(ctx, |ui| { ui.label(app.tr("settings.reset_intro")); ui.add_space(4.0); for reset in &app.settings_reset { ui.label(format!("• {}", reset.message())); } ui.add_space(6.0); ui.horizontal(|ui| { ok = ui.button(app.tr("ui.heading_ok")).clicked(); prefs = ui.button(app.tr("ui.open_preferences")).clicked(); }); }); if ok || prefs { app.settings_reset.clear(); } if prefs { open_at(app, PAGE_GRAPHICS); } } pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page. if app.keymap.open { app.keymap.open = false; open_at(app, PAGE_SHORTCUTS); } if !app.prefs_window.open { app.prefs_window.snapshot = None; return; } if app.prefs_window.snapshot.is_none() { app.prefs_window.snapshot = Some(Snapshot::take(app)); } let t = Tokens::get(); let mut open = true; let mut button: Option<&str> = None; let before = app.preferences.clone(); let shown = crate::frame::window(ctx, app.tr("ui.preferences"), crate::frame::Width::Wide) .id(egui::Id::new("sc_prefs")) .open(&mut open) .collapsible(false) .resizable(false) .show(ctx, |ui| { // One fixed size for every page (#290): the page is drawn into a clipped child of // that size and only that size is allocated, so no page (or translation) can widen // the window; descriptions wrap and tall pages scroll inside it. ui.set_max_width(PREFS_W); let (body, _) = ui.allocate_exact_size(egui::vec2(PREFS_W, PREFS_H), egui::Sense::hover()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::left_to_right(egui::Align::Min))); child.shrink_clip_rect(body); let ui_body = &mut child; ui_body.vertical(|ui| { ui.set_width(PREFS_NAV_W); for (i, s) in PAGES.iter().enumerate() { if ui.selectable_label(app.prefs_window.section == i, app.tr_label(s)).clicked() { app.prefs_window.section = i; app.keymap.read_only = false; } } }); ui_body.separator(); ui_body.vertical(|ui| { ui.set_width(ui.available_width()); ui.label(RichText::new(app.tr_label(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0)); ui.add_space(6.0); if app.prefs_window.section == PAGE_SHORTCUTS { // The shortcuts table scrolls by itself. page(app, ui, &t); } else { egui::ScrollArea::vertical().id_salt("sc_prefs_page").auto_shrink([false, false]).show(ui, |ui| page(app, ui, &t)); } }); ui.separator(); // Restore Defaults at the left; OK, Cancel and Apply flush right, as in every dialog. ui.horizontal(|ui| { ui.set_width(PREFS_W); if ui.button(app.tr("ui.restore_defaults")).on_hover_text(app.tr("ui.reset_the_settings_on_this_page")).clicked() { button = Some("defaults"); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Right to left: Apply is the rightmost. for (key, id) in [("ui.apply", "apply"), ("ui.cancel", "cancel"), ("ui.heading_ok", "ok")] { let r = ui.button(app.tr(key)); crate::scenario::publish_rect(&format!("prefs:{id}"), r.rect); if r.clicked() { button = Some(id); } } }); crate::scenario::publish_rect("prefs:footer", ui.max_rect()); }); }); if let Some(w) = &shown { crate::scenario::publish_rect("prefs:window", w.response.rect); } if app.preferences.msaa != before.msaa { save_graphics(app); } if app.preferences.up_axis != before.up_axis { up_axis_changed(app); } let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::keymap::capturing(app); match button { Some("defaults") => defaults(app, app.prefs_window.section), Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)), Some("ok") => close(app, false), Some("cancel") => close(app, true), _ if !open || esc => close(app, !open || esc), _ => {} } } /// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×). fn close(app: &mut SolveApp, revert: bool) { if let Some(s) = app.prefs_window.snapshot.take() && revert { let up = app.preferences.up_axis.clone(); s.restore(app); if app.preferences.up_axis != up { up_axis_changed(app); } } app.prefs_window.open = false; app.keymap.read_only = false; crate::keymap::stop_capture(app); } /// The default orientation changed: the view turns to match. fn up_axis_changed(app: &mut SolveApp) { use solidcraft_engine::render::UpAxis; app.prefs_window.applied_up = Some(app.preferences.up_axis.clone()); let up = if app.preferences.up_axis.eq_ignore_ascii_case("y") { UpAxis::Y } else { UpAxis::Z }; if app.ui.up_axis != up || app.cam.up != up { app.set_up_axis(up); } } /// The Preferences window's content size: the same on every page. const PREFS_W: f32 = 744.0; const PREFS_H: f32 = 400.0; /// The page list at its left. const PREFS_NAV_W: f32 = 128.0; /// One page of the dialog. fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { let note = |ui: &mut egui::Ui, s: &str| { ui.label(""); // Descriptions wrap within the page instead of widening it. ui.add(egui::Label::new(RichText::new(s).size(11.0).color(t.text_dim)).wrap()); ui.end_row(); }; if app.prefs_window.section == PAGE_SHORTCUTS { crate::keymap::page(app, ui); return; } if app.prefs_window.section == PAGE_AI { crate::agent_chat::settings::page(app, ui); return; } egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section { 0 => { ui.label(app.tr("ui.language")); egui::ComboBox::from_id_salt("pref_language").selected_text(crate::i18n::Locale::for_language(&app.preferences.language).name()).show_ui( ui, |ui| { for locale in crate::i18n::LOCALES { let (id, label) = (locale.id, locale.name); ui.selectable_value(&mut app.preferences.language, id.to_string(), label); } }, ); ui.end_row(); ui.label(app.tr("ui.theme")); ui.horizontal(|ui| { for (id, label) in THEMES { if ui.radio(app.preferences.theme == id, app.tr_label(label)).clicked() { app.preferences.theme = id.to_string(); if id != "system" { app.ui.dark = id == "dark"; } } } }); ui.end_row(); ui.label(app.tr("ui.ui_scale")); 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(); if !cfg!(target_arch = "wasm32") { ui.label(app.tr_label("Interface font")); ui_font_row(app, ui); ui.end_row(); } ui.label(app.tr("ui.feature_history")); let timeline_bottom = app.tr("ui.timeline_bottom").to_string(); let timeline_left = app.tr("ui.timeline_left_sidebar").to_string(); ui.horizontal(|ui| { ui.radio_value(&mut app.preferences.timeline_left, false, timeline_bottom); ui.radio_value(&mut app.preferences.timeline_left, true, timeline_left); }); ui.end_row(); note(ui, app.tr("ui.timeline_left_note")); 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| { for u in UNITS { ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u); } }); ui.end_row(); ui.label(app.tr("ui.autosave_every")); if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min").custom_parser(parse_number)).changed() { set_autosave(app); } ui.end_row(); note(ui, crate::i18n::tr_label("0 turns it off")); ui.label(app.tr("ui.length_decimals")); ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6)); ui.end_row(); ui.label(app.tr("ui.angle_decimals")); ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6)); ui.end_row(); ui.label(app.tr("ui.agent_cursor")); let speed_label = app.tr("ui.agent_cursor_speed"); let s = &mut app.ui.agent_cursor; ui.checkbox(&mut s.show, crate::i18n::tr_label("Show the pointer an AI agent moves (#35)")); ui.end_row(); ui.label(speed_label); ui.add_enabled_ui(s.show, |ui| { crate::agent_cursor::speed_slider(ui, &mut s.speed); }); ui.end_row(); ui.label(""); ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, crate::i18n::tr_label("Follow the camera (Look At sketches)"))); ui.end_row(); crate::update::prefs_rows(app, ui, t); } 1 => { ui.label(app.tr_label("Pan, zoom, orbit")); let current_preset_label = nav_preset(&app.preferences.nav).translated_label(); egui::ComboBox::from_id_salt("pref_nav").selected_text(current_preset_label).show_ui(ui, |ui| { for p in &NAV_PRESETS { if ui.selectable_value(&mut app.preferences.nav, p.id.to_string(), p.translated_label()).clicked() { app.preferences.zoom_reverse = p.reverse_zoom; } } }); ui.end_row(); if app.preferences.nav == TOUCHPAD_NAV { note(ui, crate::i18n::tr("nav.touchpad.help")); } else { let snap_help = if app.preferences.nav == OPENSCAD_NAV { "" } else { "; hold Shift during orbit to snap" }; let raw_help = format!("{}; Ctrl+left drag on empty space orbits{snap_help}; hold Space to pan; the wheel zooms", nav_preset(&app.preferences.nav).help); note(ui, crate::i18n::tr_label(&raw_help)); } ui.label(crate::i18n::tr_label("Context menu")); ui.checkbox(&mut app.preferences.radial_context_menu, crate::i18n::tr_label("Radial context menu")); ui.end_row(); ui.label(crate::i18n::tr_label("View navigator")); let picker = egui::ComboBox::from_id_salt("pref_navigator_style").selected_text(crate::i18n::tr_label(app.preferences.navigator_style.label())).show_ui(ui, |ui| { for style in NavigatorStyle::ALL { let response = ui.selectable_value(&mut app.preferences.navigator_style, style, crate::i18n::tr_label(style.label())); crate::scenario::publish_rect(&format!("prefs:navigator:{}", style.label()), response.rect); crate::scenario::publish_handle(&format!("prefs:navigator:{}", style.label()), response.rect.center()); } }); crate::scenario::publish_rect("prefs:navigator", picker.response.rect); crate::scenario::publish_handle("prefs:navigator", picker.response.rect.center()); ui.end_row(); ui.label(crate::i18n::tr_label("Reverse zoom direction")); ui.checkbox(&mut app.preferences.zoom_reverse, ""); ui.end_row(); ui.label(crate::i18n::tr_label("Zoom to the cursor")); ui.checkbox(&mut app.preferences.zoom_to_cursor, "") .on_hover_text(crate::i18n::tr_label("The wheel zooms toward the point under the cursor (off: toward the view's centre)")); ui.end_row(); ui.label(crate::i18n::tr_label("Smooth wheel zoom")); ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "") .on_hover_text(crate::i18n::tr_label("Turn off to apply each wheel event immediately, without a smoothing tail")); ui.end_row(); ui.label(crate::i18n::tr_label("Two-finger scroll pans")); let touchpad = app.preferences.nav == TOUCHPAD_NAV; ui.add_enabled(!touchpad, egui::Checkbox::new(&mut app.preferences.trackpad_pan, "")).on_hover_text(if touchpad { crate::i18n::tr("nav.touchpad.scroll") } else { crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms") }); ui.end_row(); ui.label(crate::i18n::tr_label("Orbit around")); // Wrapped: three choices, and German or Russian labels are long. ui.horizontal_wrapped(|ui| { ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Origin, crate::i18n::tr_label("Origin")); ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::ViewCentre, crate::i18n::tr_label("View centre")); ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Cursor, crate::i18n::tr_label("Point under the cursor")); }); ui.end_row(); ui.label(crate::i18n::tr_label("Middle double-click")); let current = MiddleDoubleClick::of(app); egui::ComboBox::from_id_salt("pref_middle_double_click").selected_text(crate::i18n::tr_label(current.label())).show_ui(ui, |ui| { for (m, id, label) in MiddleDoubleClick::ALL { if ui.selectable_label(current == m, crate::i18n::tr_label(label)).clicked() { app.preferences.middle_double_click = id.into(); } } }) .response .on_hover_text(crate::i18n::tr_label("Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing")); ui.end_row(); ui.label(crate::i18n::tr_label("Animate view changes")); ui.checkbox(&mut app.preferences.look_at_anim, crate::i18n::tr_label("Axis view, Look At, Home and Fit")); ui.end_row(); ui.label(crate::i18n::tr_label("Animation speed")); ui.add_enabled( app.preferences.look_at_anim, egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")), ); ui.end_row(); ui.label(crate::i18n::tr_label("3D mouse")); ui.checkbox(&mut app.preferences.space_mouse_enabled, crate::i18n::tr_label("Navigate with SpaceNavigator / SpaceMouse")); ui.end_row(); ui.label(crate::i18n::tr_label("3D mouse speed")); ui.add_enabled( app.preferences.space_mouse_enabled, egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")), ); ui.end_row(); note(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only.")); } PAGE_DESIGN => { ui.label(crate::i18n::tr_label("Default modelling orientation")); ui.horizontal(|ui| { ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), crate::i18n::tr_label("Z up")); ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), crate::i18n::tr_label("Y up")); }); ui.end_row(); note(ui, crate::i18n::tr_label("Which axis points up in the views (axis view, Home, Look At). Designs are unchanged.")); ui.label(crate::i18n::tr_label("Offer to stand up Y-up files")); ui.checkbox(&mut app.preferences.offer_stand_up, "") .on_hover_text(crate::i18n::tr_label("After a STEP or IGES file opens with Z up, offer a quarter turn about X")); ui.end_row(); } 4 => { ui.label(crate::i18n::tr_label("Visual style")); let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]; let selected_label = crate::i18n::tr_label(styles[usize::from(app.ui.visual_style.min(3))]); egui::ComboBox::from_id_salt("pref_style").selected_text(selected_label).show_ui(ui, |ui| { for (i, s) in styles.iter().enumerate() { ui.selectable_value(&mut app.ui.visual_style, i as u8, crate::i18n::tr_label(s)); } }); ui.end_row(); ui.label(crate::i18n::tr_label("Grid")); ui.checkbox(&mut app.ui.show_grid, ""); ui.end_row(); ui.label(crate::i18n::tr_label("Ground shadow")); ui.checkbox(&mut app.ui.ground_shadow, ""); ui.end_row(); ui.label(crate::i18n::tr_label("Perspective view")); ui.checkbox(&mut app.ui.perspective, ""); ui.end_row(); ui.label(app.tr("ui.anti_aliasing")); egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| { for m in MSAA { // Counts the graphics device can't do would keep the window from opening. let ok = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m); ui.add_enabled_ui(ok, |ui| ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"))) .inner .on_disabled_hover_text(app.tr("settings.msaa_unsupported")); } }); ui.end_row(); note(ui, app.tr("settings.msaa_applies_on_restart")); ui.label(app.tr("ui.display_quality")); { use solidcraft_engine::doc::display_quality::DisplayQuality; let label = |q: DisplayQuality| crate::i18n::tr(&format!("ui.display_quality_{}", q.id())).to_string(); let current = DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default(); egui::ComboBox::from_id_salt("pref_display_quality").selected_text(label(current)).show_ui(ui, |ui| { for q in DisplayQuality::ALL { if ui.selectable_label(q == current, label(q)).clicked() { app.preferences.display_quality = q.id().to_string(); } } }); } ui.end_row(); note(ui, app.tr("settings.display_quality_note")); ui.label(app.tr("ui.supersampling")); let ss_label = |f: f32| if f > 1.0 { format!("{f}×") } else { crate::i18n::tr("ui.supersampling_off").to_string() }; let adaptive = app.tr("ui.supersampling_adaptive").to_string(); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("pref_ssaa").selected_text(ss_label(app.preferences.supersampling)).show_ui(ui, |ui| { for f in SUPERSAMPLING { ui.selectable_value(&mut app.preferences.supersampling, f, ss_label(f)); } }); ui.add_enabled(app.preferences.supersampling > 1.0, egui::Checkbox::new(&mut app.preferences.supersampling_adaptive, adaptive)); }); ui.end_row(); note(ui, app.tr("settings.supersampling_note")); ui.label(crate::i18n::tr_label("Renderer")); ui.label(match (&app.viewport.gpu, &app.help.gpu) { (Some(_), Some(g)) => format!("GPU: {g}"), (Some(_), None) => "GPU".to_string(), _ => "CPU (software)".to_string(), }); ui.end_row(); ui.label(app.tr("settings.measure_axis_distances")); let response = ui.checkbox(&mut app.preferences.measure_axis_distances, ""); crate::scenario::publish_rect("prefs:measure_axis_distances", response.rect); response.on_hover_text(app.tr("settings.measure_axis_distances_help")); ui.end_row(); // Colour vision (#778): its own section at the end of the page. ui.label(RichText::new(app.tr("ui.colour_vision")).strong()); ui.label(""); ui.end_row(); ui.label(app.tr("ui.colour_vision_mode")); let mode = app.preferences.color_vision; egui::ComboBox::from_id_salt("pref_color_vision").selected_text(app.tr(mode.label_id())).show_ui(ui, |ui| { for m in crate::theme::ColorVision::ALL { ui.selectable_value(&mut app.preferences.color_vision, m, crate::i18n::tr(m.label_id())).on_hover_text(crate::i18n::tr(m.help_id())); } }); if app.preferences.color_vision != mode { // At once, like the theme: the next frame draws in the new colours. ui.ctx().request_repaint(); } ui.end_row(); ui.label(app.tr("ui.colour_vision_sample")); colour_vision_sample(ui, t); ui.end_row(); note(ui, app.tr("ui.colour_vision_note")); // Last, so the colour vision section above keeps its place on the page. ui.label(""); ui.end_row(); ui.label(app.tr("ui.frame_rate")); ui.checkbox(&mut app.preferences.show_frame_rate, ""); ui.end_row(); note(ui, app.tr("settings.frame_rate_note")); } 5 => { ui.label(crate::i18n::tr_label("Project face edges into new sketches")); let mut auto = app.session.auto_project; if ui.checkbox(&mut auto, "").changed() { let _ = app.run("sketch.auto_project", json!({ "value": auto })); } ui.end_row(); } _ => { ui.label(crate::i18n::tr_label("Click selects whole bodies")); ui.checkbox(&mut app.ui.pick_bodies, ""); ui.end_row(); } }); } /// The names under the Colour vision sample (message ids). pub(crate) const COLOUR_VISION_SAMPLE: [&str; 6] = ["ui.colour_vision_fixed", "ui.colour_vision_projected", "ui.axes", "Bends", "ui.draft", "ui.colour_vision_failed"]; /// The Colour vision sample (#778): fixed and projected sketch lines, the X and Y axes, bends up /// and down, Draft Analysis and a failed feature, each drawn as it is where it appears, in the /// colours on now. One fixed-size area, so the grid row is as tall as what it draws. fn colour_vision_sample(ui: &mut egui::Ui, t: &Tokens) { const W: f32 = 72.0; const LINE: f32 = 4.0; let (area, _) = ui.allocate_exact_size(egui::vec2(W * COLOUR_VISION_SAMPLE.len() as f32, 50.0), egui::Sense::hover()); let p = ui.painter_at(area); let line = |c| egui::Stroke::new(LINE, c); for (i, name) in COLOUR_VISION_SAMPLE.iter().enumerate() { let cell = egui::Rect::from_min_size(area.min + egui::vec2(W * i as f32, 0.0), egui::vec2(W - 8.0, 32.0)); let (a, b) = (cell.left_center() + egui::vec2(8.0, 0.0), cell.right_center() - egui::vec2(8.0, 0.0)); match i { 0 | 1 => { p.rect_filled(cell, 3.0, t.viewport_top); p.line_segment([a, b], line(if i == 0 { t.sketch_locked } else { t.sketch_projected })); } 2 => { // X along and Y up from one corner, as the view triad draws them. p.rect_filled(cell, 3.0, t.viewport_top); let o = cell.left_bottom() + egui::vec2(12.0, -7.0); p.line_segment([o, egui::pos2(cell.right() - 10.0, o.y)], line(t.triad[0])); p.line_segment([o, egui::pos2(o.x, cell.top() + 6.0)], line(t.triad[1])); } 3 => { // Dashed, as the flat pattern draws them. p.rect_filled(cell, 3.0, t.viewport_top); for (dy, c) in [(-7.0, t.bend_up), (7.0, t.bend_down)] { p.extend(egui::Shape::dashed_line(&[a + egui::vec2(0.0, dy), b + egui::vec2(0.0, dy)], line(c), 8.0, 5.0)); } } 4 => { // The three bands of Draft Analysis; in a mode the negative one is striped. let w = cell.width() / 3.0; for (k, c) in t.analysis.draft.iter().enumerate() { let band = egui::Rect::from_min_size(cell.min + egui::vec2(w * k as f32, 0.0), egui::vec2(w, cell.height())); p.rect_filled(band, 0.0, crate::theme::srgb(*c)); if k == 2 && t.analysis.hatch { let stripes = p.with_clip_rect(band); let dark = crate::theme::srgb(c.map(|x| x * 0.6)); let mut x = band.left() - band.height(); while x < band.right() { stripes.line_segment( [egui::pos2(x, band.bottom()), egui::pos2(x + band.height(), band.top())], egui::Stroke::new(2.0, dark), ); x += 9.0; } } } } _ => { // A failed feature on the timeline. p.rect(cell.shrink2(egui::vec2(10.0, 3.0)), 3.0, t.timeline_item_error, egui::Stroke::new(1.0, t.error), egui::StrokeKind::Inside); } } p.text( cell.center_bottom() + egui::vec2(0.0, 3.0), egui::Align2::CENTER_TOP, crate::i18n::tr(name), egui::FontId::proportional(11.0), t.text_dim, ); } } /// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button` /// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer; /// any other keys set preferences. Returns them all, the open page and the offer. pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result { let mut rest = p.as_object().cloned().unwrap_or_default(); if let Some(page) = rest.remove("open") { let name = page.as_str().unwrap_or("General"); let i = PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?; open_at(app, i); if app.prefs_window.snapshot.is_none() { app.prefs_window.snapshot = Some(Snapshot::take(app)); } } if rest.remove("stand_up").and_then(|v| v.as_bool()) == Some(true) { crate::stand_up::stand_up(app)?; } let button = rest.remove("button"); let mut out = set(app, &serde_json::Value::Object(rest))?; match button.as_ref().and_then(|b| b.as_str()) { Some("ok") => close(app, false), Some("cancel") => close(app, true), Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)), Some("defaults") => defaults(app, app.prefs_window.section), Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")), None => {} } if button.is_some() { out = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?; } if let Some(o) = out.as_object_mut() { o.insert("dialog".into(), json!(app.prefs_window.open.then(|| PAGES.get(app.prefs_window.section).copied().unwrap_or_default()))); o.insert("stand_up_offer".into(), json!(app.stand_up)); o.insert("dark".into(), json!(app.ui.dark)); } Ok(out) } /// Fold a setting an older release kept in its own field into the one this version reads. /// `orbit_cursor` (`true` = the point under the cursor) became `orbit_around`. pub fn migrate(mut p: Prefs) -> Prefs { if p.orbit_cursor { p.orbit_around = OrbitAround::Cursor; p.orbit_cursor = false; } p } /// Saved preferences, keeping every one that still reads. A value this version doesn't /// understand (a setting a newer or older release saved differently, a hand edit) falls back to /// its default on its own instead of discarding all of them. pub fn lenient(saved: &serde_json::Value) -> Prefs { if let Ok(p) = serde_json::from_value::(saved.clone()) { return migrate(p); } let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() }; if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) { for (k, v) in src.iter().take(1000) { // Keys this version doesn't have are ignored anyway. let Some(old) = o.get(k).cloned() else { continue }; o.insert(k.clone(), v.clone()); if serde_json::from_value::(serde_json::Value::Object(o.clone())).is_err() { log::warn!("preferences: `{k}` is not understood by this version; using its default"); o.insert(k.clone(), old); } } } serde_json::from_value(cur).map(migrate).unwrap_or_default() } /// Set preferences from JSON (control channel); returns them all. pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result { let mut cur = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?; if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) { for (k, v) in src { if !o.contains_key(k) { return Err(format!("unknown preference `{k}`")); } o.insert(k.clone(), v.clone()); } } // Saved preferences read an unknown mode as Off; asked for here, it is an error. if let Some(v) = p.get("color_vision") && v.as_str().and_then(crate::theme::ColorVision::from_id).is_none() { return Err("`color_vision` must be off, protan, deutan or tritan".into()); } let new: Prefs = migrate(serde_json::from_value(cur).map_err(|e| e.to_string())?); if !UNITS.contains(&new.default_units.as_str()) { return Err(format!("units must be one of {}", UNITS.join(", "))); } if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) { return Err(format!("`nav` must be one of {} and `msaa` 1, 2, 4 or 8", NAV_PRESETS.iter().map(|p| p.id).collect::>().join(", "))); } if new.msaa != app.preferences.msaa && !app.msaa_supported.is_empty() && !app.msaa_supported.contains(&new.msaa) { return Err(format!("this graphics device doesn't support `msaa` {}; it supports {:?}", new.msaa, app.msaa_supported)); } if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) { return Err("`theme` must be dark, light or system and `up_axis` z or y".into()); } if !crate::i18n::is_supported(&new.language) { return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::>().join(", "))); } if new.ui_font != app.preferences.ui_font && !new.ui_font.trim().is_empty() && crate::fonts::ui_font(&new.ui_font).is_none() { return Err(format!("`ui_font`: `{}` is not an installed font family or a readable font file", new.ui_font.trim())); } if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) { return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into()); } if solidcraft_engine::doc::display_quality::DisplayQuality::parse(&new.display_quality).is_none() { return Err("`display_quality` must be coarse, normal, fine or ultra".into()); } if !SUPERSAMPLING.contains(&new.supersampling) { return Err("`supersampling` must be 1 (off), 1.5 or 2".into()); } if MiddleDoubleClick::parse(&new.middle_double_click).is_none() { return Err("`middle_double_click` must be home, fit or off".into()); } if new.theme != app.preferences.theme && new.theme != "system" { app.ui.dark = new.theme == "dark"; } let up_changed = new.up_axis != app.preferences.up_axis; let msaa_changed = new.msaa != app.preferences.msaa; app.preferences = new; if msaa_changed { save_graphics(app); } if up_changed { up_axis_changed(app); } serde_json::to_value(&app.preferences).map_err(|e| e.to_string()) } #[cfg(test)] mod tests { /// The interface font (#1290): a font that can be read, or empty for the built-in one; saved /// with the others and reset by Restore Defaults. #[test] fn ui_font_is_checked_saved_and_restored() { use serde_json::json; let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); assert_eq!(app.preferences.ui_font, ""); let family = solidcraft_engine::sketch::DEFAULT_FONT_FAMILY; let out = super::set(&mut app, &json!({ "ui_font": family })).unwrap(); assert_eq!(out["ui_font"], family); let e = super::set(&mut app, &json!({"ui_font": "No Such Font 1290"})).unwrap_err(); assert!(e.contains("ui_font"), "{e}"); assert_eq!(app.preferences.ui_font, family, "a refused font leaves the choice as it was"); let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.ui_font, family); super::defaults(&mut app, 0); assert_eq!(app.preferences.ui_font, ""); } #[test] fn number_fields_take_a_decimal_point_or_a_decimal_comma() { // #1347 assert_eq!(super::parse_number("2.5"), Some(2.5)); assert_eq!(super::parse_number("2,5"), Some(2.5)); assert_eq!(super::parse_number(" -0,25 "), Some(-0.25)); assert_eq!(super::parse_number("\u{2212}1,5"), Some(-1.5)); assert_eq!(super::parse_number("12"), Some(12.0)); for bad in ["1.234,5", "1,2,3", ",", "abc", "", "inf", "NaN"] { assert_eq!(super::parse_number(bad), None, "{bad}"); } } /// Display quality (#1287) is a preference like any other: set by name, checked, saved with /// the others, back to Normal with Restore Defaults. #[test] fn display_quality_is_set_checked_and_restored() { use serde_json::json; let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); assert_eq!(app.preferences.display_quality, "normal"); let out = super::set(&mut app, &json!({"display_quality": "fine"})).unwrap(); assert_eq!(out["display_quality"], "fine"); assert!(super::set(&mut app, &json!({"display_quality": "silky"})).unwrap_err().contains("coarse, normal, fine or ultra")); assert_eq!(app.preferences.display_quality, "fine"); assert_eq!(super::lenient(&json!({"display_quality": "ultra"})).display_quality, "ultra"); super::defaults(&mut app, super::PAGE_GRAPHICS); assert_eq!(app.preferences.display_quality, "normal"); } /// Supersampling (#1288): off by default, adaptive, set by factor and checked. #[test] fn supersampling_is_off_by_default_and_checked() { use serde_json::json; let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); assert_eq!((app.preferences.supersampling, app.preferences.supersampling_adaptive), (1.0, true)); super::set(&mut app, &json!({"supersampling": 2.0})).unwrap(); assert_eq!(app.preferences.supersampling, 2.0); assert!(super::set(&mut app, &json!({"supersampling": 3.0})).unwrap_err().contains("1 (off), 1.5 or 2")); super::set(&mut app, &json!({"supersampling": 1.5, "supersampling_adaptive": false})).unwrap(); super::defaults(&mut app, super::PAGE_GRAPHICS); assert_eq!((app.preferences.supersampling, app.preferences.supersampling_adaptive), (1.0, true)); } #[test] fn a_value_this_version_does_not_understand_keeps_the_other_preferences() { use serde_json::json; // A navigator, a field of the wrong type and a key from another release. let saved = json!({"nav": "solidworks", "navigator_style": "hologram", "ui_scale": "big", "zoom_reverse": true, "from_the_future": 3}); let p = super::lenient(&saved); assert_eq!(p.nav, "solidworks"); assert!(p.zoom_reverse); assert_eq!(p.navigator_style, super::NavigatorStyle::Axes); assert_eq!(p.ui_scale, super::Prefs::default().ui_scale); assert_eq!(super::lenient(&json!("garbage")), super::Prefs::default()); // Whole-app load: the other saved state is kept too, and nothing panics. let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.load_prefs(&json!({"grid": false, "preferences": saved}).to_string()); assert!(!app.ui.show_grid && app.preferences.nav == "solidworks" && app.preferences.zoom_reverse); for junk in ["", "{", "[]", "null", "{\"preferences\": 7, \"recent\": {}, \"shortcuts\": 1, \"toolbar\": \"x\"}"] { crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()).load_prefs(junk); } } #[test] fn stored_prefs_fall_back_to_the_legacy_key() { let only_old = |k: &str| (k == super::LEGACY_STORAGE_KEY).then(|| "old".to_string()); assert_eq!(super::stored(only_old).as_deref(), Some("old")); let both = |k: &str| Some(if k == super::STORAGE_KEY { "new" } else { "old" }.to_string()); assert_eq!(super::stored(both).as_deref(), Some("new")); assert_eq!(super::stored(|_| None), None); } use super::*; use crate::navigation::orbit; #[test] fn unsupported_saved_msaa_is_reset_to_the_default_with_a_notice() { let dir = std::env::temp_dir().join(format!("sc_msaa_reset_{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let path = dir.join("graphics.json"); let services = crate::Services { graphics_path: Some(path.to_string_lossy().to_string()), ..Default::default() }; let mut app = SolveApp::new(solidcraft_engine::Session::default(), services); // WebGPU's guarantee for the surface format: 8x saved, the device can only do 1x and 4x. app.msaa_supported = vec![1, 4]; app.preferences.msaa = 8; std::fs::write(&path, r#"{"msaa":8,"backend":"vulkan"}"#).unwrap(); check_msaa(&mut app, 8); assert_eq!(app.preferences.msaa, 4); let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default(); assert_eq!(saved["msaa"], 4); assert_eq!(saved["backend"], "vulkan", "the backend preference is kept"); assert_eq!(app.settings_reset, [SettingReset::Msaa { requested: 8, reset: 4, supported: vec![1, 4] }]); assert_eq!( app.settings_reset[0].message(), "Graphics › Anti-aliasing: 8× isn't supported by this graphics device; it is now 4× (supported: 1×, 4×)." ); { let _de = crate::i18n::scope("de"); assert_eq!( app.settings_reset[0].message(), "Grafik › Kantenglättung: 8× wird von diesem Grafikgerät nicht unterstützt; jetzt 4× (unterstützt: 1×, 4×)." ); } // Below the default when the device can't do that either. app.settings_reset.clear(); app.msaa_supported = vec![1, 2]; check_msaa(&mut app, 8); assert_eq!(app.preferences.msaa, 2); // Supported, off, or no GPU: nothing changes (OpenGL's 1x policy isn't a reset either). for (supported, requested) in [(vec![1, 2, 4, 8], 8), (vec![1, 4], 1), (vec![], 8), (vec![1, 4], 4)] { app.settings_reset.clear(); app.preferences.msaa = requested; app.msaa_supported = supported; check_msaa(&mut app, requested); assert!(app.settings_reset.is_empty()); assert_eq!(app.preferences.msaa, requested); } let _ = std::fs::remove_dir_all(&dir); } #[test] fn control_channel_rejects_msaa_the_device_cannot_do_and_bad_saved_msaa_loads_as_default() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.msaa_supported = vec![1, 4]; assert!(set(&mut app, &serde_json::json!({ "msaa": 8 })).is_err()); assert!(set(&mut app, &serde_json::json!({ "msaa": 1 })).is_ok()); app.load_prefs(&serde_json::json!({ "preferences": { "msaa": 16 } }).to_string()); assert_eq!(app.preferences.msaa, Prefs::default().msaa); } #[test] fn length_fields_read_bare_numbers_in_design_units_and_honour_explicit_units() { let inch = unit_scale("in").0; let close = |a: Option, b: f64| a.is_some_and(|a| (a - b).abs() < 1e-9); assert!(close(parse_length_mm("2", inch), 50.8)); assert!(close(parse_length_mm(" 10 mm ", inch), 10.0)); assert!(close(parse_length_mm("0.5 in", 1.0), 12.7)); assert!(close(parse_length_mm("1 in + 5 mm", unit_scale("cm").0), 30.4)); assert!(close(parse_length_mm("3", unit_scale("cm").0), 30.0)); assert!(close(parse_length_mm("3", 1.0), 3.0)); assert_eq!(parse_length_mm("10 deg", inch), None); assert_eq!(parse_length_mm("abc", inch), None); assert_eq!(parse_length_mm("", inch), None); assert_eq!(parse_length_mm("1e400", 1.0), None); } #[test] fn navigator_picker_preserves_axes_and_follows_preferences_transactions() { let old: Prefs = serde_json::from_value(json!({"nav":"solidworks"})).unwrap(); assert_eq!(old.navigator_style, NavigatorStyle::Axes); let mut h = crate::scenario::Harness::new(); assert!(crate::scenario::handle_at("axis_view:z").is_some()); assert!(crate::scenario::handle_at("navigation_cube:front").is_none()); let camera = h.app.cam; let design = h.app.session.doc.to_json(); let nav = h.app.preferences.nav.clone(); h.call("ui.prefs", json!({"open":"Navigation"})); let picker = crate::scenario::rect_of("prefs:navigator").unwrap().center(); h.call("ui.click", json!({"x":picker.x,"y":picker.y})); let cube = crate::scenario::rect_of("prefs:navigator:Cube").unwrap().center(); assert!(crate::scenario::rect_of("prefs:navigator:Signed axes").is_some()); h.call("ui.click", json!({"x":cube.x,"y":cube.y})); assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube); assert!(crate::scenario::handle_at("navigation_cube:front").is_some()); assert_eq!(h.app.preferences.nav, nav); assert_eq!(h.app.cam, camera); assert_eq!(h.app.session.doc.to_json(), design); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); assert_eq!(restored.preferences.navigator_style, NavigatorStyle::Cube); let before = h.app.preferences.clone(); assert!(control(&mut h.app, &json!({"navigator_style":"unknown", "nav":"solidworks"})).is_err()); assert_eq!(h.app.preferences, before, "invalid settings must fail atomically"); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes); assert!(crate::scenario::handle_at("navigation_cube:front").is_none()); h.call("ui.prefs", json!({"open":"Navigation","navigator_style":"cube"})); h.call("ui.prefs", json!({"button":"apply"})); h.call("ui.prefs", json!({"button":"defaults"})); assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Axes); h.call("ui.prefs", json!({"button":"cancel"})); assert_eq!(h.app.preferences.navigator_style, NavigatorStyle::Cube); h.call("ui.prefs", json!({"navigator_style":"axes", "look_at_anim":false})); h.call("ui.view", json!({"view":"front"})); let axis = crate::scenario::handle_at("axis_view:-y").unwrap(); h.call("ui.click", json!({"x":axis.x,"y":axis.y})); assert!(h.app.cam.back().dist(Vec3::Y) < 1e-6, "original axes retain click-again reversal"); assert_eq!(h.app.session.doc.to_json(), design); } #[test] fn radial_context_menu_defaults_off_and_follows_preferences_transactions() { assert!(!Prefs::default().radial_context_menu); let old: Prefs = serde_json::from_value(json!({"nav":"solidworks","zoom_reverse":true})).unwrap(); assert!(!old.radial_context_menu && old.zoom_reverse && old.nav == "solidworks"); let mut h = crate::scenario::Harness::new(); h.call("ui.prefs", json!({"open":"Navigation"})); h.step(&json!({"widget":"Radial context menu"})).unwrap(); assert!(h.app.preferences.radial_context_menu, "the actual checkbox enables radial menus"); let mut restored = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); restored.load_prefs(&h.app.prefs()); assert!(restored.preferences.radial_context_menu, "enabled preference survives save/load"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(!h.app.preferences.radial_context_menu, "Cancel restores the default-off setting"); h.call("ui.prefs", json!({"open":"Navigation","radial_context_menu":true})); h.call("ui.prefs", json!({"button":"apply"})); h.call("ui.prefs", json!({"button":"defaults"})); assert!(!h.app.preferences.radial_context_menu, "Navigation defaults disable the ring"); h.call("ui.prefs", json!({"button":"cancel"})); assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply"); } #[test] fn wheel_smoothing_persists_and_follows_dialog_buttons() { assert!(serde_json::from_value::(json!({})).unwrap().smooth_wheel_zoom); let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap(); control(&mut app, &json!({"button":"cancel"})).unwrap(); assert!(app.preferences.smooth_wheel_zoom); control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap(); control(&mut app, &json!({"button":"apply"})).unwrap(); control(&mut app, &json!({"smooth_wheel_zoom":true, "button":"cancel"})).unwrap(); assert!(!app.preferences.smooth_wheel_zoom); let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert!(!other.preferences.smooth_wheel_zoom); control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap(); assert!(app.preferences.smooth_wheel_zoom); control(&mut app, &json!({"button":"cancel"})).unwrap(); assert!(!app.preferences.smooth_wheel_zoom); } #[test] fn touchpad_preset_persists_and_obeys_preference_transactions() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); control(&mut app, &json!({"open":"Navigation", "nav":"touchpad"})).unwrap(); assert_eq!(app.preferences.nav, "touchpad"); assert!(app.preferences.trackpad_pan, "choosing Touchpad must not overwrite the saved scroll preference"); control(&mut app, &json!({"button":"cancel"})).unwrap(); assert_eq!(app.preferences.nav, "fusion"); control(&mut app, &json!({"open":"Navigation", "nav":"touchpad", "button":"apply"})).unwrap(); let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.nav, "touchpad"); control(&mut app, &json!({"button":"defaults"})).unwrap(); assert_eq!(app.preferences.nav, "fusion"); control(&mut app, &json!({"button":"cancel"})).unwrap(); assert_eq!(app.preferences.nav, "touchpad"); } #[test] fn openscad_preset_maps_navigation_and_persists() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); set(&mut app, &json!({"nav": OPENSCAD_NAV})).unwrap(); for button in [NavButton::Middle, NavButton::Right] { assert_eq!(nav_mode(&app, button, Modifiers::default()), Some(NavMode::Pan)); assert_eq!(nav_mode(&app, button, Modifiers { shift: true, ..Default::default() }), Some(NavMode::Zoom)); } assert_eq!(nav_mode(&app, NavButton::Left, Modifiers::default()), Some(NavMode::Orbit)); let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.nav, OPENSCAD_NAV); } #[test] fn trackpad_pan_defaults_on_persists_and_follows_dialog_buttons() { assert!(serde_json::from_value::(json!({})).unwrap().trackpad_pan); assert!(Prefs::default().trackpad_pan); let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap(); control(&mut app, &json!({"button":"cancel"})).unwrap(); assert!(app.preferences.trackpad_pan); control(&mut app, &json!({"open":"Navigation", "trackpad_pan":false})).unwrap(); control(&mut app, &json!({"button":"apply"})).unwrap(); assert!(!app.preferences.trackpad_pan); let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert!(!other.preferences.trackpad_pan); control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap(); assert!(app.preferences.trackpad_pan); control(&mut app, &json!({"button":"cancel"})).unwrap(); assert!(!app.preferences.trackpad_pan); } #[test] fn blender_preset_moves_the_camera_with_the_middle_button_only() { // #1090: orbit, pan and zoom all on the middle button; no left or right drag binding, so // those stay selection and the context menu (Ctrl+left on empty space orbits in every // preset, outside the bindings). let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); set(&mut app, &json!({"nav": "blender"})).unwrap(); let none = Modifiers::default(); let shift = Modifiers { shift: true, ..Default::default() }; let ctrl = Modifiers { ctrl: true, ..Default::default() }; assert_eq!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Orbit)); assert_eq!(nav_mode(&app, NavButton::Middle, shift), Some(NavMode::Pan)); assert_eq!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Zoom)); for mods in [none, shift, ctrl] { assert_eq!(nav_mode(&app, NavButton::Left, mods), None); assert_eq!(nav_mode(&app, NavButton::Right, mods), None); } assert_eq!(zoom_sign(&app), 1.0, "the wheel turns the usual way"); let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.nav, "blender"); } #[test] fn presets_map_the_mouse_and_prefs_round_trip() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); let shift = Modifiers { shift: true, ..Default::default() }; let ctrl = Modifiers { ctrl: true, ..Default::default() }; let none = Modifiers::default(); assert!(matches!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Pan))); assert!(matches!(nav_mode(&app, NavButton::Middle, shift), Some(NavMode::Orbit))); set(&mut app, &json!({"nav": "solidworks"})).unwrap(); assert!(matches!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Orbit))); assert!(matches!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Pan))); set(&mut app, &json!({"nav": "inventor", "zoom_reverse": true})).unwrap(); assert!(matches!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Zoom))); assert_eq!(zoom_sign(&app), -1.0); assert!(matches!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit)), "right drag orbits everywhere"); assert_eq!(nav_mode(&app, NavButton::Left, none), None, "left drag selects"); let shift_alt = Modifiers { shift: true, alt: true, ..Default::default() }; assert_eq!(nav_mode(&app, NavButton::Left, shift_alt), None, "only Alias navigates with the left button"); set(&mut app, &json!({"nav": "alias"})).unwrap(); assert_eq!(nav_mode(&app, NavButton::Left, shift_alt), Some(NavMode::Orbit)); assert_eq!(nav_mode(&app, NavButton::Middle, shift_alt), Some(NavMode::Pan)); assert_eq!(nav_mode(&app, NavButton::Right, shift_alt), Some(NavMode::Zoom)); assert_eq!(nav_mode(&app, NavButton::Left, none), None, "a plain left drag still selects"); assert_eq!(nav_mode(&app, NavButton::Left, shift), None); assert_eq!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit)); assert!(set(&mut app, &json!({"nav": "no-such-preset"})).is_err()); assert!(set(&mut app, &json!({"default_units": "furlong"})).is_err()); assert!(set(&mut app, &json!({"nope": 1})).is_err()); // Kept between runs. let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences, app.preferences); assert_eq!(format_length(25.4, "in", 3), "1.000 in"); } /// The dialog: changes apply at once, Cancel puts back what was there when it opened, Apply /// keeps them, Restore Defaults resets one page, and the shortcuts menus open its page. #[test] fn preferences_dialog_cancel_apply_and_defaults() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); control(&mut app, &json!({"open": "general"})).unwrap(); assert!(app.prefs_window.open && app.prefs_window.section == 0); control(&mut app, &json!({"theme": "light", "ui_scale": 1.25, "zoom_to_cursor": false})).unwrap(); assert!(!app.ui.dark, "light applies at once"); app.keymap.custom.insert("solid.extrude".into(), "Shift+E".into()); let v = control(&mut app, &json!({"button": "cancel"})).unwrap(); assert_eq!(v["dialog"], serde_json::Value::Null); assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor); assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too"); control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap(); control(&mut app, &json!({"button": "apply"})).unwrap(); control(&mut app, &json!({"nav": "tinkercad"})).unwrap(); control(&mut app, &json!({"button": "cancel"})).unwrap(); assert_eq!(app.preferences.nav, "alias", "Cancel goes back to the last Apply"); let to = { let mut c = app.cam; c.set_view(solidcraft_engine::render::StandardView::Top); c }; app.animate_to(to); assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps"); control(&mut app, &json!({"open": "Navigation"})).unwrap(); control(&mut app, &json!({"button": "defaults"})).unwrap(); assert_eq!(app.preferences.nav, "fusion"); assert!(app.preferences.look_at_anim); control(&mut app, &json!({"button": "ok"})).unwrap(); assert!(!app.prefs_window.open); // File and Help › Keyboard Shortcuts open the same dialog at its page. app.keymap.open = true; let ctx = egui::Context::default(); let mut out = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx())); out.textures_delta.clear(); assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open); assert!(control(&mut app, &json!({"open": "Plugins"})).is_err()); assert!(set(&mut app, &json!({"up_axis": "x"})).is_err()); assert!(set(&mut app, &json!({"theme": "sepia"})).is_err()); assert!(set(&mut app, &json!({"language": "xx"})).is_err()); } #[test] fn language_preference_switches_and_persists() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); assert_eq!(app.preferences.language, "en"); assert_eq!(app.tr("ui.preferences"), "Preferences"); assert_eq!(app.tr("ui.extrude"), "Extrude"); // Switch language to Japanese set(&mut app, &json!({"language": "ja", "timeline_left": true})).unwrap(); app.preferences.timeline_left_height = 275.0; app.preferences.timeline_left_browser_collapsed = true; assert_eq!(app.preferences.language, "ja"); assert!(app.preferences.timeline_left); assert_eq!(app.tr("ui.preferences"), "環境設定"); assert_eq!(app.tr("ui.extrude"), "押し出し"); // Preferences round-trip let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); other.load_prefs(&app.prefs()); assert_eq!(other.preferences.language, "ja"); assert!(other.preferences.timeline_left); assert_eq!(other.preferences.timeline_left_height, 275.0); assert!(other.preferences.timeline_left_browser_collapsed); assert_eq!(other.tr("ui.preferences"), "環境設定"); } /// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable. #[test] fn a_y_up_step_file_can_be_stood_up() { let dir = std::env::temp_dir().join(format!("solidcraft-standup-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let path = dir.join("tall_in_y.step").to_string_lossy().to_string(); let mut src = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); src.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap(); src.run("file.export", json!({"path": path})).unwrap(); let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.open_path(&path); let offered = app.stand_up.clone().unwrap_or_default(); assert_eq!(offered.len(), 1, "the file's body is offered"); let size = |app: &SolveApp| solidcraft_engine::view::bounds(&app.session); assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6); control(&mut app, &json!({"stand_up": true})).unwrap(); let b = size(&app); assert!((b.max.z - b.min.z - 40.0).abs() < 1e-6, "Y became Z: {b:?}"); assert!(app.stand_up.is_none()); app.run("edit.undo", json!({})).unwrap(); assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6, "one undo step"); // Not offered when turned off, or with Y up as the default orientation. let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.preferences.offer_stand_up = false; app.open_path(&path); assert!(app.stand_up.is_none()); let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.preferences.up_axis = "y".into(); app.open_path(&path); assert!(app.stand_up.is_none()); let _ = std::fs::remove_dir_all(&dir); } /// Orbiting about the world origin keeps the origin where it was drawn, even when Pan has /// moved it off to the side. #[test] fn orbiting_around_the_origin_keeps_it_still() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap(); let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); app.fit_view(); assert_eq!(app.preferences.orbit_around, OrbitAround::ViewCentre, "the view centre stays the default"); app.preferences.orbit_around = OrbitAround::Origin; app.cam.pan(60.0, -25.0, 600.0); let was = (app.cam.yaw, app.cam.pitch); let before = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap(); orbit(&mut app, rect, 40.0, 25.0, None); let after = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap(); assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}"); assert_ne!((app.cam.yaw, app.cam.pitch), was, "the view must turn"); } /// Preferences saved before the origin option had `orbit_cursor`: that choice survives, and /// the control channel still accepts the old key. #[test] fn saved_cursor_preference_migrates_to_the_origin_option() { assert_eq!(lenient(&json!({"orbit_cursor": true})).orbit_around, OrbitAround::Cursor); assert_eq!(lenient(&json!({"orbit_cursor": false})).orbit_around, OrbitAround::ViewCentre); assert_eq!(lenient(&json!({})).orbit_around, OrbitAround::ViewCentre); assert_eq!(lenient(&json!({"orbit_around": "origin"})).orbit_around, OrbitAround::Origin); let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); set(&mut app, &json!({"orbit_around": "origin"})).unwrap(); assert_eq!(app.preferences.orbit_around, OrbitAround::Origin); set(&mut app, &json!({"orbit_cursor": true})).unwrap(); assert_eq!(app.preferences.orbit_around, OrbitAround::Cursor, "the old key still moves the choice"); assert!(!app.preferences.orbit_cursor, "and is folded away"); } #[test] fn orbiting_around_the_cursor_keeps_that_point_still() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap(); app.preferences.orbit_around = OrbitAround::Cursor; let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); app.fit_view(); let at = egui::pos2(500.0, 250.0); let pivot = pivot_under(&app, &crate::viewport::projection(&app, rect), at); orbit(&mut app, rect, 40.0, 25.0, Some(at)); let s = crate::viewport::projection(&app, rect).to_screen(pivot).unwrap(); assert!(s.distance(at) < 2.0, "{s:?}"); } /// A drag that starts over empty space turns around the visible geometry's centre, not a /// point far off to the side; with nothing visible it turns around the view's centre. #[test] fn orbiting_from_empty_space_turns_around_the_visible_geometry() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap(); app.preferences.orbit_around = OrbitAround::Cursor; let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); app.fit_view(); let corner = egui::pos2(10.0, 10.0); let proj = crate::viewport::projection(&app, rect); let hits = crate::viewport::pick(&app, &proj, corner); use crate::viewport::Hit; assert!(!hits.iter().any(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })), "the corner is empty space"); let centre = crate::viewport::view_bounds(&app).center(); orbit(&mut app, rect, 40.0, 25.0, Some(corner)); assert_eq!(app.viewport.orbit_pivot, Some(centre)); let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); app.preferences.orbit_around = OrbitAround::Cursor; app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0); orbit(&mut app, rect, 40.0, 25.0, Some(corner)); assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0))); } /// The Y up preference turns the view: the camera's up axis follows it, startup included. #[test] fn up_axis_preference_turns_the_view() { use solidcraft_engine::render::UpAxis; let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); let ctx = egui::Context::default(); apply(&mut app, &ctx); assert_eq!(app.cam.up, UpAxis::Z); app.preferences.up_axis = "y".into(); apply(&mut app, &ctx); assert_eq!((app.ui.up_axis, app.cam.up), (UpAxis::Y, UpAxis::Y)); // Home looks at the model with Y up: the eye is above the XZ ground. assert!(app.cam.back().y > 0.3, "{:?}", app.cam.back()); app.preferences.up_axis = "z".into(); apply(&mut app, &ctx); 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); } }