feat(settings): persist UI preferences across sessions (#68)
DYN, ORTHO, POLAR, the polar increment, the grid toggle and the object-snap configuration (OSNAP on/off, active snap modes, OTRACK) reset to defaults on every launch. Persist them to a per-user settings.txt, matching the existing recent-files / status-bar stores (plain key=value, no serialization crate). Settings load on boot and apply to live state; the update wrapper snapshots them after each message and writes only on change, so a toggle survives a restart without thrashing the file. LWT is left out on purpose — it is a drawing header variable, not a UI preference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6520195220
commit
ebf12780c5
3 changed files with 235 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ mod layers;
|
|||
mod mtext_editor;
|
||||
mod model_ops;
|
||||
mod properties;
|
||||
mod settings;
|
||||
mod text_inline;
|
||||
mod update;
|
||||
mod view;
|
||||
|
|
@ -194,6 +195,9 @@ pub(super) struct OpenCADStudio {
|
|||
cycle_candidates: Option<(iced::Point, Vec<acadrust::Handle>)>,
|
||||
/// Which status-bar pills the user has chosen to show (persisted).
|
||||
statusbar_config: crate::ui::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>,
|
||||
/// Whether Tangent snap was enabled before a tangent-pick command started.
|
||||
pre_cmd_tangent: Option<bool>,
|
||||
/// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°.
|
||||
|
|
@ -1266,6 +1270,7 @@ impl OpenCADStudio {
|
|||
isolate_popup_open: false,
|
||||
selection_filter_popup_open: false,
|
||||
statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(),
|
||||
last_saved_settings: None,
|
||||
clean_screen: false,
|
||||
quick_properties: false,
|
||||
selection_cycling: false,
|
||||
|
|
@ -1471,6 +1476,14 @@ 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);
|
||||
}
|
||||
app.last_saved_settings = Some(app.current_settings());
|
||||
app.sync_ribbon_layers();
|
||||
app
|
||||
}
|
||||
|
|
|
|||
184
src/app/settings.rs
Normal file
184
src/app/settings.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use crate::snap::SnapType;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Canonical order for serializing snap modes, so the written list is stable
|
||||
/// (a `HashSet` iterates in arbitrary order).
|
||||
const SNAP_ORDER: &[SnapType] = &[
|
||||
SnapType::Endpoint,
|
||||
SnapType::Midpoint,
|
||||
SnapType::Center,
|
||||
SnapType::Node,
|
||||
SnapType::Quadrant,
|
||||
SnapType::Intersection,
|
||||
SnapType::Extension,
|
||||
SnapType::Insertion,
|
||||
SnapType::Perpendicular,
|
||||
SnapType::Tangent,
|
||||
SnapType::Nearest,
|
||||
SnapType::ApparentIntersection,
|
||||
SnapType::Parallel,
|
||||
SnapType::Grid,
|
||||
];
|
||||
|
||||
fn snap_id(s: SnapType) -> &'static str {
|
||||
match s {
|
||||
SnapType::Endpoint => "endpoint",
|
||||
SnapType::Midpoint => "midpoint",
|
||||
SnapType::Center => "center",
|
||||
SnapType::Node => "node",
|
||||
SnapType::Quadrant => "quadrant",
|
||||
SnapType::Intersection => "intersection",
|
||||
SnapType::Extension => "extension",
|
||||
SnapType::Insertion => "insertion",
|
||||
SnapType::Perpendicular => "perpendicular",
|
||||
SnapType::Tangent => "tangent",
|
||||
SnapType::Nearest => "nearest",
|
||||
SnapType::ApparentIntersection => "apparentintersection",
|
||||
SnapType::Parallel => "parallel",
|
||||
SnapType::Grid => "grid",
|
||||
}
|
||||
}
|
||||
|
||||
fn snap_from_id(s: &str) -> Option<SnapType> {
|
||||
SNAP_ORDER.iter().copied().find(|t| snap_id(*t) == s)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct UserSettings {
|
||||
pub dyn_input: bool,
|
||||
pub ortho: bool,
|
||||
pub polar: bool,
|
||||
pub polar_increment_deg: f32,
|
||||
pub show_grid: bool,
|
||||
pub snap_enabled: bool,
|
||||
pub otrack: bool,
|
||||
/// Active snap modes, in `SNAP_ORDER`.
|
||||
pub snap_modes: Vec<SnapType>,
|
||||
}
|
||||
|
||||
impl Default for UserSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dyn_input: true,
|
||||
ortho: false,
|
||||
polar: false,
|
||||
polar_increment_deg: 45.0,
|
||||
show_grid: false,
|
||||
snap_enabled: false,
|
||||
otrack: false,
|
||||
snap_modes: vec![
|
||||
SnapType::Endpoint,
|
||||
SnapType::Midpoint,
|
||||
SnapType::Center,
|
||||
SnapType::Node,
|
||||
SnapType::Quadrant,
|
||||
SnapType::Intersection,
|
||||
SnapType::Nearest,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserSettings {
|
||||
/// Build the active-mode set in canonical order from any iterator of modes.
|
||||
pub fn modes_from<'a>(modes: impl IntoIterator<Item = &'a SnapType>) -> Vec<SnapType> {
|
||||
let set: std::collections::HashSet<SnapType> = modes.into_iter().copied().collect();
|
||||
SNAP_ORDER.iter().copied().filter(|t| set.contains(t)).collect()
|
||||
}
|
||||
|
||||
/// 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",
|
||||
"ortho" => s.ortho = val == "1",
|
||||
"polar" => s.polar = val == "1",
|
||||
"polar_increment_deg" => {
|
||||
if let Ok(v) = val.parse::<f32>() {
|
||||
s.polar_increment_deg = v;
|
||||
}
|
||||
}
|
||||
"grid" => s.show_grid = val == "1",
|
||||
"osnap" => s.snap_enabled = val == "1",
|
||||
"otrack" => s.otrack = val == "1",
|
||||
"snap_modes" => {
|
||||
let modes: Vec<SnapType> =
|
||||
val.split(',').filter_map(|t| snap_from_id(t.trim())).collect();
|
||||
s.snap_modes = UserSettings::modes_from(modes.iter());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
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 modes = self
|
||||
.snap_modes
|
||||
.iter()
|
||||
.map(|t| snap_id(*t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let body = format!(
|
||||
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\nsnap_modes={}\n",
|
||||
b(self.dyn_input),
|
||||
b(self.ortho),
|
||||
b(self.polar),
|
||||
self.polar_increment_deg,
|
||||
b(self.show_grid),
|
||||
b(self.snap_enabled),
|
||||
b(self.otrack),
|
||||
modes,
|
||||
);
|
||||
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"))
|
||||
}
|
||||
|
|
@ -58,9 +58,47 @@ impl OpenCADStudio {
|
|||
.as_ref()
|
||||
.map(|c| c.prompt());
|
||||
self.command_line.set_step_prompt(prompt);
|
||||
// Persist UI preferences whenever a toggle changes them (issue #68).
|
||||
self.persist_settings_if_changed();
|
||||
task
|
||||
}
|
||||
|
||||
/// Snapshot the persisted UI preferences from live state.
|
||||
pub(super) fn current_settings(&self) -> super::settings::UserSettings {
|
||||
super::settings::UserSettings {
|
||||
dyn_input: self.dyn_input,
|
||||
ortho: self.ortho_mode,
|
||||
polar: self.polar_mode,
|
||||
polar_increment_deg: self.polar_increment_deg,
|
||||
show_grid: self.show_grid,
|
||||
snap_enabled: self.snapper.snap_enabled,
|
||||
otrack: self.snapper.otrack_enabled,
|
||||
snap_modes: super::settings::UserSettings::modes_from(self.snapper.enabled.iter()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply restored preferences to live state.
|
||||
pub(super) fn apply_settings(&mut self, s: &super::settings::UserSettings) {
|
||||
self.dyn_input = s.dyn_input;
|
||||
self.ortho_mode = s.ortho;
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.show_grid = s.show_grid;
|
||||
self.snapper.snap_enabled = s.snap_enabled;
|
||||
self.snapper.otrack_enabled = s.otrack;
|
||||
self.snapper.enabled = s.snap_modes.iter().copied().collect();
|
||||
}
|
||||
|
||||
/// Write preferences to disk only when they differ from the last write,
|
||||
/// so a toggle persists immediately without thrashing the file.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_inner(&mut self, msg: Message) -> Task<Message> {
|
||||
match msg {
|
||||
Message::Tick(t) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue