Files
OneCAD-Draft/src/app/settings.rs
T
Hakan SevenandClaude Opus 4.8 dc7117f203 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 <[email protected]>
2026-07-10 16:41:34 +03:00

198 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! 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 serde::{Deserialize, Serialize};
/// Canonical order of the user-toggleable object-snap modes. Drives the
/// deterministic order when decoding the `$OSMODE` bitmask (see
/// [`snaps_from_osmode`]).
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,
// Grid snap (SNAPMODE) is a per-drawing view setting stored on the VPort,
// not a global OSNAP preference, so it is deliberately excluded from the
// persisted set. (#121)
];
/// `$OSMODE` bit for each running object-snap mode (standard AutoCAD bitmask).
/// `None` for OCS-only snaps (Grid, ObjectPick) that have no standard bit.
fn snap_bit(s: SnapType) -> Option<i32> {
Some(match s {
SnapType::Endpoint => 1,
SnapType::Midpoint => 2,
SnapType::Center => 4,
SnapType::Node => 8,
SnapType::Quadrant => 16,
SnapType::Intersection => 32,
SnapType::Insertion => 64,
SnapType::Perpendicular => 128,
SnapType::Tangent => 256,
SnapType::Nearest => 512,
SnapType::ApparentIntersection => 2048,
SnapType::Extension => 4096,
SnapType::Parallel => 8192,
SnapType::Grid | SnapType::ObjectPick => return None,
})
}
/// Bit 14 of `$OSMODE` — object snap turned off (master suppress).
const OSMODE_SUPPRESS: i32 = 16384;
/// Encode the running-snap set + master toggle into an `$OSMODE` bitmask for the
/// drawing header. OCS-only snaps (Grid, ObjectPick) have no bit and are
/// dropped. A cleared master toggle sets the suppress bit.
pub(crate) fn osmode_from_snaps<'a>(
enabled: impl IntoIterator<Item = &'a SnapType>,
snap_enabled: bool,
) -> i32 {
let mut bits = 0;
for t in enabled {
if let Some(b) = snap_bit(*t) {
bits |= b;
}
}
if !snap_enabled {
bits |= OSMODE_SUPPRESS;
}
bits
}
/// Decode an `$OSMODE` bitmask into `(running-snap set, master enabled)`. Only
/// the standard mappable modes are produced; the suppress bit maps (inverted) to
/// the master toggle.
pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec<SnapType>, bool) {
let modes = SNAP_ORDER
.iter()
.copied()
.filter(|t| snap_bit(*t).is_some_and(|b| osmode & b != 0))
.collect();
(modes, osmode & OSMODE_SUPPRESS == 0)
}
/// 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,
pub polar_increment_deg: f32,
pub otrack: bool,
// Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing —
// stored in the document header, not here (they used to be persisted app-
// globally, which duplicated the file's own state).
/// Whether the one-time "make Open CAD Studio the default for .dwg/.dxf?"
/// prompt has already been shown. Set once the user answers (either way),
/// so we never nag again on subsequent launches.
pub default_assoc_prompted: bool,
/// Ids of plugins the user turned off in the Plugin Manager. Disabled
/// plugins keep their manifest listed but drop their ribbon tab and command
/// dispatch.
pub disabled_plugins: Vec<String>,
/// Linked plugin source repositories (`owner/repo`) the marketplace installs
/// from.
pub plugin_repos: Vec<String>,
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
pub texteditmode: bool,
/// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false).
pub textfill: bool,
/// When true, saving over an existing file first copies it to a sibling
/// `<name>.bak` so a faulty or accidental save can be recovered (#205).
pub backup_on_save: bool,
/// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak
/// handler on every launch. Toggle with the FILEASSOC command.
pub file_assoc_enabled: bool,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command).
/// 0 disables autosave.
pub savetime_min: i32,
/// Persisted viewport background colours (0–255 RGB); `None` = app default
/// (dark grey model / off-white paper). Applied to every drawing tab on
/// launch and to tabs opened later, so a chosen background survives restarts
/// (#188).
pub bg_color: Option<[u8; 3]>,
pub paper_bg_color: Option<[u8; 3]>,
}
impl Default for UserSettings {
fn default() -> Self {
Self {
dyn_input: true,
polar: false,
polar_increment_deg: 45.0,
otrack: false,
default_assoc_prompted: false,
disabled_plugins: Vec::new(),
plugin_repos: Vec::new(),
texteditmode: false,
textfill: true,
backup_on_save: true,
file_assoc_enabled: true,
savetime_min: 10,
bg_color: None,
paper_bg_color: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn osmode_encodes_bits_and_suppress() {
// Endpoint(1) + Midpoint(2) + Intersection(32) = 35, master on.
let on = [SnapType::Endpoint, SnapType::Midpoint, SnapType::Intersection];
assert_eq!(osmode_from_snaps(on.iter(), true), 35);
// Master off sets the suppress bit (16384).
assert_eq!(osmode_from_snaps(on.iter(), false), 35 | 16384);
// OCS-only snaps carry no bit and are dropped.
assert_eq!(osmode_from_snaps([SnapType::Grid, SnapType::ObjectPick].iter(), true), 0);
}
#[test]
fn osmode_decodes_bits_and_suppress() {
let (modes, enabled) = snaps_from_osmode(35);
let set: std::collections::HashSet<_> = modes.into_iter().collect();
assert!(enabled);
assert_eq!(set.len(), 3);
assert!(set.contains(&SnapType::Endpoint));
assert!(set.contains(&SnapType::Midpoint));
assert!(set.contains(&SnapType::Intersection));
// Suppress bit → master off; the mode bits still decode.
let (_m, en) = snaps_from_osmode(35 | 16384);
assert!(!en);
}
#[test]
fn osmode_round_trips_every_mappable_mode() {
// All 13 running snaps + master on encode and decode back to the same set.
let all: Vec<SnapType> = SNAP_ORDER.to_vec();
let bits = osmode_from_snaps(all.iter(), true);
let (back, enabled) = snaps_from_osmode(bits);
assert!(enabled);
let a: std::collections::HashSet<_> = all.into_iter().collect();
let b: std::collections::HashSet<_> = back.into_iter().collect();
assert_eq!(a, b);
}
}