//! 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"` | //! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling | //! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"`, `"ko"` or `"sv"` | //! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings | //! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in | //! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) | //! | `SAVEFILEPATH` | the folder automatic saves go to | //! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu | //! | `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 | //! | `selectionColor` | the colour selected objects are highlighted in: `"#rrggbb"`, or `null` for the theme's | //! | `snapMarkerSize` | object snap marker size in points, 5–31 | //! //! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state //! rather than in the profile. //! //! 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::animation::AnimationPref; 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; /// The longest SAVEFILEPATH the JSON form accepts. const MAX_PATH_CHARS: usize = 1024; /// Object snap marker sizes the Options dialog offers, in points. pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive = 5.0..=31.0; /// 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, /// The selection highlight colour; `None` keeps the theme's. pub selection_color: Option, /// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu /// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu. pub time_sensitive_right_click: bool, } impl Default for UiOptions { fn default() -> Self { UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER, selection_color: None, time_sensitive_right_click: true, } } } 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, selection: self.selection_color, } } } /// `"#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, animations: Option, language: Option, save_format: Option, system_cursor: Option, history_lines: Option, model_background: Option>, paper_background: Option>, selection_color: Option>, snap_marker_size: Option, save_version: Option, savetime: Option, savefilepath: Option, time_sensitive_right_click: 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")?), "animations" => c.animations = Some(AnimationPref::parse(text()?).ok_or("animations must be system, on or off")?), "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)?), "selectionColor" => c.selection_color = 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); } "saveVersion" => { let name = v.as_str().map_or_else(|| v.to_string(), str::to_string); c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?); } "timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?), k if k.eq_ignore_ascii_case("SAVETIME") => { let max = cadcraft_engine::autosave::MAX_SAVETIME; let n = v .as_f64() .filter(|n| n.is_finite() && (0.0..=f64::from(max)).contains(n)) .ok_or_else(|| format!("SAVETIME must be from 0 to {max} minutes"))?; c.savetime = Some(n.round() as u32); } k if k.eq_ignore_ascii_case("SAVEFILEPATH") => { let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control)); c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string()); } _ => 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("animations".into(), json!(app.ui.animations.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("selectionColor".into(), color_json(o.selection_color)); m.insert("snapMarkerSize".into(), json!(o.snap_marker_size)); m.insert("saveVersion".into(), json!(app.ui.save_version)); m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime())); m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath())); m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click)); 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(a) = ui.animations { app.ui.animations = a; } 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(c) = ui.selection_color { app.options.selection_color = c; } if let Some(n) = ui.snap_marker_size { app.options.snap_marker_size = n; } if let Some(v) = ui.save_version { app.ui.save_version = v.year().to_string(); } if let Some(m) = ui.savetime { cadcraft_engine::autosave::set_savetime(m); } if let Some(p) = &ui.savefilepath { cadcraft_engine::autosave::set_savefilepath(p); } if let Some(b) = ui.time_sensitive_right_click { app.options.time_sensitive_right_click = b; } 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.animations = d.animations; app.ui.save_format = d.save_format; app.ui.save_version = d.save_version; 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), "selectionColor": color_json(self.options.selection_color), "snapMarkerSize": self.options.snap_marker_size, "timeSensitiveRightClick": self.options.time_sensitive_right_click, }) .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", "selectionColor", "snapMarkerSize", "timeSensitiveRightClick"] { 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(sel) = c.selection_color { self.options.selection_color = sel; } if let Some(n) = c.snap_marker_size { self.options.snap_marker_size = n; } if let Some(b) = c.time_sensitive_right_click { self.options.time_sensitive_right_click = b; } } } #[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:?}"); } }