Files
OneCAD-Draft/src/app/config.rs
T
Hakan SevenandClaude Opus 5.5 d5de798f8a Options: snap marker size (#1770)
The object-snap glyphs were fixed at 5 px; a Drafting slider now scales
them from 1 to 20 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:24:32 +03:00

765 lines
28 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.
//! 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,
/// General edge-stack dock layout (which panels are docked, side, order,
/// width and auto-collapse) for the Properties panel and block palette.
pub dock: crate::ui::dock::DockState,
/// Whether the optional Graphic Attributes palette is visible.
pub show_graphic_attributes: bool,
/// Graphic Attributes creates fills on the current layer instead of the
/// layer of the filled object.
pub graphic_fills_on_current_layer: bool,
/// 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,
/// Complete editable keyboard shortcut table.
pub shortcuts: ShortcutConfig,
/// Model space background, grid, and selection appearance.
pub model_space: ModelSpaceThemeConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
settings: UserSettings::default(),
theme: UiThemeConfig::default(),
recent: RecentConfig::default(),
start: StartConfig::default(),
statusbar: StatusBarConfig::default(),
dock: crate::ui::dock::DockState::default(),
show_graphic_attributes: false,
graphic_fills_on_current_layer: false,
annotation_auto_scale: -4,
ribbon: RibbonConfig::default(),
plot: PlotDialogState::default(),
shortcuts: ShortcutConfig::default(),
model_space: ModelSpaceThemeConfig::default(),
}
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ShortcutConfig {
pub bindings: std::collections::BTreeMap<String, String>,
}
impl Default for ShortcutConfig {
fn default() -> Self {
Self {
bindings: super::shortcuts::default_bindings(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DockSide {
Left,
Right,
}
#[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 if self.name.eq_ignore_ascii_case("System") {
resolve_system_theme()
} else {
builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon)
}
}
}
/// Resolve the `System` color scheme (COLORSCHEME SYSTEM) to a concrete
/// theme: the browser `prefers-color-scheme` media query on the web, the
/// Windows `AppsUseLightTheme` personalization value natively, and the dark
/// default on the remaining native platforms (no cheap OS query there).
/// Read at boot (`apply_config`) and re-read whenever COLORSCHEME SYSTEM
/// runs again — there is no live preference listener by design.
pub fn resolve_system_theme() -> iced::Theme {
#[cfg(target_arch = "wasm32")]
{
let light = web_sys::window().is_some_and(|window| {
window
.match_media("(prefers-color-scheme: light)")
.ok()
.flatten()
.is_some_and(|query| query.matches())
});
if light {
iced::Theme::Light
} else {
iced::Theme::Dark
}
}
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
{
use windows_sys::Win32::System::Registry::{
RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD,
};
let sub_key: Vec<u16> = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let value: Vec<u16> = "AppsUseLightTheme"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut data: u32 = 0;
let mut size = std::mem::size_of::<u32>() as u32;
// ERROR_SUCCESS (0) with data 1 means the apps theme is Light; a
// missing value (older Windows, wiped personalization) is Dark.
let light = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
value.as_ptr(),
RRF_RT_REG_DWORD,
std::ptr::null_mut(),
&mut data as *mut u32 as *mut core::ffi::c_void,
&mut size,
) == 0
&& data == 1
};
if light {
iced::Theme::Light
} else {
iced::Theme::Dark
}
}
#[cfg(not(any(target_arch = "wasm32", target_os = "windows")))]
{
iced::Theme::Dark
}
}
#[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
}
}
/// Every theme the user may pick: iced's built-ins followed by the Fusion
/// pair. The Fusion themes are `Theme::Custom`, so they are not in
/// `iced::Theme::ALL`; anything enumerating themes for display or for test
/// coverage must use this instead, or they silently vanish from the list.
pub fn all_themes() -> Vec<iced::Theme> {
iced::Theme::ALL
.iter()
.cloned()
.chain(crate::ui::style::fusion_theme::fusion_themes())
.collect()
}
pub fn builtin_theme(name: &str) -> Option<iced::Theme> {
all_themes()
.into_iter()
.find(|theme| theme.to_string() == name)
}
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])
}
pub(crate) fn rgb_to_hex(rgb: [u8; 3]) -> String {
format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
}
pub(crate) fn parse_hex(value: &str) -> Option<[u8; 3]> {
let value = value.trim().strip_prefix('#').unwrap_or(value.trim());
// Walk pairs of hex digits as chars: the old byte-length guard let a
// 6-byte multibyte string (`aébé`) through to fixed-offset slices that
// split a character. Exactly two digits per channel, or it is not a colour.
let mut bytes = [0u8; 3];
let mut chars = value.chars();
for slot in &mut bytes {
let high = chars.next()?.to_digit(16)?;
let low = chars.next()?.to_digit(16)?;
*slot = ((high << 4) | low) as u8;
}
if chars.next().is_some() {
return None;
}
Some(bytes)
}
/// Canvas mode for Model space.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ModelSpaceMode {
/// Canvas background dynamically follows the active UI theme.
#[default]
MatchTheme,
/// Classic CAD dark charcoal canvas (#212830) regardless of UI theme.
ClassicDark,
/// Custom background colors specified by the user.
Custom,
}
impl ModelSpaceMode {
pub const ALL: [Self; 3] = [Self::MatchTheme, Self::ClassicDark, Self::Custom];
pub fn label(self) -> &'static str {
match self {
Self::MatchTheme => "Match Theme",
Self::ClassicDark => "Classic CAD Dark",
Self::Custom => "Custom",
}
}
}
/// Standard classic CAD dark charcoal background [33, 40, 48].
pub const CLASSIC_CAD_DARK_BG: [u8; 3] = [33, 40, 48];
/// Standard paper space sheet background [255, 255, 255].
pub const DEFAULT_PAPER_BG: [u8; 3] = [255, 255, 255];
/// Standard paper space desk surround [138, 138, 138].
pub const DEFAULT_DESK_BG: [u8; 3] = [138, 138, 138];
/// Persistent configuration for Model Space appearance and selection visual effects.
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ModelSpaceThemeConfig {
/// How the canvas background is determined.
pub mode: ModelSpaceMode,
/// Custom model space canvas background [R, G, B] (0–255).
pub custom_bg: Option<[u8; 3]>,
/// Custom paper space sheet background [R, G, B] (0–255).
pub custom_paper_bg: Option<[u8; 3]>,
/// Custom paper space desk/surround background [R, G, B] (0–255).
pub custom_desk_bg: Option<[u8; 3]>,
/// Grid opacity percentage (5–100, default 18%).
pub grid_opacity: u8,
/// SELECTIONAREA: whether selection marquees have a translucent shaded fill.
pub selection_area: bool,
/// SELECTIONAREAOPACITY: transparency percentage of selection areas (0–100, default 12%).
pub selection_opacity: u8,
/// WINDOWSAREACOLOR: ACI index (0 = Theme Primary, 1..=255 = ACI).
pub selection_window_color: u8,
/// CROSSINGAREACOLOR: ACI index (0 = Theme Success, 1..=255 = ACI).
pub selection_crossing_color: u8,
/// SELECTIONEFFECTCOLOR: ACI index for selected entities highlight (0 = Theme Primary, 1..=255 = ACI).
pub selection_highlight_color: u8,
/// SELECTIONEFFECT: whether selected objects glow with solid highlight (true) or dash (false).
pub selection_effect: bool,
/// SELECTIONPREVIEW bitmask: 1 = idle, 2 = during a command, 3 = both.
pub selection_preview: u8,
/// GRIPSIZE: grip marker half-size in pixels (1–25, default 5).
pub grip_size: u8,
/// GRIPCOLOR: unselected grip ACI color (0 = Theme Primary outline, 1..=255 = ACI).
pub grip_color: u8,
/// GRIPHOT: selected/hot grip ACI color (0 = Theme Danger, 1..=255 = ACI).
pub grip_hot: u8,
/// GRIPHOVER: hovered/warm grip ACI color (0 = Theme Primary Strong, 1..=255 = ACI).
pub grip_hover: u8,
/// SNAPMARKERSIZE: object-snap marker half-size in pixels (1–20, default 5).
pub snap_marker_size: u8,
}
impl Default for ModelSpaceThemeConfig {
fn default() -> Self {
Self {
mode: ModelSpaceMode::MatchTheme,
custom_bg: None,
custom_paper_bg: None,
custom_desk_bg: None,
grid_opacity: 18,
selection_area: true,
selection_opacity: 12,
selection_window_color: 0,
selection_crossing_color: 0,
selection_highlight_color: 0,
selection_effect: true,
selection_preview: 3,
grip_size: 5,
grip_color: 0,
grip_hot: 0,
grip_hover: 0,
snap_marker_size: 5,
}
}
}
impl ModelSpaceThemeConfig {
/// Resolve active model space canvas background as [f32; 4] based on current theme.
pub fn resolve_model_bg(&self, theme: &iced::Theme) -> [f32; 4] {
let rgb = match self.mode {
ModelSpaceMode::MatchTheme => theme_canvas_background(theme),
ModelSpaceMode::ClassicDark => CLASSIC_CAD_DARK_BG,
ModelSpaceMode::Custom => self.custom_bg.unwrap_or(CLASSIC_CAD_DARK_BG),
};
[
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
1.0,
]
}
/// Resolve active paper space sheet background as [f32; 4].
pub fn resolve_paper_bg(&self) -> [f32; 4] {
let rgb = self.custom_paper_bg.unwrap_or(DEFAULT_PAPER_BG);
[
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
1.0,
]
}
/// Resolve active paper space desk surround background as [f32; 4].
pub fn resolve_desk_bg(&self) -> [f32; 4] {
let rgb = self.custom_desk_bg.unwrap_or(DEFAULT_DESK_BG);
[
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
1.0,
]
}
/// Resolve active selection highlight overlay color as [f32; 4].
pub fn resolve_selection_color(&self) -> [f32; 4] {
if self.selection_highlight_color == 0 {
crate::scene::model::wire_model::WireModel::SELECTED
} else if let Some((r, g, b)) =
codec::types::aci_table::aci_to_rgb(self.selection_highlight_color)
{
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
} else {
crate::scene::model::wire_model::WireModel::SELECTED
}
}
}
/// Default canvas background for a given theme variant.
pub fn theme_canvas_background(theme: &iced::Theme) -> [u8; 3] {
match theme {
iced::Theme::Light => [255, 255, 255],
iced::Theme::SolarizedLight => [253, 246, 227],
iced::Theme::GruvboxLight => [251, 241, 199],
iced::Theme::TokyoNightLight => [225, 226, 231],
iced::Theme::KanagawaLotus => [242, 236, 222],
iced::Theme::Dark => [32, 34, 37],
iced::Theme::Dracula => [40, 42, 54],
iced::Theme::Nord => [46, 52, 64],
iced::Theme::SolarizedDark => [0, 43, 54],
iced::Theme::GruvboxDark => [40, 40, 40],
iced::Theme::TokyoNight => [26, 27, 38],
iced::Theme::TokyoNightStorm => [36, 40, 59],
iced::Theme::KanagawaWave => [31, 31, 40],
iced::Theme::KanagawaDragon => [24, 26, 28],
iced::Theme::Moonfly => [8, 18, 24],
iced::Theme::Nightfly => [1, 22, 39],
iced::Theme::Oxocarbon => [22, 22, 22],
iced::Theme::Ferra => [43, 41, 46],
// The Fusion pair deliberately breaks the "canvas follows chrome"
// rule: black chrome is paired with a white canvas, so falling
// through to the palette below would paint model space black and
// undo the whole point of the theme.
other => match crate::ui::style::fusion_theme::fusion_canvas(other) {
Some(rgb) => rgb,
None => color_to_rgb(other.palette().background.base.color),
},
}
}
/// Parse a flexible theme name string into an `iced::Theme` variant.
/// Normalizes by stripping spaces, underscores, and dashes, case-insensitive.
pub fn parse_theme_name(s: &str) -> Option<iced::Theme> {
let clean: String = s
.chars()
.filter(|c| !c.is_whitespace() && *c != '_' && *c != '-')
.flat_map(|c| c.to_uppercase())
.collect();
match clean.as_str() {
"DARK" => Some(iced::Theme::Dark),
"LIGHT" => Some(iced::Theme::Light),
"DRACULA" => Some(iced::Theme::Dracula),
"NORD" => Some(iced::Theme::Nord),
"SOLARIZEDLIGHT" => Some(iced::Theme::SolarizedLight),
"SOLARIZEDDARK" => Some(iced::Theme::SolarizedDark),
"GRUVBOXLIGHT" => Some(iced::Theme::GruvboxLight),
"GRUVBOXDARK" => Some(iced::Theme::GruvboxDark),
"TOKYONIGHT" => Some(iced::Theme::TokyoNight),
"TOKYONIGHTSTORM" => Some(iced::Theme::TokyoNightStorm),
"TOKYONIGHTLIGHT" => Some(iced::Theme::TokyoNightLight),
"KANAGAWAWAVE" => Some(iced::Theme::KanagawaWave),
"KANAGAWADRAGON" => Some(iced::Theme::KanagawaDragon),
"KANAGAWALOTUS" => Some(iced::Theme::KanagawaLotus),
"MOONFLY" => Some(iced::Theme::Moonfly),
"NIGHTFLY" => Some(iced::Theme::Nightfly),
"OXOCARBON" => Some(iced::Theme::Oxocarbon),
"FERRA" => Some(iced::Theme::Ferra),
"FUSIONBLACK" => Some(crate::ui::style::fusion_theme::fusion_black()),
"FUSIONWHITE" => Some(crate::ui::style::fusion_theme::fusion_white()),
_ => None,
}
}
#[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,
/// List, icons or large previews.
pub view: super::RecentView,
}
impl Default for RecentConfig {
fn default() -> Self {
Self {
files: Vec::new(),
limit: super::recent::RECENT_DEFAULT,
view: super::RecentView::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.
///
/// Does nothing under `cfg(test)`. The path is the developer's own
/// settings file, and the suite builds whole applications and changes
/// preferences on them — without this, running `cargo test` rewrites the
/// settings of whoever ran it.
pub fn save(&self) {
if cfg!(test) {
return;
}
#[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"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_scheme_resolves_to_a_concrete_theme_and_keeps_its_name() {
// Fresh configs keep the existing default; SYSTEM is opt-in.
assert_ne!(UiThemeConfig::default().name, "System");
let mut cfg = UiThemeConfig::default();
cfg.name = "System".to_string();
let resolved = cfg.to_iced();
// Case-insensitive like the other stored names.
cfg.name = "system".to_string();
assert_eq!(cfg.to_iced(), resolved);
assert!(
matches!(resolved, iced::Theme::Light | iced::Theme::Dark),
"SYSTEM must resolve through the platform preference"
);
// The stored name survives resolution; re-runs re-resolve.
assert_eq!(cfg.name, "system");
assert_eq!(resolve_system_theme(), resolved);
}
#[test]
fn test_model_space_defaults() {
let cfg = ModelSpaceThemeConfig::default();
assert_eq!(cfg.mode, ModelSpaceMode::MatchTheme);
assert_eq!(cfg.custom_bg, None);
assert_eq!(cfg.custom_paper_bg, None);
assert_eq!(cfg.grid_opacity, 18);
assert!(cfg.selection_area);
assert_eq!(cfg.selection_opacity, 12);
assert_eq!(cfg.selection_window_color, 0);
assert_eq!(cfg.selection_crossing_color, 0);
assert_eq!(cfg.selection_highlight_color, 0);
assert!(cfg.selection_effect);
assert_eq!(cfg.selection_preview, 3);
}
#[test]
fn test_resolve_model_bg_match_theme() {
let cfg = ModelSpaceThemeConfig {
mode: ModelSpaceMode::MatchTheme,
..Default::default()
};
// Light theme should produce pure white background [1.0, 1.0, 1.0, 1.0]
let light_bg = cfg.resolve_model_bg(&iced::Theme::Light);
assert_eq!(light_bg, [1.0, 1.0, 1.0, 1.0]);
// Oxocarbon theme should produce dark background
let oxo_bg = cfg.resolve_model_bg(&iced::Theme::Oxocarbon);
assert!((oxo_bg[0] - 22.0 / 255.0).abs() < 1e-4);
assert!((oxo_bg[1] - 22.0 / 255.0).abs() < 1e-4);
assert!((oxo_bg[2] - 22.0 / 255.0).abs() < 1e-4);
assert_eq!(oxo_bg[3], 1.0);
}
#[test]
fn test_resolve_model_bg_classic_dark() {
let cfg = ModelSpaceThemeConfig {
mode: ModelSpaceMode::ClassicDark,
..Default::default()
};
// Even with Light theme active, ClassicDark must stay locked to [33, 40, 48]
let bg = cfg.resolve_model_bg(&iced::Theme::Light);
assert!((bg[0] - 33.0 / 255.0).abs() < 1e-4);
assert!((bg[1] - 40.0 / 255.0).abs() < 1e-4);
assert!((bg[2] - 48.0 / 255.0).abs() < 1e-4);
assert_eq!(bg[3], 1.0);
}
#[test]
fn test_resolve_model_bg_custom() {
let cfg = ModelSpaceThemeConfig {
mode: ModelSpaceMode::Custom,
custom_bg: Some([10, 20, 30]),
..Default::default()
};
let bg = cfg.resolve_model_bg(&iced::Theme::Light);
assert!((bg[0] - 10.0 / 255.0).abs() < 1e-4);
assert!((bg[1] - 20.0 / 255.0).abs() < 1e-4);
assert!((bg[2] - 30.0 / 255.0).abs() < 1e-4);
}
#[test]
fn test_model_space_config_serde_roundtrip() {
let mut original = AppConfig::default();
original.model_space.mode = ModelSpaceMode::Custom;
original.model_space.custom_bg = Some([12, 34, 56]);
original.model_space.grid_opacity = 45;
original.model_space.selection_opacity = 35;
original.model_space.selection_window_color = 5;
let serialized = serde_json::to_string(&original).expect("serialize config");
let deserialized: AppConfig =
serde_json::from_str(&serialized).expect("deserialize config");
assert_eq!(deserialized.model_space.mode, ModelSpaceMode::Custom);
assert_eq!(deserialized.model_space.custom_bg, Some([12, 34, 56]));
assert_eq!(deserialized.model_space.grid_opacity, 45);
assert_eq!(deserialized.model_space.selection_opacity, 35);
assert_eq!(deserialized.model_space.selection_window_color, 5);
}
#[test]
fn graphic_attributes_visibility_defaults_off_and_round_trips() {
let missing: AppConfig = serde_json::from_str("{}").expect("deserialize old config");
assert!(!missing.show_graphic_attributes);
let mut original = AppConfig::default();
original.show_graphic_attributes = true;
let serialized = serde_json::to_string(&original).expect("serialize config");
let restored: AppConfig = serde_json::from_str(&serialized).expect("deserialize config");
assert!(restored.show_graphic_attributes);
}
#[test]
fn test_parse_theme_name() {
assert_eq!(parse_theme_name("light"), Some(iced::Theme::Light));
assert_eq!(parse_theme_name("DARK"), Some(iced::Theme::Dark));
assert_eq!(
parse_theme_name("solarized light"),
Some(iced::Theme::SolarizedLight)
);
assert_eq!(
parse_theme_name("tokyo-night-storm"),
Some(iced::Theme::TokyoNightStorm)
);
assert_eq!(
parse_theme_name("Kanagawa_Lotus"),
Some(iced::Theme::KanagawaLotus)
);
assert_eq!(parse_theme_name("1"), None);
assert_eq!(parse_theme_name("0"), None);
assert_eq!(parse_theme_name("nonexistent_theme"), None);
}
/// The old guard checked the BYTE length (`value.len() != 6`) but sliced
/// at fixed byte offsets — a 6-byte multibyte string like `aébé` passed
/// the guard and then split a character:
/// `byte index 2 is not a char boundary; it is inside 'é'`. Pasting it
/// into any Options colour field killed the process on the keystroke.
#[test]
fn test_parse_hex_rejects_multibyte_input_without_panicking() {
for value in ["aébé", "#aébé", "éééééé", "#ébé#"] {
assert_eq!(parse_hex(value), None, "{value} should not parse");
}
// Non-hex garbage and wrong lengths stay None.
for value in ["gggggg", "a1b2c", "a1b2c34", "#GGHHII"] {
assert_eq!(parse_hex(value), None, "{value} should not parse");
}
// Valid input keeps working, with and without '#', with whitespace.
assert_eq!(parse_hex("a1b2c3"), Some([0xa1, 0xb2, 0xc3]));
assert_eq!(parse_hex("#A1B2C3"), Some([0xa1, 0xb2, 0xc3]));
assert_eq!(parse_hex(" 000000 "), Some([0, 0, 0]));
}
}