diff --git a/docs/screenshots/ribbon-highlight/after-dimbreak.png b/docs/screenshots/ribbon-highlight/after-dimbreak.png new file mode 100644 index 000000000..b8dff2f51 Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-dimbreak.png differ diff --git a/docs/screenshots/ribbon-highlight/after-offset.png b/docs/screenshots/ribbon-highlight/after-offset.png new file mode 100644 index 000000000..d7c79c1f7 Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-offset.png differ diff --git a/docs/screenshots/ribbon-highlight/after-xline.png b/docs/screenshots/ribbon-highlight/after-xline.png new file mode 100644 index 000000000..e0edc2e7a Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-xline.png differ diff --git a/docs/screenshots/ribbon-highlight/before-dimbreak.png b/docs/screenshots/ribbon-highlight/before-dimbreak.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-dimbreak.png differ diff --git a/docs/screenshots/ribbon-highlight/before-offset.png b/docs/screenshots/ribbon-highlight/before-offset.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-offset.png differ diff --git a/docs/screenshots/ribbon-highlight/before-xline.png b/docs/screenshots/ribbon-highlight/before-xline.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-xline.png differ diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 7b9f22f2b..43ea7742b 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -92,7 +92,26 @@ impl OpenCADStudio { } pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task { - self.dispatch_command_inner(cmd, false) + let task = self.dispatch_command_inner(cmd, false); + self.clear_idle_tool_highlight(); + task + } + + /// Turn the ribbon highlight off when the command just dispatched left + /// nothing running: no interactive command, dialog or navigation mode. + /// One-shot commands (view changes, clipboard, toggles, audits…) would + /// otherwise leave it lit forever; interactive commands and dialog owners + /// keep theirs until the command end / modal close clears it. (#355) + pub(in crate::app) fn clear_idle_tool_highlight(&mut self) { + let i = self.active_tab; + if self.tabs[i].active_cmd.is_none() + && self.active_modal.is_none() + && !self.tabs[i].pan_mode + && !self.tabs[i].orbit_mode + && !self.tabs[i].zoom_dynamic_mode + { + self.ribbon.deactivate_tool(); + } } /// Dispatch a verb typed at the interactive command line, falling back to @@ -104,7 +123,9 @@ impl OpenCADStudio { /// callers (ribbon, plugins, headless automation) use `dispatch_command` /// and never get silent substitution. pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task { - self.dispatch_command_inner(cmd, true) + let task = self.dispatch_command_inner(cmd, true); + self.clear_idle_tool_highlight(); + task } fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task { @@ -205,6 +226,12 @@ impl OpenCADStudio { return Task::none(); } + // Light the ribbon button of the command now starting, however it was + // started (typed, alias, shortcut, Repeat), so the user sees where it + // lives. A transparent command returned above and leaves the running + // command's highlight alone. + self.ribbon.show_command(cmd); + if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) { // try_dispatch returns true for both finished commands and interactive // commands that it just installed. If no command is now active, the @@ -1071,3 +1098,44 @@ fn strip_command_prefixes(cmd: &str) -> Option<&str> { let bare = cmd.trim_start_matches(['_', '.']); (bare.len() != cmd.len() && !bare.is_empty()).then_some(bare) } + +#[cfg(test)] +mod ribbon_highlight_tests { + use crate::app::OpenCADStudio; + + fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn a_typed_alias_lights_the_button_of_the_command_it_starts() { + let mut app = fresh(); + let _ = app.dispatch_command_or_suggest("O"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + assert_eq!(app.ribbon.active_tool(), Some("OFFSET")); + let _ = app.dispatch_command_or_suggest("L"); + assert_eq!(app.ribbon.active_tool(), Some("LINE")); + } + + #[test] + fn a_transparent_command_keeps_the_running_command_lit() { + let mut app = fresh(); + let _ = app.dispatch_command_or_suggest("LINE"); + let _ = app.dispatch_command_or_suggest("'ZOOM E"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + assert_eq!(app.ribbon.active_tool(), Some("LINE")); + } + + #[test] + fn a_one_shot_command_leaves_nothing_lit() { + // REGEN cancels the running LINE and finishes at once, so neither + // its own button nor LINE's may stay lit. + let mut app = fresh(); + let _ = app.dispatch_command_or_suggest("LINE"); + let _ = app.dispatch_command_or_suggest("REGEN"); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + assert_eq!(app.ribbon.active_tool(), None); + } +} diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 943ab3d4a..197091807 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -120,25 +120,9 @@ 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) => { - 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.tabs[i].pan_mode - && !self.tabs[i].orbit_mode - && !self.tabs[i].zoom_dynamic_mode - { - self.ribbon.deactivate_tool(); - } - return task; - } + // `dispatch_command` turns a one-shot tool's highlight off + // again once nothing is left running (#355). + ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd), ModuleEvent::OpenFileDialog => { self.command_line .push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref()); diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index fbebf55ee..d76bba909 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -207,22 +207,52 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> { panel_for_dropdown(id).map(|panel| panel.id) } -pub(super) fn group_title<'a>( - title: &'static str, - group_ids: &[&'static str], - open: &Option, -) -> Element<'a, Message> { - let Some(panel) = PANELS.iter().copied().find(|panel| { +/// The slide-out under the title of the ribbon group `title`, whose items +/// are `group_ids`. +fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option { + PANELS.iter().copied().find(|panel| { panel.title == title && !panel.tools.is_empty() && panel.anchor.is_none_or(|anchor| group_ids.contains(&anchor)) }) - else { +} + +/// Every command the slide-out of the group `title` offers: each tool and +/// each of its submenu options. +pub(super) fn slide_out_commands( + title: &str, + group_ids: &[&'static str], +) -> impl Iterator { + panel_for_group(title, group_ids) + .into_iter() + .flat_map(|panel| panel.tools) + .flat_map(|tool| { + std::iter::once(tool.command).chain(tool.options.iter().map(|(cmd, _)| *cmd)) + }) +} + +/// A slide-out tool is lit while it or one of its submenu options runs. +fn tool_is_active(tool: &Tool, active_tool: Option<&str>) -> bool { + active_tool.is_some_and(|active| { + tool.command == active || tool.options.iter().any(|(cmd, _)| *cmd == active) + }) +} + +pub(super) fn group_title<'a>( + title: &'static str, + group_ids: &[&'static str], + open: &Option, + active_tool: Option<&str>, +) -> Element<'a, Message> { + let Some(panel) = panel_for_group(title, group_ids) else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); }; let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id); + // A command running from the closed slide-out lights its title, the way a + // dropdown lights while one of its items runs. + let lit = expanded || panel.tools.iter().any(|tool| tool_is_active(tool, active_tool)); let arrow = if expanded { icons::themed_arrow_up(GROUP_TITLE_ARROW_SIZE) } else { @@ -239,7 +269,7 @@ pub(super) fn group_title<'a>( .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(panel.id.to_string())) - .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, lit, status)) .padding([1, 4]), ) .into(); @@ -421,7 +451,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .map(|tool| { tool_button( tool, - ribbon.active_tool.as_deref() == Some(tool.command), + tool_is_active(tool, ribbon.active_tool.as_deref()), panel.id, ) }) diff --git a/src/ui/ribbon/locate.rs b/src/ui/ribbon/locate.rs new file mode 100644 index 000000000..e4dd32c35 --- /dev/null +++ b/src/ui/ribbon/locate.rs @@ -0,0 +1,283 @@ +//! Where the ribbon offers a command, so a command started from the command +//! line, an alias, a shortcut or Repeat lights the same button a click on it +//! would. Seeing which button runs a typed command is how its place in the +//! ribbon is learned. + +use crate::modules::{ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +use super::{draw_panel, item_id, Ribbon}; + +/// One button of a group: the id its highlight is keyed on, and the command +/// line it dispatches (`None` for a button that raises a non-command event). +type Entry<'a> = (&'static str, Option<&'a str>); + +fn tool_entry(t: &ToolDef) -> Entry<'_> { + match &t.event { + ModuleEvent::Command(cmd) => (t.id, Some(cmd.as_str())), + _ => (t.id, None), + } +} + +/// The buttons one ribbon item shows: itself, the items of a dropdown, or the +/// small tools under a combo. A dropdown item is keyed on the command it +/// dispatches, which is what the dropdown's highlight looks for. +fn item_entries(item: &RibbonItem) -> Vec> { + match item { + RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => { + vec![tool_entry(t)] + } + RibbonItem::PropertiesGroup { match_prop } => vec![tool_entry(match_prop)], + RibbonItem::Dropdown { items, .. } + | RibbonItem::LabeledDropdown { items, .. } + | RibbonItem::LargeDropdown { items, .. } => { + items.iter().map(|(cmd, _, _)| (*cmd, Some(*cmd))).collect() + } + RibbonItem::ToolGrid { columns } | RibbonItem::StyleComboGroup { rows: columns, .. } => { + columns.iter().flatten().map(tool_entry).collect() + } + RibbonItem::LayerComboGroup { row2, row3 } => { + row2.iter().chain(row3).map(tool_entry).collect() + } + } +} + +/// Every button `group` offers: its items' buttons and the tools of its title +/// slide-out, keyed like dropdown items on the command they dispatch. +pub(super) fn group_entries(group: &RibbonGroup) -> Vec> { + let mut out: Vec<_> = group.tools.iter().flat_map(item_entries).collect(); + let ids: Vec<_> = group.tools.iter().filter_map(item_id).collect(); + out.extend(draw_panel::slide_out_commands(group.title, &ids).map(|cmd| (cmd, Some(cmd)))); + out +} + +/// Whether `item` shows the button keyed `id`, lit or as the item a lit +/// dropdown holds. +pub(super) fn item_holds(item: &RibbonItem, id: &str) -> bool { + item_entries(item).iter().any(|(entry, _)| *entry == id) +} + +/// Whether `group` holds the button keyed `id`. +pub(super) fn group_holds(group: &RibbonGroup, id: &str) -> bool { + group_entries(group).iter().any(|(entry, _)| *entry == id) +} + +impl Ribbon { + /// Every button of every tab, with the index of the tab it is on. The + /// tab on show comes first, so a command offered on two tabs lights the + /// button the user can already see. + fn entries(&self) -> impl Iterator)> { + let order = std::iter::once(self.active) + .chain((0..self.modules.len()).filter(move |i| *i != self.active)); + order + .filter_map(|i| self.modules.get(i).map(|m| (i, m))) + .flat_map(|(i, module)| { + module + .ribbon_groups() + .iter() + .flat_map(group_entries) + .map(move |entry| (i, entry)) + }) + } + + /// The button id that runs the command line `cmd`: an exact match first, + /// then a button that runs its bare verb (`OFFSET 5` → Offset). + pub(super) fn tool_for_command(&self, cmd: &str) -> Option<&'static str> { + let cmd = cmd.trim(); + let verb = cmd.split_whitespace().next().unwrap_or(cmd); + [cmd, verb].into_iter().find_map(|want| { + self.entries() + .find(|(_, (_, runs))| runs.is_some_and(|runs| runs.eq_ignore_ascii_case(want))) + .map(|(_, (id, _))| id) + }) + } + + /// Light the ribbon button of the command line `cmd` that is starting, + /// however it was started. A button the user just clicked to run `cmd` + /// stays lit, so a command offered on two tabs keeps the clicked one; a + /// command with no button clears the previous command's highlight. + pub fn show_command(&mut self, cmd: &str) { + let clicked = self.active_tool.as_deref().is_some_and(|current| { + self.entries().any(|(_, (id, runs))| { + id == current && runs.is_some_and(|runs| runs.eq_ignore_ascii_case(cmd.trim())) + }) + }); + if !clicked { + self.active_tool = self.tool_for_command(cmd).map(str::to_string); + } + } + + /// The tab holding the lit button, when it is not the tab on show: its + /// header lights instead, pointing at where the button lives. The Layout + /// module has no tab (its tools are on the side toolbar). + pub(super) fn tab_holding_active_tool(&self) -> Option { + let id = self.active_tool.as_deref()?; + let holds = |i: usize| { + self.modules[i] + .ribbon_groups() + .iter() + .any(|group| group_holds(group, id)) + }; + if self + .modules + .get(self.active) + .is_some_and(|_| holds(self.active)) + { + return None; + } + (0..self.modules.len()).find(|i| self.modules[*i].id() != "layout" && holds(*i)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::{CadModule, IconKind}; + + struct TestTab { + id: &'static str, + groups: Vec, + } + + impl CadModule for TestTab { + fn id(&self) -> &'static str { + self.id + } + fn title(&self) -> &'static str { + self.id + } + fn ribbon_groups(&self) -> &[RibbonGroup] { + &self.groups + } + } + + fn tool(id: &'static str, cmd: &str) -> ToolDef { + ToolDef { + id, + label: id, + icon: IconKind::Glyph("x"), + event: ModuleEvent::Command(cmd.to_string()), + } + } + + /// Home: OFFSET (button id differs from its command), a CIRCLE dropdown, + /// LAYERS (a non-command event). View: ZOOM EXTENTS and a second OFFSET. + fn ribbon() -> Ribbon { + let mut layers = tool("LAYERS", ""); + layers.event = ModuleEvent::ToggleLayers; + let home = TestTab { + id: "home", + groups: vec![RibbonGroup { + title: "Modify", + tools: vec![ + RibbonItem::LargeTool(tool("OFFSET_TOOL", "OFFSET")), + RibbonItem::Dropdown { + id: "circle_dd", + icon: IconKind::Glyph("o"), + items: vec![ + ("CIRCLE", "Center, Radius", IconKind::Glyph("o")), + ("CIRCLE_2P", "2-Point", IconKind::Glyph("o")), + ], + default: "CIRCLE", + }, + RibbonItem::Tool(layers), + ], + }], + }; + let view = TestTab { + id: "view", + groups: vec![RibbonGroup { + title: "Navigate", + tools: vec![ + RibbonItem::Tool(tool("ZOOM_EXTENTS", "ZOOM EXTENTS")), + RibbonItem::Tool(tool("OFFSET_VIEW", "OFFSET")), + ], + }], + }; + let mut ribbon = Ribbon::default(); + ribbon.set_modules(vec![Box::new(home), Box::new(view)]); + ribbon + } + + #[test] + fn a_command_lights_the_button_that_runs_it_not_one_named_like_it() { + let mut r = ribbon(); + r.show_command("OFFSET"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL")); + assert_eq!(r.tab_holding_active_tool(), None); + } + + #[test] + fn a_dropdown_item_is_keyed_on_its_command_so_the_dropdown_lights() { + let mut r = ribbon(); + r.show_command("circle_2p"); + assert_eq!(r.active_tool.as_deref(), Some("CIRCLE_2P")); + } + + #[test] + fn a_button_on_another_tab_lights_that_tab() { + let mut r = ribbon(); + r.show_command("ZOOM EXTENTS"); + assert_eq!(r.active_tool.as_deref(), Some("ZOOM_EXTENTS")); + assert_eq!(r.tab_holding_active_tool(), Some(1)); + r.select(1); + assert_eq!(r.tab_holding_active_tool(), None); + } + + #[test] + fn a_command_on_two_tabs_prefers_the_tab_on_show_then_the_clicked_one() { + let mut r = ribbon(); + r.select(1); + r.show_command("OFFSET"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW")); + // A clicked button keeps its highlight when its command dispatches, + // even though another button for the same command comes first. + r.select(0); + r.activate_tool("OFFSET_VIEW"); + r.show_command("OFFSET"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW")); + } + + #[test] + fn arguments_fall_back_to_the_bare_verb() { + let mut r = ribbon(); + r.show_command("OFFSET 5"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL")); + } + + #[test] + fn a_command_without_a_button_clears_the_previous_highlight() { + let mut r = ribbon(); + r.show_command("OFFSET"); + r.show_command("DIST"); + assert_eq!(r.active_tool, None); + assert_eq!(r.tab_holding_active_tool(), None); + // A non-command button has no command line to match. + r.show_command("LAYERS"); + assert_eq!(r.active_tool, None); + } + + #[test] + fn slide_out_tools_and_their_options_belong_to_their_group() { + // The Draw group's title slide-out holds SPLINE; the Modify one holds + // the draw-order submenu. + let draw = RibbonGroup { + title: "Draw", + tools: vec![], + }; + assert!(group_holds(&draw, "SPLINE")); + let modify = RibbonGroup { + title: "Modify", + tools: vec![], + }; + assert!(group_holds(&modify, "DRAWORDER_BACK")); + } + + #[test] + fn the_shipped_ribbon_resolves_its_own_commands() { + let mut r = Ribbon::default(); + for cmd in ["LINE", "OFFSET", "SPLINE", "ZOOM EXTENTS"] { + r.show_command(cmd); + assert!(r.active_tool.is_some(), "{cmd} has no ribbon button"); + } + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 20989bd37..1546cb166 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -30,6 +30,7 @@ pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, Under use widgets::{StyleContext, *}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; mod collapse; +mod locate; use collapse::{CollapsePanels, Panel}; pub use collapse::CollapseMode; use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; @@ -303,6 +304,10 @@ impl Ribbon { pub fn activate_tool(&mut self, id: &str) { self.active_tool = Some(id.to_string()); } + #[cfg(test)] + pub(crate) fn active_tool(&self) -> Option<&str> { + self.active_tool.as_deref() + } pub fn deactivate_tool(&mut self) { self.active_tool = None; } @@ -516,6 +521,9 @@ impl Ribbon { // The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks // them so a wrapped tab never shares a row with a quick-access button. + // A running command whose button is on another tab lights that tab's + // header in the button's own blue, pointing at where the button lives. + let holding_tab = self.tab_holding_active_tool(); let tab_items = self.modules.iter().enumerate().fold( Vec::>::new(), |mut acc, (i, module)| { @@ -527,6 +535,7 @@ impl Ribbon { let is_active = i == self.active; let is_contextual = module.id() == "layout"; + let holds_tool = holding_tab == Some(i); let btn = container( button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12)) .on_press(Message::RibbonSelectTab(i)) @@ -539,6 +548,7 @@ impl Ribbon { }; let pair = match (is_active, status) { (true, _) => palette.background.weakest, + (false, _) if holds_tool => palette.primary.weak, (false, button::Status::Hovered) => { if is_contextual { palette.warning.weak @@ -550,9 +560,10 @@ impl Ribbon { }; button::Style { background: (is_active + || holds_tool || matches!(status, button::Status::Hovered)) .then_some(Background::Color(pair.color)), - text_color: if is_active { + text_color: if is_active || holds_tool { pair.text } else if is_contextual { accent.color @@ -1414,6 +1425,7 @@ fn render_group<'a>( group.title, &group.tools.iter().filter_map(item_id).collect::>(), open_dd, + active_tool.as_deref(), ), ] .align_x(iced::Center) @@ -1495,6 +1507,19 @@ fn collapse_button<'a>( ) -> Element<'a, Message> { let title = group.title; let localized_title = t!(title).into_owned(); + // A collapsed panel lights its opener while one of its hidden tools runs, + // the way a dropdown lights while one of its items does. + let active = active_tool.as_deref(); + let holds = active.is_some_and(|id| locate::group_holds(group, id)); + let opener_style = move |lit: bool| { + move |theme: &Theme, status| { + if lit { + tool_btn_style(theme, true, status) + } else { + button::subtle(theme, status) + } + } + }; // Tightest form: one button = the panel's FIRST tool icon + its title + ▾. // Clicking opens the flyout listing every tool; no tool runs directly at this @@ -1525,7 +1550,7 @@ fn collapse_button<'a>( .width(Fill), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) - .style(button::subtle) + .style(opener_style(holds)) .width(Fill) .padding([3, 5]); return automatic_large_button(localized_title, content.into()); @@ -1535,6 +1560,8 @@ fn collapse_button<'a>( // that runs the last-used tool — above a title + ▾ opener for the full flyout. // For a Properties panel the representative is its Match button. let rep = representative(group, last_used); + // The face lights itself when it is the running tool. + let face_lit = rep.zip(active).is_some_and(|(item, id)| locate::item_holds(item, id)); let face: Element<'_, Message> = match rep { Some(RibbonItem::PropertiesGroup { match_prop }) => { render_large( @@ -1590,7 +1617,7 @@ fn collapse_button<'a>( .align_y(iced::Center), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) - .style(button::subtle) + .style(opener_style(holds && !face_lit)) .width(Fill) .padding([1, 4]); let opener = automatic_large_button(localized_title, opener.into());