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

+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, *};