Files
OneCAD-Draft/src/app/config.rs
T
Hakan Seven 4be8d9735c feat(ui): add dockable properties panel
Preserve panel width across left/right docking and persist resize, position, and auto-collapse preferences.

Refs #348
Refs #650
2026-08-04 13:46:02 +03:00

289 lines
8.6 KiB
Rust

//! Consolidated user configuration. Native builds use one grouped JSON file
//! (`<config>/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> {
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<String>,
/// 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<std::path::PathBuf> {
Some(crate::config::config_dir()?.join("settings.json"))
}