refactor(config): consolidate settings into one grouped settings.json
App preferences were spread across six flat plain-text files (settings.txt,
recent.txt, recent_limit.txt, statusbar.txt, ribbon.txt, plot.txt), each with a
bespoke line parser and no grouping. Replace them with a single grouped JSON,
serialized via serde (enabling the derive feature; serde_json was already a
dependency).
- New app/config.rs: AppConfig { settings, recent, statusbar, ribbon, plot } →
<config>/settings.json (pretty JSON), load()/save().
- Make UserSettings, StatusBarConfig/StatusPill, CollapseMode and
PlotDialogState serde-derived; #[serde(skip)] marks the plot dialog's runtime
fields so only print preferences persist. Their bespoke .txt load/save is
removed.
- App gains current_config/apply_config/save_config: one AppConfig::load at
startup distributes into live state; every settings-change site
(persist_settings_if_changed, recent add/remove/limit, status-bar pill
toggle, ribbon density, plot commit) routes through save_config, which diffs
against the last write to avoid thrashing.
- Clean start: the old flat files are ignored (no migration). ocad.pgp stays a
separate hand-editable file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
256f9a282b
commit
dc7117f203
12 changed files with 227 additions and 410 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -38,6 +38,7 @@ dependencies = [
|
|||
"rayon",
|
||||
"rfd",
|
||||
"rustc-hash 2.1.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
94
src/app/config.rs
Normal file
94
src/app/config.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Consolidated user configuration — one grouped JSON file
|
||||
//! (`<config>/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<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 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<std::path::PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("settings.json"))
|
||||
}
|
||||
|
|
@ -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<settings::UserSettings>,
|
||||
last_saved_config: Option<config::AppConfig>,
|
||||
/// 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PathBuf> {
|
||||
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<PathBuf> = 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::<usize>().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<PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("recent.txt"))
|
||||
}
|
||||
|
||||
fn limit_file_path() -> Option<PathBuf> {
|
||||
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::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(path, body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SnapType>, 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::<u8>());
|
||||
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<Self> {
|
||||
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::<f32>() {
|
||||
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::<i32>() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// `<config-dir>/OpenCADStudio/settings.txt`, matching the recent-files store.
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
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::*;
|
||||
|
|
|
|||
|
|
@ -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<Message> {
|
|||
/// preview PDF, export a PDF, or send the job to the chosen printer.
|
||||
fn on_plot_dlg_commit(&mut self, preview: bool) -> Task<Message> {
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
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<u8> {
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
/// `<config-dir>/OpenCADStudio/ribbon.txt`, matching the other settings stores.
|
||||
fn config_path() -> Option<std::path::PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("ribbon.txt"))
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CollapseMode {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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<StatusPill>,
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>()
|
||||
.join("\n");
|
||||
let _ = std::fs::write(path, body);
|
||||
}
|
||||
}
|
||||
|
||||
/// `<config-dir>/OpenCADStudio/statusbar.txt`, matching the recent-files store.
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("statusbar.txt"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// Chosen printer name, or `None` for the system default.
|
||||
pub printer: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// `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 `<config>/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<std::path::PathBuf> {
|
||||
Some(crate::config::config_dir()?.join("plot.txt"))
|
||||
}
|
||||
|
||||
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
|
|
|
|||
Loading…
Reference in a new issue