feat(model-space): theme-aware model space canvas and CAD selection/grip sysvars

Model space appearance now follows the active UI theme instead of a
hardcoded dark background. Canvas mode is user-selectable (Match Theme /
Classic CAD Dark / Custom RGB), with matching controls for the paper
sheet and desk surround backgrounds and grid opacity.
Implements industry-standard CAD system variables, all settable via
command name, SETVAR, and the Options dialog:
- COLORTHEME (0/1 dark/light UI scheme, preserves custom palettes)
- COLORSCHEME (interactive named-theme selection)
- BACKGROUND (THEME/CLASSIC/DEFAULT/DESK presets, RGB triplets)
- SELECTIONAREA, SELECTIONAREAOPACITY (translucent marquee fill)
- WINDOWSAREACOLOR/WINDOWAREACOLOR, CROSSINGAREACOLOR (ACI or theme)
- SELECTIONEFFECT, SELECTIONEFFECTCOLOR (GPU highlight pass, driven by
  selection_generation invalidation — no retessellation)
- SELECTIONPREVIEW (bit 0 = idle rollover, bit 1 = in-command hover)
- GRIPSIZE, GRIPCOLOR, GRIPHOT, GRIPHOVER (grip overlay styling)
This commit is contained in:
Karim Jerbi committed 2026-09-03 17:44:44 +01:00
1 parent 89b1a22850
commit 688785a6cf
18 files changed
+1949 -316

No files matched your search

+5
View File
@@ -427,6 +427,11 @@ impl OpenCADStudio {
self.finish_active_command(&toks);
return Task::none();
}
if tokens[0].eq_ignore_ascii_case("BACKGROUND")
|| tokens[0].eq_ignore_ascii_case("COLORSCHEME")
{
return self.dispatch_command(cmd);
}
let _ = self.dispatch_command(tokens[0]);
if self.tabs[i].active_cmd.is_none() {
// Not an interactive tool — an inline-argument command (`PDMODE 3`).
+73 -40
View File
@@ -243,7 +243,7 @@ impl OpenCADStudio {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new(
"BACKGROUND",
"BACKGROUND colour [Default/Black/DarkGray/Gray/LightGray/White] or R G B (0–255):",
"BACKGROUND colour [Theme/Classic/Default/Black/DarkGray/Gray/LightGray/White] or R G B (0–255):",
);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
@@ -251,56 +251,78 @@ impl OpenCADStudio {
cmd if cmd.starts_with("BACKGROUND ") => {
let args = cmd.split_whitespace().skip(1).collect::<Vec<_>>();
let is_paper = self.tabs[i].scene.current_layout != "Model";
if args
.first()
.map(|s| s.eq_ignore_ascii_case("DEFAULT") || s.eq_ignore_ascii_case("RESET"))
.unwrap_or(false)
{
let first_arg = args.first().map(|s| s.to_uppercase()).unwrap_or_default();
if first_arg == "THEME" || first_arg == "AUTO" {
self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::t!("Model space background set to Match Theme.").as_ref());
} else if first_arg == "CLASSIC" {
self.model_space.mode = crate::app::config::ModelSpaceMode::ClassicDark;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::t!("Model space background set to Classic CAD Dark.").as_ref());
} else if first_arg == "DEFAULT" || first_arg == "RESET" {
if is_paper {
self.tabs[i].paper_bg_color = None;
self.tabs[i].scene.paper_bg_color = [1.0, 1.0, 1.0, 1.0];
self.default_paper_bg_color = None;
self.model_space.custom_paper_bg = None;
self.paper_bg_input.clear();
} else {
self.tabs[i].bg_color = None;
self.tabs[i].scene.bg_color = [33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0];
self.default_bg_color = None;
self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme;
self.model_space.custom_bg = None;
self.model_bg_input.clear();
}
// Wire colour adaptation (`adapt_to_bg`) reads the bg
// at tessellation time, so the cached wires need to
// refresh — otherwise a light→dark bg flip leaves
// black lines invisible against the new bg. Meshes
// bake colour into per-vertex GPU buffers at upload
// time; `recolor_meshes` rewrites the CPU side so
// the next epoch-driven re-upload picks up the new
// colour.
self.tabs[i].scene.recolor_meshes();
self.tabs[i].scene.bump_geometry();
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::t!("Background reset to default.").as_ref());
} else if let Some(rgba) = parse_background_color(&args) {
if is_paper {
self.tabs[i].paper_bg_color = Some(rgba);
self.tabs[i].scene.paper_bg_color = rgba;
self.default_paper_bg_color = Some(rgba);
} else if first_arg == "DESK" {
let sub_args = &args[1..];
let sub_first = sub_args.first().map(|s| s.to_uppercase()).unwrap_or_default();
if sub_first == "DEFAULT" || sub_first == "RESET" {
self.model_space.custom_desk_bg = None;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::t!("Paper desk surround background reset to default.").as_ref());
} else if let Some(rgba) = parse_background_color(sub_args) {
let [r, g, b, _] = rgba;
let rgb = [(r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8];
self.model_space.custom_desk_bg = Some(rgb);
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
self.command_line.push_output(crate::tf!(
"Desk surround background: rgb({}, {}, {})",
rgb[0],
rgb[1],
rgb[2]
).as_ref());
} else {
self.tabs[i].bg_color = Some(rgba);
self.tabs[i].scene.bg_color = rgba;
self.default_bg_color = Some(rgba);
self.command_line.push_info("Usage: BACKGROUND DESK <r> <g> <b> | DEFAULT");
}
self.tabs[i].scene.recolor_meshes();
self.tabs[i].scene.bump_geometry();
} else if let Some(rgba) = parse_background_color(&args) {
let [r, g, b, _] = rgba;
let rgb = [(r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8];
if is_paper {
self.model_space.custom_paper_bg = Some(rgb);
self.paper_bg_input = crate::app::config::rgb_to_hex(rgb);
} else {
self.model_space.mode = crate::app::config::ModelSpaceMode::Custom;
self.model_space.custom_bg = Some(rgb);
self.model_bg_input = crate::app::config::rgb_to_hex(rgb);
}
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line.push_output(crate::tf!(
"Background: rgb({}, {}, {})",
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8
rgb[0],
rgb[1],
rgb[2]
).as_ref());
// Persisted centrally after this message via
// `persist_settings_if_changed()`.
} else {
self.command_line.push_info(
"Usage: BACKGROUND <r> <g> <b> (0–255) | DEFAULT|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE",
"Usage: BACKGROUND <r> <g> <b> (0–255) | THEME|CLASSIC|DEFAULT|DESK|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE",
);
}
}
@@ -366,9 +388,20 @@ impl OpenCADStudio {
/// Returns `None` if the arguments don't match either form.
fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
let to_rgba = |[r, g, b]: [u8; 3]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0];
// Single token: a named preset.
// Single token: a named preset or comma-separated "r,g,b".
if args.len() == 1 {
let preset = match args[0].to_ascii_uppercase().as_str() {
let text = args[0].trim();
let parts: Vec<&str> = text.split(',').collect();
if parts.len() == 3 {
if let (Ok(r), Ok(g), Ok(b)) = (
parts[0].trim().parse::<u8>(),
parts[1].trim().parse::<u8>(),
parts[2].trim().parse::<u8>(),
) {
return Some(to_rgba([r, g, b]));
}
}
let preset = match text.to_ascii_uppercase().as_str() {
"WHITE" => [255, 255, 255],
"BLACK" => [0, 0, 0],
"GRAY" | "GREY" => [128, 128, 128],
+13
View File
@@ -534,6 +534,19 @@ inventory::submit!(crate::command::CommandRegistration {
"SKPOLY",
"SKTOLERANCE",
"CLIPROMPTLINES",
"COLORTHEME",
"SELECTIONAREA",
"SELECTIONAREAOPACITY",
"SELECTIONEFFECT",
"SELECTIONEFFECTCOLOR",
"WINDOWSAREACOLOR",
"WINDOWAREACOLOR",
"CROSSINGAREACOLOR",
"SELECTIONPREVIEW",
"GRIPSIZE",
"GRIPCOLOR",
"GRIPHOT",
"GRIPHOVER",
// Reset selected entities' overrides to follow their layer.
"SETBYLAYER",
// Remove duplicate objects; set drawing base point; audit integrity;
+419 -1
View File
@@ -888,6 +888,19 @@ impl OpenCADStudio {
| "CENTERCROSSGAP"
| "CENTERMARKEXE"
| "DIMCONTINUEMODE"
| "COLORTHEME"
| "SELECTIONAREA"
| "SELECTIONAREAOPACITY"
| "SELECTIONEFFECT"
| "SELECTIONEFFECTCOLOR"
| "WINDOWSAREACOLOR"
| "WINDOWAREACOLOR"
| "CROSSINGAREACOLOR"
| "SELECTIONPREVIEW"
| "GRIPSIZE"
| "GRIPCOLOR"
| "GRIPHOT"
| "GRIPHOVER"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -910,7 +923,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)",
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only)",
);
} else {
let frame_kind = crate::scene::frame::kind_for_name(&name);
@@ -1251,6 +1264,171 @@ impl OpenCADStudio {
false,
)),
},
"COLORTHEME" => match &value {
Some(v) => match v.as_str() {
"0" => {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
self.ui_theme.name = iced::Theme::Dark.to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(iced::Theme::Dark.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = iced::Theme::Dark;
self.sync_model_space_theme(true);
Ok(("COLORTHEME = 0 (Dark)".to_string(), true))
}
"1" => {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
self.ui_theme.name = iced::Theme::Light.to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(iced::Theme::Light.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = iced::Theme::Light;
self.sync_model_space_theme(true);
Ok(("COLORTHEME = 1 (Light)".to_string(), true))
}
_ => Err("SETVAR: 0 (Dark) or 1 (Light) required.".into()),
},
None => {
let bg = self.ui_theme.palette.background;
let lum = 0.299 * (bg[0] as f32) + 0.587 * (bg[1] as f32) + 0.114 * (bg[2] as f32);
let code = if lum > 128.0 { 1 } else { 0 };
Ok((format!("COLORTHEME = {code} ({})", self.ui_theme.name), false))
}
},
"SELECTIONAREA" => match &value {
Some(v) => match v.as_str() {
"0" => {
self.model_space.selection_area = false;
self.sync_model_space_theme(false);
Ok(("SELECTIONAREA = 0".to_string(), true))
}
"1" => {
self.model_space.selection_area = true;
self.sync_model_space_theme(false);
Ok(("SELECTIONAREA = 1".to_string(), true))
}
_ => Err("SETVAR: 0 or 1 required.".into()),
},
None => Ok((format!("SELECTIONAREA = {}", if self.model_space.selection_area { 1 } else { 0 }), false)),
},
"SELECTIONAREAOPACITY" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(op) if op <= 100 => {
self.model_space.selection_opacity = op;
self.sync_model_space_theme(false);
Ok((format!("SELECTIONAREAOPACITY = {op}"), true))
}
_ => Err("SETVAR: integer from 0 to 100 required.".into()),
},
None => Ok((format!("SELECTIONAREAOPACITY = {}", self.model_space.selection_opacity), false)),
},
"SELECTIONEFFECT" => match &value {
Some(v) => match v.as_str() {
"0" => {
self.model_space.selection_effect = false;
self.sync_model_space_theme(false);
Ok(("SELECTIONEFFECT = 0".to_string(), true))
}
"1" => {
self.model_space.selection_effect = true;
self.sync_model_space_theme(false);
Ok(("SELECTIONEFFECT = 1".to_string(), true))
}
_ => Err("SETVAR: 0 or 1 required.".into()),
},
None => Ok((format!("SELECTIONEFFECT = {}", if self.model_space.selection_effect { 1 } else { 0 }), false)),
},
"SELECTIONEFFECTCOLOR" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
self.model_space.selection_highlight_color = color;
self.sync_model_space_theme(false);
Ok((format!("SELECTIONEFFECTCOLOR = {color}"), true))
}
_ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()),
},
None => Ok((format!("SELECTIONEFFECTCOLOR = {}", self.model_space.selection_highlight_color), false)),
},
"WINDOWSAREACOLOR" | "WINDOWAREACOLOR" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
self.model_space.selection_window_color = color;
self.sync_model_space_theme(false);
Ok((format!("WINDOWSAREACOLOR = {color}"), true))
}
_ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()),
},
None => Ok((format!("WINDOWSAREACOLOR = {}", self.model_space.selection_window_color), false)),
},
"CROSSINGAREACOLOR" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
self.model_space.selection_crossing_color = color;
self.sync_model_space_theme(false);
Ok((format!("CROSSINGAREACOLOR = {color}"), true))
}
_ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()),
},
None => Ok((format!("CROSSINGAREACOLOR = {}", self.model_space.selection_crossing_color), false)),
},
"SELECTIONPREVIEW" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(mode @ 0..=3) => {
self.model_space.selection_preview = mode;
Ok((format!("SELECTIONPREVIEW = {mode}"), true))
}
_ => Err("SETVAR: integer from 0 to 3 required.".into()),
},
None => Ok((format!("SELECTIONPREVIEW = {}", self.model_space.selection_preview), false)),
},
"GRIPSIZE" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(size @ 1..=25) => {
self.model_space.grip_size = size;
self.sync_model_space_theme(false);
Ok((format!("GRIPSIZE = {size}"), true))
}
_ => Err("SETVAR: integer from 1 to 25 required.".into()),
},
None => Ok((format!("GRIPSIZE = {}", self.model_space.grip_size), false)),
},
"GRIPCOLOR" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
self.model_space.grip_color = color;
self.sync_model_space_theme(false);
Ok((format!("GRIPCOLOR = {color}"), true))
}
_ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()),
},
None => Ok((format!("GRIPCOLOR = {}", self.model_space.grip_color), false)),
},
"GRIPHOT" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
self.model_space.grip_hot = color;
self.sync_model_space_theme(false);
Ok((format!("GRIPHOT = {color}"), true))
}
_ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()),
},
None => Ok((format!("GRIPHOT = {}", self.model_space.grip_hot), false)),
},
"GRIPHOVER" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
self.model_space.grip_hover = color;
self.sync_model_space_theme(false);
Ok((format!("GRIPHOVER = {color}"), true))
}
_ => Err("SETVAR: integer from 0 (Theme) to 255 required.".into()),
},
None => Ok((format!("GRIPHOVER = {}", self.model_space.grip_hover), false)),
},
"SNAPANG" => match &value {
Some(v) => match v.parse::<f32>() {
Ok(angle) if angle.is_finite() => {
@@ -1872,6 +2050,19 @@ impl OpenCADStudio {
| "CURSORTYPE"
| "SNAPANG"
| "CLIPROMPTLINES"
| "COLORTHEME"
| "SELECTIONAREA"
| "SELECTIONAREAOPACITY"
| "SELECTIONEFFECT"
| "SELECTIONEFFECTCOLOR"
| "WINDOWSAREACOLOR"
| "WINDOWAREACOLOR"
| "CROSSINGAREACOLOR"
| "SELECTIONPREVIEW"
| "GRIPSIZE"
| "GRIPCOLOR"
| "GRIPHOT"
| "GRIPHOVER"
) {
self.persist_settings_if_changed();
} else {
@@ -2671,3 +2862,230 @@ fn rename_symbol(doc: &mut acadrust::CadDocument, ty: &str, old: &str, new: &str
_ => false,
}
}
#[cfg(test)]
mod tests {
use crate::app::OpenCADStudio;
fn fresh_app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn test_cad_selection_and_model_space_sysvars() {
let mut app = fresh_app();
// COLORTHEME 1 (Light)
let _ = app.run_command_line("SETVAR COLORTHEME 1");
assert_eq!(app.active_theme, iced::Theme::Light);
assert_eq!(app.ui_theme.name, "Light");
// COLORTHEME 0 (Dark)
let _ = app.run_command_line("SETVAR COLORTHEME 0");
assert_eq!(app.active_theme, iced::Theme::Dark);
assert_eq!(app.ui_theme.name, "Dark");
// Direct command name: COLORTHEME 1
let _ = app.run_command_line("COLORTHEME 1");
assert_eq!(app.active_theme, iced::Theme::Light);
// SELECTIONAREA toggle
let _ = app.run_command_line("SETVAR SELECTIONAREA 0");
assert!(!app.model_space.selection_area);
let _ = app.run_command_line("SELECTIONAREA 1");
assert!(app.model_space.selection_area);
// SELECTIONAREAOPACITY
let _ = app.run_command_line("SETVAR SELECTIONAREAOPACITY 45");
assert_eq!(app.model_space.selection_opacity, 45);
let _ = app.run_command_line("SELECTIONAREAOPACITY 80");
assert_eq!(app.model_space.selection_opacity, 80);
// SELECTIONEFFECT
let _ = app.run_command_line("SETVAR SELECTIONEFFECT 0");
assert!(!app.model_space.selection_effect);
assert!(!app.tabs[app.active_tab].scene.selection_effect);
let _ = app.run_command_line("SELECTIONEFFECT 1");
assert!(app.model_space.selection_effect);
assert!(app.tabs[app.active_tab].scene.selection_effect);
// SELECTIONEFFECTCOLOR
let _ = app.run_command_line("SETVAR SELECTIONEFFECTCOLOR 4");
assert_eq!(app.model_space.selection_highlight_color, 4);
assert_eq!(
app.tabs[app.active_tab].scene.selection_color,
app.model_space.resolve_selection_color()
);
// WINDOWSAREACOLOR & CROSSINGAREACOLOR
let _ = app.run_command_line("SETVAR WINDOWSAREACOLOR 5");
assert_eq!(app.model_space.selection_window_color, 5);
let _ = app.run_command_line("WINDOWSAREACOLOR 1");
assert_eq!(app.model_space.selection_window_color, 1);
let _ = app.run_command_line("SETVAR CROSSINGAREACOLOR 3");
assert_eq!(app.model_space.selection_crossing_color, 3);
let _ = app.run_command_line("CROSSINGAREACOLOR 2");
assert_eq!(app.model_space.selection_crossing_color, 2);
// SELECTIONPREVIEW
let _ = app.run_command_line("SETVAR SELECTIONPREVIEW 2");
assert_eq!(app.model_space.selection_preview, 2);
}
#[test]
fn test_background_command_modes() {
let mut app = fresh_app();
let i = app.active_tab;
// BACKGROUND CLASSIC
let _ = app.run_command_line("BACKGROUND CLASSIC");
assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::ClassicDark);
assert_eq!(
app.tabs[i].scene.bg_color,
[33.0 / 255.0, 40.0 / 255.0, 48.0 / 255.0, 1.0]
);
// Switch to light theme and set BACKGROUND THEME
let _ = app.run_command_line("COLORTHEME 1");
let _ = app.run_command_line("BACKGROUND THEME");
assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme);
assert_eq!(app.tabs[i].scene.bg_color, [1.0, 1.0, 1.0, 1.0]);
// BACKGROUND RGB custom
let _ = app.run_command_line("BACKGROUND 50 60 70");
assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::Custom);
assert_eq!(app.model_space.custom_bg, Some([50, 60, 70]));
assert!((app.tabs[i].scene.bg_color[0] - 50.0 / 255.0).abs() < 1e-4);
assert!((app.tabs[i].scene.bg_color[1] - 60.0 / 255.0).abs() < 1e-4);
assert!((app.tabs[i].scene.bg_color[2] - 70.0 / 255.0).abs() < 1e-4);
// BACKGROUND DEFAULT restores MatchTheme
let _ = app.run_command_line("BACKGROUND DEFAULT");
assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme);
assert_eq!(app.model_space.custom_bg, None);
// BACKGROUND DESK
let _ = app.run_command_line("BACKGROUND DESK 80 85 90");
assert_eq!(app.model_space.custom_desk_bg, Some([80, 85, 90]));
let _ = app.run_command_line("BACKGROUND DESK DEFAULT");
assert_eq!(app.model_space.custom_desk_bg, None);
}
#[test]
fn test_colorscheme_command() {
let mut app = fresh_app();
app.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme;
let i = app.active_tab;
// Bare COLORSCHEME launches interactive ValuePromptCommand
let _ = app.run_command_line("COLORSCHEME");
assert!(app.tabs[i].active_cmd.is_some());
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "COLORSCHEME");
// Cancel the interactive command
app.tabs[i].active_cmd = None;
// COLORSCHEME LIGHT sets Light theme and synchronizes model space
let _ = app.run_command_line("COLORSCHEME LIGHT");
assert_eq!(app.active_theme, iced::Theme::Light);
assert_eq!(app.tabs[i].scene.bg_color, [1.0, 1.0, 1.0, 1.0]);
// Case-insensitive and spaces: "colorscheme tokyo night"
let _ = app.run_command_line("colorscheme tokyo night");
assert_eq!(app.active_theme, iced::Theme::TokyoNight);
// COLORSCHEME rejects 1 and 0 (which belong to COLORTHEME)
let _ = app.run_command_line("colorscheme 1");
assert_ne!(app.active_theme, iced::Theme::Light); // stays TokyoNight
// COLORTHEME accepts 1 and 0
let _ = app.run_command_line("COLORTHEME 1");
assert_eq!(app.active_theme, iced::Theme::Light);
let _ = app.run_command_line("COLORTHEME 0");
assert_eq!(app.active_theme, iced::Theme::Dark);
}
#[test]
fn test_reviewer_feedback_fixes() {
let mut app = fresh_app();
// 1. COLORTHEME query returns UI theme scheme regardless of canvas background
let _ = app.run_command_line("COLORTHEME 0");
// Set canvas background to bright white in custom mode
let _ = app.run_command_line("BACKGROUND 255 255 255");
let _ = app.run_command_line("SETVAR COLORTHEME");
let output = app.command_line.history_plain_text();
assert!(output.contains("<0 (Dark)>"), "UI is dark, so query must return 0 despite white canvas: {output}");
let _ = app.run_command_line("COLORTHEME 1");
let _ = app.run_command_line("SETVAR COLORTHEME");
let output = app.command_line.history_plain_text();
assert!(output.contains("<1 (Light)>"), "UI is light, so query must return 1: {output}");
// 2. Custom theme safety when COLORTHEME 0/1 or COLORSCHEME runs
app.ui_theme.name = "Custom".to_string();
app.ui_theme.palette.background = [12, 34, 56];
let _ = app.run_command_line("COLORTHEME 0");
assert_eq!(app.saved_custom_palette.map(|p| p.background), Some([12, 34, 56]));
app.saved_custom_palette = None;
app.ui_theme.name = "Custom".to_string();
app.ui_theme.palette.background = [78, 90, 12];
let _ = app.run_command_line("COLORSCHEME DRACULA");
assert_eq!(app.saved_custom_palette.map(|p| p.background), Some([78, 90, 12]));
// 3. ModelSpaceBgChanged empty reverts mode to MatchTheme
let _ = app.update(crate::app::Message::ModelSpaceBgChanged("".to_string()));
assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme);
assert_eq!(app.model_space.custom_bg, None);
// 4. Split restore defaults: Display vs Selection
app.model_space.mode = crate::app::config::ModelSpaceMode::Custom;
app.model_space.custom_bg = Some([10, 20, 30]);
app.model_space.selection_opacity = 99;
let _ = app.update(crate::app::Message::RestoreModelSpaceDisplayDefaults);
assert_eq!(app.model_space.mode, crate::app::config::ModelSpaceMode::MatchTheme);
assert_eq!(app.model_space.selection_opacity, 99, "Selection visual must not be wiped by display restore");
let _ = app.update(crate::app::Message::RestoreSelectionVisualDefaults);
assert_eq!(app.model_space.selection_opacity, 12, "Selection restore resets selection visual defaults");
}
#[test]
fn test_grip_and_desk_surround_sysvars() {
let mut app = fresh_app();
// 1. Desk surround background message
let _ = app.update(crate::app::Message::DeskSpaceBgChanged("#1a2b3c".to_string()));
assert_eq!(app.model_space.custom_desk_bg, Some([0x1a, 0x2b, 0x3c]));
assert_eq!(app.desk_bg_input, "#1a2b3c");
let _ = app.update(crate::app::Message::DeskSpaceBgChanged("".to_string()));
assert_eq!(app.model_space.custom_desk_bg, None);
// 2. GRIPSIZE, GRIPCOLOR, GRIPHOT, GRIPHOVER via command line & SETVAR
let _ = app.run_command_line("GRIPSIZE 8");
assert_eq!(app.model_space.grip_size, 8);
let _ = app.run_command_line("GRIPCOLOR 4");
assert_eq!(app.model_space.grip_color, 4);
let _ = app.run_command_line("GRIPHOT 1");
assert_eq!(app.model_space.grip_hot, 1);
let _ = app.run_command_line("GRIPHOVER 2");
assert_eq!(app.model_space.grip_hover, 2);
// Direct messages
let _ = app.update(crate::app::Message::GripSizeChanged(12));
assert_eq!(app.model_space.grip_size, 12);
let _ = app.update(crate::app::Message::GripColorChanged(5));
assert_eq!(app.model_space.grip_color, 5);
// Restore defaults resets grips
let _ = app.update(crate::app::Message::RestoreSelectionVisualDefaults);
assert_eq!(app.model_space.grip_size, 5);
assert_eq!(app.model_space.grip_color, 0);
assert_eq!(app.model_space.grip_hot, 0);
assert_eq!(app.model_space.grip_hover, 0);
}
}
+61 -45
View File
@@ -375,56 +375,72 @@ impl OpenCADStudio {
}
// ── Color Scheme / Theme selector ─────────────────────────────
cmd if cmd == "COLORSCHEME" || cmd.starts_with("COLORSCHEME ") => {
use iced::Theme;
let sub = cmd
.split_once(' ')
.map(|(_, r)| r.trim())
.unwrap_or("")
.to_uppercase();
// Map name to Theme variant.
let theme: Option<Theme> = match sub.as_str() {
"DARK" => Some(Theme::Dark),
"LIGHT" => Some(Theme::Light),
"DRACULA" => Some(Theme::Dracula),
"NORD" => Some(Theme::Nord),
"SOLARIZED_LIGHT" | "SOLARIZEDLIGHT" => Some(Theme::SolarizedLight),
"SOLARIZED_DARK" | "SOLARIZEDDARK" => Some(Theme::SolarizedDark),
"GRUVBOX_LIGHT" | "GRUVBOXLIGHT" => Some(Theme::GruvboxLight),
"GRUVBOX_DARK" | "GRUVBOXDARK" => Some(Theme::GruvboxDark),
"TOKYONIGHT" | "TOKYO_NIGHT" => Some(Theme::TokyoNight),
"TOKYONIGHTSTORM" | "TOKYO_NIGHT_STORM" => Some(Theme::TokyoNightStorm),
"TOKYONIGHTLIGHT" | "TOKYO_NIGHT_LIGHT" => Some(Theme::TokyoNightLight),
"KANAGAWAWAVE" | "KANAGAWA_WAVE" => Some(Theme::KanagawaWave),
"KANAGAWADRAGON" | "KANAGAWA_DRAGON" => Some(Theme::KanagawaDragon),
"KANAGAWALOTUS" | "KANAGAWA_LOTUS" => Some(Theme::KanagawaLotus),
"MOONFLY" => Some(Theme::Moonfly),
"NIGHTFLY" => Some(Theme::Nightfly),
"OXOCARBON" => Some(Theme::Oxocarbon),
"FERRA" => Some(Theme::Ferra),
"" | "LIST" | "?" => {
self.command_line.push_output(
"Available themes: DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA"
);
return Some(Task::none());
}
_ => {
self.command_line.push_error(crate::tf!(
"COLORSCHEME: unknown theme '{}'. Type COLORSCHEME LIST for options.",
sub
).as_ref());
return Some(Task::none());
}
};
cmd if cmd.eq_ignore_ascii_case("COLORSCHEME") => {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new(
"COLORSCHEME",
"COLORSCHEME Enter theme name (or ? to list):",
);
self.command_line.push_output(
"Available themes: DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
);
self.command_line.push_info(&format!(
"Current color scheme: <{}>",
self.ui_theme.name
));
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
return Some(Task::none());
}
cmd if cmd.to_ascii_uppercase().starts_with("COLORSCHEME ") => {
let sub = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or("");
let upper_sub = sub.to_ascii_uppercase();
if upper_sub.is_empty() {
self.command_line.push_output(&format!(
"Current color scheme: <{}>",
self.ui_theme.name
));
return Some(Task::none());
}
if upper_sub == "LIST" || upper_sub == "?" {
self.command_line.push_output(
"Available themes: DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
);
return Some(Task::none());
}
if sub == "0" || sub == "1" {
self.command_line.push_error(
"COLORSCHEME: 0 and 1 are COLORTHEME options. Enter a theme name (e.g. DARK, LIGHT, DRACULA) or use COLORTHEME.",
);
return Some(Task::none());
}
let theme = crate::app::config::parse_theme_name(sub);
if let Some(t) = theme {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
let name = format!("{:?}", t);
self.ui_theme.name = t.to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(t.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = t.clone();
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::tf!("Color scheme set to '{name}'.").as_ref());
return Some(Task::done(Message::SetTheme(t)));
return Some(Task::none());
} else {
self.command_line.push_error(crate::tf!(
"COLORSCHEME: unknown theme '{}'. Type COLORSCHEME LIST for options.",
sub
).as_ref());
return Some(Task::none());
}
return Some(Task::none());
}
// ── Layout Manager GUI ─────────────────────────────────────────
+300
View File
@@ -39,6 +39,8 @@ pub struct AppConfig {
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 {
@@ -54,6 +56,7 @@ impl Default for AppConfig {
ribbon: RibbonConfig::default(),
plot: PlotDialogState::default(),
shortcuts: ShortcutConfig::default(),
model_space: ModelSpaceThemeConfig::default(),
}
}
}
@@ -208,6 +211,206 @@ pub(crate) fn parse_hex(value: &str) -> Option<[u8; 3]> {
])
}
/// 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: rollover/hover highlight mode (0 = off, 1 = in cmd, 2 = idle, 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,
}
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,
}
}
}
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)) = acadrust::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],
_ => color_to_rgb(theme.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),
_ => None,
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RecentConfig {
@@ -286,3 +489,100 @@ const WEB_CONFIG_KEY: &str = "opencadstudio.settings";
fn config_path() -> Option<std::path::PathBuf> {
Some(crate::config::config_dir()?.join("settings.json"))
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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);
}
}
+42
View File
@@ -977,6 +977,11 @@ pub(super) struct OpenCADStudio {
active_theme: Theme,
ui_theme: config::UiThemeConfig,
theme_color_inputs: [String; 6],
model_space: config::ModelSpaceThemeConfig,
model_bg_input: String,
paper_bg_input: String,
desk_bg_input: String,
pub(crate) saved_custom_palette: Option<config::UiThemePalette>,
// ── Keyboard Shortcut Editor ──────────────────────────────────────────
/// Complete editable key → command/action table.
@@ -1928,6 +1933,38 @@ pub enum Message {
OptionsThemeChanged(String),
/// Edit one of Custom theme's six base colours as #RRGGBB.
OptionsThemeColorChanged(usize, String),
/// Change Model Space canvas mode (MatchTheme, ClassicDark, Custom).
ModelSpaceModeChanged(config::ModelSpaceMode),
/// Change Model Space custom background color as hex or empty for default.
ModelSpaceBgChanged(String),
/// Change Paper Space custom sheet background color as hex or empty for default.
PaperSpaceBgChanged(String),
/// Change Paper Space desk surround background (#RRGGBB).
DeskSpaceBgChanged(String),
/// Change Grid opacity percentage (5–100).
GridOpacityChanged(u8),
/// Change Selection Area indicator toggle (SELECTIONAREA).
SelectionAreaToggled(bool),
/// Change Selection Area opacity percentage (0–100, SELECTIONAREAOPACITY).
SelectionOpacityChanged(u8),
/// Change Window selection color ACI index (0 = Theme Primary, 1..=255 = ACI).
SelectionWindowColorChanged(u8),
/// Change Crossing selection color ACI index (0 = Theme Success, 1..=255 = ACI).
SelectionCrossingColorChanged(u8),
/// Change Selection Highlight color ACI index (0 = Theme Primary, 1..=255 = ACI).
SelectionHighlightColorChanged(u8),
/// Change Grip size in pixels (1–25, GRIPSIZE).
GripSizeChanged(u8),
/// Change Unselected Grip color ACI index (0 = Theme Primary, 1..=255 = ACI, GRIPCOLOR).
GripColorChanged(u8),
/// Change Selected/Hot Grip color ACI index (0 = Theme Danger, 1..=255 = ACI, GRIPHOT).
GripHotChanged(u8),
/// Change Hover/Warm Grip color ACI index (0 = Theme Primary Strong, 1..=255 = ACI, GRIPHOVER).
GripHoverChanged(u8),
/// Restore Model Space display/canvas appearance to defaults.
RestoreModelSpaceDisplayDefaults,
/// Restore Selection visual effect settings to defaults.
RestoreSelectionVisualDefaults,
/// Register or unregister as the .dwg/.dxf handler, from Options. Same
/// setting the FILEASSOC command carries.
FileAssocChanged(bool),
@@ -3472,6 +3509,11 @@ impl OpenCADStudio {
active_theme: Theme::Oxocarbon,
ui_theme: config::UiThemeConfig::default(),
theme_color_inputs: config::UiThemePalette::default().hex_values(),
model_space: config::ModelSpaceThemeConfig::default(),
model_bg_input: String::new(),
paper_bg_input: String::new(),
desk_bg_input: String::new(),
saved_custom_palette: None,
// Keyboard shortcuts
shortcut_bindings: rustc_hash::FxHashMap::default(),
shortcut_editor_rows: Vec::new(),
+55 -4
View File
@@ -393,8 +393,8 @@ impl OpenCADStudio {
pick_add: self.pick_add,
pick_drag_rect: self.pick_drag_rect,
quick_properties: self.quick_properties,
bg_color: self.default_bg_color.map(f4_to_u3),
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
bg_color: None,
paper_bg_color: None,
language: self.language,
cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines),
block_mru: self.block_mru.clone(),
@@ -455,8 +455,7 @@ impl OpenCADStudio {
self.pick_add = s.pick_add;
self.pick_drag_rect = s.pick_drag_rect;
self.quick_properties = s.quick_properties;
self.default_bg_color = s.bg_color.map(u3_to_f4);
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
// Legacy settings.bg_color / paper_bg_color are superseded by model_space config.
if crate::i18n::set_language(s.language).is_ok() {
self.language = s.language;
}
@@ -775,6 +774,7 @@ impl OpenCADStudio {
.map(|(key, command)| (key.clone(), command.clone()))
.collect(),
},
model_space: self.model_space.clone(),
}
}
@@ -784,6 +784,23 @@ impl OpenCADStudio {
self.ui_theme = cfg.theme.clone();
self.active_theme = self.ui_theme.to_iced();
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.model_space = cfg.model_space;
self.model_bg_input = self
.model_space
.custom_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.paper_bg_input = self
.model_space
.custom_paper_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.desk_bg_input = self
.model_space
.custom_desk_bg
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.sync_model_space_theme(false);
self.recent_files = cfg
.recent
.files
@@ -831,6 +848,40 @@ impl OpenCADStudio {
}
}
/// Sync the active theme and model space configuration into all open tabs
/// and the application default background settings.
pub(in crate::app) fn sync_model_space_theme(&mut self, geometry_changed: bool) {
let model_bg = self.model_space.resolve_model_bg(&self.active_theme);
let paper_bg = self.model_space.resolve_paper_bg();
let sel_color = self.model_space.resolve_selection_color();
let sel_effect = self.model_space.selection_effect;
self.default_bg_color = Some(model_bg);
self.default_paper_bg_color = Some(paper_bg);
for tab in &mut self.tabs {
let old_bg = tab.scene.bg_color;
let old_paper = tab.scene.paper_bg_color;
let old_sel_color = tab.scene.selection_color;
let old_sel_effect = tab.scene.selection_effect;
tab.scene.bg_color = model_bg;
tab.scene.paper_bg_color = paper_bg;
tab.scene.selection_color = sel_color;
tab.scene.selection_effect = sel_effect;
tab.bg_color = Some(model_bg);
tab.paper_bg_color = Some(paper_bg);
if old_sel_color != sel_color || old_sel_effect != sel_effect {
tab.scene.selection_generation = tab.scene.selection_generation.wrapping_add(1);
}
if geometry_changed || old_bg != model_bg || old_paper != paper_bg {
tab.scene.recolor_meshes();
tab.scene.bump_geometry();
}
tab.scene.request_refresh(crate::scene::ViewportRefreshScope::All);
}
}
/// 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) {
+171 -12
View File
@@ -726,12 +726,9 @@ impl OpenCADStudio {
let size_label = format_size(size_bytes);
self.command_line
.push_info(crate::tf!("Opening \"{name}\" ({size_label})…").as_ref());
let model_bg = self.default_bg_color.unwrap_or([
33.0 / 255.0,
40.0 / 255.0,
48.0 / 255.0,
1.0,
]);
let model_bg = self.default_bg_color.unwrap_or_else(|| {
self.model_space.resolve_model_bg(&self.active_theme)
});
Task::perform(
crate::io::open_path_with_phase(path, progress, model_bg),
move |result| Message::FileOpened(open_id, result),
@@ -5564,11 +5561,15 @@ impl OpenCADStudio {
}
Message::SetTheme(theme) => {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
self.ui_theme.name = theme.to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(theme.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = theme;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
Task::none()
}
@@ -5817,6 +5818,9 @@ impl OpenCADStudio {
}
Message::OptionsThemeChanged(name) => {
if self.ui_theme.name == "Custom" && name != "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
self.ui_theme.name = name;
if let Some(theme) =
crate::app::config::builtin_theme(&self.ui_theme.name)
@@ -5827,8 +5831,13 @@ impl OpenCADStudio {
self.active_theme = theme;
} else {
self.ui_theme.name = "Custom".to_string();
if let Some(saved) = self.saved_custom_palette {
self.ui_theme.palette = saved;
self.theme_color_inputs = saved.hex_values();
}
self.active_theme = self.ui_theme.to_iced();
}
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
Task::none()
}
@@ -5841,11 +5850,164 @@ impl OpenCADStudio {
if self.ui_theme.palette.set_hex(index, &value) {
self.ui_theme.name = "Custom".to_string();
self.active_theme = self.ui_theme.to_iced();
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
}
Task::none()
}
Message::ModelSpaceModeChanged(mode) => {
self.model_space.mode = mode;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
Task::none()
}
Message::ModelSpaceBgChanged(hex) => {
self.model_bg_input = hex.clone();
if hex.trim().is_empty() {
self.model_space.custom_bg = None;
self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
} else if let Some(rgb) = crate::app::config::parse_hex(&hex) {
self.model_space.custom_bg = Some(rgb);
self.model_space.mode = crate::app::config::ModelSpaceMode::Custom;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
}
Task::none()
}
Message::PaperSpaceBgChanged(hex) => {
self.paper_bg_input = hex.clone();
if hex.trim().is_empty() {
self.model_space.custom_paper_bg = None;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
} else if let Some(rgb) = crate::app::config::parse_hex(&hex) {
self.model_space.custom_paper_bg = Some(rgb);
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
}
Task::none()
}
Message::DeskSpaceBgChanged(hex) => {
self.desk_bg_input = hex.clone();
if hex.trim().is_empty() {
self.model_space.custom_desk_bg = None;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
} else if let Some(rgb) = crate::app::config::parse_hex(&hex) {
self.model_space.custom_desk_bg = Some(rgb);
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
}
Task::none()
}
Message::GridOpacityChanged(opacity) => {
self.model_space.grid_opacity = opacity.clamp(5, 100);
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionAreaToggled(enabled) => {
self.model_space.selection_area = enabled;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionOpacityChanged(opacity) => {
self.model_space.selection_opacity = opacity.clamp(0, 100);
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionWindowColorChanged(color) => {
self.model_space.selection_window_color = color;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionCrossingColorChanged(color) => {
self.model_space.selection_crossing_color = color;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionHighlightColorChanged(color) => {
self.model_space.selection_highlight_color = color;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::GripSizeChanged(size) => {
self.model_space.grip_size = size.clamp(1, 25);
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::GripColorChanged(color) => {
self.model_space.grip_color = color;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::GripHotChanged(color) => {
self.model_space.grip_hot = color;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::GripHoverChanged(color) => {
self.model_space.grip_hover = color;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::RestoreModelSpaceDisplayDefaults => {
self.model_space.mode = crate::app::config::ModelSpaceMode::MatchTheme;
self.model_space.custom_bg = None;
self.model_space.custom_paper_bg = None;
self.model_space.custom_desk_bg = None;
self.model_space.grid_opacity = 18;
self.model_bg_input.clear();
self.paper_bg_input.clear();
self.desk_bg_input.clear();
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
Task::none()
}
Message::RestoreSelectionVisualDefaults => {
self.model_space.selection_area = true;
self.model_space.selection_opacity = 12;
self.model_space.selection_window_color = 0;
self.model_space.selection_crossing_color = 0;
self.model_space.selection_highlight_color = 0;
self.model_space.selection_effect = true;
self.model_space.selection_preview = 3;
self.model_space.grip_size = 5;
self.model_space.grip_color = 0;
self.model_space.grip_hot = 0;
self.model_space.grip_hover = 0;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
Message::FileAssocChanged(enabled) => {
// The same two calls FILEASSOC makes, so the checkbox and the
// command cannot leave the setting and the registration
@@ -5943,12 +6105,9 @@ impl OpenCADStudio {
self.close_active_modal();
return Task::none();
};
let model_bg = self.default_bg_color.unwrap_or([
33.0 / 255.0,
40.0 / 255.0,
48.0 / 255.0,
1.0,
]);
let model_bg = self.default_bg_color.unwrap_or_else(|| {
self.model_space.resolve_model_bg(&self.active_theme)
});
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = opening.source_path.clone() {
let current_fingerprint =
-16
View File
@@ -96,19 +96,3 @@ pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) {
}
(1.0, 1.0)
}
/// Convert an internal `[r,g,b,a]` colour (0.0–1.0) to a persisted 0–255 RGB
/// triplet, dropping alpha (backgrounds are always opaque).
pub(super) fn f4_to_u3([r, g, b, _]: [f32; 4]) -> [u8; 3] {
[
(r * 255.0).round().clamp(0.0, 255.0) as u8,
(g * 255.0).round().clamp(0.0, 255.0) as u8,
(b * 255.0).round().clamp(0.0, 255.0) as u8,
]
}
/// Inverse of [`f4_to_u3`]: a persisted 0–255 RGB triplet back to an opaque
/// `[r,g,b,a]` colour.
pub(super) fn u3_to_f4([r, g, b]: [u8; 3]) -> [f32; 4] {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
}
+15 -6
View File
@@ -2270,13 +2270,17 @@ impl OpenCADStudio {
)
})?
});
let highlights_hover = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.entity_pick_highlights_hover())
.unwrap_or(false);
let preview_in_command = (self.model_space.selection_preview & 2) != 0;
let highlights_hover = preview_in_command
&& self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.entity_pick_highlights_hover())
.unwrap_or(false);
if highlights_hover {
self.tabs[i].scene.set_hover_highlight(hovered);
} else {
self.tabs[i].scene.set_hover_highlight(None);
}
let hover_handle = hovered.unwrap_or(acadrust::Handle::NULL);
let shift = self.shift_down;
@@ -5118,7 +5122,12 @@ impl OpenCADStudio {
);
solid_ms = started.elapsed().as_secs_f64() * 1000.0;
}
self.tabs[i].scene.set_hover_highlight(hovered);
let preview_idle = (self.model_space.selection_preview & 1) != 0;
if preview_idle {
self.tabs[i].scene.set_hover_highlight(hovered);
} else {
self.tabs[i].scene.set_hover_highlight(None);
}
self.hover_dwell = None;
let hover_ms = hover_started.elapsed().as_secs_f64() * 1000.0;
if perf && hover_ms >= 5.0 {
+38 -30
View File
@@ -35,28 +35,13 @@ pub(in crate::app) use overlay::{MTEXT_TEXT_ID, TEXT_INLINE_ID};
pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bounds";
const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX;
/// The desk shown around the sheet, as the widget wants it. One definition,
/// shared with the renderer that clears the sheet viewport to the same thing.
const PAPER_SPACE_BACKGROUND: Color = Color {
r: crate::scene::PAPER_DESK_COLOR[0],
g: crate::scene::PAPER_DESK_COLOR[1],
b: crate::scene::PAPER_DESK_COLOR[2],
a: crate::scene::PAPER_DESK_COLOR[3],
};
/// Base surface directly under the crosshair. Paper content viewports render
/// transparently over the sheet/desk, including while MSPACE input is active.
fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] {
fn crosshair_background(tab: &DocumentTab, is_paper: bool, desk: [f32; 4]) -> [f32; 4] {
if !is_paper {
return tab.scene.bg_color;
}
let desk = [
PAPER_SPACE_BACKGROUND.r,
PAPER_SPACE_BACKGROUND.g,
PAPER_SPACE_BACKGROUND.b,
PAPER_SPACE_BACKGROUND.a,
];
let (cursor, viewport_size) = {
let selection = tab.scene.selection.borrow();
(selection.last_move_pos, selection.vp_size)
@@ -428,7 +413,14 @@ impl OpenCADStudio {
}
})
.collect();
crate::ui::overlay::grid_overlay(grid)
let desk_bg = self.model_space.resolve_desk_bg();
let bg = crosshair_background(tab, is_paper, desk_bg);
let bg_lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2];
let grid_style = crate::ui::overlay::GridStyle {
opacity: self.model_space.grid_opacity,
bg_luminance: bg_lum,
};
crate::ui::overlay::grid_overlay(grid, grid_style)
};
let selection_overlay = {
@@ -777,7 +769,7 @@ impl OpenCADStudio {
tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode,
self.ribbon.open_dropdown.is_some(),
hover_locked,
crosshair_background(tab, is_paper),
crosshair_background(tab, is_paper, self.model_space.resolve_desk_bg()),
crate::ui::overlay::CrosshairOptions {
size_percent: self.cursor_size,
pick_box: self.pick_box,
@@ -787,6 +779,17 @@ impl OpenCADStudio {
iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg,
},
crate::ui::overlay::SelectionVisualOptions {
area: self.model_space.selection_area,
opacity: self.model_space.selection_opacity,
window_color: self.model_space.selection_window_color,
crossing_color: self.model_space.selection_crossing_color,
highlight_color: self.model_space.selection_highlight_color,
grip_size: self.model_space.grip_size as f32,
grip_color: self.model_space.grip_color,
grip_hot: self.model_space.grip_hot,
grip_hover: self.model_space.grip_hover,
},
)
};
@@ -803,18 +806,23 @@ impl OpenCADStudio {
.on_scroll(Message::ViewportScroll)
.on_exit(Message::ViewportExit);
let desk_bg = self.model_space.resolve_desk_bg();
let desk_color = Color {
r: desk_bg[0],
g: desk_bg[1],
b: desk_bg[2],
a: desk_bg[3],
};
let bg_color = if is_paper {
PAPER_SPACE_BACKGROUND
desk_color
} else {
tab.bg_color
.map(|[r, g, b, a]| Color { r, g, b, a })
.unwrap_or(Color {
// Default model background: RGB (33, 40, 48).
r: 33.0 / 255.0,
g: 40.0 / 255.0,
b: 48.0 / 255.0,
a: 1.0,
})
let c = tab.scene.bg_color;
Color {
r: c[0],
g: c[1],
b: c[2],
a: c[3],
}
};
// Dynamic input overlay — editable boxes near the cursor, one per
@@ -944,7 +952,7 @@ impl OpenCADStudio {
.width(Fill)
.height(Fill)
} else if thumbnail_capture_clean {
let capture_bg = if is_paper { PAPER_SPACE_BACKGROUND } else { bg_color };
let capture_bg = if is_paper { desk_color } else { bg_color };
stack![
container(Space::new())
.style(move |_: &Theme| container::Style {
@@ -962,7 +970,7 @@ impl OpenCADStudio {
stack![
container(Space::new())
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(PAPER_SPACE_BACKGROUND)),
background: Some(Background::Color(desk_color)),
..Default::default()
})
.width(Fill)
+6 -2
View File
@@ -174,8 +174,8 @@ impl OpenCADStudio {
}
super::super::ModalKind::Options => sized_flow(
ex,
520,
500,
540,
560,
|flow| {
crate::ui::window::options::view_window(
&self.default_save_format,
@@ -190,6 +190,10 @@ impl OpenCADStudio {
self.crosshair_color,
&self.crosshair_color_input,
self.lineweight_display_scale,
&self.model_space,
&self.model_bg_input,
&self.paper_bg_input,
&self.desk_bg_input,
flow,
)
},