//! Consolidated user configuration. Native builds use one grouped JSON file //! (`/OpenCADStudio/settings.json`); web builds keep the same JSON in //! `localStorage`. It holds every app preference except the command aliases, //! which use native `ocad.pgp` or a separate web storage key. Serialized via //! serde so the data 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, /// Iced theme selection and the six base colours used by a custom theme. pub theme: UiThemeConfig, /// Recent-files list + retained count. pub recent: RecentConfig, /// Last selected section on the tabbed Start page. pub start: StartConfig, /// Which status-bar pills the user has hidden. pub statusbar: StatusBarConfig, /// Dock position, width and auto-collapse behavior of the Properties panel. pub properties: PropertiesDockConfig, /// Add a newly selected annotation scale to existing annotative objects. pub annotation_auto_scale: i8, /// 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(), theme: UiThemeConfig::default(), recent: RecentConfig::default(), start: StartConfig::default(), statusbar: StatusBarConfig::default(), properties: PropertiesDockConfig::default(), annotation_auto_scale: -4, ribbon: RibbonConfig::default(), plot: PlotDialogState::default(), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum DockSide { Left, Right, } #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PropertiesDockConfig { pub side: DockSide, pub width: f32, pub auto_collapse: bool, } impl Default for PropertiesDockConfig { fn default() -> Self { Self { side: DockSide::Left, width: 250.0, auto_collapse: false, } } } #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiThemeConfig { pub name: String, pub palette: UiThemePalette, } impl Default for UiThemeConfig { fn default() -> Self { let theme = iced::Theme::Oxocarbon; Self { name: theme.to_string(), palette: UiThemePalette::from_iced(theme.seed()), } } } impl UiThemeConfig { pub fn to_iced(&self) -> iced::Theme { if self.name == "Custom" { iced::Theme::custom("Custom", self.palette.to_iced()) } else { builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon) } } } #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiThemePalette { pub background: [u8; 3], pub text: [u8; 3], pub primary: [u8; 3], pub success: [u8; 3], pub warning: [u8; 3], pub danger: [u8; 3], } impl Default for UiThemePalette { fn default() -> Self { Self::from_iced(iced::Theme::Oxocarbon.seed()) } } impl UiThemePalette { pub fn from_iced(palette: iced::theme::palette::Seed) -> Self { Self { background: color_to_rgb(palette.background), text: color_to_rgb(palette.text), primary: color_to_rgb(palette.primary), success: color_to_rgb(palette.success), warning: color_to_rgb(palette.warning), danger: color_to_rgb(palette.danger), } } pub fn to_iced(self) -> iced::theme::palette::Seed { iced::theme::palette::Seed { background: rgb_to_color(self.background), text: rgb_to_color(self.text), primary: rgb_to_color(self.primary), success: rgb_to_color(self.success), warning: rgb_to_color(self.warning), danger: rgb_to_color(self.danger), } } pub fn hex_values(self) -> [String; 6] { [ rgb_to_hex(self.background), rgb_to_hex(self.text), rgb_to_hex(self.primary), rgb_to_hex(self.success), rgb_to_hex(self.warning), rgb_to_hex(self.danger), ] } pub fn set_hex(&mut self, index: usize, value: &str) -> bool { let Some(rgb) = parse_hex(value) else { return false; }; match index { 0 => self.background = rgb, 1 => self.text = rgb, 2 => self.primary = rgb, 3 => self.success = rgb, 4 => self.warning = rgb, 5 => self.danger = rgb, _ => return false, } true } } pub fn builtin_theme(name: &str) -> Option { iced::Theme::ALL .iter() .find(|theme| theme.to_string() == name) .cloned() } fn color_to_rgb(color: iced::Color) -> [u8; 3] { [ (color.r * 255.0).round() as u8, (color.g * 255.0).round() as u8, (color.b * 255.0).round() as u8, ] } fn rgb_to_color(rgb: [u8; 3]) -> iced::Color { iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]) } fn rgb_to_hex(rgb: [u8; 3]) -> String { format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) } fn parse_hex(value: &str) -> Option<[u8; 3]> { let value = value.trim().strip_prefix('#').unwrap_or(value.trim()); if value.len() != 6 { return None; } Some([ u8::from_str_radix(&value[0..2], 16).ok()?, u8::from_str_radix(&value[2..4], 16).ok()?, u8::from_str_radix(&value[4..6], 16).ok()?, ]) } #[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 StartConfig { pub section: super::StartSection, } #[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. Unknown or missing fields fall back to their section defaults /// via `#[serde(default)]`. pub fn load() -> Self { #[cfg(not(target_arch = "wasm32"))] let body = config_path().and_then(|p| std::fs::read_to_string(p).ok()); #[cfg(target_arch = "wasm32")] let body = web_sys::window() .and_then(|window| window.local_storage().ok().flatten()) .and_then(|storage| storage.get_item(WEB_CONFIG_KEY).ok().flatten()); body.and_then(|body| serde_json::from_str(&body).ok()) .unwrap_or_default() } /// Persist the config as JSON. Best-effort; silent on unavailable or /// read-only storage. pub fn save(&self) { #[cfg(not(target_arch = "wasm32"))] { 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); } } #[cfg(target_arch = "wasm32")] if let (Some(storage), Ok(json)) = ( web_sys::window().and_then(|window| window.local_storage().ok().flatten()), serde_json::to_string(self), ) { let _ = storage.set_item(WEB_CONFIG_KEY, &json); } } } #[cfg(target_arch = "wasm32")] const WEB_CONFIG_KEY: &str = "opencadstudio.settings"; #[cfg(not(target_arch = "wasm32"))] fn config_path() -> Option { Some(crate::config::config_dir()?.join("settings.json")) }