From ebf12780c57f4d8733a96090d03098b7b7c3b92b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 9 Jun 2026 19:50:23 +0300 Subject: [PATCH] feat(settings): persist UI preferences across sessions (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DYN, ORTHO, POLAR, the polar increment, the grid toggle and the object-snap configuration (OSNAP on/off, active snap modes, OTRACK) reset to defaults on every launch. Persist them to a per-user settings.txt, matching the existing recent-files / status-bar stores (plain key=value, no serialization crate). Settings load on boot and apply to live state; the update wrapper snapshots them after each message and writes only on change, so a toggle survives a restart without thrashing the file. LWT is left out on purpose — it is a drawing header variable, not a UI preference. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 13 ++++ src/app/settings.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++ src/app/update.rs | 38 +++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/app/settings.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 27ad15b2f..d1887fe3a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,6 +8,7 @@ mod layers; mod mtext_editor; mod model_ops; mod properties; +mod settings; mod text_inline; mod update; mod view; @@ -194,6 +195,9 @@ pub(super) struct OpenCADStudio { cycle_candidates: Option<(iced::Point, Vec)>, /// Which status-bar pills the user has chosen to show (persisted). statusbar_config: crate::ui::statusbar_config::StatusBarConfig, + /// Last persisted user preferences (DYN/OSNAP/OTRACK/POLAR/…). Compared + /// after each message so a change is written to disk exactly once. + last_saved_settings: Option, /// Whether Tangent snap was enabled before a tangent-pick command started. pre_cmd_tangent: Option, /// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°. @@ -1266,6 +1270,7 @@ impl OpenCADStudio { isolate_popup_open: false, selection_filter_popup_open: false, statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(), + last_saved_settings: None, clean_screen: false, quick_properties: false, selection_cycling: false, @@ -1471,6 +1476,14 @@ impl OpenCADStudio { ds_dimtolj: "1".to_string(), ds_dimtzin: "0".to_string(), }; + // Restore persisted UI preferences (DYN/OSNAP/OTRACK/POLAR/…) so they + // survive across sessions (issue #68). Seed `last_saved_settings` from + // the resulting state so the first change — not the boot — triggers a + // write. + if let Some(s) = settings::UserSettings::load() { + app.apply_settings(&s); + } + app.last_saved_settings = Some(app.current_settings()); app.sync_ribbon_layers(); app } diff --git a/src/app/settings.rs b/src/app/settings.rs new file mode 100644 index 000000000..aef3fc407 --- /dev/null +++ b/src/app/settings.rs @@ -0,0 +1,184 @@ +//! Persisted user preferences — DYN, ORTHO, POLAR, the polar increment, the +//! grid toggle, and the object-snap configuration (OSNAP on/off, which snap +//! modes are active, OTRACK). These are UI choices, not drawing data, so they +//! live in a per-user config file and survive across sessions. +//! +//! Plain `key=value` text, matching the recent-files / status-bar stores so we +//! don't pull in a serialization crate just for a handful of flags. Drawing +//! header settings such as LWT (lineweight display) are intentionally NOT here +//! — those belong to the file. + +use crate::snap::SnapType; +use std::path::PathBuf; + +/// Canonical order for serializing snap modes, so the written list is stable +/// (a `HashSet` iterates in arbitrary order). +const SNAP_ORDER: &[SnapType] = &[ + SnapType::Endpoint, + SnapType::Midpoint, + SnapType::Center, + SnapType::Node, + SnapType::Quadrant, + SnapType::Intersection, + SnapType::Extension, + SnapType::Insertion, + SnapType::Perpendicular, + SnapType::Tangent, + SnapType::Nearest, + SnapType::ApparentIntersection, + SnapType::Parallel, + SnapType::Grid, +]; + +fn snap_id(s: SnapType) -> &'static str { + match s { + SnapType::Endpoint => "endpoint", + SnapType::Midpoint => "midpoint", + SnapType::Center => "center", + SnapType::Node => "node", + SnapType::Quadrant => "quadrant", + SnapType::Intersection => "intersection", + SnapType::Extension => "extension", + SnapType::Insertion => "insertion", + SnapType::Perpendicular => "perpendicular", + SnapType::Tangent => "tangent", + SnapType::Nearest => "nearest", + SnapType::ApparentIntersection => "apparentintersection", + SnapType::Parallel => "parallel", + SnapType::Grid => "grid", + } +} + +fn snap_from_id(s: &str) -> Option { + SNAP_ORDER.iter().copied().find(|t| snap_id(*t) == s) +} + +/// A snapshot of the persisted preferences. Field defaults mirror the app's +/// in-code defaults so a missing key restores the same value the app boots +/// with. +#[derive(Clone, PartialEq)] +pub struct UserSettings { + pub dyn_input: bool, + pub ortho: bool, + pub polar: bool, + pub polar_increment_deg: f32, + pub show_grid: bool, + pub snap_enabled: bool, + pub otrack: bool, + /// Active snap modes, in `SNAP_ORDER`. + pub snap_modes: Vec, +} + +impl Default for UserSettings { + fn default() -> Self { + Self { + dyn_input: true, + ortho: false, + polar: false, + polar_increment_deg: 45.0, + show_grid: false, + snap_enabled: false, + otrack: false, + snap_modes: vec![ + SnapType::Endpoint, + SnapType::Midpoint, + SnapType::Center, + SnapType::Node, + SnapType::Quadrant, + SnapType::Intersection, + SnapType::Nearest, + ], + } + } +} + +impl UserSettings { + /// Build the active-mode set in canonical order from any iterator of modes. + pub fn modes_from<'a>(modes: impl IntoIterator) -> Vec { + let set: std::collections::HashSet = modes.into_iter().copied().collect(); + SNAP_ORDER.iter().copied().filter(|t| set.contains(t)).collect() + } + + /// Read the saved preferences, or `None` when no settings file exists yet. + /// Unknown / missing keys fall back to [`UserSettings::default`]. + pub fn load() -> Option { + let path = config_path()?; + let body = std::fs::read_to_string(path).ok()?; + let mut s = UserSettings::default(); + for line in body.lines() { + let line = line.trim(); + let Some((key, val)) = line.split_once('=') else { continue }; + let (key, val) = (key.trim(), val.trim()); + match key { + "dyn" => s.dyn_input = val == "1", + "ortho" => s.ortho = val == "1", + "polar" => s.polar = val == "1", + "polar_increment_deg" => { + if let Ok(v) = val.parse::() { + s.polar_increment_deg = v; + } + } + "grid" => s.show_grid = val == "1", + "osnap" => s.snap_enabled = val == "1", + "otrack" => s.otrack = val == "1", + "snap_modes" => { + let modes: Vec = + val.split(',').filter_map(|t| snap_from_id(t.trim())).collect(); + s.snap_modes = UserSettings::modes_from(modes.iter()); + } + _ => {} + } + } + Some(s) + } + + /// Best-effort persist; silent on failure (read-only home, full disk). + pub fn save(&self) { + let Some(path) = config_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let b = |v: bool| if v { "1" } else { "0" }; + let modes = self + .snap_modes + .iter() + .map(|t| snap_id(*t)) + .collect::>() + .join(","); + let body = format!( + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\nsnap_modes={}\n", + b(self.dyn_input), + b(self.ortho), + b(self.polar), + self.polar_increment_deg, + b(self.show_grid), + b(self.snap_enabled), + b(self.otrack), + modes, + ); + let _ = std::fs::write(path, body); + } +} + +/// `/OpenCADStudio/settings.txt`, matching the recent-files store. +fn config_path() -> Option { + let base: PathBuf = if cfg!(target_os = "windows") { + std::env::var_os("APPDATA").map(PathBuf::from)? + } else if cfg!(target_os = "macos") { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push("Library"); + p.push("Application Support"); + p + } else if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") { + PathBuf::from(d) + } else { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push(".config"); + p + }; + let mut p = base; + p.push("OpenCADStudio"); + Some(p.join("settings.txt")) +} diff --git a/src/app/update.rs b/src/app/update.rs index a4f67811e..5adf2ff42 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -58,9 +58,47 @@ impl OpenCADStudio { .as_ref() .map(|c| c.prompt()); self.command_line.set_step_prompt(prompt); + // Persist UI preferences whenever a toggle changes them (issue #68). + self.persist_settings_if_changed(); task } + /// Snapshot the persisted UI preferences from live state. + pub(super) fn current_settings(&self) -> super::settings::UserSettings { + super::settings::UserSettings { + dyn_input: self.dyn_input, + ortho: self.ortho_mode, + polar: self.polar_mode, + polar_increment_deg: self.polar_increment_deg, + show_grid: self.show_grid, + snap_enabled: self.snapper.snap_enabled, + otrack: self.snapper.otrack_enabled, + snap_modes: super::settings::UserSettings::modes_from(self.snapper.enabled.iter()), + } + } + + /// Apply restored preferences to live state. + pub(super) fn apply_settings(&mut self, s: &super::settings::UserSettings) { + self.dyn_input = s.dyn_input; + self.ortho_mode = s.ortho; + self.polar_mode = s.polar; + self.polar_increment_deg = s.polar_increment_deg; + self.show_grid = s.show_grid; + self.snapper.snap_enabled = s.snap_enabled; + self.snapper.otrack_enabled = s.otrack; + self.snapper.enabled = s.snap_modes.iter().copied().collect(); + } + + /// Write preferences to disk only when they differ from the last write, + /// so a toggle persists immediately without thrashing the file. + fn persist_settings_if_changed(&mut self) { + let cur = self.current_settings(); + if self.last_saved_settings.as_ref() != Some(&cur) { + cur.save(); + self.last_saved_settings = Some(cur); + } + } + fn update_inner(&mut self, msg: Message) -> Task { match msg { Message::Tick(t) => {