feat(ui): show ribbon command icons in menus and suggestions

The ribbon definition beside each command stays the one source of its
label and icon. One cached lookup (modules::registry::ribbon_commands)
walks the core ribbon and the expanded Draw/Modify panels; the shortcut
list, the right-click menu, the grip menu and the command-line
suggestions read it.

- Context menu: command rows carry their ribbon icon; Undo/Redo and
  Isolate use interface glyphs, Draw Order its ribbon icon; icons and
  the caret fade on disabled rows.
- Grip menu: Stretch, Move, Rotate, Scale, Mirror and Copy icons.
- Suggestions: an icon column, aligned when a command has none.
- Shortcut list: dropdown entries were read as (label, command) though
  the ribbon stores (command, label), so they never got a label or icon.

Reworked from #1449.

Co-Authored-By: MarcelP102 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
committed 2026-09-24 22:46:32 +03:00
1 parent 6b37476687
commit 7ed6a362dc
8 files changed
+195 -81

No files matched your search

+6 -46
View File
@@ -299,7 +299,7 @@ impl OpenCADStudio {
}
pub(super) fn actions() -> Vec<Action> {
use crate::modules::{IconKind, ModuleEvent, RibbonItem, ToolDef};
use crate::modules::IconKind;
use std::collections::BTreeMap;
let mut names = crate::command::all_registered_command_names();
names.extend([
@@ -342,56 +342,16 @@ pub(super) fn actions() -> Vec<Action> {
)
})
.collect();
fn describe(
actions: &mut BTreeMap<String, Action>,
command: &str,
label: &str,
icon: IconKind,
) {
if let Some(action) = actions.get_mut(command) {
action.label = crate::t!(label).into_owned();
for (command, (label, icon)) in crate::modules::registry::ribbon_commands() {
if let Some(action) = actions.get_mut(command.as_str()) {
action.label = crate::t!(*label).into_owned();
action.description = format!("{} ({command})", action.label);
action.icon = match icon {
IconKind::Svg(bytes) => Some(bytes),
_ => None,
IconKind::Svg(bytes) => Some(*bytes),
IconKind::Glyph(_) => None,
};
}
}
fn tool(actions: &mut BTreeMap<String, Action>, tool: &ToolDef) {
if let ModuleEvent::Command(command) = &tool.event {
describe(actions, command, tool.label, tool.icon);
}
}
for module in crate::modules::registry::all_modules() {
for group in module.ribbon_groups() {
for item in &group.tools {
match item {
RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => {
tool(&mut actions, t)
}
RibbonItem::Dropdown { items, .. }
| RibbonItem::LabeledDropdown { items, .. }
| RibbonItem::LargeDropdown { items, .. } => {
for (label, command, icon) in items {
describe(&mut actions, command, label, *icon);
}
}
RibbonItem::ToolGrid { columns }
| RibbonItem::StyleComboGroup { rows: columns, .. } => {
for t in columns.iter().flatten() {
tool(&mut actions, t);
}
}
RibbonItem::LayerComboGroup { row2, row3 } => {
for t in row2.iter().chain(row3) {
tool(&mut actions, t);
}
}
RibbonItem::PropertiesGroup { match_prop } => tool(&mut actions, match_prop),
}
}
}
}
actions.into_values().collect()
}
+25 -15
View File
@@ -1124,20 +1124,27 @@ impl Widget<Message, Theme, iced::Renderer> for ClampedPin<'_> {
/// panel's border, when the menu opens.
const MENU_CURSOR_INSET_X: f32 = 24.0;
/// Width of the icon gutter every row reserves so labels line up whether or
/// not the row carries a glyph (object snaps, Pan / Zoom).
/// not the row carries a glyph (object snaps, command icons).
const MENU_GUTTER_W: f32 = 18.0;
const MENU_ICON_SIZE: f32 = 14.0;
/// The gutter cell: the row's glyph, or empty space of the same width.
fn context_menu_gutter(icon: Option<MenuIcon>) -> Element<'static, Message> {
let bytes = icon.map(|icon| match icon {
MenuIcon::Snap(t) => crate::ui::icons::osnap(t),
MenuIcon::Mtp => crate::ui::icons::mtp_icon(),
MenuIcon::Pan => crate::ui::icons::pan_icon(),
MenuIcon::Zoom => crate::ui::icons::zoom_icon(),
});
let cell: Element<'static, Message> = match bytes {
Some(bytes) => crate::ui::icons::themed::<Message>(bytes, MENU_ICON_SIZE),
/// The gutter cell: the row's glyph, faded with a disabled row, or empty
/// space of the same width.
fn context_menu_gutter(icon: Option<MenuIcon>, enabled: bool) -> Element<'static, Message> {
use crate::ui::icons;
let chrome = |bytes| {
if enabled {
icons::themed::<Message>(bytes, MENU_ICON_SIZE)
} else {
icons::themed_disabled::<Message>(bytes, MENU_ICON_SIZE)
}
};
let cell: Element<'static, Message> = match icon {
Some(MenuIcon::Snap(t)) => chrome(icons::osnap(t)),
Some(MenuIcon::Mtp) => chrome(icons::mtp_icon()),
Some(MenuIcon::Chrome(bytes)) => chrome(bytes),
Some(MenuIcon::Tool(bytes)) if enabled => icons::semantic(bytes, MENU_ICON_SIZE),
Some(MenuIcon::Tool(bytes)) => icons::semantic_disabled(bytes, MENU_ICON_SIZE),
None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(),
};
container(cell)
@@ -1188,24 +1195,27 @@ pub(super) fn viewport_context_menu_overlay(
MenuRow::Submenu {
id,
label,
icon,
items: children,
open,
} => {
let is_hl = highlighted == Some(sel_idx);
let caret = if *open {
let enabled = !children.is_empty();
let caret = if !enabled {
crate::ui::icons::themed_disabled_arrow_right(9.0)
} else if *open {
crate::ui::icons::themed_arrow_down(9.0)
} else {
crate::ui::icons::themed_arrow_right(9.0)
};
let content = row![
context_menu_gutter(None),
context_menu_gutter(*icon, enabled),
text(label.clone()).size(12),
iced::widget::Space::new().width(Fill),
caret,
]
.spacing(4)
.align_y(iced::Center);
let enabled = !children.is_empty();
let mut btn = button(content)
.padding(iced::Padding {
top: 3.0,
@@ -1271,7 +1281,7 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<
});
}
let enabled = item.enabled;
let mut content = row![context_menu_gutter(item.icon), label_text]
let mut content = row![context_menu_gutter(item.icon, enabled), label_text]
.spacing(4)
.align_y(iced::Center);
if let Some(hint) = item.hint.as_ref() {