diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 4b714ce..3d1f4a2 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -41,6 +41,7 @@ mod web { } /// Preferences (interface theme and language) survive reloads; egui state is not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); } fn persist_egui_memory(&self) -> bool { @@ -112,6 +113,9 @@ mod web { let mut app = CadApp::new(session, services()); app.system_languages = web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default(); + if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) { + app.load_profile(&profile); + } if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 965c5f4..6d89892 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -81,6 +81,7 @@ impl eframe::App for App { } /// Preferences (the interface theme) survive restarts; window and egui state are not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json()); storage.set_string(cadcraft_ui_egui::autosave::PREFS_KEY, cadcraft_ui_egui::autosave::prefs_json()); } @@ -262,6 +263,10 @@ fn main() -> eframe::Result { app.set_status(error); } app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect()); + // The user profile (Options): drafting settings, selection sizes, drawing-area colours. + if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) { + app.load_profile(&profile); + } if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index be1c690..118abfc 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -43,7 +43,9 @@ impl NativeMenu { ("Show All", PredefinedMenuItem::show_all(Some(i18n::tr(language, "Show All")))), ("Quit CADCraft", PredefinedMenuItem::quit(Some(i18n::tr(language, "Quit CADCraft")))), ]; - let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator()]); + // The Mac place for preferences: CADCraft ▸ Settings… (⌘,) opens the Options dialog. + let settings = MenuItem::with_id("cc-settings", i18n::tr(language, "Settings..."), true, accel("Cmd+Comma")); + let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator()]); for (label, item) in &predefined { let _ = app_menu.append(item); if ["Services", "Show All"].contains(label) { @@ -52,6 +54,7 @@ impl NativeMenu { } items.insert("cc-about".into(), ("ui.dialog.about".into(), "About CADCraft".into(), about)); items.insert("cc-discord".into(), ("ui.discord".into(), "Join the ArtCraft Discord…".into(), discord)); + items.insert("cc-settings".into(), ("options".into(), "Settings...".into(), settings)); let _ = menu.append(&app_menu); for (title, entries) in menus::tree(app) { let sub = Submenu::new(i18n::tr(language, &title), true); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9efc88d..9746f9a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -42,6 +42,7 @@ mod view; pub mod curves; pub mod helpers; pub mod machines; +pub mod options; use cadcraft_doc::Handle; use cadcraft_geom::Vec2; @@ -189,6 +190,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(props::specs()); v.extend(inquiry::specs()); v.extend(settings::specs()); + v.extend(options::specs()); v.extend(utility::specs()); v.extend(table::specs()); v.extend(tolerance::specs()); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs new file mode 100644 index 0000000..10d585b --- /dev/null +++ b/crates/engine/src/cmd/options.rs @@ -0,0 +1,182 @@ +//! OPTIONS: the user-profile settings (the system variables a user keeps across sessions and +//! drawings, as AutoCAD keeps them in the user profile rather than in the drawing). +//! +//! The JSON form reads and changes them without a dialog: `{}` returns every profile variable by +//! name, `{"PICKBOX": 5, "OSMODE": 7}` sets those (all or nothing), `{"reset": true}` restores the +//! defaults first. The desktop app opens its Options dialog when OPTIONS is typed or chosen from +//! the menu, and saves [`profile`] between runs ([`restore`] reads it back). + +use serde_json::{Map, Value, json}; + +use super::*; +use crate::{Result, Session, Settings}; + +/// The profile variables, in the order [`profile`] lists them. +pub const PROFILE_VARS: &[&str] = &[ + "OSMODE", + "AUTOSNAP", + "POLARMODE", + "POLARANG", + "POLARADDANG", + "ORTHOMODE", + "SNAPMODE", + "SNAPUNIT", + "GRIDMODE", + "GRIDUNIT", + "GRIDMAJOR", + "DYNMODE", + "DYNPIFORMAT", + "DYNPICOORDS", + "DYNPROMPT", + "DYNDIGRIP", + "DYNDIVIS", + "OSNAPHATCH", + "LWDISPLAY", + "QPMODE", + "PICKBOX", + "APERTURE", + "GRIPSIZE", + "CURSORSIZE", + "PICKFIRST", + "PICKADD", + "MAXARRAY", +]; + +/// The most profile entries [`restore`] reads (a saved profile is untrusted input). +const MAX_ENTRIES: usize = 256; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("options", "Options...", run_options) + .menu(&["Tools", "Options..."]) + .alias(&["op", "preferences"]) + .params("{reset?: bool, OSMODE?, PICKBOX?, APERTURE?, GRIPSIZE?, CURSORSIZE?, PICKFIRST?, PICKADD?, …: any profile variable}") + .enabled(always) + .noundo(), + ] +} + +/// Whether `name` is a profile variable (any case). +pub fn is_profile_var(name: &str) -> bool { + PROFILE_VARS.iter().any(|v| v.eq_ignore_ascii_case(name.trim())) +} + +/// Every profile variable and its value, by name. +pub fn profile(s: &Session) -> Value { + let mut m = Map::new(); + for name in PROFILE_VARS { + if let Some(v) = crate::sysvars::get(s, name) { + m.insert((*name).to_string(), v); + } + } + Value::Object(m) +} + +/// Set the profile variables named in `values` (an object; other keys are refused). All or +/// nothing: one bad name or value leaves every setting as it was. +pub fn apply(s: &mut Session, values: &Value) -> Result<()> { + let Some(obj) = values.as_object() else { return Err(bad("options", "expected an object of profile variables")) }; + let before = s.settings.clone(); + for (name, v) in obj { + if !is_profile_var(name) { + s.settings = before; + return Err(bad("options", format!("{name} is not a profile setting"))); + } + if let Err(e) = crate::sysvars::set(s, name, v) { + s.settings = before; + return Err(bad("options", format!("invalid value for {}: {e}", name.to_ascii_uppercase()))); + } + } + Ok(()) +} + +/// Restore a saved [`profile`]: known variables with valid values are applied, anything else is +/// skipped (an old or hand-edited profile never stops the app from starting). +pub fn restore(s: &mut Session, saved: &Value) { + let Some(obj) = saved.as_object() else { return }; + for (name, v) in obj.iter().take(MAX_ENTRIES) { + if is_profile_var(name) { + let before = s.settings.clone(); + if crate::sysvars::set(s, name, v).is_err() { + s.settings = before; + } + } + } +} + +/// Put every profile variable back to its default. +pub fn reset(s: &mut Session) { + let d = Settings::default(); + let st = &mut s.settings; + st.osmode = d.osmode; + st.polarmode = d.polarmode; + st.otrack = d.otrack; + st.polar_flags = d.polar_flags; + st.polarang = d.polarang; + st.polaraddang = d.polaraddang; + st.orthomode = d.orthomode; + st.snapmode = d.snapmode; + st.snapunit = d.snapunit; + st.gridmode = d.gridmode; + st.gridunit = d.gridunit; + st.gridmajor = d.gridmajor; + st.dynmode = d.dynmode; + st.dynpi_cartesian = d.dynpi_cartesian; + st.dynpi_absolute = d.dynpi_absolute; + st.osnaphatch = d.osnaphatch; + st.lwdisplay = d.lwdisplay; + st.qpmode = d.qpmode; + st.pickbox = d.pickbox; + st.aperture = d.aperture; + st.gripsize = d.gripsize; + st.cursorsize = d.cursorsize; + st.pickfirst = d.pickfirst; + st.pickadd = d.pickadd; + st.maxarray = d.maxarray; +} + +fn run_options(s: &mut Session, p: &Value) -> Result { + // Typed without the desktop app's dialog (the CLI, scripts): report how to change them. + if p.is_null() { + let mut v = profile(s); + if let Some(m) = v.as_object_mut() { + m.insert("message".into(), json!("Options: change profile settings with SETVAR, or OPTIONS {\"NAME\": value}")); + } + return Ok(v); + } + let Some(obj) = p.as_object() else { return Err(bad("options", "expected an object")) }; + let reset_first = match obj.get("reset") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad("options", "`reset` must be true or false")), + }; + let values: Map = obj.iter().filter(|(k, _)| k.as_str() != "reset").map(|(k, v)| (k.clone(), v.clone())).collect(); + let before = s.settings.clone(); + if reset_first { + reset(s); + } + if let Err(e) = apply(s, &Value::Object(values)) { + s.settings = before; + return Err(e); + } + s.touch(); + Ok(profile(s)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_profile_var_round_trips_through_restore() { + let mut a = Session::new(); + apply(&mut a, &json!({"PICKBOX": 7, "OSMODE": 3, "AUTOSNAP": 37, "POLARANG": 30, "SNAPUNIT": [2, 3], "DYNPIFORMAT": 1})).unwrap(); + let saved = profile(&a); + for name in PROFILE_VARS { + assert!(saved.get(*name).is_some(), "{name} is reported"); + } + let mut b = Session::new(); + restore(&mut b, &saved); + assert_eq!(profile(&b), saved); + } +} diff --git a/crates/engine/tests/options_profile.rs b/crates/engine/tests/options_profile.rs new file mode 100644 index 0000000..2fab656 --- /dev/null +++ b/crates/engine/tests/options_profile.rs @@ -0,0 +1,61 @@ +//! OPTIONS: the JSON form reads and sets the user-profile variables without a dialog. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::options::{PROFILE_VARS, profile, restore}; +use serde_json::json; + +#[test] +fn options_json_reports_sets_and_resets_the_profile() { + let mut s = Session::new(); + let all = s.execute("options", &json!({})).unwrap(); + for name in PROFILE_VARS { + assert!(all.get(*name).is_some(), "{name} is reported"); + } + assert_eq!(all["PICKBOX"], json!(3.0)); + + // Names in any case; the reply is the whole profile. + let r = s.execute("options", &json!({"pickbox": 6, "GRIPSIZE": 9, "PICKFIRST": 0, "CursorSize": 25})).unwrap(); + assert_eq!( + (r["PICKBOX"].clone(), r["GRIPSIZE"].clone(), r["PICKFIRST"].clone(), r["CURSORSIZE"].clone()), + (json!(6.0), json!(9.0), json!(0), json!(25.0)) + ); + assert_eq!(s.execute("getvar", &json!({"name": "PICKBOX"})).unwrap()["value"], json!(6.0)); + + // All or nothing: a bad name or value changes nothing. + assert!(s.execute("options", &json!({"PICKBOX": 2, "LUNITS": 4})).is_err(), "drawing variables are not profile settings"); + assert!(s.execute("options", &json!({"APERTURE": 20, "SNAPUNIT": "wide"})).is_err()); + assert!(s.execute("options", &json!({"reset": "yes"})).is_err()); + assert!(s.execute("options", &json!([1, 2])).is_err()); + let now = s.execute("options", &json!({})).unwrap(); + assert_eq!((now["PICKBOX"].clone(), now["APERTURE"].clone()), (json!(6.0), json!(10.0))); + + // Reset, then apply in the same call. + let r = s.execute("options", &json!({"reset": true, "APERTURE": 15})).unwrap(); + assert_eq!((r["PICKBOX"].clone(), r["GRIPSIZE"].clone(), r["APERTURE"].clone()), (json!(3.0), json!(5.0), json!(15.0))); +} + +#[test] +fn saved_profile_restores_and_hostile_entries_are_skipped() { + let mut a = Session::new(); + a.execute("options", &json!({"OSMODE": 515, "ORTHOMODE": 1, "POLARANG": 15, "GRIDUNIT": [2, 2], "DYNMODE": 0, "QPMODE": 1})).unwrap(); + let saved = profile(&a); + let mut b = Session::new(); + restore(&mut b, &saved); + assert_eq!(profile(&b), saved); + + // Unknown names, drawing variables, bad values and non-objects are ignored, never panic. + let mut c = Session::new(); + restore(&mut c, &json!({"PICKBOX": "huge", "LUNITS": 4, "NOPE": 1, "POLARANG": -5, "SNAPUNIT": ["x"], "GRIPSIZE": 8})); + restore(&mut c, &json!("not an object")); + restore(&mut c, &json!(null)); + let p = profile(&c); + assert_eq!((p["PICKBOX"].clone(), p["GRIPSIZE"].clone()), (json!(3.0), json!(8.0))); + assert_ne!(c.execute("getvar", &json!({"name": "LUNITS"})).ok().map(|v| v["value"].clone()), Some(json!(4))); +} + +#[test] +fn typed_options_without_a_dialog_reports_how_to_change_settings() { + let mut s = Session::new(); + s.cmdline("OP").unwrap(); + assert!(s.log.iter().any(|l| l.contains("SETVAR")), "{:?}", s.log); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9b1bd6c..601f8cd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -386,7 +386,7 @@ fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { let t = Tokens::get(); - let s = 5.5; + let s = crate::theme::snap_marker_half(); let st = Stroke::new(2.0, t.snap); use snap::mode; match hit.mode { @@ -834,14 +834,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let b = xf_paper.to_screen(Vec2::new(sh.size.x, 0.0)); let paper = Rect::from_two_pos(a, b); painter.rect_filled(paper.translate(vec2(5.0, 5.0)), 0.0, Color32::from_black_alpha(110)); - painter.rect_filled(paper, 0.0, Color32::WHITE); + painter.rect_filled(paper, 0.0, t.paper); let pa = Rect::from_two_pos( xf_paper.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)), xf_paper.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)), ); let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()]; painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); - Rgb(255, 255, 255) + Rgb(t.paper.r(), t.paper.g(), t.paper.b()) } None => { draw_grid(app, &painter, &xf); @@ -1368,7 +1368,6 @@ fn viewport_label(p: &egui::Painter, rect: Rect) { } fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { - let t = Tokens::get(); let l = crate::viewcube::Layout::new(rect); let (c, ring) = (l.center, l.ring); let p = ui.painter_at(rect); @@ -1376,7 +1375,7 @@ fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { // WCS pill. let pill = Rect::from_center_size(c + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)); p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); - p.text(pill.center(), egui::Align2::CENTER_CENTER, crate::tl!("WCS ⌄"), crate::theme::small(), t.canvas_ink); + p.text(pill.center(), egui::Align2::CENTER_CENTER, crate::tl!("WCS ⌄"), crate::theme::small(), Tokens::DARK.canvas_ink); } /// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f6a2dba..ed425e6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -25,6 +25,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "commands" => commands(app, ctx, &mut open), "blocks" => blocks(app, ctx, &mut open), "stylesmanager" => crate::plotstyles::dialog(app, ctx, &mut open), + o if o == "options" || o.starts_with("options:") => crate::options::dialog(app, ctx, o, &mut open), other => crate::managers::dialog(app, ctx, other, &mut open), } if !open { diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index c125a45..ea17018 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -22,6 +22,15 @@ pub enum Preference { impl Preference { pub const ALL: [Self; 3] = [Self::Auto, Self::English, Self::Ukrainian]; + /// The codes a preference can be given as, for messages ("auto, en or uk"). + pub fn codes() -> String { + let codes: Vec<&str> = Self::ALL.iter().map(|p| p.code()).collect(); + match codes.split_last() { + Some((last, rest)) if !rest.is_empty() => format!("{} or {last}", rest.join(", ")), + _ => codes.join(""), + } + } + pub fn code(self) -> &'static str { match self { Self::Auto => "auto", diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2b8c19e..231d30e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -879,6 +879,42 @@ Specify included angle Укажіть кут між сторонами Specify insertion base point Укажіть базову точку вставлення Specify insertion point Укажіть точку вставлення +# Options dialog (OPTIONS). + A drawing opened from a file keeps that file's format. Креслення, відкрите з файлу, зберігає формат цього файлу. + An array that would create more objects than this is refused. Масив, що створив би більше об'єктів, буде відхилено. + CADCraft keeps these preferences, object snap modes and drafting toggles for your user account and restores them when it starts. CADCraft зберігає ці параметри, режими об'єктної прив'язки та перемикачі креслення для вашого облікового запису й відновлює їх під час запуску. + Changes apply at once and are kept for next time. Зміни застосовуються одразу й зберігаються на наступний раз. + Command line history Історія командного рядка + Copy as JSON Копіювати як JSON + Crosshair size Розмір перехрестя + Display Відображення + Grip size Розмір ручок + Hold Shift to add to the selection Утримувати Shift, щоб додати до вибору + Interface language Мова інтерфейсу + Interface theme Тема інтерфейсу + Keep the system cursor visible over the drawing area Показувати системний курсор над областю креслення + Largest array Найбільший масив + Layout paper Папір аркуша + Model background Тло простору моделі + Object snap aperture Апертура об'єктної прив'язки + Object snap marker size Розмір маркера об'єктної прив'язки + Open and Save Відкриття та збереження + Options Параметри + Options... Параметри... + Pickbox size Розмір прицілу вибору + Profiles Профілі + Reset keeps the interface language. Скидання не змінює мову інтерфейсу. + Reset to Defaults Скинути до типових + Save new drawings as Зберігати нові креслення як + Screen magnifiers, screen readers and recorders follow the system cursor. Екранні лупи, програми читання з екрана та запису стежать за системним курсором. + Select objects first, then choose a command Спочатку вибирати об'єкти, потім команду + Selection Вибір + Settings... Налаштування... + Show Quick Properties for selected objects Показувати швидкі властивості вибраних об'єктів + Snap, grid, polar tracking and Dynamic Input: Крок, сітка, полярне відстеження та динамічне введення: + The aperture is how near the cursor must come to a point for an object snap to find it. Апертура — це наскільки близько курсор має підійти до точки, щоб об'єктна прив'язка її знайшла. + User Preferences Налаштування користувача + objects об'єктів Specify inside diameter of donut Укажіть внутрішній діаметр кільця Specify leader arrowhead location Укажіть розташування стрілки виноски Specify leader endpoint Укажіть кінцеву точку виноски diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb64c20..bb6d2f8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ pub mod icons; pub mod layers; pub mod managers; pub mod menus; +pub mod options; pub mod palettes; pub mod parametric; pub mod plotstyles; @@ -172,6 +173,8 @@ pub struct CadApp { pub system_languages: Vec, pub session: Session, pub ui: UiState, + /// The interface's own preferences from the Options dialog ([`options`]). + pub options: options::UiOptions, pub services: Services, pub canvas: canvas::CanvasState, pub cmd: cmdline::CmdLine, @@ -209,6 +212,7 @@ impl CadApp { system_languages: Vec::new(), session, ui: UiState::default(), + options: options::UiOptions::default(), services, canvas: canvas::CanvasState::default(), cmd: cmdline::CmdLine::default(), @@ -394,6 +398,7 @@ impl CadApp { // browser report changes as input); the saved choice itself never changes here. let shown = self.ui.theme.resolve(ctx.system_theme()); theme::set_active(shown); + theme::set_canvas(self.options.canvas()); let follows_system = self.ui.theme == theme::ThemePref::System; if self.shown_theme != Some(shown) || self.shown_follows_system != follows_system { theme::apply(ctx, shown, follows_system); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fd9499f..fc54e92 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Option { let Some(preference) = params.get("lang").and_then(Value::as_str).and_then(crate::i18n::Preference::parse) else { - return Some(Err("language must be auto, en or uk".into())); + return Some(Err(format!("language must be {}", crate::i18n::Preference::codes()))); }; app.ui.interface_language = preference; Ok(json!({"interfaceLanguage": preference.code()})) diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs new file mode 100644 index 0000000..7f45a99 --- /dev/null +++ b/crates/ui-egui/src/options/dialog.rs @@ -0,0 +1,302 @@ +//! The Options dialog. It reads the engine's settings and the interface state, and changes them +//! only through the OPTIONS JSON form (`app.run("options", …)`), so the dialog, scripts and the +//! control channel share one path. Changes apply at once. +//! +//! The open tab is part of the dialog name (`"options:selection"`), so `ui.set` can show any tab. + +use egui::{Color32, RichText, Sense, Stroke, vec2}; +use serde_json::{Map, Value, json}; + +use crate::CadApp; +use crate::theme::{ThemePref, Tokens}; + +/// The tabs: (id in the dialog name, label). +pub(super) const TABS: [(&str, &str); 6] = [ + ("display", "Display"), + ("files", "Open and Save"), + ("drafting", "Drafting"), + ("selection", "Selection"), + ("preferences", "User Preferences"), + ("profiles", "Profiles"), +]; + +const WIDTH: f32 = 580.0; +const MIN_TAB_HEIGHT: f32 = 200.0; + +/// Show the Options dialog (`name` is `"options"` or `"options:"`). +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool) { + let tab = name.strip_prefix("options:").and_then(|t| TABS.iter().find(|(id, _)| *id == t)).map_or("display", |(id, _)| *id); + let mut changes = Map::new(); + let mut next_tab = None; + let mut start = None; + let mut close = false; + let center = ctx.content_rect().center(); + egui::Window::new(crate::tl!("Options")) + .id(egui::Id::new("cc_options")) + .open(open) + .resizable(false) + .collapsible(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(center) + .show(ctx, |ui| { + ui.set_width(WIDTH); + ui.horizontal_wrapped(|ui| { + for (id, label) in TABS { + if ui.selectable_label(tab == id, crate::i18n::t(label)).clicked() { + next_tab = Some(id); + } + } + }); + ui.separator(); + ui.add_space(4.0); + let top = ui.cursor().top(); + match tab { + "files" => files(app, ui, &mut changes), + "drafting" => drafting(app, ui, &mut changes, &mut start), + "selection" => selection(app, ui, &mut changes), + "preferences" => preferences(app, ui, &mut changes), + "profiles" => profiles(app, ui, &mut changes), + _ => display(app, ui, &mut changes), + } + // The same height on every tab, so switching tabs doesn't move the buttons. + ui.add_space((MIN_TAB_HEIGHT - (ui.cursor().top() - top)).max(8.0)); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(crate::tl!("Close")).clicked() { + close = true; + } + ui.label(RichText::new(crate::tl!("Changes apply at once and are kept for next time.")).small().color(Tokens::get().text_faint)); + }); + }); + }); + if close { + *open = false; + } + if let Some(t) = next_tab { + app.ui.dialog = Some(format!("options:{t}")); + } + if !changes.is_empty() { + let _ = app.run("options", Value::Object(changes)); + } + if let Some(id) = start { + app.start(id); + } +} + +fn grid(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) { + egui::Grid::new(id).num_columns(2).spacing(vec2(14.0, 8.0)).show(ui, add); +} + +fn hint(ui: &mut egui::Ui, text: &str) { + ui.label(RichText::new(text).small().color(Tokens::get().text_dim)); +} + +fn theme_label(t: ThemePref) -> &'static str { + match t { + ThemePref::System => crate::tl!("Use System Setting"), + ThemePref::Light => crate::tl!("Light"), + ThemePref::Dark => crate::tl!("Dark"), + } +} + +/// A colour button, its hex value and a Default button; a change goes into `changes` under `key`. +fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map) { + ui.horizontal(|ui| { + let shown = current.unwrap_or(default); + let mut rgb = [shown.r(), shown.g(), shown.b()]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + changes.insert(key.into(), json!(super::hex(Color32::from_rgb(rgb[0], rgb[1], rgb[2])))); + } + ui.monospace(super::hex(shown)); + if ui.add_enabled(current.is_some(), egui::Button::new(crate::tl!("Default"))).clicked() { + changes.insert(key.into(), Value::Null); + } + }); +} + +/// A square of `side` points in `color` on the drawing-area background (marker and pickbox +/// previews). +fn preview_square(ui: &mut egui::Ui, side: f32, color: Color32) { + let t = Tokens::get(); + let (rect, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, 3.0, t.canvas); + let side = side.clamp(1.0, 32.0); + p.rect_stroke(egui::Rect::from_center_size(rect.center(), vec2(side, side)), 0.0, Stroke::new(2.0, color), egui::StrokeKind::Middle); +} + +fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { + grid(ui, "options_display", |ui| { + ui.label(crate::tl!("Interface theme")); + let mut theme = app.ui.theme; + egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { + for t in ThemePref::ALL { + ui.selectable_value(&mut theme, t, theme_label(t)); + } + }); + if theme != app.ui.theme { + changes.insert("theme".into(), json!(theme.as_str())); + } + ui.end_row(); + + ui.label(crate::tl!("Model background")); + color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes); + ui.end_row(); + + ui.label(crate::tl!("Layout paper")); + color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes); + ui.end_row(); + + ui.label(crate::tl!("Crosshair size")); + let mut size = app.session.settings.cursorsize; + if ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().suffix(" %")).changed() { + changes.insert("CURSORSIZE".into(), json!(size)); + } + ui.end_row(); + + ui.label(crate::tl!("Command line history")); + let mut lines = app.ui.history_lines; + if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).changed() { + changes.insert("historyLines".into(), json!(lines)); + } + ui.end_row(); + }); + ui.add_space(6.0); + let mut system_cursor = app.ui.system_cursor; + if ui.checkbox(&mut system_cursor, crate::tl!("Keep the system cursor visible over the drawing area")).changed() { + changes.insert("systemCursor".into(), json!(system_cursor)); + } + hint(ui, crate::tl!("Screen magnifiers, screen readers and recorders follow the system cursor.")); +} + +fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { + grid(ui, "options_files", |ui| { + ui.label(crate::tl!("Save new drawings as")); + ui.horizontal(|ui| { + for f in crate::SaveFormat::ALL { + let label = match f { + crate::SaveFormat::Dxf => crate::tl!("DXF"), + crate::SaveFormat::Dwg => crate::tl!("DWG"), + }; + if ui.radio(app.ui.save_format == f, label).clicked() && app.ui.save_format != f { + changes.insert("saveFormat".into(), json!(f.as_str())); + } + } + }); + ui.end_row(); + }); + hint(ui, crate::tl!("A drawing opened from a file keeps that file's format.")); +} + +fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { + let t = Tokens::get(); + grid(ui, "options_drafting", |ui| { + ui.label(crate::tl!("Object snap marker size")); + ui.horizontal(|ui| { + let mut size = app.options.snap_marker_size; + if ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer()).changed() { + changes.insert("snapMarkerSize".into(), json!(size)); + } + preview_square(ui, size, t.snap); + }); + ui.end_row(); + + ui.label(crate::tl!("Object snap aperture")); + ui.horizontal(|ui| { + let mut aperture = app.session.settings.aperture; + if ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().suffix(" px")).changed() { + changes.insert("APERTURE".into(), json!(aperture)); + } + preview_square(ui, aperture as f32, t.crosshair); + }); + ui.end_row(); + }); + hint(ui, crate::tl!("The aperture is how near the cursor must come to a point for an object snap to find it.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(crate::tl!("Snap, grid, polar tracking and Dynamic Input:")); + if ui.button(crate::tl!("Drafting Settings...")).clicked() { + *start = Some("dsettings"); + } + }); +} + +fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { + let t = Tokens::get(); + let st = &app.session.settings; + grid(ui, "options_selection", |ui| { + ui.label(crate::tl!("Pickbox size")); + ui.horizontal(|ui| { + let mut pickbox = st.pickbox; + if ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().suffix(" px")).changed() { + changes.insert("PICKBOX".into(), json!(pickbox)); + } + preview_square(ui, (pickbox * 3.0) as f32, t.crosshair); + }); + ui.end_row(); + + ui.label(crate::tl!("Grip size")); + ui.horizontal(|ui| { + let mut grip = st.gripsize; + if ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().suffix(" px")).changed() { + changes.insert("GRIPSIZE".into(), json!(grip)); + } + preview_square(ui, (grip * 2.0) as f32, t.grip); + }); + ui.end_row(); + }); + ui.add_space(6.0); + let mut pickfirst = st.pickfirst; + if ui.checkbox(&mut pickfirst, crate::tl!("Select objects first, then choose a command")).changed() { + changes.insert("PICKFIRST".into(), json!(i32::from(pickfirst))); + } + let mut shift_adds = !st.pickadd; + if ui.checkbox(&mut shift_adds, crate::tl!("Hold Shift to add to the selection")).changed() { + changes.insert("PICKADD".into(), json!(i32::from(!shift_adds))); + } + let mut qp = st.qpmode; + if ui.checkbox(&mut qp, crate::tl!("Show Quick Properties for selected objects")).changed() { + changes.insert("QPMODE".into(), json!(i32::from(qp))); + } +} + +fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { + grid(ui, "options_preferences", |ui| { + ui.label(crate::tl!("Interface language")); + let mut lang = app.ui.interface_language; + egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { + for p in crate::i18n::Preference::ALL { + ui.selectable_value(&mut lang, p, p.name()); + } + }); + if lang != app.ui.interface_language { + changes.insert("interfaceLanguage".into(), json!(lang.code())); + } + ui.end_row(); + + ui.label(crate::tl!("Largest array")); + let mut max = app.session.settings.maxarray; + if ui.add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))).changed() { + changes.insert("MAXARRAY".into(), json!(max)); + } + ui.end_row(); + }); + hint(ui, crate::tl!("An array that would create more objects than this is refused.")); +} + +fn profiles(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { + ui.label(crate::tl!( + "CADCraft keeps these preferences, object snap modes and drafting toggles for your user account and restores them when it starts." + )); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button(crate::tl!("Reset to Defaults")).clicked() { + changes.insert("reset".into(), json!(true)); + } + if ui.button(crate::tl!("Copy as JSON")).clicked() { + ui.ctx().copy_text(serde_json::to_string_pretty(&super::report(app)).unwrap_or_default()); + } + }); + hint(ui, crate::tl!("Reset keeps the interface language.")); +} diff --git a/crates/ui-egui/src/options/mod.rs b/crates/ui-egui/src/options/mod.rs new file mode 100644 index 0000000..d164344 --- /dev/null +++ b/crates/ui-egui/src/options/mod.rs @@ -0,0 +1,300 @@ +//! OPTIONS: the user's preferences, in one dialog and one JSON form. +//! +//! Typed (OPTIONS, OP), chosen from Tools ▸ Options… or, on macOS, CADCraft ▸ Settings… (⌘,), +//! OPTIONS opens the Options dialog ([`dialog`]). A JSON call never opens it: `options {}` reports +//! every preference and `options {"PICKBOX": 5, "modelBackground": "#ffffff"}` changes some (all +//! or nothing; `"reset": true` restores the defaults first). Upper-case names are the engine's +//! profile variables (`cadcraft_engine::cmd::options`); camel-case names are the interface's own: +//! +//! | name | value | +//! |---|---| +//! | `theme` | `"system"`, `"light"` or `"dark"` | +//! | `interfaceLanguage` | `"auto"`, `"en"` or `"uk"` | +//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings | +//! | `systemCursor` | keep the system cursor visible over the drawing area | +//! | `historyLines` | command-line history lines, 0–12 | +//! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default | +//! | `snapMarkerSize` | object snap marker size in points, 5–31 | +//! +//! The profile variables and the interface preferences survive restarts: the host saves +//! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with +//! [`CadApp::load_profile`]. + +mod dialog; + +pub use dialog::dialog; + +use egui::Color32; +use serde_json::{Map, Value, json}; + +use crate::CadApp; +use crate::theme::{self, ThemePref}; + +/// The host storage key for [`CadApp::profile_json`]. +pub const PROFILE_KEY: &str = "cadcraft.profile"; + +/// The largest saved profile [`CadApp::load_profile`] reads. +const MAX_PROFILE_BYTES: usize = 64 * 1024; + +/// Object snap marker sizes the Options dialog offers, in points. +pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive = 5.0..=31.0; + +/// The interface's own preferences (the drafting ones are engine settings). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct UiOptions { + /// Model-space background; `None` keeps the theme's. + pub model_background: Option, + /// The sheet colour on layout tabs; `None` is white. + pub paper_background: Option, + /// Object snap marker size in points. + pub snap_marker_size: f32, +} + +impl Default for UiOptions { + fn default() -> Self { + UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER } + } +} + +impl UiOptions { + /// What the drawing area paints with. + pub fn canvas(&self) -> theme::Canvas { + theme::Canvas { model: self.model_background, paper: self.paper_background, snap_marker: self.snap_marker_size } + } +} + +/// `"#rrggbb"`. +pub fn hex(c: Color32) -> String { + format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()) +} + +/// Parse `"#rrggbb"` (the `#` is optional). +pub fn parse_hex(s: &str) -> Option { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.is_ascii() { + return None; + } + let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); + Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?)) +} + +fn color_json(c: Option) -> Value { + c.map_or(Value::Null, |c| json!(hex(c))) +} + +/// Open the dialog for a typed or menu-invoked OPTIONS; run the JSON form for a call with +/// parameters. `None` if `id` isn't OPTIONS. +pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option> { + if !matches!(id, "options" | "op" | "preferences") { + return None; + } + if params.is_null() { + app.ui.dialog = Some("options".into()); + return Some(Ok(Value::Null)); + } + Some(run_json(app, params)) +} + +/// The interface preferences a JSON call may set, parsed before anything changes. +#[derive(Default)] +struct UiChanges { + theme: Option, + language: Option, + save_format: Option, + system_cursor: Option, + history_lines: Option, + model_background: Option>, + paper_background: Option>, + snap_marker_size: Option, +} + +fn color_param(key: &str, v: &Value) -> Result, String> { + match v { + Value::Null => Ok(None), + Value::String(s) => parse_hex(s).map(Some).ok_or_else(|| format!("{key} must be \"#rrggbb\" or null")), + _ => Err(format!("{key} must be \"#rrggbb\" or null")), + } +} + +fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result { + let text = || v.as_str().ok_or_else(|| format!("{key} must be text")); + match key { + "theme" => c.theme = Some(ThemePref::parse(text()?).ok_or("theme must be system, light or dark")?), + "interfaceLanguage" => { + c.language = Some( + crate::i18n::Preference::parse(text()?).ok_or_else(|| format!("interfaceLanguage must be {}", crate::i18n::Preference::codes()))?, + ) + } + "saveFormat" => c.save_format = Some(crate::SaveFormat::parse(text()?).ok_or("saveFormat must be dxf or dwg")?), + "systemCursor" => c.system_cursor = Some(v.as_bool().ok_or("systemCursor must be true or false")?), + "historyLines" => { + let n = v.as_u64().filter(|n| *n <= crate::cmdline::MAX_LINES as u64).ok_or("historyLines must be a whole number from 0 to 12")?; + c.history_lines = Some(n as usize); + } + "modelBackground" => c.model_background = Some(color_param(key, v)?), + "paperBackground" => c.paper_background = Some(color_param(key, v)?), + "snapMarkerSize" => { + let n = v.as_f64().filter(|n| SNAP_MARKER_SIZES.contains(&(*n as f32)) && n.is_finite()).ok_or("snapMarkerSize must be from 5 to 31")?; + c.snap_marker_size = Some(n as f32); + } + _ => return Ok(false), + } + Ok(true) +} + +/// The interface preferences as the JSON form reports them. +fn ui_json(app: &CadApp) -> Map { + let o = &app.options; + let mut m = Map::new(); + m.insert("theme".into(), json!(app.ui.theme.as_str())); + m.insert("interfaceLanguage".into(), json!(app.ui.interface_language.code())); + m.insert("saveFormat".into(), json!(app.ui.save_format.as_str())); + m.insert("systemCursor".into(), json!(app.ui.system_cursor)); + m.insert("historyLines".into(), json!(app.ui.history_lines)); + m.insert("modelBackground".into(), color_json(o.model_background)); + m.insert("paperBackground".into(), color_json(o.paper_background)); + m.insert("snapMarkerSize".into(), json!(o.snap_marker_size)); + m +} + +/// Every preference: the interface's and the engine's profile variables. +pub fn report(app: &CadApp) -> Value { + let mut m = ui_json(app); + if let Value::Object(engine) = cadcraft_engine::cmd::options::profile(&app.session) { + m.extend(engine); + } + Value::Object(m) +} + +/// The OPTIONS JSON form: validate everything, then change it. +fn run_json(app: &mut CadApp, params: &Value) -> Result { + let Some(obj) = params.as_object() else { return Err("options: expected an object".into()) }; + let mut ui = UiChanges::default(); + let mut engine = Map::new(); + for (k, v) in obj { + if !parse_ui(k, v, &mut ui)? { + engine.insert(k.clone(), v.clone()); + } + } + let reset = engine.get("reset").and_then(Value::as_bool) == Some(true); + // The engine checks its part (and refuses unknown names) before anything here changes. + app.session.execute("options", &Value::Object(engine)).map_err(|e| e.to_string())?; + if reset { + reset_ui(app); + } + if let Some(t) = ui.theme { + app.ui.theme = t; + } + if let Some(l) = ui.language { + app.ui.interface_language = l; + } + if let Some(f) = ui.save_format { + app.ui.save_format = f; + } + if let Some(b) = ui.system_cursor { + app.ui.system_cursor = b; + } + if let Some(n) = ui.history_lines { + app.ui.history_lines = n; + } + if let Some(c) = ui.model_background { + app.options.model_background = c; + } + if let Some(c) = ui.paper_background { + app.options.paper_background = c; + } + if let Some(n) = ui.snap_marker_size { + app.options.snap_marker_size = n; + } + Ok(report(app)) +} + +/// The interface preferences back to their defaults (the interface language stays). +fn reset_ui(app: &mut CadApp) { + let d = crate::UiState::default(); + app.ui.theme = d.theme; + app.ui.save_format = d.save_format; + app.ui.system_cursor = d.system_cursor; + app.ui.history_lines = d.history_lines; + app.options = UiOptions::default(); +} + +impl CadApp { + /// The user profile kept across restarts, as JSON for the host's storage ([`PROFILE_KEY`]): + /// the engine's profile variables and the interface preferences the interface prefs + /// ([`CadApp::prefs_json`]) and the desktop workspace file (panels, command line) do not + /// already keep. + pub fn profile_json(&self) -> String { + json!({ + "version": 1, + "sysvars": cadcraft_engine::cmd::options::profile(&self.session), + "systemCursor": self.ui.system_cursor, + "modelBackground": color_json(self.options.model_background), + "paperBackground": color_json(self.options.paper_background), + "snapMarkerSize": self.options.snap_marker_size, + }) + .to_string() + } + + /// Restore a profile saved by [`Self::profile_json`]. Unknown, malformed or out-of-range + /// entries are skipped; a profile from a newer version still restores what it can. + pub fn load_profile(&mut self, json: &str) { + if json.len() > MAX_PROFILE_BYTES { + return; + } + let Ok(v) = serde_json::from_str::(json) else { return }; + if let Some(vars) = v.get("sysvars") { + cadcraft_engine::cmd::options::restore(&mut self.session, vars); + } + let mut c = UiChanges::default(); + for key in ["systemCursor", "modelBackground", "paperBackground", "snapMarkerSize"] { + if let Some(value) = v.get(key) { + let _ = parse_ui(key, value, &mut c); + } + } + // CADCRAFT_SYSTEM_CURSOR=1 turns the system cursor on whatever was saved. + if c.system_cursor == Some(true) { + self.ui.system_cursor = true; + } + if let Some(bg) = c.model_background { + self.options.model_background = bg; + } + if let Some(bg) = c.paper_background { + self.options.paper_background = bg; + } + if let Some(n) = c.snap_marker_size { + self.options.snap_marker_size = n; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_colours_round_trip_and_bad_ones_are_refused() { + let c = Color32::from_rgb(0x12, 0xab, 0xff); + assert_eq!(parse_hex(&hex(c)), Some(c)); + assert_eq!(parse_hex("FFFFFF"), Some(Color32::WHITE)); + for bad in ["", "#fff", "#gggggg", "#12345678", "#ééé", "#1234567"] { + assert_eq!(parse_hex(bad), None, "{bad}"); + } + } + + #[test] + fn dialog_labels_have_ukrainian_translations() { + let sources = [include_str!("dialog.rs")]; + let mut missing: Vec = dialog::TABS.iter().filter(|(_, l)| crate::i18n::tr("uk", l) == *l).map(|(_, l)| l.to_string()).collect(); + for source in sources { + for rest in source.split("crate::tl!(").skip(1) { + let Some(text) = rest.strip_prefix('"').and_then(|r| r.split('"').next()) else { continue }; + // File format names read the same in every language. + if crate::i18n::tr("uk", text) == text && !["DXF", "DWG"].contains(&text) { + missing.push(text.to_string()); + } + } + } + assert!(missing.is_empty(), "missing translations: {missing:?}"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 00c541c..88b6775 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -118,6 +118,8 @@ pub struct Tokens { pub warn: Color32, /// The drawing-area crosshair cursor and its pick box. pub crosshair: Color32, + /// The sheet of paper on a layout tab. + pub paper: Color32, } impl Tokens { @@ -163,6 +165,7 @@ impl Tokens { list_row: Color32::from_rgb(0x3a, 0x42, 0x50), warn: Color32::from_rgb(0xff, 0xd0, 0x80), crosshair: Color32::from_rgb(0xe8, 0xe8, 0xe8), + paper: Color32::WHITE, }; /// Light grey chrome with dark text. The drawing-area colours (canvas, grid, axes, selection, @@ -204,10 +207,81 @@ impl Tokens { } } - /// The palette of the theme in use. + /// The palette of the theme in use, with the user's drawing-area colours ([`set_canvas`]). pub fn get() -> Tokens { - Tokens::of(active()) + CANVAS.with(Cell::get).over(Tokens::of(active())) } + + /// These tokens with `bg` as the model-space background: the grid, axes, crosshair and the + /// lines drawn on the drawing area are recomputed to stay visible on it. + pub fn with_model_background(self, bg: Color32) -> Tokens { + let light = luminance(bg) > 0.5; + let ink = if light { Color32::BLACK } else { Color32::WHITE }; + Tokens { + canvas: bg, + grid_minor: mix(bg, ink, 0.05), + grid_major: mix(bg, ink, 0.11), + axis_x: mix(bg, Color32::from_rgb(0xd0, 0x30, 0x30), 0.3), + axis_y: mix(bg, Color32::from_rgb(0x30, 0xb0, 0x40), 0.3), + canvas_ink: if light { Color32::from_rgb(0x47, 0x50, 0x5c) } else { Tokens::DARK.canvas_ink }, + crosshair: if light { Color32::from_rgb(0x20, 0x20, 0x20) } else { Tokens::DARK.crosshair }, + ..self + } + } +} + +/// The user's drawing-area preferences (Options ▸ Display and Drafting); `None` keeps the theme's +/// colour. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Canvas { + pub model: Option, + pub paper: Option, + /// The side of an object snap marker, in points. + pub snap_marker: f32, +} + +/// The default object snap marker size. +pub const SNAP_MARKER: f32 = 11.0; + +impl Default for Canvas { + fn default() -> Self { + Canvas { model: None, paper: None, snap_marker: SNAP_MARKER } + } +} + +impl Canvas { + fn over(self, t: Tokens) -> Tokens { + let t = match self.model { + Some(bg) => t.with_model_background(bg), + None => t, + }; + Tokens { paper: self.paper.unwrap_or(t.paper), ..t } + } +} + +thread_local! { + /// The drawing-area preferences in use on the UI thread (set by [`set_canvas`] each frame). + static CANVAS: Cell = Cell::new(Canvas::default()); +} + +pub fn set_canvas(c: Canvas) { + CANVAS.with(|v| v.set(c)); +} + +/// Half the side of an object snap marker, in points. +pub fn snap_marker_half() -> f32 { + CANVAS.with(Cell::get).snap_marker / 2.0 +} + +/// Relative luminance of `c` (0 black … 1 white). +pub fn luminance(c: Color32) -> f32 { + (0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b())) / 255.0 +} + +/// `a` moved the fraction `f` towards `b`. +fn mix(a: Color32, b: Color32, f: f32) -> Color32 { + let m = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * f).round().clamp(0.0, 255.0) as u8; + Color32::from_rgb(m(a.r(), b.r()), m(a.g(), b.g()), m(a.b(), b.b())) } pub fn small() -> FontId { diff --git a/crates/ui-egui/tests/options_dialog.rs b/crates/ui-egui/tests/options_dialog.rs new file mode 100644 index 0000000..dc10b65 --- /dev/null +++ b/crates/ui-egui/tests/options_dialog.rs @@ -0,0 +1,190 @@ +//! OPTIONS: the dialog opens only when typed or chosen from the menu; the JSON form reads and sets +//! the preferences; the user profile survives a restart (`profile_json` → `load_profile`). + +use cadcraft_engine::Session; +use cadcraft_ui_egui::theme::Tokens; +use cadcraft_ui_egui::{CadApp, Services, menus, options}; +use egui::Color32; +use serde_json::{Value, json}; + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +fn painted_text(shapes: &[egui::epaint::ClippedShape]) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(s) => { + text.push_str(s.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let mut text = String::new(); + shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + text +} + +/// Two frames (windows fade in), returning the painted text of the last. +fn render(app: &mut CadApp, ctx: &egui::Context) -> String { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + let mut text = String::new(); + for _ in 0..2 { + app.logic(ctx); + let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui)); + text = painted_text(&output.shapes); + output.textures_delta.clear(); + } + text +} + +#[test] +fn typed_and_menu_open_the_dialog_json_never_does() { + let mut a = app(); + menus::activate(&mut a, "options"); + assert_eq!(a.ui.dialog.as_deref(), Some("options")); + for typed in ["OPTIONS", "op", "Preferences"] { + a.ui.dialog = None; + a.cmdline(typed); + assert_eq!(a.ui.dialog.as_deref(), Some("options"), "{typed}"); + } + a.ui.dialog = None; + let all = a.run("options", json!({})).unwrap(); + assert!(a.ui.dialog.is_none(), "JSON calls never open dialogs"); + for key in ["theme", "saveFormat", "systemCursor", "modelBackground", "paperBackground", "snapMarkerSize", "OSMODE", "PICKBOX", "CURSORSIZE"] { + assert!(all.get(key).is_some(), "{key} is reported"); + } + a.cmdline(r#"options {"PICKBOX": 4}"#); + assert!(a.ui.dialog.is_none()); + assert_eq!(a.session.settings.pickbox, 4.0); + // Tools ▸ Options… is in the menu. + let tools = menus::tree(&a).into_iter().find(|(m, _)| m == "Tools").map(|(_, e)| e).unwrap_or_default(); + assert!(tools.iter().any(|e| matches!(e, menus::Entry::Item { id, .. } if id == "options"))); +} + +#[test] +fn json_form_sets_interface_and_engine_preferences_all_or_nothing() { + let mut a = app(); + let r = a + .run( + "options", + json!({"theme": "light", "modelBackground": "#FFFFFF", "snapMarkerSize": 15, "systemCursor": true, "GRIPSIZE": 8, "PICKADD": 0}), + ) + .unwrap(); + assert_eq!(r["theme"], "light"); + assert_eq!(r["modelBackground"], "#ffffff"); + assert_eq!(r["snapMarkerSize"], json!(15.0)); + assert_eq!(r["GRIPSIZE"], json!(8.0)); + assert_eq!(r["PICKADD"], json!(0)); + assert!(a.ui.system_cursor); + + // A bad interface value or engine name changes nothing. + let before = a.run("options", json!({})).unwrap(); + for bad in [ + json!({"theme": "sepia", "PICKBOX": 9}), + json!({"modelBackground": "#12345", "PICKBOX": 9}), + json!({"modelBackground": 7}), + json!({"snapMarkerSize": 400}), + json!({"snapMarkerSize": f64::NAN}), + json!({"historyLines": 99}), + json!({"saveFormat": "dxf", "LUNITS": 4}), + json!({"systemCursor": false, "PICKBOX": "big"}), + json!("options"), + ] { + assert!(a.run("options", bad.clone()).is_err(), "{bad}"); + assert_eq!(a.run("options", json!({})).unwrap(), before, "{bad} changed nothing"); + } + + // null puts a colour back to the default; reset restores everything but the language. + a.run("options", json!({"modelBackground": null, "interfaceLanguage": "uk"})).unwrap(); + assert_eq!(a.options.model_background, None); + let r = a.run("options", json!({"reset": true})).unwrap(); + assert_eq!((r["theme"].clone(), r["GRIPSIZE"].clone(), r["snapMarkerSize"].clone()), (json!("dark"), json!(5.0), json!(11.0))); + assert_eq!(r["interfaceLanguage"], "uk"); +} + +#[test] +fn profile_survives_a_restart_and_keeps_the_prefs_format() { + let mut a = app(); + let prefs = a.prefs_json(); + a.run( + "options", + json!({"OSMODE": 7, "ORTHOMODE": 1, "AUTOSNAP": 37, "POLARANG": 45, "SNAPMODE": 1, "SNAPUNIT": [2, 2], "GRIDUNIT": [5, 5], + "DYNMODE": 0, "DYNPIFORMAT": 1, "DYNPICOORDS": 1, "PICKBOX": 6, "APERTURE": 14, "GRIPSIZE": 9, "CURSORSIZE": 100, + "PICKFIRST": 0, "PICKADD": 0, "QPMODE": 1, "systemCursor": true, "modelBackground": "#000000", + "paperBackground": "#f0f0e8", "snapMarkerSize": 21}), + ) + .unwrap(); + assert_eq!(a.prefs_json(), prefs, "the interface prefs keep their own key and format"); + let saved = a.profile_json(); + + let mut b = app(); + b.load_profile(&saved); + let (ra, rb) = (a.run("options", json!({})).unwrap(), b.run("options", json!({})).unwrap()); + assert_eq!(ra, rb); + assert_eq!(rb["POLARANG"], json!(45.0)); + assert!(!b.session.settings.polarmode, "AUTOSNAP 37 is polar tracking off"); + assert!(b.ui.system_cursor); +} + +#[test] +fn hostile_saved_profiles_are_ignored() { + let defaults = app().run("options", json!({})).unwrap(); + for bad in [ + "".to_string(), + "not json".into(), + "[]".into(), + r#"{"sysvars": "PICKBOX"}"#.into(), + r#"{"sysvars": {"PICKBOX": "x", "LUNITS": 4, "FONTALT": "evil.ttf"}, "modelBackground": "red", "snapMarkerSize": -1}"#.into(), + format!(r#"{{"sysvars": {{"PICKBOX": 5}}, "pad": "{}"}}"#, "x".repeat(70_000)), + ] { + let mut b = app(); + b.load_profile(&bad); + assert_eq!(b.run("options", json!({})).unwrap(), defaults, "{}", bad.get(..60).unwrap_or(&bad)); + } +} + +#[test] +fn model_background_reaches_the_drawing_area_tokens() { + let mut a = app(); + let ctx = egui::Context::default(); + a.logic(&ctx); + assert_eq!(Tokens::get().canvas, Tokens::DARK.canvas); + a.run("options", json!({"modelBackground": "#ffffff", "paperBackground": "#202020"})).unwrap(); + a.logic(&ctx); + let t = Tokens::get(); + assert_eq!(t.canvas, Color32::WHITE); + assert_eq!(t.paper, Color32::from_gray(0x20)); + assert!(cadcraft_ui_egui::theme::luminance(t.crosshair) < 0.5, "a dark crosshair on a white background"); + assert_ne!(t.grid_minor, Tokens::DARK.grid_minor); + assert_eq!(t.chrome, Tokens::DARK.chrome, "the interface keeps its theme"); + a.run("options", json!({"reset": true})).unwrap(); + a.logic(&ctx); + assert_eq!(Tokens::get(), Tokens::DARK); +} + +#[test] +fn every_tab_renders() { + let mut a = app(); + let ctx = egui::Context::default(); + for (tab, label) in [ + ("display", "Crosshair size"), + ("files", "Save new drawings as"), + ("drafting", "Object snap aperture"), + ("selection", "Pickbox size"), + ("preferences", "Interface language"), + ("profiles", "Reset to Defaults"), + ] { + a.ui.dialog = Some(format!("options:{tab}")); + let text = render(&mut a, &ctx); + assert!(text.contains(label), "{tab}: {text}"); + assert!(text.contains("User Preferences"), "the tab row shows"); + } + // Unknown tabs show the first one. + a.ui.dialog = Some("options:nope".into()); + assert!(render(&mut a, &ctx).contains("Crosshair size")); + assert_eq!(options::PROFILE_KEY, "cadcraft.profile"); + let _: Value = serde_json::from_str(&a.profile_json()).unwrap(); +}