From 3e45331de1c19d506e123a60cb6570ad9087d246 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 29 Sep 2026 21:34:31 +0200 Subject: [PATCH] Add Graphic Attributes palette toggle and persistence --- assets/icons/graphic_attributes.svg | 6 + src/app/commands/display.rs | 5 + src/app/commands/mod.rs | 1 + src/app/config.rs | 15 +++ src/app/mod.rs | 4 +- src/app/update/dialog.rs | 38 +++++- src/app/update/file.rs | 4 + src/app/update/mod.rs | 37 ++++++ src/command.rs | 31 +++-- .../view/graphic_attributes_palette.rs | 14 ++ src/modules/view/mod.rs | 2 + src/ui/ribbon/mod.rs | 7 + src/ui/ribbon/widgets.rs | 2 + src/ui/window/graphic_attributes.rs | 125 ++++++++++-------- 14 files changed, 220 insertions(+), 71 deletions(-) create mode 100644 assets/icons/graphic_attributes.svg create mode 100644 src/modules/view/graphic_attributes_palette.rs diff --git a/assets/icons/graphic_attributes.svg b/assets/icons/graphic_attributes.svg new file mode 100644 index 000000000..aed1edd32 --- /dev/null +++ b/assets/icons/graphic_attributes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 4dd6b2d5b..c3c40b3b0 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -472,6 +472,11 @@ impl OpenCADStudio { return Some(Task::done(Message::ToggleProperties)); } + // ── GRAPHICATTRIBUTES — toggle Graphic Attributes palette ─────────── + "GRAPHICATTRIBUTES" => { + return Some(Task::done(Message::ToggleGraphicAttributes)); + } + // ── FILETAB — toggle file/document tabs ────────────────────────────── "FILETAB" => { return Some(Task::done(Message::ToggleFileTabs)); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 6fb283b99..a27263c5b 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -386,6 +386,7 @@ inventory::submit!(crate::command::CommandRegistration { "DSETTINGS", "PARAMETERS", "GRID", + "GRAPHICATTRIBUTES", "ISODRAFT", "ISOPLANE", "OSNAP", diff --git a/src/app/config.rs b/src/app/config.rs index f8d2f26d3..68e329c44 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -30,6 +30,8 @@ pub struct AppConfig { /// General edge-stack dock layout (which panels are docked, side, order, /// width and auto-collapse) for the Properties panel and block palette. pub dock: crate::ui::dock::DockState, + /// Whether the optional Graphic Attributes palette is visible. + pub show_graphic_attributes: bool, /// Add a newly selected annotation scale to existing annotative objects. pub annotation_auto_scale: i8, /// Ribbon collapse density. @@ -52,6 +54,7 @@ impl Default for AppConfig { start: StartConfig::default(), statusbar: StatusBarConfig::default(), dock: crate::ui::dock::DockState::default(), + show_graphic_attributes: false, annotation_auto_scale: -4, ribbon: RibbonConfig::default(), plot: PlotDialogState::default(), @@ -601,6 +604,18 @@ mod tests { assert_eq!(deserialized.model_space.selection_window_color, 5); } + #[test] + fn graphic_attributes_visibility_defaults_off_and_round_trips() { + let missing: AppConfig = serde_json::from_str("{}").expect("deserialize old config"); + assert!(!missing.show_graphic_attributes); + + let mut original = AppConfig::default(); + original.show_graphic_attributes = true; + let serialized = serde_json::to_string(&original).expect("serialize config"); + let restored: AppConfig = serde_json::from_str(&serialized).expect("deserialize config"); + assert!(restored.show_graphic_attributes); + } + #[test] fn test_parse_theme_name() { assert_eq!(parse_theme_name("light"), Some(iced::Theme::Light)); diff --git a/src/app/mod.rs b/src/app/mod.rs index 7b26e0aec..713ec9d67 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2807,6 +2807,8 @@ pub enum Message { ToggleViewCube, /// Toggle the Properties panel visibility (PROPERTIES). ToggleProperties, + /// Toggle the Graphic Attributes panel visibility (GRAPHICATTRIBUTES). + ToggleGraphicAttributes, /// Toggle the document file tabs at the top (FILETAB). ToggleFileTabs, /// Toggle the layout tabs at the bottom (LAYOUTTAB). @@ -4167,7 +4169,7 @@ impl OpenCADStudio { render_mode_menu_open: false, render_mode_preview: None, show_properties: true, - show_graphic_attributes: true, + show_graphic_attributes: false, graphic_attributes_header_menu_open: false, graphic_attribute_menu_open: false, line_color_menu_open: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 455ab5d99..542370d3e 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -130,7 +130,6 @@ fn close_fill_boundary(entity: &mut acadrust::EntityType) -> bool { } } -#[derive(Clone)] struct ConcreteGraphicAttributes { color: acadrust::types::Color, linetype: String, @@ -212,10 +211,11 @@ fn valid_layer_name(name: &str) -> bool { !name.is_empty() && name.chars().count() <= 255 && !name.chars().any(|character| { - matches!( - character, - '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`' - ) + character.is_control() + || matches!( + character, + '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | '=' | '`' + ) }) } @@ -918,7 +918,10 @@ impl OpenCADStudio { } pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> iced::Task { - let scale = ((scale * 10.0).round() / 10.0).clamp(0.1, 1000.0); + let Some(scale) = crate::ui::window::graphic_attributes::normalized_linetype_scale(scale) + else { + return iced::Task::none(); + }; let i = self.active_tab; let selected = self.tabs[i].scene.selected_handles_in_order(); if selected.is_empty() { @@ -1595,8 +1598,12 @@ impl OpenCADStudio { } PanelId::GraphicAttributes => { self.show_graphic_attributes = false; + self.ribbon.set_graphic_attributes(false); self.graphic_attributes_header_menu_open = false; self.graphic_attribute_menu_open = false; + self.gradient_editor = None; + self.hatch_editor_handles = None; + self.save_config(); } } if self.dock_expanded == Some(id) { @@ -2588,6 +2595,17 @@ mod tests { .abs() < f64::EPSILON ); + let _ = app.update(Message::LineLinetypeScaleChanged(f64::NAN)); + assert_eq!( + app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale, + 2.5 + ); let _ = app.update(Message::LineLinetypeScaleChanged(1.2000000000000002)); assert_eq!( app.tabs[i] @@ -2817,6 +2835,14 @@ mod tests { let _ = app.on_line_lineweight(acadrust::types::LineWeight::Value(35)); let _ = app.on_line_transparency(acadrust::types::Transparency::from_percent(0.3)); + let _ = app.create_graphic_attributes_layer("Invalid\nLayer"); + assert!(app.tabs[i] + .scene + .document + .layers + .get("Invalid\nLayer") + .is_none()); + let _ = app.update(Message::GraphicAttributesCreateLayer); assert_eq!( app.tabs[i] diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bca1aa378..ab8db72ea 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1086,6 +1086,7 @@ impl OpenCADStudio { dock.ensure_settings(); dock }, + show_graphic_attributes: self.show_graphic_attributes, annotation_auto_scale: self.annotation_auto_scale, ribbon: crate::app::config::RibbonConfig { collapse: self.ribbon.collapse_mode(), @@ -1144,6 +1145,9 @@ impl OpenCADStudio { let mut dock = cfg.dock; dock.ensure_settings(); self.dock = dock; + self.show_graphic_attributes = cfg.show_graphic_attributes; + self.ribbon + .set_graphic_attributes(self.show_graphic_attributes); self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2047d0067..a88e44a38 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4229,6 +4229,19 @@ impl OpenCADStudio { self.ribbon.set_properties(self.show_properties); Task::none() } + Message::ToggleGraphicAttributes => { + self.show_graphic_attributes ^= true; + self.ribbon + .set_graphic_attributes(self.show_graphic_attributes); + if !self.show_graphic_attributes { + self.graphic_attributes_header_menu_open = false; + self.graphic_attribute_menu_open = false; + self.gradient_editor = None; + self.hatch_editor_handles = None; + } + self.save_config(); + Task::none() + } Message::ToggleFileTabs => { self.show_file_tabs ^= true; self.ribbon.set_file_tabs(self.show_file_tabs); @@ -11220,6 +11233,30 @@ mod prop_pointer_tests { } } +#[cfg(test)] +mod graphic_attributes_visibility_tests { + use super::Message; + use crate::app::OpenCADStudio; + + #[test] + fn graphic_attributes_starts_hidden_and_toggle_is_persisted() { + let mut app = OpenCADStudio::new_for_test(); + app.apply_config(crate::app::config::AppConfig::default()); + assert!(!app.show_graphic_attributes); + assert!(!app.ribbon.show_graphic_attributes); + + let _ = app.update(Message::ToggleGraphicAttributes); + assert!(app.show_graphic_attributes); + assert!(app.ribbon.show_graphic_attributes); + assert!(app.current_config().show_graphic_attributes); + + let _ = app.update(Message::ToggleGraphicAttributes); + assert!(!app.show_graphic_attributes); + assert!(!app.ribbon.show_graphic_attributes); + assert!(!app.current_config().show_graphic_attributes); + } +} + #[cfg(test)] mod free_text_entry_tests { //! The free-form text prompts (table cell content, TEXT / MTEXT bodies) diff --git a/src/command.rs b/src/command.rs index bfa4c19eb..5fb96016c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -308,6 +308,10 @@ pub struct ValuePromptCommand { prompt: &'static str, } +fn dispatch_prompt_value(name: &str, text: &str) -> CmdResult { + CmdResult::Dispatch(format!("{name} {}", text.trim())) +} + impl ValuePromptCommand { pub fn new(name: &'static str, prompt: &'static str) -> Self { Self { name, prompt } @@ -328,12 +332,7 @@ impl CadCommand for ValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); - if t.is_empty() { - Some(CmdResult::Dispatch(format!("{} ", self.name))) - } else { - Some(CmdResult::Dispatch(format!("{} {t}", self.name))) - } + Some(dispatch_prompt_value(self.name, text)) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -343,7 +342,7 @@ impl CadCommand for ValuePromptCommand { fn on_enter(&mut self) -> CmdResult { // Bare Enter → report the current value via the inline handler. - CmdResult::Dispatch(format!("{} ", self.name)) + dispatch_prompt_value(self.name, "") } } @@ -375,7 +374,7 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim()))) + Some(dispatch_prompt_value(self.name, text)) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -383,7 +382,7 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_enter(&mut self) -> CmdResult { - CmdResult::Dispatch(format!("{} ", self.name)) + dispatch_prompt_value(self.name, "") } } @@ -3079,6 +3078,20 @@ mod constraint_registry_tests { mod tests { use super::*; + #[test] + fn prompt_dispatch_trims_values_and_preserves_empty_argument() { + let CmdResult::Dispatch(value) = dispatch_prompt_value("TEST", " value with spaces ") + else { + panic!("prompt input should dispatch a command"); + }; + assert_eq!(value, "TEST value with spaces"); + + let CmdResult::Dispatch(value) = dispatch_prompt_value("TEST", " ") else { + panic!("empty prompt input should dispatch a command"); + }; + assert_eq!(value, "TEST "); + } + #[test] fn test_mid2point_command() { let mut cmd = Mid2PointCommand::new(); diff --git a/src/modules/view/graphic_attributes_palette.rs b/src/modules/view/graphic_attributes_palette.rs new file mode 100644 index 000000000..03896aa11 --- /dev/null +++ b/src/modules/view/graphic_attributes_palette.rs @@ -0,0 +1,14 @@ +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub const ICON: IconKind = IconKind::Svg(include_bytes!( + "../../../assets/icons/graphic_attributes.svg" +)); + +pub fn tool() -> ToolDef { + ToolDef { + id: "GRAPHICATTRIBUTES", + label: "Graphic Attributes", + icon: ICON, + event: ModuleEvent::Command("GRAPHICATTRIBUTES".to_string()), + } +} diff --git a/src/modules/view/mod.rs b/src/modules/view/mod.rs index d29f1826d..b9b987f4c 100644 --- a/src/modules/view/mod.rs +++ b/src/modules/view/mod.rs @@ -2,6 +2,7 @@ mod cascade; mod file_tabs; +mod graphic_attributes_palette; mod layout_tabs; pub mod limits; mod orbit; @@ -120,6 +121,7 @@ impl CadModule for ViewModule { tools: vec![ RibbonItem::LargeTool(tool_palettes::tool()), RibbonItem::LargeTool(properties_palette::tool()), + RibbonItem::LargeTool(graphic_attributes_palette::tool()), RibbonItem::LargeTool(sheetset::tool()), ], }, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index c1c2852f3..d8004c769 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -54,6 +54,8 @@ pub struct Ribbon { pub show_ucs_icon: bool, /// Properties panel (PROPERTIES) visibility — drives the Properties button highlight. pub show_properties: bool, + /// Graphic Attributes panel visibility — drives its ribbon button highlight. + pub show_graphic_attributes: bool, /// File tabs (FILETAB) visibility — drives the File Tabs button highlight. pub show_file_tabs: bool, /// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight. @@ -162,6 +164,7 @@ impl Ribbon { show_viewcube: true, show_ucs_icon: true, show_properties: true, + show_graphic_attributes: false, show_file_tabs: true, show_layout_tabs: true, open_dropdown: None, @@ -315,6 +318,9 @@ impl Ribbon { pub fn set_properties(&mut self, on: bool) { self.show_properties = on; } + pub fn set_graphic_attributes(&mut self, on: bool) { + self.show_graphic_attributes = on; + } pub fn set_file_tabs(&mut self, on: bool) { self.show_file_tabs = on; } @@ -332,6 +338,7 @@ impl Ribbon { show_viewcube: self.show_viewcube, show_ucs_icon: self.show_ucs_icon, show_properties: self.show_properties, + show_graphic_attributes: self.show_graphic_attributes, show_block_palette, show_file_tabs: self.show_file_tabs, show_layout_tabs: self.show_layout_tabs, diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 72b765fa2..6adf4b07a 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -37,6 +37,7 @@ pub(super) struct ToggleState { pub show_viewcube: bool, pub show_ucs_icon: bool, pub show_properties: bool, + pub show_graphic_attributes: bool, pub show_block_palette: bool, pub show_file_tabs: bool, pub show_layout_tabs: bool, @@ -374,6 +375,7 @@ pub(super) fn is_active_tool( "NAVVCUBE" => state.show_viewcube, "UCSICON" => state.show_ucs_icon, "PROPERTIES" => state.show_properties, + "GRAPHICATTRIBUTES" => state.show_graphic_attributes, "BLOCKPALETTE" => state.show_block_palette, "FILETAB" => state.show_file_tabs, "LAYOUTTAB" => state.show_layout_tabs, diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs index 8758985f1..1a44ca701 100644 --- a/src/ui/window/graphic_attributes.rs +++ b/src/ui/window/graphic_attributes.rs @@ -57,10 +57,19 @@ struct LinetypeScaleInfo { const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0; const COMPACT_BUTTON_HEIGHT: f32 = 22.0; +const LINETYPE_SCALE_MIN: f64 = 0.1; +const LINETYPE_SCALE_MAX: f64 = 1000.0; +const LINETYPE_SCALE_STEP: f64 = 0.1; const TRANSPARENCY_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/transparency.svg"); const LINEWEIGHT_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/lineweight.svg"); +pub(crate) fn normalized_linetype_scale(value: f64) -> Option { + value + .is_finite() + .then(|| ((value * 10.0).round() / 10.0).clamp(LINETYPE_SCALE_MIN, LINETYPE_SCALE_MAX)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GraphicAttribute { Varies, @@ -737,6 +746,60 @@ fn transparency_control<'a>( row![control, more].spacing(4).align_y(iced::Center).into() } +fn header_menu(side: crate::app::config::DockSide, open: bool) -> Element<'static, Message> { + let menu_button = button(crate::ui::icons::themed_secondary( + crate::ui::icons::MENU, + 12.0, + )) + .on_press(Message::ToggleGraphicAttributesHeaderMenu) + .style(move |theme: &Theme, status| { + if open { + button::primary(theme, status) + } else { + button::subtle(theme, status) + } + }) + .padding([3, 5]); + if !open { + return menu_button.into(); + } + + let item = |label: &'static str, message| { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([4, 8]) + }; + let popup = container(column![ + item( + "Set all attributes ByLayer", + Message::GraphicAttributesSetAllByLayer, + ), + item( + "Set all attributes ByBlock", + Message::GraphicAttributesSetAllByBlock, + ), + item( + "Remove ByLayer / ByBlock", + Message::GraphicAttributesRemoveReferences, + ), + item( + "Create Layer with active settings", + Message::GraphicAttributesCreateLayer, + ), + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + iced_aw::DropDown::new(menu_button, popup, true) + .width(Length::Fixed(220.0)) + .height(Length::Shrink) + .alignment(popup_alignment(side)) + .offset(2.0) + .on_dismiss(Message::CloseGraphicAttributesHeaderMenu) + .into() +} + pub fn view<'a>( document: &'a CadDocument, properties: &'a crate::ui::properties::PropertiesPanel, @@ -759,57 +822,7 @@ pub fn view<'a>( gradient_editor: Option<&'a crate::ui::window::gradient_editor::GradientEditorState>, gradient_color_picker_open: bool, ) -> Element<'a, Message> { - let menu_button = button(crate::ui::icons::themed_secondary( - crate::ui::icons::MENU, - 12.0, - )) - .on_press(Message::ToggleGraphicAttributesHeaderMenu) - .style(move |theme: &Theme, status| { - if header_menu_open { - button::primary(theme, status) - } else { - button::subtle(theme, status) - } - }) - .padding([3, 5]); - let menu: Element<'a, Message> = if header_menu_open { - let item = |label: &'static str, message| { - button(text(label).size(11)) - .on_press(message) - .style(crate::ui::color_select::list_row_style) - .width(Fill) - .padding([4, 8]) - }; - let popup = container(column![ - item( - "Set all attributes ByLayer", - Message::GraphicAttributesSetAllByLayer, - ), - item( - "Set all attributes ByBlock", - Message::GraphicAttributesSetAllByBlock, - ), - item( - "Remove ByLayer / ByBlock", - Message::GraphicAttributesRemoveReferences, - ), - item( - "Create Layer with active settings", - Message::GraphicAttributesCreateLayer, - ), - ]) - .style(crate::ui::color_select::popup_panel_style) - .padding(2); - iced_aw::DropDown::new(menu_button, popup, true) - .width(Length::Fixed(220.0)) - .height(Length::Shrink) - .alignment(popup_alignment(side)) - .offset(2.0) - .on_dismiss(Message::CloseGraphicAttributesHeaderMenu) - .into() - } else { - menu_button.into() - }; + let menu = header_menu(side, header_menu_open); let pin_icon = if auto_collapse { crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) } else { @@ -1040,15 +1053,17 @@ pub fn view<'a>( .width(Fill) .into() } else { - let scale_value = ((scale.value * 10.0).round() / 10.0).clamp(0.1, 1000.0); + let scale_value = normalized_linetype_scale(scale.value).unwrap_or(1.0); let mut input = iced_aw::number_input( &scale_value, - 0.1..=1000.0, + LINETYPE_SCALE_MIN..=LINETYPE_SCALE_MAX, |value: f64| { - Message::LineLinetypeScaleChanged((value * 10.0).round() / 10.0) + Message::LineLinetypeScaleChanged( + normalized_linetype_scale(value).unwrap_or(1.0), + ) }, ) - .step(0.1) + .step(LINETYPE_SCALE_STEP) .set_size(crate::ui::ROW_H * 0.42) .padding([5, 5]) .input_style(move |theme: &Theme, status| {