diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d6023da9..3031d003 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -433,7 +433,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> }, None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})), }, - "ui.prefs" => wrap(crate::prefs::set(app, p)), + "ui.prefs" => wrap(crate::prefs::control(app, p)), "ui.help" => match s("item") { Some(i @ ("about" | "shortcuts" | "report")) => { crate::help::run(app, ctx, i); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1056e229..c065080d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -151,8 +151,6 @@ pub enum Kind { Stock { margin: String, }, - /// Application preferences (applied as they change; kept between runs). - Preferences, /// Physical material (and with it the appearance) of bodies. Material { index: usize, @@ -502,10 +500,6 @@ impl Dialog { Some(d) } - pub fn preferences() -> Dialog { - Dialog::new(Kind::Preferences, vec![]) - } - pub fn rename(feature: u64, name: &str) -> Dialog { Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![]) } @@ -775,7 +769,6 @@ fn title(k: &Kind) -> &'static str { Kind::Mirror => "MIRROR", Kind::Measure { .. } => "MEASURE", Kind::Material { .. } => "PHYSICAL MATERIAL", - Kind::Preferences => "PREFERENCES", Kind::OffsetPlane { .. } => "OFFSET PLANE", Kind::Rib { web: false, .. } => "RIB", Kind::Rib { web: true, .. } => "WEB", @@ -1136,30 +1129,6 @@ fn update_measure(app: &SolveApp, d: &mut Dialog) { } } -/// The preferences, applied as they change. -fn preferences_rows(app: &mut SolveApp, ui: &mut egui::Ui) { - ui.label("Theme"); - let mut theme = usize::from(!app.ui.dark); - combo(ui, "pref_theme", &["Dark", "Light"], &mut theme); - app.ui.dark = theme == 0; - ui.end_row(); - ui.label("Grid"); - ui.checkbox(&mut app.ui.show_grid, ""); - ui.end_row(); - ui.label("Perspective view"); - ui.checkbox(&mut app.ui.perspective, ""); - ui.end_row(); - ui.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("Click selects whole bodies"); - ui.checkbox(&mut app.ui.pick_bodies, ""); - ui.end_row(); -} - fn fmt_mm(v: &Value) -> String { v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default() } @@ -1251,7 +1220,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }) .shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) }); // OK is offered once the inputs make a feature. - let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences); + let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. }); let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none()); let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal }; let wkey = egui::Id::new(("sc_dialog_w", wide)); @@ -1490,7 +1459,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()), - Kind::Preferences => preferences_rows(app, ui), Kind::OffsetPlane { offset } => { row_label(ui, "Distance"); enter |= field(ui, offset); @@ -1811,7 +1779,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ok = true; } } - let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" }; + let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" }; if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() { cancel = true; } @@ -1824,7 +1792,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let nothing_focused = ctx.memory(|m| m.focused().is_none()); let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value"))); // Tables (configurations, a study's keys) take Enter for their cells. - let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences) + let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. }) && !matches!(&d.kind, Kind::Motion(k) if k.wide()); if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) { ok = true; @@ -2533,7 +2501,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})), Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})), Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })), - Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()), + Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()), Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs), Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra), Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 2563502a..0a1654ec 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -13,13 +13,14 @@ use crate::SolveApp; use crate::theme::Tokens; /// Keys the application handles before any command shortcut. -pub const RESERVED: &[&str] = &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O"]; +pub const RESERVED: &[&str] = + &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"]; #[derive(Default)] pub struct Keymap { /// Command id → key (`""`: no key). Only the changed ones. pub custom: BTreeMap, - /// The Keyboard Shortcuts window. + /// Asks for the Keyboard Shortcuts page of Preferences (File and Help menus). pub open: bool, /// Opened from Help: a list to read, not to edit. pub read_only: bool, @@ -113,11 +114,10 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) { } } -/// The Keyboard Shortcuts window. -pub fn show(app: &mut SolveApp, ctx: &egui::Context) { - if !app.keymap.open { - return; - } +/// The Keyboard Shortcuts page of Preferences: search, rebind (click, then press the key), clear, +/// reset; a key another command uses is reported and taken over only on request. +pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); // A key press while waiting for one binds it (Esc cancels). if let Some(id) = app.keymap.capturing.clone() { let press = ctx.input(|i| { @@ -142,13 +142,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } let t = Tokens::get(); - let mut open = true; let mut action: Option<(String, &str)> = None; - crate::frame::window(ctx, "Keyboard Shortcuts", crate::frame::Width::Wide) - .id(egui::Id::new("sc_keymap")) - .open(&mut open) - .default_size([520.0, 480.0]) - .show(ctx, |ui| { + { + { ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0)); if !app.keymap.read_only && ui.button("Reset All").clicked() { @@ -170,7 +166,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } ui.separator(); let q = app.keymap.filter.to_lowercase(); - egui::ScrollArea::vertical().show(ui, |ui| { + egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| { egui::Grid::new("sc_keymap_grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| { for c in command_specs() { let key = effective(app, c.id).unwrap_or_default(); @@ -193,8 +189,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); continue; } - let b = - egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0)); + let b = egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(80.0, 0.0)); if ui.add(b).on_hover_text("Click, then press the new key").clicked() { action = Some((c.id.to_string(), "capture")); } @@ -210,7 +205,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } }); }); - }); + } + } match action { Some((id, "capture")) => { app.keymap.capturing = Some(id); @@ -233,15 +229,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } _ => {} } - if !open { - app.keymap.open = false; - app.keymap.capturing = None; - } } -/// Is the window waiting for a key (so the key must not also run a command)? +/// Stop waiting for a key (the dialog closed). +pub fn stop_capture(app: &mut SolveApp) { + app.keymap.capturing = None; + app.keymap.conflict = None; + app.keymap.message = None; +} + +/// Is the page waiting for a key (so the key must not also run a command)? pub fn capturing(app: &SolveApp) -> bool { - app.keymap.open && app.keymap.capturing.is_some() + app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && app.keymap.capturing.is_some() } #[cfg(test)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index df20645c..8cad317f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,7 @@ pub mod sketch_dims; mod sketch_edit_tests; pub mod sketch_palette; pub mod sketch_tools; +pub mod stand_up; pub mod theme; pub mod timeline; pub mod titlebar; @@ -194,6 +195,8 @@ pub struct SolveApp { pub autosave: Option, /// Designs left by crashed apps, offered for recovery. pub recoverable: Vec, + /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). + pub stand_up: Option>, /// The last command was a big operation: autosave after it. pub big_operation: bool, /// Autosave interval (minutes; 0 = only after big operations). A preference. @@ -250,6 +253,7 @@ impl SolveApp { quit_requested: false, autosave: None, recoverable: Vec::new(), + stand_up: None, big_operation: false, autosave_minutes: 5.0, custom_titlebar: false, @@ -292,6 +296,8 @@ impl SolveApp { "shortcut_box": self.sbox.prefs(), "shortcuts": self.keymap.prefs(), "autosave_minutes": self.autosave_minutes, + "ground_shadow": self.ui.ground_shadow, + "agent_cursor": self.ui.agent_cursor, "preferences": self.preferences, }) .to_string() @@ -345,6 +351,16 @@ impl SolveApp { if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::(p.clone()).ok()) { self.preferences = p; } + // Saved before the theme was a preference: the Dark/Light flag says which. + if v.get("preferences").and_then(|p| p.get("theme")).is_none() { + self.preferences.theme = if self.ui.dark { "dark" } else { "light" }.into(); + } + if let Some(g) = flag("ground_shadow") { + self.ui.ground_shadow = g; + } + if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) { + self.ui.agent_cursor = a; + } } pub fn with_control(mut self, rx: Receiver) -> Self { @@ -564,6 +580,7 @@ impl SolveApp { documents::prepare_open(self); if self.run("doc.open", json!({ "path": path })).is_ok() { self.fit_view(); + stand_up::offer(self, path, &[]); } } @@ -571,8 +588,10 @@ impl SolveApp { /// current design. pub fn insert_path(&mut self, path: &str) { let cmd = if solvecraft_engine::io::is_mesh_path(path) { "file.insert_mesh" } else { "file.insert_step" }; + let before: Vec = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect(); if let Ok(r) = self.run(cmd, json!({ "path": path })) { self.fit_view(); + stand_up::offer(self, path, &before); if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false); } @@ -623,7 +642,18 @@ impl SolveApp { if !to.is_valid() { return; } - self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now)); + // Preferences › Navigation: animated (at a chosen speed) or immediate. + if !self.preferences.look_at_anim { + self.cam = to; + self.cam_anim = None; + return; + } + let mut anim = CameraAnim::new(self.cam, to, self.now); + let speed = f64::from(self.preferences.look_at_speed); + if speed.is_finite() && speed > 0.0 { + anim.duration /= speed; + } + self.cam_anim = Some(anim); } /// Animated view changes for the view cube and navigation bar. @@ -666,6 +696,7 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { self.esc_handled = false; + prefs::apply(self, ctx); if !self.styled || theme::is_dark() != self.ui.dark { theme::set_dark(self.ui.dark); theme::apply(ctx); @@ -753,13 +784,13 @@ impl SolveApp { sketch_palette::show(self, ui.ctx()); params_dialog::show(self, ui.ctx()); recovery_ui::show(self, ui.ctx()); + stand_up::show(self, ui.ctx()); context_menu::show(self, ui.ctx()); delete::show(self, ui.ctx()); if self.custom_titlebar { titlebar::resize_zones(ui); } shortcut_box::show(self, ui.ctx()); - keymap::show(self, ui.ctx()); prefs::show(self, ui.ctx()); agent_cursor::paint(self, ui.ctx()); self.frame_ms = now_ms() - t0; diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index bd5337e5..779d260b 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,7 +1,12 @@ -//! Preferences (File › Preferences), in the spirit of Fusion's: General (theme, units of new -//! designs, autosave interval), Navigation (mouse mapping preset, zoom direction, orbit centre), Display -//! (grid, perspective, visual style with or without edges, display precision, anti-aliasing), Sketch and Selection. Everything -//! applies at once and is kept between runs, except anti-aliasing, which needs a restart. +//! 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, 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), 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}; @@ -14,7 +19,13 @@ use crate::viewport::NavMode; pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"]; /// Mouse mappings: (id, label). -pub const NAV_PRESETS: [(&str, &str); 3] = [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor")]; +pub const NAV_PRESETS: [(&str, &str); 5] = + [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor"), ("alias", "Alias"), ("tinkercad", "Tinkercad")]; +/// The dialog's pages, in order. +pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"]; +pub const PAGE_SHORTCUTS: usize = 2; +pub const PAGE_DESIGN: usize = 3; +pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")]; pub const MSAA: [u32; 4] = [1, 2, 4, 8]; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -33,6 +44,19 @@ pub struct Prefs { /// 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 scale (1 = 100 %). + pub ui_scale: f32, + /// The wheel zooms toward the point under the cursor (else toward the view's centre). + pub zoom_to_cursor: bool, + /// View changes (view cube, 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, } impl Default for Prefs { @@ -45,6 +69,13 @@ impl Default for Prefs { msaa: 4, length_decimals: 2, angle_decimals: 1, + theme: "dark".into(), + ui_scale: 1.0, + zoom_to_cursor: true, + look_at_anim: true, + look_at_speed: 1.0, + up_axis: "z".into(), + offer_stand_up: true, } } } @@ -53,7 +84,127 @@ impl Default for Prefs { #[derive(Default)] pub struct PrefsWindow { pub open: bool, - section: usize, + /// 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, +} + +/// 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; + app.preferences = self.prefs; + 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); + } + } +} + +/// 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.ui_scale = d.ui_scale; + p.default_units = d.default_units; + p.length_decimals = d.length_decimals; + p.angle_decimals = d.angle_decimals; + app.ui.dark = true; + app.ui.agent_cursor = Default::default(); + app.autosave_minutes = 5.0; + set_autosave(app); + } + 1 => { + p.nav = d.nav; + p.zoom_reverse = d.zoom_reverse; + p.zoom_to_cursor = d.zoom_to_cursor; + p.orbit_cursor = d.orbit_cursor; + p.look_at_anim = d.look_at_anim; + p.look_at_speed = d.look_at_speed; + } + PAGE_SHORTCUTS => crate::keymap::reset(app, None), + PAGE_DESIGN => { + p.up_axis = d.up_axis; + p.offer_stand_up = d.offer_stand_up; + } + 4 => { + let msaa = p.msaa != d.msaa; + p.msaa = d.msaa; + 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) { + if app.preferences.theme == "system" + && let Some(t) = ctx.system_theme() + { + app.ui.dark = t == egui::Theme::Dark; + } + let z = app.preferences.ui_scale.clamp(0.5, 2.5); + if z.is_finite() && (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). @@ -80,7 +231,8 @@ pub fn set_autosave(app: &mut SolveApp) { /// Which navigation a mouse drag does under the preset. pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option { if secondary && !middle { - return Some(NavMode::Orbit); + // Tinkercad: Shift+right pans; everywhere else right drag orbits. + return Some(if app.preferences.nav == "tinkercad" && mods.shift { NavMode::Pan } else { NavMode::Orbit }); } if !middle { return None; @@ -103,6 +255,10 @@ pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) } else { NavMode::Pan }), + // Alias: middle pans, Ctrl+middle zooms (right drag orbits). + "alias" => Some(if ctrl { NavMode::Zoom } else { NavMode::Pan }), + // Tinkercad: middle pans. + "tinkercad" => Some(NavMode::Pan), // Fusion: middle pans, Shift+middle orbits. _ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }), } @@ -198,141 +354,315 @@ fn save_graphics(app: &SolveApp) { } 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 sections = ["General", "Navigation", "Display", "Sketch", "Selection"]; + let mut button: Option<&str> = None; let before = app.preferences.clone(); crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide) .id(egui::Id::new("sc_prefs")) .open(&mut open) - .default_size([560.0, 360.0]) + .collapsible(false) + .resizable(false) .show(ctx, |ui| { - ui.horizontal_top(|ui| { + // A fixed page area: the vertical separator fills the height it is given, so an + // auto-sized one would grow every frame. + ui.allocate_ui_with_layout(egui::vec2(700.0, 400.0), egui::Layout::left_to_right(egui::Align::Min), |ui| { + ui.set_min_size(egui::vec2(700.0, 400.0)); ui.vertical(|ui| { - ui.set_width(120.0); - for (i, s) in sections.iter().enumerate() { + ui.set_width(140.0); + for (i, s) in PAGES.iter().enumerate() { if ui.selectable_label(app.prefs_window.section == i, *s).clicked() { app.prefs_window.section = i; + app.keymap.read_only = false; } } }); ui.separator(); ui.vertical(|ui| { - egui::Grid::new("sc_prefs_grid").num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section { - 0 => { - ui.label("Theme"); - ui.horizontal(|ui| { - ui.radio_value(&mut app.ui.dark, true, "Dark"); - ui.radio_value(&mut app.ui.dark, false, "Light"); - }); - ui.end_row(); - ui.label("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("Autosave every"); - if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() { - set_autosave(app); - } - ui.end_row(); - ui.label(""); - ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim)); - ui.end_row(); - } - 1 => { - ui.label("Mouse"); - egui::ComboBox::from_id_salt("pref_nav") - .selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1)) - .show_ui(ui, |ui| { - for (id, label) in NAV_PRESETS { - if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() { - app.preferences.zoom_reverse = id == "solidworks"; - } - } - }); - ui.end_row(); - ui.label(""); - let how = match app.preferences.nav.as_str() { - "solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms", - "inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms", - _ => "Middle drag pans, Shift+middle orbits", - }; - ui.label(RichText::new(format!("{how}; right drag orbits; the wheel zooms at the cursor")).size(11.0).color(t.text_dim)); - ui.end_row(); - ui.label("Reverse zoom direction"); - ui.checkbox(&mut app.preferences.zoom_reverse, ""); - ui.end_row(); - ui.label("Orbit around"); - ui.horizontal(|ui| { - ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre"); - ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor"); - }); - ui.end_row(); - } - 2 => { - ui.label("Grid"); - ui.checkbox(&mut app.ui.show_grid, ""); - ui.end_row(); - ui.label("Perspective view"); - ui.checkbox(&mut app.ui.perspective, ""); - ui.end_row(); - ui.label("Visual style"); - let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]; - egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui( - ui, - |ui| { - for (i, s) in styles.iter().enumerate() { - ui.selectable_value(&mut app.ui.visual_style, i as u8, *s); - } - }, - ); - ui.end_row(); - ui.label("Length decimals"); - ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6)); - ui.end_row(); - ui.label("Angle decimals"); - ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6)); - ui.end_row(); - ui.label("Anti-aliasing"); - egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| { - for m in MSAA { - ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×")); - } - }); - ui.end_row(); - ui.label(""); - ui.label(RichText::new("Anti-aliasing applies the next time SolveCraft starts").size(11.0).color(t.text_dim)); - ui.end_row(); - } - 3 => { - ui.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("Click selects whole bodies"); - ui.checkbox(&mut app.ui.pick_bodies, ""); - ui.end_row(); - } - }); + // A fixed width, so the window keeps its size from page to page. + ui.set_width(540.0); + ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0)); + ui.add_space(6.0); + page(app, ui, &t); + }); + }); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() { + button = Some("defaults"); + } + crate::frame::right_aligned(ui, 700.0, 170.0, |ui| { + if ui.button("OK").clicked() { + button = Some("ok"); + } + if ui.button("Cancel").clicked() { + button = Some("cancel"); + } + if ui.button("Apply").clicked() { + button = Some("apply"); + } }); }); }); if app.preferences.msaa != before.msaa { save_graphics(app); } - if !open { - app.prefs_window.open = false; + 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) { + app.animate_view("home"); +} + +/// 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(""); + ui.label(RichText::new(s).size(11.0).color(t.text_dim)); + ui.end_row(); + }; + if app.prefs_window.section == PAGE_SHORTCUTS { + crate::keymap::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("Theme"); + ui.horizontal(|ui| { + for (id, label) in THEMES { + if ui.radio(app.preferences.theme == id, label).clicked() { + app.preferences.theme = id.to_string(); + if id != "system" { + app.ui.dark = id == "dark"; + } + } + } + }); + ui.end_row(); + ui.label("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))); + ui.end_row(); + ui.label("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("Autosave every"); + if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() { + set_autosave(app); + } + ui.end_row(); + note(ui, "0 turns it off"); + ui.label("Length decimals"); + ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6)); + ui.end_row(); + ui.label("Angle decimals"); + ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6)); + ui.end_row(); + ui.label("Agent cursor"); + let s = &mut app.ui.agent_cursor; + ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)"); + ui.end_row(); + ui.label("Agent cursor speed"); + ui.add_enabled_ui(s.show, |ui| { + ui.horizontal(|ui| { + for sp in crate::agent_cursor::Speed::ALL { + ui.selectable_value(&mut s.speed, sp, sp.label()); + } + }); + }); + ui.end_row(); + ui.label(""); + ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, "Follow the camera (Look At sketches)")); + ui.end_row(); + } + 1 => { + ui.label("Pan, zoom, orbit"); + egui::ComboBox::from_id_salt("pref_nav") + .selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1)) + .show_ui(ui, |ui| { + for (id, label) in NAV_PRESETS { + if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() { + app.preferences.zoom_reverse = id == "solidworks"; + } + } + }); + ui.end_row(); + note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav))); + ui.label("Reverse zoom direction"); + ui.checkbox(&mut app.preferences.zoom_reverse, ""); + ui.end_row(); + ui.label("Zoom to the cursor"); + ui.checkbox(&mut app.preferences.zoom_to_cursor, "") + .on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)"); + ui.end_row(); + ui.label("Orbit around"); + ui.horizontal(|ui| { + ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre"); + ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor"); + }); + ui.end_row(); + ui.label("Animate view changes"); + ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit"); + ui.end_row(); + ui.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(); + } + PAGE_DESIGN => { + ui.label("Default modelling orientation"); + ui.horizontal(|ui| { + ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), "Z up"); + ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up"); + }); + ui.end_row(); + note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged."); + ui.label("Offer to stand up Y-up files"); + ui.checkbox(&mut app.preferences.offer_stand_up, "") + .on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X"); + ui.end_row(); + } + 4 => { + ui.label("Visual style"); + let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]; + egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(ui, |ui| { + for (i, s) in styles.iter().enumerate() { + ui.selectable_value(&mut app.ui.visual_style, i as u8, *s); + } + }); + ui.end_row(); + ui.label("Grid"); + ui.checkbox(&mut app.ui.show_grid, ""); + ui.end_row(); + ui.label("Ground shadow"); + ui.checkbox(&mut app.ui.ground_shadow, ""); + ui.end_row(); + ui.label("Perspective view"); + ui.checkbox(&mut app.ui.perspective, ""); + ui.end_row(); + ui.label("Anti-aliasing"); + egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| { + for m in MSAA { + ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×")); + } + }); + ui.end_row(); + note(ui, "Anti-aliasing applies the next time SolveCraft starts"); + ui.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(); + } + 5 => { + ui.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("Click selects whole bodies"); + ui.checkbox(&mut app.ui.pick_bodies, ""); + ui.end_row(); + } + }); +} + +/// What the mouse does under a preset. +pub fn nav_help(preset: &str) -> &'static str { + match preset { + "solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits", + "inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits", + "alias" => "Alt+right or right drag orbits, middle drag pans, Ctrl+middle zooms", + "tinkercad" => "Right drag orbits, middle or Shift+right drag pans", + _ => "Middle drag pans, Shift+middle orbits, right drag orbits", + } +} + +/// 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) } /// Set preferences from JSON (control channel); returns them all. @@ -351,13 +681,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result Result<(), String> { return Err(format!("start page open: got {}, want {v}", ui["home"])); } } + "prefs" => { + // {key: value…}: those entries of `ui.prefs` (preferences, `dialog`, `dark`…). + let got = h.call("ui.prefs", json!({}))["result"].clone(); + for (pk, pv) in v.as_object().into_iter().flatten() { + if got[pk.as_str()] != *pv { + return Err(format!("preference {pk}: got {}, want {pv}", got[pk.as_str()])); + } + } + } "quit_requested" | "quitting" => { if ui[k] != *v { return Err(format!("{k}: got {}, want {v}", ui[k])); diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs new file mode 100644 index 00000000..edf5b417 --- /dev/null +++ b/crates/ui-egui/src/stand_up.rs @@ -0,0 +1,72 @@ +//! Standing up a Y-up file (#3). Many CAD programs save parts with Y up, so a STEP or IGES file +//! from them opens lying on its back in a Z-up design. After such a file opens, SolveCraft +//! offers to stand it up: a quarter turn about X (Y → Z) of the bodies it brought, as one +//! Move feature that can be undone or edited. Preferences › Design › "Offer to stand up Y-up +//! files" turns the offer off; with Y up as the default orientation it isn't needed. + +use serde_json::json; + +use crate::SolveApp; + +/// The bodies a just-opened file brought (`before`: body names before it opened, none for a +/// new design): offer to stand them up. +pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) { + let io = &solvecraft_engine::io::is_step_path; + if !(io(path) || solvecraft_engine::io::is_iges_path(path)) || !app.preferences.offer_stand_up || app.preferences.up_axis != "z" { + return; + } + let bodies: Vec = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|n| !before.contains(n)).collect(); + if !bodies.is_empty() { + app.stand_up = Some(bodies); + } +} + +/// Turn the offered bodies a quarter turn about X (Y up becomes Z up). +pub fn stand_up(app: &mut SolveApp) -> Result<(), String> { + let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) }; + app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })).map_err(|e| e.to_string())?; + app.animate_view("home"); + Ok(()) +} + +/// The offer, under the toolbar. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + if app.stand_up.is_none() { + return; + } + let mut act: Option<&str> = None; + crate::frame::window(ctx, "Stand this part up?", crate::frame::Width::Normal) + .id(egui::Id::new("sc_stand_up")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 140.0)) + .show(ctx, |ui| { + ui.label("Files from many CAD programs have Y up, so they open lying on their back here (Z up)."); + ui.label("Stand it up: a quarter turn about X, as one Move feature you can undo."); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Stand it up").clicked() { + act = Some("stand"); + } + if ui.button("Leave it").clicked() { + act = Some("leave"); + } + if ui.button("Don't ask again").on_hover_text("Preferences › Design turns the offer back on").clicked() { + act = Some("never"); + } + }); + }); + match act { + Some("stand") => { + if let Err(e) = stand_up(app) { + app.set_status(e, true); + } + } + Some("leave") => app.stand_up = None, + Some("never") => { + app.stand_up = None; + app.preferences.offer_stand_up = false; + } + _ => {} + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index bf097c6a..6031f422 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -85,6 +85,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" }; if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) { app.ui.dark = !app.ui.dark; + app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into(); } if click(ui, "help", "Help") { app.help.menu = !app.help.menu; @@ -108,7 +109,18 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { } // Document tabs. let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 }; - crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 60.0); + crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 90.0); + // Preferences, at the top right like Fusion's profile menu (also Ctrl+,). + let gear = Rect::from_center_size(pos2(r.right() - captions - 70.0, r.center().y), vec2(24.0, 24.0)); + let gresp = ui.interact(gear, ui.id().with("sc_prefs_button"), Sense::click()); + if gresp.hovered() { + ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30)); + } + icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar); + crate::scenario::publish_handle("titlebar:preferences", gear.center()); + if gresp.on_hover_text(if cfg!(target_os = "macos") { "Preferences (Cmd+,)" } else { "Preferences (Ctrl+,)" }).clicked() { + crate::prefs::open_at(app, 0); + } ui.painter().text( pos2(r.right() - 12.0 - captions, r.center().y), Align2::RIGHT_CENTER, @@ -436,6 +448,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { Key::Y => drop(app.run("edit.redo", json!({}))), Key::S => save(app), Key::N => crate::documents::new_design(app), + Key::Comma => crate::prefs::open_at(app, 0), Key::O => { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { app.open_path(&p); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 909bb1eb..9ee756f2 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1292,7 +1292,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let h = rect.height().max(1.0) as f64; if inside && scroll != 0.0 { let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); - let anchor = hover.map(|p| { + // Preferences › Navigation › Zoom to the cursor (off: toward the view's centre). + let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| { let proj = projection(app, rect); // Toward the model's surface under the cursor, else the target's depth. if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) { diff --git a/crates/ui-egui/tests/scenarios/s45_preferences.json b/crates/ui-egui/tests/scenarios/s45_preferences.json new file mode 100644 index 00000000..133cf7ef --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s45_preferences.json @@ -0,0 +1,23 @@ +[ + {"note": "#36: one Preferences dialog, from the gear at the top right (and Ctrl+,). Changes apply at once; Cancel puts them back, OK keeps them."}, + {"click": {"handle": "titlebar:preferences"}}, + {"expect": {"shown": "Restore Defaults", "prefs": {"dialog": "General", "theme": "dark", "dark": true}}}, + {"widget": "Light"}, + {"expect": {"prefs": {"theme": "light", "dark": false}}}, + {"widget": "Cancel"}, + {"expect": {"not_shown": "Restore Defaults", "prefs": {"dialog": null, "theme": "dark", "dark": true}}}, + {"note": "Ctrl+, opens it again; the shortcuts page is in the same dialog."}, + {"key": "Comma", "cmd": true}, + {"expect": {"prefs": {"dialog": "General"}}}, + {"widget": "Keyboard Shortcuts"}, + {"expect": {"shown": "Reset All", "prefs": {"dialog": "Keyboard Shortcuts"}}}, + {"widget": "Navigation"}, + {"widget": "Fusion"}, + {"widget": "Tinkercad"}, + {"expect": {"prefs": {"nav": "tinkercad"}}}, + {"widget": "Design"}, + {"widget": "Y up"}, + {"expect": {"prefs": {"up_axis": "y", "dialog": "Design"}}}, + {"widget": "OK"}, + {"expect": {"not_shown": "Restore Defaults", "prefs": {"up_axis": "y", "dialog": null}}} +] diff --git a/crates/ui-egui/tests/window_widths.rs b/crates/ui-egui/tests/window_widths.rs index b4a80d4c..0174cc1d 100644 --- a/crates/ui-egui/tests/window_widths.rs +++ b/crates/ui-egui/tests/window_widths.rs @@ -85,7 +85,8 @@ fn floating_windows_stay_within_limits() { solvecraft_ui_egui::params_dialog::open(&mut h.app); h.frames(6); let ws = window_widths(&h); - assert!(ws.len() >= 4, "windows on screen: {ws:?}"); + // Keyboard Shortcuts is a page of Preferences (one window, #36). + assert!(ws.len() >= 3, "windows on screen: {ws:?}"); for (layer, w) in ws { assert!(w <= WINDOW_WIDE_MAX_W + 4.0, "{layer} is {w} px wide"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b81021eb..32eef94b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text | -| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all | +| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.agent_cursor` | `show` (bool), `speed`: `instant`, `normal`, `slow`, `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |