forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f54f2f494
commit
1093900a49
11 files changed
+425
-33
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+70
-2
@@ -92,7 +92,26 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
|
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
|
/// 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`
|
/// callers (ribbon, plugins, headless automation) use `dispatch_command`
|
||||||
/// and never get silent substitution.
|
/// and never get silent substitution.
|
||||||
pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> {
|
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> {
|
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task<Message> {
|
||||||
@@ -205,6 +226,12 @@ impl OpenCADStudio {
|
|||||||
return Task::none();
|
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) {
|
if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) {
|
||||||
// try_dispatch returns true for both finished commands and interactive
|
// try_dispatch returns true for both finished commands and interactive
|
||||||
// commands that it just installed. If no command is now active, the
|
// 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(['_', '.']);
|
let bare = cmd.trim_start_matches(['_', '.']);
|
||||||
(bare.len() != cmd.len() && !bare.is_empty()).then_some(bare)
|
(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.note_panel_tool(&tool_id);
|
||||||
self.ribbon.activate_tool(&tool_id);
|
self.ribbon.activate_tool(&tool_id);
|
||||||
match event {
|
match event {
|
||||||
ModuleEvent::Command(cmd) => {
|
// `dispatch_command` turns a one-shot tool's highlight off
|
||||||
let task = self.dispatch_command(&cmd);
|
// again once nothing is left running (#355).
|
||||||
// One-shot tools (view changes, clipboard, toggles,
|
ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd),
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
ModuleEvent::OpenFileDialog => {
|
ModuleEvent::OpenFileDialog => {
|
||||||
self.command_line
|
self.command_line
|
||||||
.push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref());
|
.push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref());
|
||||||
|
|||||||
@@ -207,22 +207,52 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> {
|
|||||||
panel_for_dropdown(id).map(|panel| panel.id)
|
panel_for_dropdown(id).map(|panel| panel.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn group_title<'a>(
|
/// The slide-out under the title of the ribbon group `title`, whose items
|
||||||
title: &'static str,
|
/// are `group_ids`.
|
||||||
group_ids: &[&'static str],
|
fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option<Panel> {
|
||||||
open: &Option<String>,
|
PANELS.iter().copied().find(|panel| {
|
||||||
) -> Element<'a, Message> {
|
|
||||||
let Some(panel) = PANELS.iter().copied().find(|panel| {
|
|
||||||
panel.title == title
|
panel.title == title
|
||||||
&& !panel.tools.is_empty()
|
&& !panel.tools.is_empty()
|
||||||
&& panel.anchor.is_none_or(|anchor| group_ids.contains(&anchor))
|
&& 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<Item = &'static str> {
|
||||||
|
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<String>,
|
||||||
|
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))
|
return container(text(t!(title)).size(9).style(muted_text_style))
|
||||||
.padding([1, 4])
|
.padding([1, 4])
|
||||||
.into();
|
.into();
|
||||||
};
|
};
|
||||||
let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id);
|
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 {
|
let arrow = if expanded {
|
||||||
icons::themed_arrow_up(GROUP_TITLE_ARROW_SIZE)
|
icons::themed_arrow_up(GROUP_TITLE_ARROW_SIZE)
|
||||||
} else {
|
} else {
|
||||||
@@ -239,7 +269,7 @@ pub(super) fn group_title<'a>(
|
|||||||
.align_y(iced::Center),
|
.align_y(iced::Center),
|
||||||
)
|
)
|
||||||
.on_press(Message::ToggleRibbonDropdown(panel.id.to_string()))
|
.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]),
|
.padding([1, 4]),
|
||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
@@ -421,7 +451,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element
|
|||||||
.map(|tool| {
|
.map(|tool| {
|
||||||
tool_button(
|
tool_button(
|
||||||
tool,
|
tool,
|
||||||
ribbon.active_tool.as_deref() == Some(tool.command),
|
tool_is_active(tool, ribbon.active_tool.as_deref()),
|
||||||
panel.id,
|
panel.id,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<Entry<'_>> {
|
||||||
|
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<Entry<'_>> {
|
||||||
|
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<Item = (usize, Entry<'_>)> {
|
||||||
|
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<usize> {
|
||||||
|
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<RibbonGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-3
@@ -30,6 +30,7 @@ pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, Under
|
|||||||
use widgets::{StyleContext, *};
|
use widgets::{StyleContext, *};
|
||||||
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
|
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
|
||||||
mod collapse;
|
mod collapse;
|
||||||
|
mod locate;
|
||||||
use collapse::{CollapsePanels, Panel};
|
use collapse::{CollapsePanels, Panel};
|
||||||
pub use collapse::CollapseMode;
|
pub use collapse::CollapseMode;
|
||||||
use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
|
use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow};
|
||||||
@@ -303,6 +304,10 @@ impl Ribbon {
|
|||||||
pub fn activate_tool(&mut self, id: &str) {
|
pub fn activate_tool(&mut self, id: &str) {
|
||||||
self.active_tool = Some(id.to_string());
|
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) {
|
pub fn deactivate_tool(&mut self) {
|
||||||
self.active_tool = None;
|
self.active_tool = None;
|
||||||
}
|
}
|
||||||
@@ -516,6 +521,9 @@ impl Ribbon {
|
|||||||
// The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks
|
// 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.
|
// 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(
|
let tab_items = self.modules.iter().enumerate().fold(
|
||||||
Vec::<Element<'_, Message>>::new(),
|
Vec::<Element<'_, Message>>::new(),
|
||||||
|mut acc, (i, module)| {
|
|mut acc, (i, module)| {
|
||||||
@@ -527,6 +535,7 @@ impl Ribbon {
|
|||||||
|
|
||||||
let is_active = i == self.active;
|
let is_active = i == self.active;
|
||||||
let is_contextual = module.id() == "layout";
|
let is_contextual = module.id() == "layout";
|
||||||
|
let holds_tool = holding_tab == Some(i);
|
||||||
let btn = container(
|
let btn = container(
|
||||||
button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12))
|
button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12))
|
||||||
.on_press(Message::RibbonSelectTab(i))
|
.on_press(Message::RibbonSelectTab(i))
|
||||||
@@ -539,6 +548,7 @@ impl Ribbon {
|
|||||||
};
|
};
|
||||||
let pair = match (is_active, status) {
|
let pair = match (is_active, status) {
|
||||||
(true, _) => palette.background.weakest,
|
(true, _) => palette.background.weakest,
|
||||||
|
(false, _) if holds_tool => palette.primary.weak,
|
||||||
(false, button::Status::Hovered) => {
|
(false, button::Status::Hovered) => {
|
||||||
if is_contextual {
|
if is_contextual {
|
||||||
palette.warning.weak
|
palette.warning.weak
|
||||||
@@ -550,9 +560,10 @@ impl Ribbon {
|
|||||||
};
|
};
|
||||||
button::Style {
|
button::Style {
|
||||||
background: (is_active
|
background: (is_active
|
||||||
|
|| holds_tool
|
||||||
|| matches!(status, button::Status::Hovered))
|
|| matches!(status, button::Status::Hovered))
|
||||||
.then_some(Background::Color(pair.color)),
|
.then_some(Background::Color(pair.color)),
|
||||||
text_color: if is_active {
|
text_color: if is_active || holds_tool {
|
||||||
pair.text
|
pair.text
|
||||||
} else if is_contextual {
|
} else if is_contextual {
|
||||||
accent.color
|
accent.color
|
||||||
@@ -1414,6 +1425,7 @@ fn render_group<'a>(
|
|||||||
group.title,
|
group.title,
|
||||||
&group.tools.iter().filter_map(item_id).collect::<Vec<_>>(),
|
&group.tools.iter().filter_map(item_id).collect::<Vec<_>>(),
|
||||||
open_dd,
|
open_dd,
|
||||||
|
active_tool.as_deref(),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
.align_x(iced::Center)
|
.align_x(iced::Center)
|
||||||
@@ -1495,6 +1507,19 @@ fn collapse_button<'a>(
|
|||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let title = group.title;
|
let title = group.title;
|
||||||
let localized_title = t!(title).into_owned();
|
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 + ▾.
|
// 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
|
// Clicking opens the flyout listing every tool; no tool runs directly at this
|
||||||
@@ -1525,7 +1550,7 @@ fn collapse_button<'a>(
|
|||||||
.width(Fill),
|
.width(Fill),
|
||||||
)
|
)
|
||||||
.on_press(Message::ToggleRibbonPanel(title.to_string()))
|
.on_press(Message::ToggleRibbonPanel(title.to_string()))
|
||||||
.style(button::subtle)
|
.style(opener_style(holds))
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.padding([3, 5]);
|
.padding([3, 5]);
|
||||||
return automatic_large_button(localized_title, content.into());
|
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.
|
// that runs the last-used tool — above a title + ▾ opener for the full flyout.
|
||||||
// For a Properties panel the representative is its Match button.
|
// For a Properties panel the representative is its Match button.
|
||||||
let rep = representative(group, last_used);
|
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 {
|
let face: Element<'_, Message> = match rep {
|
||||||
Some(RibbonItem::PropertiesGroup { match_prop }) => {
|
Some(RibbonItem::PropertiesGroup { match_prop }) => {
|
||||||
render_large(
|
render_large(
|
||||||
@@ -1590,7 +1617,7 @@ fn collapse_button<'a>(
|
|||||||
.align_y(iced::Center),
|
.align_y(iced::Center),
|
||||||
)
|
)
|
||||||
.on_press(Message::ToggleRibbonPanel(title.to_string()))
|
.on_press(Message::ToggleRibbonPanel(title.to_string()))
|
||||||
.style(button::subtle)
|
.style(opener_style(holds && !face_lit))
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.padding([1, 4]);
|
.padding([1, 4]);
|
||||||
let opener = automatic_large_button(localized_title, opener.into());
|
let opener = automatic_large_button(localized_title, opener.into());
|
||||||
|
|||||||
Reference in new issue
Block a user