feat(ribbon): highlight the running command's button

A command started from the command line, an alias, a shortcut or Repeat
now lights the same ribbon button a click on it would, so users learn
where the tools they type live. A button inside a dropdown lights the
dropdown, a slide-out tool lights its group title, a tool hidden in a
collapsed panel lights the panel's opener, and a button on another tab
lights that tab's header. Always on, in the existing active-button blue.

Buttons are matched by the command they dispatch, so ids that differ
from their command still resolve; the tab on show wins when a command
appears twice, and a clicked button keeps its highlight. Transparent
commands leave the running command lit. The #355 rule that a highlight
lasts only while something runs moves from the ribbon click handler into
dispatch_command, so typed commands are cleared the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Christopher OverbeckandClaude Opus 5.5 committed 2026-10-02 15:38:47 -05:00
1 parent 3f54f2f494
commit 1093900a49
11 files changed
+425 -33

No files matched your search

+70 -2
View File
@@ -92,7 +92,26 @@ impl OpenCADStudio {
}
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
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<Message> {
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<Message> {
@@ -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);
}
}
+3 -19
View File
@@ -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());