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 <30177694+MarcelP102@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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() {
+64 -1
View File
@@ -7,7 +7,9 @@
// 2. Add `pub mod my_name;` to src/modules/mod.rs
// 3. Add one `Box::new(super::my_name::MyNameModule)` line below, in the
// position you want its tab to appear.
use crate::modules::CadModule;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonItem, ToolDef};
use std::collections::HashMap;
use std::sync::OnceLock;
/// Returns one boxed instance of every registered core CAD module.
/// Called once at startup by `Ribbon::new()`.
@@ -23,3 +25,64 @@ pub fn all_modules() -> Vec<Box<dyn CadModule>> {
Box::new(super::layout::LayoutModule),
]
}
/// Label and icon of every core ribbon command, keyed by its upper-case
/// command line (`LINE`, `ZOOM EXTENTS`). The ribbon definition beside each
/// command stays the one source; the shortcut list, the context menus and the
/// command-line suggestions read it from here.
pub fn ribbon_commands() -> &'static HashMap<String, (&'static str, IconKind)> {
static COMMANDS: OnceLock<HashMap<String, (&'static str, IconKind)>> = OnceLock::new();
COMMANDS.get_or_init(|| {
let mut out = HashMap::new();
let tool = |out: &mut HashMap<_, _>, t: &ToolDef| {
if let ModuleEvent::Command(command) = &t.event {
out.insert(command.to_ascii_uppercase(), (t.label, t.icon));
}
};
for module in 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 out, t),
RibbonItem::Dropdown { items, .. }
| RibbonItem::LabeledDropdown { items, .. }
| RibbonItem::LargeDropdown { items, .. } => {
for (command, label, icon) in items {
out.insert(command.to_ascii_uppercase(), (*label, *icon));
}
}
RibbonItem::ToolGrid { columns }
| RibbonItem::StyleComboGroup { rows: columns, .. } => {
for t in columns.iter().flatten() {
tool(&mut out, t);
}
}
RibbonItem::LayerComboGroup { row2, row3 } => {
for t in row2.iter().chain(row3) {
tool(&mut out, t);
}
}
RibbonItem::PropertiesGroup { match_prop } => tool(&mut out, match_prop),
}
}
}
}
for (command, label, icon) in crate::ui::ribbon::panel_tools() {
out.entry(command.to_string())
.or_insert((label, IconKind::Svg(icon)));
}
out
})
}
/// The ribbon icon of a command line, for surfaces that show the command
/// outside the ribbon. A leading `'` (transparent use) is ignored.
pub fn command_icon(command: &str) -> Option<&'static [u8]> {
let key = command.trim().trim_start_matches('\'').to_ascii_uppercase();
match ribbon_commands().get(&key)?.1 {
IconKind::Svg(bytes) => Some(bytes),
IconKind::Glyph(_) => None,
}
}
+10 -1
View File
@@ -798,7 +798,16 @@ impl CommandLine {
let mut col = column![].spacing(0).width(Length::Fill);
for (idx, cmd) in matches.iter().enumerate() {
let is_selected = idx == cursor;
let row = button(text(cmd.clone()).size(11))
// Every row keeps the icon's width so names line up.
let icon: Element<'_, Message> =
match crate::modules::registry::command_icon(cmd) {
Some(bytes) => crate::ui::icons::semantic(bytes, 14.0),
None => Space::new().width(14.0).into(),
};
let label = row![icon, text(cmd.clone()).size(11)]
.spacing(6)
.align_y(iced::Center);
let row = button(label)
.on_press(Message::CommandSuggestionPick(cmd.clone()))
.width(Length::Fill)
.padding([2, 8])
+21
View File
@@ -314,6 +314,15 @@ pub fn semantic<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
})
}
/// [`semantic`] faded like disabled menu text.
pub fn semantic_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
Element::new(SemanticIcon {
bytes,
size,
opacity: 0.42,
})
}
fn semantic_handle(bytes: &'static [u8], theme: &Theme) -> svg::Handle {
let key = SemanticCacheKey {
address: bytes.as_ptr() as usize,
@@ -654,6 +663,14 @@ pub fn zoom_icon() -> &'static [u8] {
NAV_ZOOM
}
pub fn undo_icon() -> &'static [u8] {
UNDO
}
pub fn redo_icon() -> &'static [u8] {
REDO
}
/// Layer visibility icon bytes (on / off).
pub fn layer_visible(visible: bool) -> &'static [u8] {
if visible {
@@ -709,6 +726,10 @@ pub fn themed_disabled_arrow_down<'a, M: 'a>(size: f32) -> Element<'a, M> {
themed_disabled(TRI_DOWN, size)
}
pub fn themed_disabled_arrow_right<'a, M: 'a>(size: f32) -> Element<'a, M> {
themed_disabled(TRI_RIGHT, size)
}
pub fn themed_home<'a, M: 'a>(size: f32) -> Element<'a, M> {
themed(HOME, size)
}
+59 -18
View File
@@ -50,8 +50,10 @@ pub enum SubmenuId {
pub enum MenuIcon {
Snap(SnapType),
Mtp,
Pan,
Zoom,
/// Monochrome interface glyph, drawn in the text colour.
Chrome(&'static [u8]),
/// A command's ribbon icon, drawn in its own colours.
Tool(&'static [u8]),
}
/// What a grip-menu row does (the grip-mode shortcut menu of commercial solutions).
@@ -146,6 +148,12 @@ impl MenuItem {
self
}
/// The command's ribbon icon, when the ribbon has one.
fn command_icon(mut self, command: &str) -> Self {
self.icon = command_icon(command);
self
}
fn hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
@@ -179,11 +187,16 @@ pub enum MenuRow {
Submenu {
id: SubmenuId,
label: String,
icon: Option<MenuIcon>,
items: Vec<MenuItem>,
open: bool,
},
}
fn command_icon(command: &str) -> Option<MenuIcon> {
crate::modules::registry::command_icon(command).map(MenuIcon::Tool)
}
/// A keyboard-navigable row in display order: either an item or a submenu
/// header. Separators and the children of collapsed submenus are skipped.
#[derive(Clone, Debug, PartialEq)]
@@ -470,6 +483,7 @@ fn command_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::SnapOverrides,
label: t!("Snap Overrides").into_owned(),
icon: None,
items: snap_override_items(),
open: open_submenu == Some(SubmenuId::SnapOverrides),
});
@@ -486,6 +500,7 @@ fn recent_input_submenu(items: Vec<MenuItem>, open_submenu: Option<SubmenuId>) -
MenuRow::Submenu {
id: SubmenuId::RecentInput,
label: t!("Recent Input").into_owned(),
icon: None,
items,
open: open_submenu == Some(SubmenuId::RecentInput),
}
@@ -528,14 +543,14 @@ fn navigation_rows(transparent: bool) -> Vec<MenuRow> {
vec![
MenuRow::Item(
MenuItem::new(t!("Pan").into_owned(), MenuAction::Command(format!("{prefix}PAN")))
.icon(MenuIcon::Pan),
.icon(MenuIcon::Chrome(crate::ui::icons::pan_icon())),
),
MenuRow::Item(
MenuItem::new(
t!("Zoom").into_owned(),
MenuAction::Command(format!("{prefix}ZOOM DYNAMIC")),
)
.icon(MenuIcon::Zoom),
.icon(MenuIcon::Chrome(crate::ui::icons::zoom_icon())),
),
]
}
@@ -552,7 +567,9 @@ fn idle_rows(
props_open: bool,
open_submenu: Option<SubmenuId>,
) -> Vec<MenuRow> {
let cmd = |label: String, name: &str| MenuItem::new(label, MenuAction::Command(name.to_string()));
let cmd = |label: String, name: &str| {
MenuItem::new(label, MenuAction::Command(name.to_string())).command_icon(name)
};
let mut rows = Vec::new();
// ── Repeat / Recent Input ──────────────────────────────────────────
@@ -562,6 +579,7 @@ fn idle_rows(
t!("Repeat %{last}", last = last.clone()).into_owned(),
MenuAction::Command(last.to_uppercase()),
)
.command_icon(last)
.default(),
));
}
@@ -569,7 +587,9 @@ fn idle_rows(
recent_cmds
.iter()
.take(RECENT_LIMIT)
.map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())))
.map(|c| {
MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase())).command_icon(c)
})
.collect(),
open_submenu,
));
@@ -579,6 +599,7 @@ fn idle_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::Clipboard,
label: t!("Clipboard").into_owned(),
icon: None,
items: vec![
cmd(t!("Cut").into_owned(), "CUTCLIP").enabled(has_selection),
cmd(t!("Copy").into_owned(), "COPYCLIP").enabled(has_selection),
@@ -602,19 +623,25 @@ fn idle_rows(
None => t!("Redo").into_owned(),
};
rows.push(MenuRow::Item(
MenuItem::new(undo, MenuAction::Undo).hint("Ctrl+Z").enabled(undo_label.is_some()),
MenuItem::new(undo, MenuAction::Undo)
.icon(MenuIcon::Chrome(crate::ui::icons::undo_icon()))
.hint("Ctrl+Z")
.enabled(undo_label.is_some()),
));
rows.push(MenuRow::Item(
MenuItem::new(redo, MenuAction::Redo).hint("Ctrl+Y").enabled(redo_label.is_some()),
MenuItem::new(redo, MenuAction::Redo)
.icon(MenuIcon::Chrome(crate::ui::icons::redo_icon()))
.hint("Ctrl+Y")
.enabled(redo_label.is_some()),
));
rows.push(MenuRow::Separator);
// ── Selection edit block (the "Edit" shortcut menu of commercial solutions) ──
if has_selection {
rows.push(MenuRow::Item(MenuItem::new(
t!("Erase").into_owned(),
MenuAction::DeleteSelected,
)));
rows.push(MenuRow::Item(
MenuItem::new(t!("Erase").into_owned(), MenuAction::DeleteSelected)
.command_icon("ERASE"),
));
rows.push(MenuRow::Item(cmd(t!("Move").into_owned(), "MOVE")));
rows.push(MenuRow::Item(cmd(t!("Copy Selection").into_owned(), "COPY")));
rows.push(MenuRow::Item(cmd(t!("Scale").into_owned(), "SCALE")));
@@ -623,6 +650,7 @@ fn idle_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::DrawOrder,
label: t!("Draw Order").into_owned(),
icon: command_icon("DRAWORDER_FRONT"),
items: vec![
cmd(t!("Bring to Front").into_owned(), "DRAWORDER F"),
cmd(t!("Send to Back").into_owned(), "DRAWORDER B"),
@@ -652,6 +680,7 @@ fn idle_rows(
rows.push(MenuRow::Submenu {
id: SubmenuId::Isolate,
label: t!("Isolate").into_owned(),
icon: Some(MenuIcon::Chrome(crate::ui::icons::ST_ISOLATE)),
items: vec![
cmd(t!("Isolate Objects").into_owned(), "ISOLATEOBJECTS").enabled(has_selection),
cmd(t!("Hide Objects").into_owned(), "HIDEOBJECTS").enabled(has_selection),
@@ -697,17 +726,29 @@ fn idle_rows(
// ── Panels ─────────────────────────────────────────────────────────
rows.push(MenuRow::Item(
MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties).checked(props_open),
MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties)
.command_icon("PROPERTIES")
.checked(props_open),
));
rows.push(MenuRow::Item(
MenuItem::new(t!("Options...").into_owned(), MenuAction::Options).command_icon("OPTIONS"),
));
rows.push(MenuRow::Item(MenuItem::new(
t!("Options...").into_owned(),
MenuAction::Options,
)));
rows
}
fn grip_rows(grip: &GripMenuContext) -> Vec<MenuRow> {
let g = |label: String, cmd: GripMenuCmd| MenuItem::new(label, MenuAction::Grip(cmd));
let g = |label: String, cmd: GripMenuCmd| {
let command = match cmd {
GripMenuCmd::Stretch => "STRETCH",
GripMenuCmd::Move => "MOVE",
GripMenuCmd::Rotate => "ROTATE",
GripMenuCmd::Scale => "SCALE",
GripMenuCmd::Mirror => "MIRROR",
GripMenuCmd::CopyToggle => "COPY",
GripMenuCmd::BasePoint | GripMenuCmd::Undo | GripMenuCmd::Exit => "",
};
MenuItem::new(label, MenuAction::Grip(cmd)).command_icon(command)
};
vec![
MenuRow::Item(MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint("⏎").default()),
MenuRow::Separator,
+9
View File
@@ -126,6 +126,15 @@ const PANELS: &[Panel] = &[
},
];
/// Command, label and icon of every tool in the expanded Draw and Modify
/// panels.
pub(crate) fn tools() -> impl Iterator<Item = (&'static str, &'static str, &'static [u8])> {
PANELS
.iter()
.flat_map(|panel| panel.tools)
.map(|tool| (tool.command, tool.label, tool.icon))
}
fn panel_for_dropdown(id: &str) -> Option<Panel> {
PANELS.iter().copied().find(|panel| {
panel.id == id
+1
View File
@@ -22,6 +22,7 @@ use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem};
mod widgets;
mod draw_panel;
pub(crate) use draw_panel::tools as panel_tools;
mod modify_panel;
mod color_dropdown;
use widgets::{StyleContext, *};