diff --git a/Cargo.lock b/Cargo.lock index 7fcf59a2..8bd2dad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,7 @@ dependencies = [ "rayon", "rfd", "rustc-hash 2.1.2", + "serde", "serde_json", "truck-meshalgo", "truck-modeling", diff --git a/Cargo.toml b/Cargo.toml index 1de7086c..57e16802 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,10 @@ default = ["solid3d"] solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"] [dependencies] -# JSON: plugin marketplace / Patreon fetch (native) + web supporters.json parse. +# JSON: plugin marketplace / Patreon fetch (native) + web supporters.json parse, +# and the consolidated user config (settings.json) via serde-derived structs. serde_json = "1" +serde = { version = "1", features = ["derive"] } # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Plugin authors target this crate's semver, not OpenCADStudio internals. # The `host` feature (out-of-process plugin runtime: interprocess, libloading, diff --git a/src/app/config.rs b/src/app/config.rs new file mode 100644 index 00000000..31294620 --- /dev/null +++ b/src/app/config.rs @@ -0,0 +1,94 @@ +//! Consolidated user configuration — one grouped JSON file +//! (`/OpenCADStudio/settings.json`) holding every app preference except +//! the command aliases (which stay in the hand-editable `ocad.pgp`). Serialized +//! via serde so the file is structured and grouped, replacing the former +//! scattered flat stores (`settings.txt` / `recent.txt` / `recent_limit.txt` / +//! `statusbar.txt` / `ribbon.txt` / `plot.txt`). + +use serde::{Deserialize, Serialize}; + +use super::settings::UserSettings; +use crate::ui::ribbon::CollapseMode; +use crate::ui::statusbar::statusbar_config::StatusBarConfig; +use crate::ui::window::plot::PlotDialogState; + +/// The whole persisted config, grouped into top-level sections. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AppConfig { + /// Input modes, backup, plugin lists, viewport background colours, … + pub settings: UserSettings, + /// Recent-files list + retained count. + pub recent: RecentConfig, + /// Which status-bar pills the user has hidden. + pub statusbar: StatusBarConfig, + /// Ribbon collapse density. + pub ribbon: RibbonConfig, + /// Print dialog preferences (only the persisted fields; runtime state is + /// skipped by `PlotDialogState`'s serde attributes). + pub plot: PlotDialogState, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + settings: UserSettings::default(), + recent: RecentConfig::default(), + statusbar: StatusBarConfig::default(), + ribbon: RibbonConfig::default(), + plot: PlotDialogState::default(), + } + } +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RecentConfig { + /// Recently opened file paths, newest first. + pub files: Vec, + /// How many recent files to keep. + pub limit: usize, +} + +impl Default for RecentConfig { + fn default() -> Self { + Self { + files: Vec::new(), + limit: super::recent::RECENT_DEFAULT, + } + } +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RibbonConfig { + pub collapse: CollapseMode, +} + +impl AppConfig { + /// Read the saved config, or all-defaults when the file is missing or + /// unreadable (fresh install / wasm). Unknown or missing fields fall back to + /// their section defaults via `#[serde(default)]`. + pub fn load() -> Self { + config_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|body| serde_json::from_str(&body).ok()) + .unwrap_or_default() + } + + /// Persist the config as pretty JSON. Best-effort; silent on failure + /// (read-only home, full disk, wasm — where `config_dir` is `None`). + pub fn save(&self) { + let Some(path) = config_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, json); + } + } +} + +fn config_path() -> Option { + Some(crate::config::config_dir()?.join("settings.json")) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index f38fb04b..c0bd332b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,7 @@ mod alias; #[cfg(not(target_arch = "wasm32"))] mod automation; +mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; mod command_driver; @@ -283,7 +284,7 @@ pub(super) struct OpenCADStudio { statusbar_config: crate::ui::statusbar::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, + last_saved_config: Option, /// Active OTRACK alignment `(tracking_point, unit_direction)` when the /// cursor is on a tracking ray. Lets a typed distance place a point along /// the ray from the tracking point (issue #69). `None` when not aligned. @@ -2086,10 +2087,11 @@ impl OpenCADStudio { active_tab: 0, tab_counter: 0, ribbon: Ribbon::new(), - // Restore recents from disk so the Start page lists them across runs. - recent_files: recent::load_recent_files(), - recent_limit: recent::load_recent_limit(), - recent_limit_input: recent::load_recent_limit().to_string(), + // Populated from the consolidated config after construction + // (`apply_config`); default empty here. + recent_files: Vec::new(), + recent_limit: recent::RECENT_DEFAULT, + recent_limit_input: recent::RECENT_DEFAULT.to_string(), command_line: CommandLine::new(), patrons: Vec::new(), start_section: StartSection::default(), @@ -2109,8 +2111,8 @@ impl OpenCADStudio { units_popup_open: false, isolate_popup_open: false, selection_filter_popup_open: false, - statusbar_config: crate::ui::statusbar::statusbar_config::StatusBarConfig::load(), - last_saved_settings: None, + statusbar_config: crate::ui::statusbar::statusbar_config::StatusBarConfig::default(), + last_saved_config: None, otrack_active: None, clean_screen: false, quick_properties: false, @@ -2204,7 +2206,7 @@ impl OpenCADStudio { plot_window: None, plot_format: crate::io::paper_sizes::PaperSize::A4, plot_orientation: crate::io::paper_sizes::Orientation::Landscape, - plot_dialog: crate::ui::window::plot::PlotDialogState::load(), + plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, opening: None, pending_close: None, @@ -2359,13 +2361,11 @@ 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); - } + // Restore the consolidated user config (settings.json) into live state + // so preferences, recents, status-bar layout, ribbon density and print + // options survive across sessions (issue #68). `last_saved_config` is + // seeded below so the first change — not the boot — triggers a write. + app.apply_config(config::AppConfig::load()); // Load command aliases from ocad.pgp (writes the shipped default file // on first launch). The hide-set keeps aliases out of autocomplete while // their target command still shows. @@ -2384,7 +2384,7 @@ impl OpenCADStudio { app.loaded_plugin_ids = crate::plugin::external::loaded_ids().into_iter().collect(); app.rebuild_ribbon_modules(); } - app.last_saved_settings = Some(app.current_settings()); + app.last_saved_config = Some(app.current_config()); app.sync_ribbon_layers(); app } diff --git a/src/app/recent.rs b/src/app/recent.rs index 6ce3948d..f041f160 100644 --- a/src/app/recent.rs +++ b/src/app/recent.rs @@ -1,7 +1,7 @@ -//! Persistent recent-files store backing the Start page's Recent Documents -//! panel. Stored as plain text on disk (one path per line, newest first) next -//! to the other per-user config, so no serialization crate is pulled in just -//! for this. +//! Recent-files list backing the Start page's Recent Documents panel. The list +//! itself lives in the consolidated app config (`settings.json`, the "recent" +//! section); this module just mutates the in-memory list and persists via +//! `save_config`. use super::OpenCADStudio; use std::path::{Path, PathBuf}; @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; /// Bounds and default for how many recent files are kept. pub(super) const RECENT_MIN: usize = 5; pub(super) const RECENT_MAX: usize = 100; -const RECENT_DEFAULT: usize = 20; +pub(super) const RECENT_DEFAULT: usize = 20; impl OpenCADStudio { /// Record a freshly opened file at the top of the recents list. @@ -17,80 +17,20 @@ impl OpenCADStudio { self.recent_files.retain(|r| r != &path); self.recent_files.insert(0, path); self.recent_files.truncate(self.recent_limit); - // Best-effort persist; silent on failure (read-only home, full disk). - let _ = save_recents(&self.recent_files); + self.save_config(); } /// Drop a path from the recents list (manual removal from the Start page). pub(super) fn remove_recent(&mut self, path: &Path) { self.recent_files.retain(|r| r.as_path() != path); - let _ = save_recents(&self.recent_files); + self.save_config(); } /// Set how many recent files are kept, trim the current list to fit, and - /// persist both the new limit and the trimmed list. + /// persist both. pub(super) fn set_recent_limit(&mut self, limit: usize) { self.recent_limit = limit.clamp(RECENT_MIN, RECENT_MAX); self.recent_files.truncate(self.recent_limit); - save_recent_limit(self.recent_limit); - let _ = save_recents(&self.recent_files); + self.save_config(); } } - -/// Rehydrate the recents list from disk, trimmed to the saved limit. Call once -/// at app boot. -pub(super) fn load_recent_files() -> Vec { - let Some(path) = recents_file_path() else { - return vec![]; - }; - let Ok(body) = std::fs::read_to_string(path) else { - return vec![]; - }; - let mut list: Vec = body - .lines() - .filter(|l| !l.trim().is_empty()) - .map(PathBuf::from) - .collect(); - list.truncate(load_recent_limit()); - list -} - -/// Load the saved recent-file limit (clamped), defaulting to `RECENT_DEFAULT`. -pub(super) fn load_recent_limit() -> usize { - limit_file_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| s.trim().parse::().ok()) - .map(|n| n.clamp(RECENT_MIN, RECENT_MAX)) - .unwrap_or(RECENT_DEFAULT) -} - -fn save_recent_limit(limit: usize) { - let Some(path) = limit_file_path() else { return }; - if let Some(dir) = path.parent() { - let _ = std::fs::create_dir_all(dir); - } - let _ = std::fs::write(path, limit.to_string()); -} - -fn recents_file_path() -> Option { - Some(crate::config::config_dir()?.join("recent.txt")) -} - -fn limit_file_path() -> Option { - Some(crate::config::config_dir()?.join("recent_limit.txt")) -} - -fn save_recents(list: &[PathBuf]) -> std::io::Result<()> { - let Some(path) = recents_file_path() else { - return Ok(()); - }; - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; - } - let body: String = list - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect::>() - .join("\n"); - std::fs::write(path, body) -} diff --git a/src/app/settings.rs b/src/app/settings.rs index 6f61bcc6..2bba40dd 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -1,15 +1,15 @@ -//! 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. +//! Persisted user preferences — DYN, POLAR (+ increment), OTRACK, and assorted +//! app-level flags (backup, autosave, plugin lists, viewport background). These +//! are UI choices, not drawing data, so they live in the consolidated per-user +//! config ([`crate::app::config`], the "settings" section) and survive across +//! sessions. Drawing-scoped state (Ortho `$ORTHOMODE`, running OSNAP `$OSMODE`, +//! lineweight display `$LWDISPLAY`, …) belongs to the file, not here. //! -//! 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. +//! Also home to the `$OSMODE` bit conversions ([`osmode_from_snaps`] / +//! [`snaps_from_osmode`]) that bridge the running-snap set and the drawing header. use crate::snap::SnapType; -use std::path::PathBuf; +use serde::{Deserialize, Serialize}; /// Canonical order of the user-toggleable object-snap modes. Drives the /// deterministic order when decoding the `$OSMODE` bitmask (see @@ -88,29 +88,11 @@ pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { (modes, osmode & OSMODE_SUPPRESS == 0) } -/// Parse a persisted `r,g,b` background triplet (each 0–255). Returns `None` -/// for an empty or malformed value so a missing/garbage key falls back to the -/// app default rather than a wrong colour. -fn parse_rgb(val: &str) -> Option<[u8; 3]> { - let mut it = val.split(',').map(|t| t.trim().parse::()); - let r = it.next()?.ok()?; - let g = it.next()?.ok()?; - let b = it.next()?.ok()?; - if it.next().is_some() { - return None; - } - Some([r, g, b]) -} - -/// Serialize an optional background triplet back to `r,g,b`, or empty when unset. -fn rgb_to_str(c: Option<[u8; 3]>) -> String { - c.map(|[r, g, b]| format!("{r},{g},{b}")).unwrap_or_default() -} - -/// 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)] +/// The "settings" section of the consolidated config ([`crate::app::config`]). +/// Field defaults mirror the app's in-code defaults so a missing key restores +/// the value the app boots with. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct UserSettings { pub dyn_input: bool, pub polar: bool, @@ -172,117 +154,6 @@ impl Default for UserSettings { } } -impl UserSettings { - /// 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", - "polar" => s.polar = val == "1", - "polar_increment_deg" => { - if let Ok(v) = val.parse::() { - s.polar_increment_deg = v; - } - } - "otrack" => s.otrack = val == "1", - "bg_color" => s.bg_color = parse_rgb(val), - "paper_bg_color" => s.paper_bg_color = parse_rgb(val), - "default_assoc_prompted" => s.default_assoc_prompted = val == "1", - "texteditmode" => { - if let Some(v) = - crate::modules::annotate::textedit::parse_texteditmode(val) - { - s.texteditmode = v; - } - } - "backup_on_save" => s.backup_on_save = val == "1", - "textfill" => s.textfill = val == "1", - "file_assoc_enabled" => s.file_assoc_enabled = val == "1", - "savetime_min" => { - if let Ok(v) = val.parse::() { - s.savetime_min = v.max(0); - } - } - "disabled_plugins" => { - s.disabled_plugins = val - .split(',') - .map(|t| t.trim()) - .filter(|t| !t.is_empty()) - .map(|t| t.to_string()) - .collect(); - } - "plugin_repos" => { - s.plugin_repos = val - .split(',') - .map(|t| t.trim()) - .filter(|t| !t.is_empty()) - .map(|t| t.to_string()) - .collect(); - } - _ => {} - } - } - 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 body = format!( - "dyn={}\npolar={}\npolar_increment_deg={}\notrack={}\ndefault_assoc_prompted={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\ntextfill={}\nbackup_on_save={}\nfile_assoc_enabled={}\nsavetime_min={}\nbg_color={}\npaper_bg_color={}\n", - b(self.dyn_input), - b(self.polar), - self.polar_increment_deg, - b(self.otrack), - b(self.default_assoc_prompted), - self.disabled_plugins.join(","), - self.plugin_repos.join(","), - self.texteditmode, - b(self.textfill), - b(self.backup_on_save), - b(self.file_assoc_enabled), - self.savetime_min, - rgb_to_str(self.bg_color), - rgb_to_str(self.paper_bg_color), - ); - 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")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b8ea68ba..d6e06fac 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -261,16 +261,61 @@ impl OpenCADStudio { Task::none() } - /// Write preferences to disk only when they differ from the last write, - /// so a toggle persists immediately without thrashing the file. - pub(in crate::app) 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); + /// Gather the full persisted config (all sections) from live app state. + pub(in crate::app) fn current_config(&self) -> crate::app::config::AppConfig { + crate::app::config::AppConfig { + settings: self.current_settings(), + recent: crate::app::config::RecentConfig { + files: self + .recent_files + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(), + limit: self.recent_limit, + }, + statusbar: self.statusbar_config.clone(), + ribbon: crate::app::config::RibbonConfig { + collapse: self.ribbon.collapse_mode(), + }, + plot: self.plot_dialog.clone(), } } + /// Distribute a loaded config into live app state (called once at startup). + pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) { + self.apply_settings(&cfg.settings); + self.recent_files = cfg + .recent + .files + .iter() + .map(std::path::PathBuf::from) + .collect(); + self.recent_limit = cfg + .recent + .limit + .clamp(crate::app::recent::RECENT_MIN, crate::app::recent::RECENT_MAX); + self.recent_files.truncate(self.recent_limit); + self.recent_limit_input = self.recent_limit.to_string(); + self.statusbar_config = cfg.statusbar; + self.ribbon.set_collapse_mode(cfg.ribbon.collapse); + self.plot_dialog = cfg.plot; + } + + /// Write the config to disk only when it changed since the last write, so a + /// toggle persists immediately without thrashing the file. + pub(in crate::app) fn save_config(&mut self) { + let cur = self.current_config(); + if self.last_saved_config.as_ref() != Some(&cur) { + cur.save(); + self.last_saved_config = Some(cur); + } + } + + /// Back-compat name for the many "a preference changed, persist it" sites. + pub(in crate::app) fn persist_settings_if_changed(&mut self) { + self.save_config(); + } + /// Record that the one-time default-association prompt has been answered and /// flush it to disk, so the dialog never reappears on later launches. pub(in crate::app) fn mark_assoc_prompted(&mut self) { @@ -1774,7 +1819,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// preview PDF, export a PDF, or send the job to the chosen printer. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { // Remember the user's print preferences across sessions. - self.plot_dialog.save(); + self.save_config(); let d = self.plot_dialog.clone(); // Persist the dialog's page settings into the layout, then reuse the // tested layout-plot derivation. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1b7cc851..cf356ab4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -641,6 +641,7 @@ impl OpenCADStudio { Message::SetRibbonCollapseMode(mode) => { self.ribbon.set_collapse_mode(mode); self.ribbon.close_dropdown(); + self.save_config(); Task::none() } @@ -1674,6 +1675,7 @@ impl OpenCADStudio { Message::ToggleStatusPill(pill) => { // Keep the menu open so several pills can be toggled in a row. self.statusbar_config.toggle(pill); + self.save_config(); Task::none() } Message::ToggleCleanScreen => { diff --git a/src/ui/ribbon/collapse.rs b/src/ui/ribbon/collapse.rs index 24d8fecb..129678a1 100644 --- a/src/ui/ribbon/collapse.rs +++ b/src/ui/ribbon/collapse.rs @@ -54,7 +54,9 @@ const MAX_PANEL_SQUEEZE: f32 = 8.0; /// How the ribbon tool panels are sized. `Auto` adapts to the window width (the /// step-by-step degradation); the others pin every panel to one density so the /// user can override the automatic choice. The selection is persisted. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +#[derive( + Clone, Copy, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize, +)] pub enum CollapseMode { /// Size panels to the window: degrade from the right as space runs out. #[default] @@ -86,19 +88,6 @@ impl CollapseMode { } } - /// Stable identifier used for persistence. - fn id(self) -> &'static str { - match self { - CollapseMode::Auto => "auto", - CollapseMode::Full => "full", - CollapseMode::Compact => "compact", - CollapseMode::Collapsed => "collapsed", - } - } - - fn from_id(s: &str) -> Option { - CollapseMode::ALL.iter().copied().find(|m| m.id() == s) - } /// The degradation level every panel is pinned to, or `None` for `Auto`. fn forced_level(self) -> Option { @@ -110,27 +99,6 @@ impl CollapseMode { } } - /// Load the saved mode, defaulting to `Auto`. - pub fn load() -> Self { - config_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|s| CollapseMode::from_id(s.trim())) - .unwrap_or_default() - } - - /// Persist this mode (best-effort; silent on failure). - 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 _ = std::fs::write(path, self.id()); - } -} - -/// `/OpenCADStudio/ribbon.txt`, matching the other settings stores. -fn config_path() -> Option { - Some(crate::config::config_dir()?.join("ribbon.txt")) } impl std::fmt::Display for CollapseMode { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 8af3f7d1..7089912d 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -161,15 +161,20 @@ impl Ribbon { active_table_style: String::new(), tab_bar_h: Arc::new(AtomicU32::new(28.0f32.to_bits())), tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())), - collapse_mode: CollapseMode::load(), + collapse_mode: CollapseMode::default(), collapse_tight: Arc::new(AtomicBool::new(false)), } } - /// Change the tool-panel density and persist the choice. + /// Change the tool-panel density. Persistence is handled by the caller via + /// the consolidated app config (`save_config`). pub fn set_collapse_mode(&mut self, mode: CollapseMode) { self.collapse_mode = mode; - mode.save(); + } + + /// The current tool-panel density (for saving into the app config). + pub fn collapse_mode(&self) -> CollapseMode { + self.collapse_mode } /// Current tab-bar height as last measured by the `WrapBar` widget. diff --git a/src/ui/statusbar/statusbar_config.rs b/src/ui/statusbar/statusbar_config.rs index cd219cc3..a7ecb384 100644 --- a/src/ui/statusbar/statusbar_config.rs +++ b/src/ui/statusbar/statusbar_config.rs @@ -6,10 +6,10 @@ //! survives across sessions. use rustc_hash::FxHashSet as HashSet; -use std::path::PathBuf; +use serde::{Deserialize, Serialize}; /// Identifies a toggleable status-bar pill. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub enum StatusPill { Coords, Ortho, @@ -99,13 +99,12 @@ impl StatusPill { } } - fn from_id(s: &str) -> Option { - StatusPill::ALL.iter().copied().find(|p| p.id() == s) - } } -/// Tracks which pills the user has hidden. -#[derive(Clone)] +/// Tracks which pills the user has hidden. Serialized as the "statusbar" section +/// of the app config ([`crate::app::config`]). +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct StatusBarConfig { hidden: HashSet, } @@ -133,50 +132,15 @@ impl Default for StatusBarConfig { } impl StatusBarConfig { - /// Load the saved customization. When no config file exists yet, fall back - /// to the shipped defaults ([`StatusBarConfig::default`]); an existing file - /// (even an empty one — the user showed every pill) is authoritative. - pub fn load() -> Self { - match config_path().and_then(|p| std::fs::read_to_string(p).ok()) { - Some(body) => { - let hidden = body - .lines() - .filter_map(|l| StatusPill::from_id(l.trim())) - .collect(); - Self { hidden } - } - None => Self::default(), - } - } - pub fn is_visible(&self, pill: StatusPill) -> bool { !self.hidden.contains(&pill) } - /// Flip a pill's visibility and persist the change. + /// Flip a pill's visibility. Persistence is handled by the caller via the + /// consolidated app config (`save_config`). pub fn toggle(&mut self, pill: StatusPill) { if !self.hidden.remove(&pill) { self.hidden.insert(pill); } - self.save(); - } - - fn save(&self) { - let Some(path) = config_path() else { return }; - if let Some(dir) = path.parent() { - let _ = std::fs::create_dir_all(dir); - } - let body: String = StatusPill::ALL - .iter() - .filter(|p| self.hidden.contains(p)) - .map(|p| p.id()) - .collect::>() - .join("\n"); - let _ = std::fs::write(path, body); } } - -/// `/OpenCADStudio/statusbar.txt`, matching the recent-files store. -fn config_path() -> Option { - Some(crate::config::config_dir()?.join("statusbar.txt")) -} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 77440f34..cf0d858c 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -93,22 +93,34 @@ pub enum PlotDlgMsg { /// Transient state backing the Plot dialog. Seeded from the layout's plot /// settings when the dialog opens; consumed on commit. -#[derive(Debug, Clone)] +// The persisted fields form the "plot" section of the app config +// ([`crate::app::config`]); `#[serde(skip)]` marks the runtime-only fields +// (discovered printers, live page/offset choices, name-entry state) so only the +// user's print preferences are written, matching the former plot.txt subset. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] pub struct PlotDialogState { /// Printer names discovered on the system (via `lpstat`), never the /// sentinels. + #[serde(skip)] pub printers: Vec, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. pub to_file: bool, + #[serde(skip)] pub paper: String, + #[serde(skip)] pub orientation: String, + #[serde(skip)] pub rotation: String, pub copies: String, pub area: String, + #[serde(skip)] pub center: bool, + #[serde(skip)] pub offset_x: String, + #[serde(skip)] pub offset_y: String, pub scale: String, pub scale_lw: bool, @@ -126,12 +138,16 @@ pub struct PlotDialogState { /// Display name of the active plot style table ("" = none). pub style_name: String, /// Named page setups in the document (refreshed when the dialog opens). + #[serde(skip)] pub page_setups: Vec, /// Currently selected named page setup ("" = none / current layout). + #[serde(skip)] pub selected_setup: String, /// When `Some`, a name-entry row is showing (for New / Rename). + #[serde(skip)] pub name_input: Option, /// `true` when `name_input` is renaming the selected setup, else creating. + #[serde(skip)] pub name_rename: bool, } @@ -202,97 +218,6 @@ impl PlotDialogState { self.style_name = o.style_name.clone(); } - /// Load the persisted print preferences (printer, copies, quality, output - /// options) from `/OpenCADStudio/plot.txt`. Drawing-specific fields - /// (paper, scale, offset, rotation…) are NOT persisted here — they are - /// seeded from the active layout each time the dialog opens. - pub fn load() -> Self { - #[cfg(target_arch = "wasm32")] - { - Self::default() - } - #[cfg(not(target_arch = "wasm32"))] - { - let mut s = Self::default(); - if let Some(body) = prefs_path().and_then(|p| std::fs::read_to_string(p).ok()) { - let flag = |v: &str| v == "1"; - for line in body.lines() { - let Some((k, v)) = line.split_once('=') else { - continue; - }; - let (k, v) = (k.trim(), v.trim()); - match k { - "printer" => { - s.printer = if v.is_empty() { None } else { Some(v.to_string()) } - } - "to_file" => s.to_file = flag(v), - "area" => s.area = v.to_string(), - "scale" => s.scale = v.to_string(), - "copies" => s.copies = v.to_string(), - "quality" => s.quality = v.to_string(), - "dpi" => s.dpi = v.to_string(), - "shade" => s.shade = v.to_string(), - "mono" => s.mono = flag(v), - "lineweights" => s.lineweights = flag(v), - "with_styles" => s.with_styles = flag(v), - "transparency" => s.transparency = flag(v), - "paperspace_last" => s.paperspace_last = flag(v), - "hide_paperspace" => s.hide_paperspace = flag(v), - "stamp" => s.stamp = flag(v), - "save_layout" => s.save_layout = flag(v), - "scale_lw" => s.scale_lw = flag(v), - _ => {} - } - } - } - s - } - } - - /// Best-effort persist of the print preferences (silent on failure). - #[cfg(not(target_arch = "wasm32"))] - pub fn save(&self) { - let Some(path) = prefs_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 printer = self.printer.clone().unwrap_or_default(); - let body = format!( - "printer={}\nto_file={}\narea={}\nscale={}\ncopies={}\nquality={}\ndpi={}\nshade={}\nmono={}\n\ - lineweights={}\nwith_styles={}\ntransparency={}\npaperspace_last={}\n\ - hide_paperspace={}\nstamp={}\nsave_layout={}\nscale_lw={}\n", - printer, - b(self.to_file), - self.area, - self.scale, - self.copies, - self.quality, - self.dpi, - self.shade, - b(self.mono), - b(self.lineweights), - b(self.with_styles), - b(self.transparency), - b(self.paperspace_last), - b(self.hide_paperspace), - b(self.stamp), - b(self.save_layout), - b(self.scale_lw), - ); - let _ = std::fs::write(path, body); - } - - /// No-op persist on the web build (no filesystem). - #[cfg(target_arch = "wasm32")] - pub fn save(&self) {} -} - -#[cfg(not(target_arch = "wasm32"))] -fn prefs_path() -> Option { - Some(crate::config::config_dir()?.join("plot.txt")) } fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {