From 688785a6cf228c3d36ab51b235a7ac155d5bebce Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 3 Sep 2026 17:44:08 +0100 Subject: [PATCH] feat(model-space): theme-aware model space canvas and CAD selection/grip sysvars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/command_driver.rs | 5 + src/app/commands/fileops.rs | 113 +++++--- src/app/commands/mod.rs | 13 + src/app/commands/styleprops.rs | 420 ++++++++++++++++++++++++++++- src/app/commands/view.rs | 106 ++++---- src/app/config.rs | 300 +++++++++++++++++++++ src/app/mod.rs | 42 +++ src/app/update/file.rs | 59 +++- src/app/update/mod.rs | 183 ++++++++++++- src/app/update/util.rs | 16 -- src/app/update/viewport.rs | 21 +- src/app/view/mod.rs | 68 ++--- src/app/view/modal.rs | 8 +- src/scene/mod.rs | 6 + src/scene/pipeline/mod.rs | 88 +++--- src/scene/view/render.rs | 21 +- src/ui/overlay.rs | 323 ++++++++++++++++------ src/ui/window/options.rs | 473 ++++++++++++++++++++++++++++++--- 18 files changed, 1949 insertions(+), 316 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e04bdaa25..e11d4d0e1 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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`). diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 3a3d8a5d9..d0f42dcdc 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -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::>(); 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 | 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 (0–255) | DEFAULT|BLACK|DARKGRAY|GRAY|LIGHTGRAY|WHITE", + "Usage: BACKGROUND (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::(), + parts[1].trim().parse::(), + parts[2].trim().parse::(), + ) { + 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], diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 44a33084b..a4d0f4578 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -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; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 53d0b232e..db96ff828 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -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::() { + 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::() { + 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::() { + 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::() { + 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::() { + 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::() { + 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::() { + 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::() { + 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::() { + 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::() { 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); + } +} diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index dc90c07ea..9310032e4 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -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 = 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 ───────────────────────────────────────── diff --git a/src/app/config.rs b/src/app/config.rs index dab59c952..5edcab58e 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -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 { + 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 { 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); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 8057bfd84..a8c407776 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, // ── 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(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 269e57a13..3defb1f27 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1f05bb5c4..62ab2c13c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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 = diff --git a/src/app/update/util.rs b/src/app/update/util.rs index 8779ffc2f..cb78c36ab 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -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] -} diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 48153179a..e62e16961 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index eb22be3ec..b3e711616 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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) diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6a4e896c4..4a51688d2 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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, ) }, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 89e035cf9..0a2a3b917 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1466,6 +1466,10 @@ pub struct Scene { /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, + /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). + pub selection_color: [f32; 4], + /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). + pub selection_effect: bool, /// Whether entity transparency is honoured on screen. When false the /// wire shader forces every line opaque (a uniform toggle, no retessellate). pub transparency_display: bool, @@ -1882,6 +1886,8 @@ impl Scene { command_preview_hidden: HashSet::default(), refedit_keep: None, hover_highlight: None, + selection_color: crate::scene::model::wire_model::WireModel::SELECTED, + selection_effect: true, transparency_display: true, model_lineweight_scale: 1.0, selection_filter: HashSet::default(), diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 218e7d5a3..12fa10bdc 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2430,6 +2430,7 @@ impl Pipeline { hovered: &rustc_hash::FxHashSet, annotation_context_wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, + selected_tint: Option<[f32; 4]>, ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { @@ -2490,13 +2491,17 @@ impl Pipeline { .copied() .filter(|wire| wire.render_instance.is_some()) .collect(); - let mut gpu = WireGpu::from_highlight_refs( - device, - &selected_regular, - WireModel::SELECTED, - depth_map, - self.wire_const_bgl.as_ref(), - ); + let mut gpu = if let Some(tint) = selected_tint { + WireGpu::from_highlight_refs( + device, + &selected_regular, + tint, + depth_map, + self.wire_const_bgl.as_ref(), + ) + } else { + Vec::new() + }; gpu.extend(WireGpu::from_highlight_refs( device, &hover_regular, @@ -2505,13 +2510,17 @@ impl Pipeline { self.wire_const_bgl.as_ref(), )); self.gpu_selected_wires = gpu; - let mut block_gpu = BlockWireGpu::from_wires( - device, - &selected_blocks, - depth_map, - Some(WireModel::SELECTED), - &self.block_wire_const_bgl, - ); + let mut block_gpu = if let Some(tint) = selected_tint { + BlockWireGpu::from_wires( + device, + &selected_blocks, + depth_map, + Some(tint), + &self.block_wire_const_bgl, + ) + } else { + Vec::new() + }; block_gpu.extend(BlockWireGpu::from_wires( device, &hover_blocks, @@ -2549,6 +2558,7 @@ impl Pipeline { hovered: &rustc_hash::FxHashSet, annotation_context_wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, + selected_tint: Option<[f32; 4]>, ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { @@ -2585,15 +2595,17 @@ impl Pipeline { } } } - for h in selected { - push( - &self.wire_handle_index, - h.value(), - WireModel::SELECTED, - wires, - &mut out, - &mut selected_blocks, - ); + if let Some(tint) = selected_tint { + for h in selected { + push( + &self.wire_handle_index, + h.value(), + tint, + wires, + &mut out, + &mut selected_blocks, + ); + } } for h in hovered.iter().filter(|handle| !selected.contains(handle)) { push( @@ -2607,25 +2619,31 @@ impl Pipeline { } for wire in annotation_context_wires { let tint = if wire.selected { - WireModel::SELECTED + selected_tint.unwrap_or(WireModel::SELECTED) } else { WireModel::HOVER }; - for vertex in &wire.text_verts { - out.push(text_gpu::TextVertex { - color: [tint[0], tint[1], tint[2], vertex.color[3]], - ..*vertex - }); + if !wire.selected || selected_tint.is_some() { + for vertex in &wire.text_verts { + out.push(text_gpu::TextVertex { + color: [tint[0], tint[1], tint[2], vertex.color[3]], + ..*vertex + }); + } } } self.text_highlight_vcount = out.len() as u32; self.text_highlight_vbuf = text_gpu::upload_vertices(device, &out); - let mut block_gpu = text_gpu::upload_block_vertex_refs( - device, - &selected_blocks, - depth_map, - Some(WireModel::SELECTED), - ); + let mut block_gpu = if let Some(tint) = selected_tint { + text_gpu::upload_block_vertex_refs( + device, + &selected_blocks, + depth_map, + Some(tint), + ) + } else { + Vec::new() + }; block_gpu.extend(text_gpu::upload_block_vertex_refs( device, &hover_blocks, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index e04809efc..3a151c5d8 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -255,6 +255,10 @@ pub struct Primitive { pub(in crate::scene) bg_color: [f32; 4], /// Active Iced theme text colour for GPU-rendered ViewCube labels. pub(in crate::scene) viewcube_text_color: [f32; 4], + /// Color used to draw the selection highlight overlay wires. + pub(in crate::scene) selection_color: [f32; 4], + /// Whether selection visual effect (glow/highlight) is enabled. + pub(in crate::scene) selection_effect: bool, /// One input-to-render sample, carried only when PERF tracing is enabled. pub(in crate::scene) nav_perf: Option, } @@ -989,6 +993,11 @@ impl shader::Primitive for Primitive { && !Arc::ptr_eq(previous, &vp.annotation_context_wires) }); if selection_changed || highlighted_geometry_changed || annotation_context_changed { + let sel_color = if self.selection_effect { + Some(self.selection_color) + } else { + None + }; inner.upload_selected_wires( device, &vp_wires[..], @@ -996,6 +1005,7 @@ impl shader::Primitive for Primitive { &vp.hover_handles, &vp.annotation_context_wires, &draw_depths, + sel_color, ); // Text highlight rides the same selection key: a pick / rollover // recolours the selected / hovered glyphs without touching the @@ -1007,6 +1017,7 @@ impl shader::Primitive for Primitive { &vp.hover_handles, &vp.annotation_context_wires, &draw_depths, + sel_color, ); inner.cached_annotation_highlight_source = Some(Arc::clone(&vp.annotation_context_wires)); @@ -3339,7 +3350,7 @@ impl Scene { } let tint = if selected { - WireModel::SELECTED + self.selection_color } else { WireModel::HOVER }; @@ -3387,7 +3398,7 @@ impl Scene { entity, content_viewport.then_some(inst.handle), ); - wire.color = WireModel::SELECTED; + wire.color = self.selection_color; wire.selected = true; wire.pattern_length = pattern_length; wire.pattern = pattern; @@ -3527,6 +3538,8 @@ impl Scene { viewports, bg_color, viewcube_text_color, + selection_color: self.selection_color, + selection_effect: self.selection_effect, nav_perf: perf_nav, } } @@ -3555,6 +3568,8 @@ impl Scene { viewports: vec![], bg_color, viewcube_text_color, + selection_color: self.selection_color, + selection_effect: self.selection_effect, nav_perf: None, }; }; @@ -3615,6 +3630,8 @@ impl Scene { viewports, bg_color, viewcube_text_color, + selection_color: self.selection_color, + selection_effect: self.selection_effect, nav_perf: perf_nav, } } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 7db68dc09..138a47036 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -71,6 +71,52 @@ pub struct CrosshairOptions { pub snap_angle_deg: f32, } +/// Rendering style for the viewport grid. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridStyle { + pub opacity: u8, + pub bg_luminance: f32, +} + +impl Default for GridStyle { + fn default() -> Self { + Self { + opacity: 18, + bg_luminance: 0.15, + } + } +} + +/// Visual effect options for selection windows, crossings, entity highlights, and grips. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SelectionVisualOptions { + pub area: bool, + pub opacity: u8, + pub window_color: u8, + pub crossing_color: u8, + pub highlight_color: u8, + pub grip_size: f32, + pub grip_color: u8, + pub grip_hot: u8, + pub grip_hover: u8, +} + +impl Default for SelectionVisualOptions { + fn default() -> Self { + Self { + area: true, + opacity: 12, + window_color: 0, + crossing_color: 0, + highlight_color: 0, + grip_size: 5.0, + grip_color: 0, + grip_hot: 0, + grip_hover: 0, + } + } +} + // ── Grip marker data ────────────────────────────────────────────────────── /// Describes one grip to be drawn in the viewport overlay. @@ -147,13 +193,13 @@ impl GridGeometry { pub(crate) struct GridKey { pub grids: Vec, pub bounds: iced::Rectangle, + pub style: GridStyle, } impl GridKey { - /// Build a key from the per-pane `GridParams` and the overlay bounds. The - /// input slice is copied; callers can drop the original. - pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle) -> Self { - Self { grids: grids.to_vec(), bounds } + /// Build a key from the per-pane `GridParams`, overlay bounds, and grid style. + pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle, style: GridStyle) -> Self { + Self { grids: grids.to_vec(), bounds, style } } } @@ -281,8 +327,9 @@ pub struct OstTrackPoint { pub fn grid_overlay<'a>( grid: Vec, + style: GridStyle, ) -> Element<'a, Message> { - canvas(GridCanvas { grid }) + canvas(GridCanvas { grid, style }) .width(Length::Fill) .height(Length::Fill) .into() @@ -290,6 +337,7 @@ pub fn grid_overlay<'a>( struct GridCanvas { grid: Vec, + style: GridStyle, } impl canvas::Program for GridCanvas { @@ -303,10 +351,10 @@ impl canvas::Program for GridCanvas { bounds: iced::Rectangle, _cursor: mouse::Cursor, ) -> Vec { - let key = GridKey::from_grids(&self.grid, bounds); + let key = GridKey::from_grids(&self.grid, bounds, self.style); - // Hit check: same params, same bounds ⇒ the cached geometry is still - // valid. The key includes bounds, so a `should_reuse` match implies + // Hit check: same params, same bounds, same style ⇒ the cached geometry is still + // valid. The key includes bounds and style, so a `should_reuse` match implies // both are equal, and the fork's `draw_with_bounds` will return the // cached `Arc` clone (essentially free). let hit = should_reuse(state.key.borrow().as_ref(), &key); @@ -316,11 +364,8 @@ impl canvas::Program for GridCanvas { // returns the cached geometry without invoking the closure. state.cache.draw_with_bounds(renderer, bounds, |_frame| {}) } else { - // Params or bounds changed. Clear so the closure runs even when - // bounds happen to match the previously-cached frame (e.g. a pan - // within the same canvas size would otherwise leave stale - // geometry served — the fork's bounds-equality test would return - // the cached clone without calling our rebuild closure). + // Params, style, or bounds changed. Clear so the closure runs even when + // bounds happen to match the previously-cached frame. state.cache.clear(); state.cache.draw_with_bounds(renderer, bounds, |frame| { for g in &self.grid { @@ -348,14 +393,14 @@ impl canvas::Program for GridCanvas { g.origin, g.axes, g.limits, + self.style, ) }); } }) }; - // Update the stored key. Safe: the `borrow()` for `should_reuse` is - // dropped at the end of the `if` expression above; no live Ref here. + // Update the stored key. *state.key.borrow_mut() = Some(key); vec![geometry] @@ -383,6 +428,7 @@ pub fn selection_overlay<'a>( hover_locked: bool, crosshair_bg: [f32; 4], crosshair: CrosshairOptions, + selection_visual: SelectionVisualOptions, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -405,6 +451,7 @@ pub fn selection_overlay<'a>( hover_locked, crosshair_bg, crosshair, + selection_visual, }) .width(Length::Fill) .height(Length::Fill) @@ -462,11 +509,17 @@ struct SelectionCanvas { /// rather than the UI theme, which may be light over a dark model viewport. crosshair_bg: [f32; 4], crosshair: CrosshairOptions, + selection_visual: SelectionVisualOptions, } -fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) { +fn draw_grip_marker( + frame: &mut canvas::Frame, + grip: &GripMarker, + theme: &Theme, + visual: &SelectionVisualOptions, +) { let sp = grip.pos; - let h = crate::scene::pick::grip::GRIP_HALF_PX; + let h = visual.grip_size.clamp(1.0, 25.0); let path = match grip.shape { GripShape::Square => canvas::Path::rectangle( Point::new(sp.x - h, sp.y - h), @@ -521,10 +574,28 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) }; if grip.is_hot { - frame.fill(&path, theme.palette().danger.base.color); + let hot_color = if visual.grip_hot > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_hot) { + Color::from_rgb8(r, g, b) + } else { + theme.palette().danger.base.color + } + } else { + theme.palette().danger.base.color + }; + frame.fill(&path, hot_color); } else if grip.is_hovered { let pair = theme.palette().primary.strong; - frame.fill(&path, pair.color); + let hover_color = if visual.grip_hover > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_hover) { + Color::from_rgb8(r, g, b) + } else { + pair.color + } + } else { + pair.color + }; + frame.fill(&path, hover_color); frame.stroke( &path, canvas::Stroke { @@ -535,7 +606,15 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) ); } else { let palette = theme.palette(); - let color = palette.primary.base.color; + let color = if visual.grip_color > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_color) { + Color::from_rgb8(r, g, b) + } else { + palette.primary.base.color + } + } else { + palette.primary.base.color + }; let fill = if grip.shape == GripShape::Dropdown { color } else { @@ -715,20 +794,40 @@ impl canvas::Program for SelectionCanvas { b: Point, crossing: bool, theme: &Theme, + visual: &SelectionVisualOptions, ) { let base = if crossing { - theme.palette().success.base.color + if visual.crossing_color > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.crossing_color) { + Color::from_rgb8(r, g, b) + } else { + theme.palette().success.base.color + } + } else { + theme.palette().success.base.color + } } else { - theme.palette().primary.base.color + if visual.window_color > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.window_color) { + Color::from_rgb8(r, g, b) + } else { + theme.palette().primary.base.color + } + } else { + theme.palette().primary.base.color + } }; - let fill = base.scale_alpha(0.12); - let stroke = base.scale_alpha(0.9); let x0 = a.x.min(b.x); let y0 = a.y.min(b.y); let w = (a.x - b.x).abs(); let h = (a.y - b.y).abs(); let rect = canvas::Path::rectangle(Point::new(x0, y0), Size::new(w, h)); - frame.fill(&rect, fill); + if visual.area && visual.opacity > 0 { + let alpha = (visual.opacity as f32 / 100.0).clamp(0.0, 1.0); + let fill = base.scale_alpha(alpha); + frame.fill(&rect, fill); + } + let stroke = base.scale_alpha(0.9); frame.stroke( &rect, canvas::Stroke { @@ -740,33 +839,52 @@ impl canvas::Program for SelectionCanvas { } if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) { - draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme); + draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual); } // Preview marquee for point-picked windows (STRETCH) — same look, no pick. if let Some((a, b, crossing)) = self.selection.borrow().preview_box { - draw_marquee(&mut frame, a, b, crossing, theme); + draw_marquee(&mut frame, a, b, crossing, theme, &self.selection_visual); } if self.selection.borrow().poly_active && self.selection.borrow().poly_points.len() > 1 { let base = if self.selection.borrow().poly_crossing { - theme.palette().success.base.color - } else { - theme.palette().primary.base.color - }; - let fill = base.scale_alpha(0.12); - let stroke = base.scale_alpha(0.9); - if let Some(cur) = self.selection.borrow().last_move_pos { - let start = self.selection.borrow().poly_points[0]; - let fill_path = canvas::Path::new(|p| { - p.move_to(start); - for pt in &self.selection.borrow().poly_points[1..] { - p.line_to(*pt); + if self.selection_visual.crossing_color > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(self.selection_visual.crossing_color) { + Color::from_rgb8(r, g, b) + } else { + theme.palette().success.base.color } - p.line_to(cur); - p.line_to(start); - }); - frame.fill(&fill_path, fill); + } else { + theme.palette().success.base.color + } + } else { + if self.selection_visual.window_color > 0 { + if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(self.selection_visual.window_color) { + Color::from_rgb8(r, g, b) + } else { + theme.palette().primary.base.color + } + } else { + theme.palette().primary.base.color + } + }; + if self.selection_visual.area && self.selection_visual.opacity > 0 { + let alpha = (self.selection_visual.opacity as f32 / 100.0).clamp(0.0, 1.0); + let fill = base.scale_alpha(alpha); + if let Some(cur) = self.selection.borrow().last_move_pos { + let start = self.selection.borrow().poly_points[0]; + let fill_path = canvas::Path::new(|p| { + p.move_to(start); + for pt in &self.selection.borrow().poly_points[1..] { + p.line_to(*pt); + } + p.line_to(cur); + p.line_to(start); + }); + frame.fill(&fill_path, fill); + } } + let stroke = base.scale_alpha(0.9); let path = canvas::Path::new(|p| { p.move_to(self.selection.borrow().poly_points[0]); for pt in &self.selection.borrow().poly_points[1..] { @@ -840,7 +958,7 @@ impl canvas::Program for SelectionCanvas { } } for grip in &self.grips { - draw_grip_marker(frame, grip, theme); + draw_grip_marker(frame, grip, theme, &self.selection_visual); } }); } @@ -1242,6 +1360,7 @@ impl canvas::Program for SelectionCanvas { ucs.origin_screen, ucs.hover, ucs.selected, + self.crosshair_bg, ); } @@ -1341,6 +1460,7 @@ const MIN_GRID_PX: f32 = 20.0; /// Stop an infinite perspective grid before adjacent lines merge at the horizon. const MIN_HORIZON_GRID_PX: f32 = 5.0; +#[allow(clippy::too_many_arguments)] fn draw_grid( frame: &mut canvas::Frame, view_rot: Mat4, @@ -1350,12 +1470,25 @@ fn draw_grid( grid_origin: glam::DVec3, grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, + style: GridStyle, ) { - let gc = Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 0.7, + let alpha = (style.opacity as f32 / 100.0).clamp(0.02, 1.0); + let gc = if style.bg_luminance > 0.5 { + // Light background: subtle dark grid lines + Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: alpha, + } + } else { + // Dark background: subtle light grid lines + Color { + r: 0.80, + g: 0.80, + b: 0.80, + a: alpha, + } }; let st = canvas::Stroke { width: 0.5, @@ -1375,7 +1508,7 @@ fn draw_grid( if geometry.axis_extent > 0.0 { let (gx, gy, gz) = grid_axes; let extent = (geometry.axis_extent + step) * 1.5; - draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz)); + draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz), style.bg_luminance); } } @@ -1922,6 +2055,7 @@ pub(crate) fn grid_segments( // ── Coloured UCS axes ────────────────────────────────────────────────────── +#[allow(clippy::too_many_arguments)] fn draw_axes( frame: &mut canvas::Frame, view_rot: Mat4, @@ -1930,6 +2064,7 @@ fn draw_axes( extent: f32, origin: glam::DVec3, axes: (Vec3, Vec3, Vec3), + bg_luminance: f32, ) { let w2s = |world: glam::DVec3| -> Point { let ndc = view_rot.project_point3((world - eye).as_vec3()); @@ -1955,8 +2090,9 @@ fn draw_axes( axis_stroke(r, g, b), ); }; + let y_green = if bg_luminance > 0.5 { 0.60 } else { 0.85 }; line(ax, 0.90, 0.20, 0.20); // X — red - line(ay, 0.20, 0.85, 0.20); // Y — green + line(ay, 0.20, y_green, 0.20); // Y — green line(az, 0.20, 0.40, 0.90); // Z — blue } @@ -2090,6 +2226,7 @@ fn draw_ucs_icon( origin_screen: Option, hover: bool, selected: bool, + crosshair_bg: [f32; 4], ) { let Some((icon_origin, at_origin, geom)) = ucs_icon_geometry(vp, bounds, axes, origin_screen) @@ -2101,6 +2238,8 @@ fn draw_ucs_icon( // corner), so hover/selection highlight applies there too. let _ = at_origin; let highlight = hover || selected; + let is_light_bg = 0.299 * crosshair_bg[0] + 0.587 * crosshair_bg[1] + 0.114 * crosshair_bg[2] > 0.5; + let y_green = if is_light_bg { 0.60 } else { 0.85 }; struct AxisInfo { dx: f32, @@ -2129,7 +2268,7 @@ fn draw_ucs_icon( sc_len: geom[1].sc_len, depth: geom[1].depth, r: 0.22, - g: 0.85, + g: y_green, b: 0.22, label: "Y", }, @@ -2228,15 +2367,23 @@ fn draw_ucs_icon( // Origin dot. let circle = canvas::Path::circle(icon_origin, 3.5); - frame.fill( - &circle, + let dot_lum = 0.299 * crosshair_bg[0] + 0.587 * crosshair_bg[1] + 0.114 * crosshair_bg[2]; + let dot_color = if dot_lum > 0.5 { + Color { + r: 0.15, + g: 0.15, + b: 0.15, + a: 0.95, + } + } else { Color { r: 0.9, g: 0.9, b: 0.9, a: 0.95, - }, - ); + } + }; + frame.fill(&circle, dot_color); // Draggable grips when selected: a square at the origin and at the X / Y // tips. Warm grip colour with a light border, like an entity grip. @@ -3043,11 +3190,11 @@ mod bench_grid_geometry_tests { // Pre-seed a `GridCanvasState` with the same key the bench will // build each iteration — guaranteed hit path. let state = GridCanvasState::default(); - let stored_key = GridKey::from_grids(&grids, canvas_bounds); + let stored_key = GridKey::from_grids(&grids, canvas_bounds, GridStyle::default()); *state.key.borrow_mut() = Some(stored_key); for _ in 0..20 { - let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds)); + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); let hit = should_reuse(state.key.borrow().as_ref(), &key); black_box(hit); } @@ -3056,7 +3203,7 @@ mod bench_grid_geometry_tests { let start = Instant::now(); let mut hit_count = 0u32; for _ in 0..n { - let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds)); + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); if should_reuse(state.key.borrow().as_ref(), &key) { hit_count += 1; } @@ -3098,17 +3245,17 @@ mod grid_key_tests { } } - /// Same `Vec` + same bounds ⇒ keys compare equal. + /// Same `Vec` + same bounds + same style ⇒ keys compare equal. #[test] fn grid_key_matches_identical_params() { let grids = vec![baseline_params(), baseline_params()]; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; - let a = GridKey::from_grids(&grids, bounds); - let b = GridKey::from_grids(&grids, bounds); + let a = GridKey::from_grids(&grids, bounds, GridStyle::default()); + let b = GridKey::from_grids(&grids, bounds, GridStyle::default()); assert_eq!(a, b); } - /// One test, one baseline. Every change of any of the 7 inputs must produce + /// One test, one baseline. Every change of any of the inputs must produce /// a key that differs from the baseline. This is the entire correctness /// contract for cache hit/miss — if any input is ignored, the cache serves /// a stale grid. @@ -3121,13 +3268,13 @@ mod grid_key_tests { height: 720.0, }; let baseline_grids = vec![baseline_params()]; - let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds); + let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds, GridStyle::default()); // view_rot: small extra rotation let mut p = baseline_params(); p.view_rot = Mat4::from_rotation_x(0.15 + 0.01) * Mat4::from_rotation_y(0.05); assert_ne!( - GridKey::from_grids(&[p], baseline_bounds), + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, "view_rot change must invalidate" ); @@ -3136,7 +3283,7 @@ mod grid_key_tests { let mut p = baseline_params(); p.eye = glam::DVec3::new(4.0, 3.5, 9.5); assert_ne!( - GridKey::from_grids(&[p], baseline_bounds), + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, "eye change must invalidate" ); @@ -3145,7 +3292,7 @@ mod grid_key_tests { let mut p = baseline_params(); p.step = 40.0; assert_ne!( - GridKey::from_grids(&[p], baseline_bounds), + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, "step change must invalidate" ); @@ -3154,7 +3301,7 @@ mod grid_key_tests { let mut p = baseline_params(); p.origin = glam::DVec3::new(100.0, 0.0, 0.0); assert_ne!( - GridKey::from_grids(&[p], baseline_bounds), + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, "origin change must invalidate" ); @@ -3163,7 +3310,7 @@ mod grid_key_tests { let mut p = baseline_params(); p.axes = (Vec3::Y, Vec3::X, Vec3::Z); assert_ne!( - GridKey::from_grids(&[p], baseline_bounds), + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, "axes change must invalidate" ); @@ -3172,7 +3319,7 @@ mod grid_key_tests { let mut p = baseline_params(); p.limits = Some((glam::DVec2::new(0.0, 0.0), glam::DVec2::new(100.0, 100.0))); assert_ne!( - GridKey::from_grids(&[p], baseline_bounds), + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, "limits change must invalidate" ); @@ -3185,10 +3332,28 @@ mod grid_key_tests { height: 720.0, }; assert_ne!( - GridKey::from_grids(&baseline_grids, other_bounds), + GridKey::from_grids(&baseline_grids, other_bounds, GridStyle::default()), baseline_key, "bounds change must invalidate" ); + + // style opacity: change opacity + let mut style = GridStyle::default(); + style.opacity = 50; + assert_ne!( + GridKey::from_grids(&baseline_grids, baseline_bounds, style), + baseline_key, + "style opacity change must invalidate" + ); + + // style bg_luminance: change luminance (dark to light) + let mut style = GridStyle::default(); + style.bg_luminance = 0.9; + assert_ne!( + GridKey::from_grids(&baseline_grids, baseline_bounds, style), + baseline_key, + "style bg_luminance change must invalidate" + ); } /// In a 2-pane tiled layout, changing the second pane's params (with pane 1 @@ -3200,13 +3365,13 @@ mod grid_key_tests { let pane1 = baseline_params(); let pane2 = baseline_params(); let both = vec![pane1.clone(), pane2.clone()]; - let baseline = GridKey::from_grids(&both, bounds); + let baseline = GridKey::from_grids(&both, bounds, GridStyle::default()); let mut pane2_changed = pane2; pane2_changed.step = 160.0; let dirty = vec![pane1, pane2_changed]; assert_ne!( - GridKey::from_grids(&dirty, bounds), + GridKey::from_grids(&dirty, bounds, GridStyle::default()), baseline, "second pane change must invalidate" ); @@ -3217,7 +3382,7 @@ mod grid_key_tests { fn should_reuse_empty() { let grids = vec![baseline_params()]; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; - let key = GridKey::from_grids(&grids, bounds); + let key = GridKey::from_grids(&grids, bounds, GridStyle::default()); assert!(!should_reuse(None, &key)); } @@ -3226,7 +3391,7 @@ mod grid_key_tests { fn should_reuse_equal() { let grids = vec![baseline_params()]; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; - let key = GridKey::from_grids(&grids, bounds); + let key = GridKey::from_grids(&grids, bounds, GridStyle::default()); assert!(should_reuse(Some(&key), &key)); } @@ -3239,8 +3404,8 @@ mod grid_key_tests { pane2.step = 160.0; let grids_b = vec![pane2]; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; - let old = GridKey::from_grids(&grids_a, bounds); - let new = GridKey::from_grids(&grids_b, bounds); + let old = GridKey::from_grids(&grids_a, bounds, GridStyle::default()); + let new = GridKey::from_grids(&grids_b, bounds, GridStyle::default()); assert!(!should_reuse(Some(&old), &new)); } } @@ -3281,7 +3446,7 @@ mod grid_canvas_state_tests { axes: grid_axes, limits, }; - let key = GridKey::from_grids(&[params], bounds); + let key = GridKey::from_grids(&[params], bounds, GridStyle::default()); let state = GridCanvasState::default(); *state.key.borrow_mut() = Some(key.clone()); @@ -3291,7 +3456,7 @@ mod grid_canvas_state_tests { // Different bounds on the same params ⇒ different key, do not reuse. let other_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 640.0, height: 480.0 }; - let other_key = GridKey::from_grids(&[params], other_bounds); + let other_key = GridKey::from_grids(&[params], other_bounds, GridStyle::default()); assert!(!should_reuse(state.key.borrow().as_ref(), &other_key)); } } diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 4ef3981d8..571330909 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -27,6 +27,7 @@ impl fmt::Display for Labelled { } } +#[allow(clippy::too_many_arguments)] pub fn view_window<'a>( default_save_format: &'a str, file_assoc_enabled: bool, @@ -40,6 +41,10 @@ pub fn view_window<'a>( crosshair_color: Option<[u8; 3]>, crosshair_color_input: &'a str, lineweight_display_scale: i32, + model_space: &'a crate::app::config::ModelSpaceThemeConfig, + model_bg_input: &'a str, + paper_bg_input: &'a str, + desk_bg_input: &'a str, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let selected_format = crate::io::SAVE_FORMAT_OPTIONS @@ -204,7 +209,144 @@ pub fn view_window<'a>( ..Default::default() }); - let display = column![ + let model_bg_rgb = model_space.custom_bg.unwrap_or(crate::app::config::CLASSIC_CAD_DARK_BG); + let model_bg_swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(iced::Color::from_rgb8( + model_bg_rgb[0], + model_bg_rgb[1], + model_bg_rgb[2], + ))), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + + let paper_bg_rgb = model_space.custom_paper_bg.unwrap_or(crate::app::config::DEFAULT_PAPER_BG); + let paper_bg_swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(iced::Color::from_rgb8( + paper_bg_rgb[0], + paper_bg_rgb[1], + paper_bg_rgb[2], + ))), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + + let desk_bg_rgb = model_space.custom_desk_bg.unwrap_or(crate::app::config::DEFAULT_DESK_BG); + let desk_bg_swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(iced::Color::from_rgb8( + desk_bg_rgb[0], + desk_bg_rgb[1], + desk_bg_rgb[2], + ))), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + + let mode_options = crate::app::config::ModelSpaceMode::ALL + .into_iter() + .map(|value| Labelled { + label: crate::t!(value.label()).into_owned(), + value, + }) + .collect::>(); + let selected_mode = mode_options + .iter() + .find(|choice| choice.value == model_space.mode) + .cloned(); + + let color_choice_options = [ + (0u8, "0: Theme Default"), + (1, "1: Red"), + (2, "2: Yellow"), + (3, "3: Green"), + (4, "4: Cyan"), + (5, "5: Blue"), + (6, "6: Magenta"), + (7, "7: White/Black"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: label.to_string(), + }) + .collect::>(); + + let selected_window_color = color_choice_options + .iter() + .find(|c| c.value == model_space.selection_window_color) + .cloned() + .unwrap_or_else(|| Labelled { + value: model_space.selection_window_color, + label: format!("ACI {}", model_space.selection_window_color), + }); + + let selected_crossing_color = color_choice_options + .iter() + .find(|c| c.value == model_space.selection_crossing_color) + .cloned() + .unwrap_or_else(|| Labelled { + value: model_space.selection_crossing_color, + label: format!("ACI {}", model_space.selection_crossing_color), + }); + + let selected_highlight_color = color_choice_options + .iter() + .find(|c| c.value == model_space.selection_highlight_color) + .cloned() + .unwrap_or_else(|| Labelled { + value: model_space.selection_highlight_color, + label: format!("ACI {}", model_space.selection_highlight_color), + }); + + let selected_grip_color = color_choice_options + .iter() + .find(|c| c.value == model_space.grip_color) + .cloned() + .unwrap_or_else(|| Labelled { + value: model_space.grip_color, + label: format!("ACI {}", model_space.grip_color), + }); + + let selected_grip_hot = color_choice_options + .iter() + .find(|c| c.value == model_space.grip_hot) + .cloned() + .unwrap_or_else(|| Labelled { + value: model_space.grip_hot, + label: format!("ACI {}", model_space.grip_hot), + }); + + let selected_grip_hover = color_choice_options + .iter() + .find(|c| c.value == model_space.grip_hover) + .cloned() + .unwrap_or_else(|| Labelled { + value: model_space.grip_hover, + label: format!("ACI {}", model_space.grip_hover), + }); + + let mut display = column![ text(crate::tr!("options", "theme-section")).size(15), Space::new().height(10), row![ @@ -226,71 +368,176 @@ pub fn view_window<'a>( Space::new().height(12), color_controls, Space::new().height(24), - text(crate::t!("Crosshair")).size(15), - Space::new().height(10), row![ - text(crate::t!("Crosshair size")).size(12).width(140), - slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged) - .step(1) - .width(Fill), - text(format!("{}%", cursor_size.clamp(1, 100))) - .size(11) - .width(44), + text(crate::t!("Model Space Appearance")).size(15), + Space::new().width(Fill), + button(text(crate::t!("Restore Defaults")).size(11)) + .on_press(Message::RestoreModelSpaceDisplayDefaults) + .padding([4, 10]) + .style(button::secondary), ] - .spacing(10) .align_y(iced::Center), Space::new().height(10), row![ - text(crate::t!("Cursor type")).size(12).width(140), + text(crate::t!("Canvas mode")).size(12).width(140), iced::widget::pick_list( - selected_cursor, - cursor_options, + selected_mode, + mode_options, |choice| choice.label.clone(), ) - .on_select(|choice| Message::CursorTypeChanged(choice.value)) + .on_select(|choice| Message::ModelSpaceModeChanged(choice.value)) .width(Fill), ] .spacing(10) .align_y(iced::Center), - Space::new().height(10), + ]; + + if model_space.mode == crate::app::config::ModelSpaceMode::Custom { + display = display.push(Space::new().height(10)).push( + row![ + text(crate::t!("Model background")).size(12).width(140), + model_bg_swatch, + text_input("#RRGGBB", model_bg_input) + .on_input(Message::ModelSpaceBgChanged) + .width(150), + ] + .spacing(10) + .align_y(iced::Center), + ); + } + + display = display.push(Space::new().height(10)).push( row![ - text(crate::t!("Crosshair color")).size(12).width(140), - crosshair_swatch, - text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input) - .on_input(Message::CrosshairColorChanged) + text(crate::t!("Paper background")).size(12).width(140), + paper_bg_swatch, + text_input("#RRGGBB", paper_bg_input) + .on_input(Message::PaperSpaceBgChanged) .width(150), ] .spacing(10) .align_y(iced::Center), - Space::new().height(6), - text(crate::t!("Leave the color blank to keep automatic viewport contrast.")) - .size(11) - .width(sizing.width), - Space::new().height(24), - text(crate::t!("Lineweight")).size(15), - Space::new().height(10), + ); + + display = display.push(Space::new().height(10)).push( row![ - text(crate::t!("Model display scale")).size(12).width(140), - slider( - 25..=200, - lineweight_display_scale.clamp(25, 200), - Message::LineweightDisplayScaleChanged, - ) - .step(1) - .width(Fill), - text(format!("{}%", lineweight_display_scale.clamp(25, 200))) - .size(11) - .width(44), + text(crate::t!("Desk surround")).size(12).width(140), + desk_bg_swatch, + text_input("#RRGGBB", desk_bg_input) + .on_input(Message::DeskSpaceBgChanged) + .width(150), ] .spacing(10) .align_y(iced::Center), - Space::new().height(6), - text(crate::t!("Changes the on-screen width in Model without affecting plotted output.")) + ); + + display = display + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Grid opacity")).size(12).width(140), + slider(5..=100, model_space.grid_opacity.clamp(5, 100), Message::GridOpacityChanged) + .step(1) + .width(Fill), + text(format!("{}%", model_space.grid_opacity.clamp(5, 100))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(match model_space.mode { + crate::app::config::ModelSpaceMode::MatchTheme => { + crate::t!("Canvas background, grid, and default line colors automatically adapt to the active theme.") + } + crate::app::config::ModelSpaceMode::ClassicDark => { + crate::t!("Model space canvas remains locked to classic CAD dark charcoal (#212830).") + } + crate::app::config::ModelSpaceMode::Custom => { + crate::t!("Custom background colors set above are applied to Model and Paper space.") + } + }) .size(11) .width(sizing.width), - ] - .spacing(0) - .width(sizing.width); + ) + .push(Space::new().height(24)) + .push(text(crate::t!("Crosshair")).size(15)) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Crosshair size")).size(12).width(140), + slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged) + .step(1) + .width(Fill), + text(format!("{}%", cursor_size.clamp(1, 100))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Cursor type")).size(12).width(140), + iced::widget::pick_list( + selected_cursor, + cursor_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::CursorTypeChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Crosshair color")).size(12).width(140), + crosshair_swatch, + text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input) + .on_input(Message::CrosshairColorChanged) + .width(150), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(crate::t!("Leave the color blank to keep automatic viewport contrast.")) + .size(11) + .width(sizing.width), + ) + .push(Space::new().height(24)) + .push(text(crate::t!("Lineweight")).size(15)) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Model display scale")).size(12).width(140), + slider( + 25..=200, + lineweight_display_scale.clamp(25, 200), + Message::LineweightDisplayScaleChanged, + ) + .step(1) + .width(Fill), + text(format!("{}%", lineweight_display_scale.clamp(25, 200))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(crate::t!("Changes the on-screen width in Model without affecting plotted output.")) + .size(11) + .width(sizing.width), + ); + + let display_element = display.spacing(0).width(sizing.width); let selection = column![ text(crate::t!("Selection")).size(15), @@ -310,13 +557,151 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), + Space::new().height(24), + row![ + text(crate::t!("Visual Effect Settings")).size(15), + Space::new().width(Fill), + button(text(crate::t!("Restore Defaults")).size(11)) + .on_press(Message::RestoreSelectionVisualDefaults) + .padding([4, 10]) + .style(button::secondary), + ] + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(model_space.selection_area) + .on_toggle(Message::SelectionAreaToggled) + .size(15), + text(crate::t!("Indicate selection area with transparent fill (SELECTIONAREA)")) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Selection area opacity")).size(12).width(140), + slider( + 0..=100, + model_space.selection_opacity.clamp(0, 100), + Message::SelectionOpacityChanged, + ) + .step(1) + .width(Fill), + text(format!("{}%", model_space.selection_opacity.clamp(0, 100))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Transparency of the window and crossing selection areas (SELECTIONAREAOPACITY)." + )) + .size(11) + .width(sizing.width), + Space::new().height(10), + row![ + text(crate::t!("Window selection color")).size(12).width(140), + iced::widget::pick_list( + Some(selected_window_color), + color_choice_options.clone(), + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::SelectionWindowColorChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Crossing selection color")).size(12).width(140), + iced::widget::pick_list( + Some(selected_crossing_color), + color_choice_options.clone(), + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::SelectionCrossingColorChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Selection highlight color")).size(12).width(140), + iced::widget::pick_list( + Some(selected_highlight_color), + color_choice_options.clone(), + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::SelectionHighlightColorChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(24), + text(crate::t!("Grip Settings")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Grip size")).size(12).width(140), + slider( + 1..=25, + model_space.grip_size.clamp(1, 25), + Message::GripSizeChanged, + ) + .step(1) + .width(Fill), + text(format!("{} px", model_space.grip_size.clamp(1, 25))) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Unselected grip color")).size(12).width(140), + iced::widget::pick_list( + Some(selected_grip_color), + color_choice_options.clone(), + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::GripColorChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Selected/hot grip color")).size(12).width(140), + iced::widget::pick_list( + Some(selected_grip_hot), + color_choice_options.clone(), + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::GripHotChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Hover grip color")).size(12).width(140), + iced::widget::pick_list( + Some(selected_grip_hover), + color_choice_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::GripHoverChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), ] .spacing(0) .width(sizing.width); let content: Element<'a, Message> = match active_tab { OptionsTab::General => general.into(), - OptionsTab::Display => display.into(), + OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), };