forked from 1b-agent/opencadstudio-upstream
Preserve panel width across left/right docking and persist resize, position, and auto-collapse preferences. Refs #348 Refs #650
289 lines
8.6 KiB
Rust
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"))
|
|
}
|