diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 864365498..c0085e5ac 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -108,27 +108,7 @@ impl OpenCADStudio { // editors (shortcuts, aliases) — none of them read the scene. This is // the single place that decides; `on_ribbon_tool_click` defers to it // rather than keeping a second, blunter copy (#388, #389). - if self.tabs[i].is_start - && !matches!( - cmd, - "NEW" - | "OPEN" - | "EXIT" - | "QUIT" - | "REPORT" - | "CHANGELOG" - | "ABOUT" - | "PLUGINS" - | "PLUGINMANAGER" - | "DONATE" - | "WEBVERSION" - | "HELP" - | "CUI" - | "ALIASEDIT" - | "CUILOAD" - | "CUIIMPORT" - ) - { + if self.tabs[i].is_start && !start_allowed(cmd) { self.command_line .push_info("No drawing open. Use NEW or OPEN to start a drawing."); return Task::none(); @@ -235,6 +215,32 @@ impl OpenCADStudio { } } +/// Whether `cmd` makes sense on the Start (welcome) tab — document lifecycle, +/// links, and app-wide configuration; nothing that reads the scene. Single +/// source of truth: the dispatch gate refuses everything else, and the ribbon +/// dims the tools this rejects. +pub fn start_allowed(cmd: &str) -> bool { + matches!( + cmd, + "NEW" + | "OPEN" + | "EXIT" + | "QUIT" + | "REPORT" + | "CHANGELOG" + | "ABOUT" + | "PLUGINS" + | "PLUGINMANAGER" + | "DONATE" + | "WEBVERSION" + | "HELP" + | "CUI" + | "ALIASEDIT" + | "CUILOAD" + | "CUIIMPORT" + ) +} + // ── Autocomplete registry — one-shot commands ────────────────────────────── // These commands dispatch a single action (file ops, view, layer/style // managers, undo/redo, …) rather than installing an interactive `CadCommand`, diff --git a/src/app/mod.rs b/src/app/mod.rs index f14418145..c8e02cfb0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,7 +5,7 @@ mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; mod command_driver; -mod commands; +pub(crate) mod commands; mod document; mod expr_eval; mod helpers; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 784815f8f..976dc08c1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -114,7 +114,20 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.ribbon.note_panel_tool(&tool_id); self.ribbon.activate_tool(&tool_id); match event { - ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd), + ModuleEvent::Command(cmd) => { + let task = self.dispatch_command(&cmd); + // One-shot tools (view changes, clipboard, toggles, + // audits…) leave nothing running: no interactive + // command and no dialog. Their highlight would stick + // forever — turn it off now. Interactive commands and + // dialog owners keep theirs; the command end / modal + // close clears those. (#355) + let i = self.active_tab; + if self.tabs[i].active_cmd.is_none() && self.active_modal.is_none() { + self.ribbon.deactivate_tool(); + } + return task; + } ModuleEvent::OpenFileDialog => { self.command_line .push_info("Open DWG/DXF: not yet implemented."); @@ -166,6 +179,10 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven ); } } + // Every non-Command event above is a one-shot (state toggle, + // clear, dialog spawn) — nothing stays running to clear the + // highlight later, so turn it off here. (#355) + self.ribbon.deactivate_tool(); Task::none() } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 81ed06887..0d2dbf806 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -81,33 +81,6 @@ impl OpenCADStudio { self.scale_stage_discard(); } match self.active_modal { - Some(Layers) => self.ribbon.deactivate_tool_if("LAYERS"), - Some(Plot) => { - self.ribbon.deactivate_tool_if("PLOT"); - self.ribbon.deactivate_tool_if("PRINT"); - self.ribbon.deactivate_tool_if("PAGESETUP"); - } - Some(TextStyle) => { - self.ribbon.deactivate_tool_if("STYLE"); - self.ribbon.deactivate_tool_if("TEXTSTYLE"); - } - Some(TableStyle) => self.ribbon.deactivate_tool_if("TABLESTYLE"), - Some(MlStyle) => self.ribbon.deactivate_tool_if("MLSTYLE"), - Some(MLeaderStyle) => self.ribbon.deactivate_tool_if("MLEADERSTYLE"), - Some(LayoutManager) => { - self.ribbon.deactivate_tool_if("LAYOUTMANAGER"); - self.ribbon.deactivate_tool_if("LAYOUTPANEL"); - } - Some(Plotstyle) => { - self.ribbon.deactivate_tool_if("PLOTSTYLE"); - self.ribbon.deactivate_tool_if("STYLESMANAGER"); - } - Some(DimStyle) => self.ribbon.deactivate_tool_if("DIMSTYLE"), - Some(Shortcuts) => { - self.ribbon.deactivate_tool_if("SHORTCUTS"); - self.ribbon.deactivate_tool_if("KEYBOARD"); - } - Some(About) => self.ribbon.deactivate_tool_if("ABOUT"), // Dismissing these via ✕ is the cancel/decline path. Some(Unsaved) => self.pending_close = None, Some(AssocPrompt) => self.mark_assoc_prompted(), @@ -125,10 +98,17 @@ impl OpenCADStudio { // style editors. Committing happens only through the Apply button. Some(Aliases) => { self.alias_editor_rows.clear(); - self.ribbon.deactivate_tool_if("ALIASEDIT"); } _ => {} } + // The tool that opened this dialog is done with it now. Keep the + // highlight only while an interactive command still runs (it owns + // it). Replaces the old per-modal deactivate_tool_if list, which + // missed every newly added dialog (CUI, Plugin Manager, Point + // Style, Attribute Editor…). (#355) + if self.tabs[self.active_tab].active_cmd.is_none() { + self.ribbon.deactivate_tool(); + } self.active_modal = None; // Recentre / reset the size of the next dialog and drop any drag. self.modal_offset = iced::Vector::ZERO; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 965fbab36..d0be2b8b7 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1304,6 +1304,7 @@ impl OpenCADStudio { if !self.clean_screen { col = col.push(self.ribbon.view( is_paper, + self.tabs[self.active_tab].is_start, self.tabs[self.active_tab].history.undo_stack.len(), self.tabs[self.active_tab].history.redo_stack.len(), )); diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index bb634e113..22a44c3eb 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -312,6 +312,7 @@ impl Ribbon { fn toggle_state(&self) -> widgets::ToggleState { use widgets::ToggleState; ToggleState { + start_mode: false, wireframe: self.wireframe, ortho_mode: self.ortho_mode, show_viewcube: self.show_viewcube, @@ -387,16 +388,17 @@ impl Ribbon { pub fn view( &self, is_paper: bool, + is_start: bool, undo_count: usize, redo_count: usize, ) -> Element<'_, Message> { // ── Quick-access file commands + undo/redo, one merged flow ──────── let lead = WrapFlow::new(vec![ - quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW").into(), - quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN").into(), - quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE").into(), - quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS").into(), - quick_access_btn(crate::ui::icons::PRINT, "Print", "PRINT").into(), + quick_access_btn(crate::ui::icons::DOC_NEW, "New", "NEW", is_start).into(), + quick_access_btn(crate::ui::icons::FOLDER_OPEN, "Open", "OPEN", is_start).into(), + quick_access_btn(crate::ui::icons::SAVE, "Save", "SAVE", is_start).into(), + quick_access_btn(crate::ui::icons::FILE_EXPORT, "Save As", "SAVEAS", is_start).into(), + quick_access_btn(crate::ui::icons::PRINT, "Print", "PRINT", is_start).into(), render_history_control("Undo", UNDO_HISTORY_ID, undo_count, &self.open_dropdown).into(), render_history_control("Redo", REDO_HISTORY_ID, redo_count, &self.open_dropdown).into(), ]) @@ -575,7 +577,8 @@ impl Ribbon { let panels: Vec> = groups .iter() .map(|g| { - let ts = self.toggle_state(); + let mut ts = self.toggle_state(); + ts.start_mode = is_start; Panel { id: g.title.to_string(), full: render_group( diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index aa8ef6d02..f555e6795 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -25,6 +25,9 @@ use super::LayerInfo; /// and call site. #[derive(Clone, Copy)] pub(super) struct ToggleState { + /// Start (welcome) tab is active — tools whose command the start-tab + /// gate refuses render dimmed and read as unusable. + pub start_mode: bool, pub wireframe: bool, pub ortho_mode: bool, pub show_viewcube: bool, @@ -299,6 +302,32 @@ pub(super) fn make_icon(icon: IconKind, size: f32) -> Element<'static, Message> } } +/// Unusable on the Start tab: dim it. Mirrors the dispatch gate — the single +/// authority is `crate::app::commands::start_allowed`. +pub(super) fn start_dimmed(state: &ToggleState, event: &ModuleEvent) -> bool { + state.start_mode + && !matches!(event, ModuleEvent::Command(c) if crate::app::commands::start_allowed(c)) +} + +/// Label / glyph color for a possibly-dimmed tool. +pub(super) const DIM_TOOL: Color = Color { + r: 0.42, + g: 0.42, + b: 0.45, + a: 1.0, +}; + +/// `make_icon`, greyed out when `dim` (SVGs render monochrome via tint). +pub(super) fn make_icon_dim(icon: IconKind, size: f32, dim: bool) -> Element<'static, Message> { + if !dim { + return make_icon(icon, size); + } + match icon { + IconKind::Glyph(s) => text(s).size(size * 0.7).color(DIM_TOOL).into(), + IconKind::Svg(bytes) => icons::tinted(bytes, size, DIM_TOOL), + } +} + pub(super) fn is_active_tool( id: &str, active_tool: &Option, @@ -373,10 +402,11 @@ pub(super) fn render_small<'a>( // large buttons to icon-only columns when the width is tight. RibbonItem::Tool(t) | RibbonItem::LargeTool(t) => { let active = is_active_tool(t.id, active_tool, &state); + let dim = start_dimmed(&state, &t.event); let event = t.event.clone(); let tool_id = t.id.to_string(); let tip_text = format!("{}\nCommand: {}", t.label, t.id); - let btn = button(make_icon(t.icon, SMALL_ICON)) + let btn = button(make_icon_dim(t.icon, SMALL_ICON, dim)) .on_press(Message::RibbonToolClick { tool_id, event }) .style(move |_: &Theme, status| tool_btn_style(active, status)) .width(Length::Fixed(SMALL_W)) @@ -403,10 +433,15 @@ pub(super) fn render_small<'a>( default, .. } => { - let active = active_tool.as_deref() == Some(*id) - || items + let dim = state.start_mode + && !items .iter() - .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd)); + .any(|(cmd, _, _)| crate::app::commands::start_allowed(cmd)); + let active = !dim + && (active_tool.as_deref() == Some(*id) + || items + .iter() + .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd))); let dd_open = open_dd.as_deref() == Some(*id); let last = last_cmd.get(id).copied().unwrap_or(*default); let cur_icon = last_cmd @@ -434,7 +469,7 @@ pub(super) fn render_small<'a>( .unwrap_or(*id); let tip_text = format!("{}\nCommand: {}", cur_label, last); - let icon_btn = button(make_icon(cur_icon, SMALL_ICON)) + let icon_btn = button(make_icon_dim(cur_icon, SMALL_ICON, dim)) .on_press(Message::RibbonToolClick { tool_id: last.to_string(), event: ModuleEvent::Command(last.to_string()), @@ -505,11 +540,13 @@ pub(super) fn render_large_dropdown<'a>( active_tool: &Option, open_dd: &Option, last_cmd: &HashMap<&'static str, &'static str>, + dim: bool, ) -> Element<'a, Message> { - let active = active_tool.as_deref() == Some(id) - || items - .iter() - .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd)); + let active = !dim + && (active_tool.as_deref() == Some(id) + || items + .iter() + .any(|(cmd, _, _)| active_tool.as_deref() == Some(*cmd))); let dd_open = open_dd.as_deref() == Some(id); let last = last_cmd.get(id).copied().unwrap_or(default); let cur_icon = last_cmd @@ -531,8 +568,8 @@ pub(super) fn render_large_dropdown<'a>( // Icon on top with the label beneath it, then the ▾ strip at the very bottom. let top_btn = button( column![ - make_icon(cur_icon, LARGE_ICON), - text(label.to_string()).size(10).color(LABEL_COLOR), + make_icon_dim(cur_icon, LARGE_ICON, dim), + text(label.to_string()).size(10).color(if dim { DIM_TOOL } else { LABEL_COLOR }), ] .align_x(iced::Center) .spacing(3), @@ -615,13 +652,14 @@ pub(super) fn render_large<'a>( // representative tool as a big icon. RibbonItem::LargeTool(t) | RibbonItem::Tool(t) => { let active = is_active_tool(t.id, active_tool, &state); + let dim = start_dimmed(&state, &t.event); let event = t.event.clone(); let tool_id = t.id.to_string(); let tip_text = format!("{}\nCommand: {}", t.label, t.id); let btn = button( column![ - make_icon(t.icon, LARGE_ICON), - text(t.label).size(10).color(LABEL_COLOR), + make_icon_dim(t.icon, LARGE_ICON, dim), + text(t.label).size(10).color(if dim { DIM_TOOL } else { LABEL_COLOR }), ] .align_x(iced::Center) .spacing(3), @@ -649,16 +687,23 @@ pub(super) fn render_large<'a>( icon, items, default, - } => render_large_dropdown( - *id, - *icon, - Some(*label), - items, - *default, - active_tool, - open_dd, - last_cmd, - ), + } => { + let dim = state.start_mode + && !items + .iter() + .any(|(cmd, _, _)| crate::app::commands::start_allowed(cmd)); + render_large_dropdown( + *id, + *icon, + Some(*label), + items, + *default, + active_tool, + open_dd, + last_cmd, + dim, + ) + } // A plain Dropdown renders large too (used by a collapsed panel whose // representative tool is a dropdown). @@ -667,9 +712,16 @@ pub(super) fn render_large<'a>( icon, items, default, - } => render_large_dropdown( - *id, *icon, None, items, *default, active_tool, open_dd, last_cmd, - ), + } => { + let dim = state.start_mode + && !items + .iter() + .any(|(cmd, _, _)| crate::app::commands::start_allowed(cmd)); + render_large_dropdown( + *id, *icon, None, items, *default, active_tool, open_dd, last_cmd, + dim, + ) + } RibbonItem::LayerComboGroup { row2, row3 } => { const COMBO_W: f32 = LARGE_W * 2.5; @@ -736,9 +788,12 @@ pub(super) fn render_large<'a>( .iter() .map(|t| { let is_active = active_tool.as_deref() == Some(t.id); + let dim = start_dimmed(&state, &t.event); let tip = t.label; let event = t.event.clone(); - let icon_el: Element = match t.icon { + let icon_el: Element = if dim { + make_icon_dim(t.icon, 16.0, true) + } else { match t.icon { IconKind::Glyph(g) => text(g).size(13).color(Color::WHITE).into(), IconKind::Svg(bytes) => { iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes)) @@ -746,7 +801,7 @@ pub(super) fn render_large<'a>( .height(16) .into() } - }; + } }; let msg = module_event_to_message(event); tooltip( button(icon_el) @@ -802,13 +857,14 @@ pub(super) fn render_large<'a>( ) } else { let mp_active = is_active_tool(match_prop.id, active_tool, &state); + let mp_dim = start_dimmed(&state, &match_prop.event); let mp_event = match_prop.event.clone(); let mp_id = match_prop.id.to_string(); let mp_tip = format!("{}\nCommand: {}", match_prop.label, match_prop.id); let mp_btn = button( column![ - make_icon(match_prop.icon, LARGE_ICON), - text(match_prop.label).size(10).color(LABEL_COLOR), + make_icon_dim(match_prop.icon, LARGE_ICON, mp_dim), + text(match_prop.label).size(10).color(if mp_dim { DIM_TOOL } else { LABEL_COLOR }), ] .align_x(iced::Center) .spacing(3), @@ -984,9 +1040,12 @@ pub(super) fn render_large<'a>( .iter() .map(|t| { let is_active = active_tool.as_deref() == Some(t.id); + let dim = start_dimmed(&state, &t.event); let tip = t.label; let event = t.event.clone(); - let icon_el: Element = match t.icon { + let icon_el: Element = if dim { + make_icon_dim(t.icon, 16.0, true) + } else { match t.icon { IconKind::Glyph(g) => text(g).size(13).color(Color::WHITE).into(), IconKind::Svg(bytes) => { iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes)) @@ -994,7 +1053,7 @@ pub(super) fn render_large<'a>( .height(16) .into() } - }; + } }; let msg = module_event_to_message(event); tooltip( button(icon_el) @@ -1063,10 +1122,13 @@ pub(super) fn quick_access_btn<'a>( icon_bytes: &'static [u8], label: &'static str, cmd: &'static str, + is_start: bool, ) -> Element<'a, Message> { // The bundled UI SVGs are black-stroked; tint them to a light chrome grey so // they read on the dark top strip (raw black is invisible there). - let icon = icons::tinted(icon_bytes, 16.0, QA_ICON_COLOR); + // On the Start tab, commands the start gate refuses render dimmed. + let dim = is_start && !crate::app::commands::start_allowed(cmd); + let icon = icons::tinted(icon_bytes, 16.0, if dim { DIM_TOOL } else { QA_ICON_COLOR }); let btn = button( container(icon) .width(Fill)