From 7ed6a362dca368b517836d8ace30149f52cb8736 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 24 Sep 2026 22:46:31 +0300 Subject: [PATCH] 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 --- src/app/navigation.rs | 52 +++--------------------- src/app/view/overlay.rs | 40 ++++++++++++------- src/modules/registry.rs | 65 +++++++++++++++++++++++++++++- src/ui/command_line.rs | 11 +++++- src/ui/icons.rs | 21 ++++++++++ src/ui/popup/context_menu.rs | 77 +++++++++++++++++++++++++++--------- src/ui/ribbon/draw_panel.rs | 9 +++++ src/ui/ribbon/mod.rs | 1 + 8 files changed, 195 insertions(+), 81 deletions(-) diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 5a9575230..61a04f5c3 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -299,7 +299,7 @@ impl OpenCADStudio { } pub(super) fn actions() -> Vec { - 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 { ) }) .collect(); - fn describe( - actions: &mut BTreeMap, - 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, 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() } diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 630721df4..7ae567243 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1124,20 +1124,27 @@ impl Widget 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) -> 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::(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, enabled: bool) -> Element<'static, Message> { + use crate::ui::icons; + let chrome = |bytes| { + if enabled { + icons::themed::(bytes, MENU_ICON_SIZE) + } else { + icons::themed_disabled::(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() { diff --git a/src/modules/registry.rs b/src/modules/registry.rs index cb2ce90ff..14e7a3d31 100644 --- a/src/modules/registry.rs +++ b/src/modules/registry.rs @@ -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::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 { + static COMMANDS: OnceLock> = 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, + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 855e1d4f5..3bce5460d 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -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]) diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 9879c0564..55ac85b5b 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -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) } diff --git a/src/ui/popup/context_menu.rs b/src/ui/popup/context_menu.rs index 539e28378..4c68f01ae 100644 --- a/src/ui/popup/context_menu.rs +++ b/src/ui/popup/context_menu.rs @@ -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) -> Self { self.hint = Some(hint.into()); self @@ -179,11 +187,16 @@ pub enum MenuRow { Submenu { id: SubmenuId, label: String, + icon: Option, items: Vec, open: bool, }, } +fn command_icon(command: &str) -> Option { + 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, open_submenu: Option) - 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 { 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, ) -> Vec { - 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 { - 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, diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 54c6ed688..0a5fa27f3 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -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 { + PANELS + .iter() + .flat_map(|panel| panel.tools) + .map(|tool| (tool.command, tool.label, tool.icon)) +} + fn panel_for_dropdown(id: &str) -> Option { PANELS.iter().copied().find(|panel| { panel.id == id diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index c1c2852f3..661b5f597 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -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, *};