Skip to content

Commit 1a2621a

Browse files
committed
Add top menu icons
1 parent 3dc639d commit 1a2621a

3 files changed

Lines changed: 176 additions & 1 deletion

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,10 @@ pub fn run() {
314314
#[cfg(target_os = "macos")]
315315
menu::cleanup_macos_menus();
316316

317+
// Set SF Symbol icons on menu items (macOS only)
318+
#[cfg(target_os = "macos")]
319+
menu::set_macos_menu_icons();
320+
317321
// Store the CheckMenuItem references in app state
318322
let menu_state = MenuState::default();
319323
*menu_state.show_hidden_files.lock_ignore_poison() = Some(menu_items.show_hidden_files);

apps/desktop/src-tauri/src/menu/CLAUDE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ frontend when Settings or file viewer gains/loses focus. Iterates the `items` Ha
5656
Uses `objc2::exception::catch` because NSMenu operations can raise ObjC exceptions inside Tauri's
5757
`did_finish_launching` callback, which aborts on panic.
5858

59+
### SF Symbol icons (macOS only)
60+
61+
`set_macos_menu_icons()` runs post-construction via objc2 FFI, walking
62+
`NSApplication.mainMenu()` and calling `NSImage(systemSymbolName:)` + `setImage:` on each
63+
`NSMenuItem` matched by title. This produces true template images that auto-tint on
64+
selection highlighting. Also handles nested submenus (Sort by) via
65+
`apply_sf_symbols_to_nested_submenu`. Context menus do NOT have icons — Tauri doesn't expose the
66+
raw `NSMenu` pointer for context menus, and rasterized SF Symbol bitmaps via `IconMenuItem` look
67+
poor (no template auto-tinting).
68+
5969
## Platform differences
6070

6171
| Aspect | macOS | Linux |
@@ -66,6 +76,7 @@ Uses `objc2::exception::catch` because NSMenu operations can raise ObjC exceptio
6676
| Mnemonics | Not used | `&` prefixes for GTK keyboard navigation, unique per submenu |
6777
| Help search | Native NSMenu search field via `setHelpMenu:` | Not available |
6878
| System cleanup | objc2 strips injected Edit items | Not needed |
79+
| Menu icons | SF Symbols via objc2 (menu bar) and IconMenuItem (context menus) | Not supported |
6980

7081
## Menu structure
7182

@@ -101,6 +112,9 @@ also Window and Help.
101112
**Decision**: CheckMenuItems (view modes, show hidden) use separate event paths instead of `"execute-command"`.
102113
**Why**: CheckMenuItems auto-toggle their checked state on click. If the click also emitted `"execute-command"` and the frontend toggled the setting, the state would double-toggle (menu toggles once, frontend toggles again). Instead, these items emit `"settings-changed"` or `"view-mode-changed"` directly, treating the menu click as the authoritative state change.
103114

115+
**Decision**: SF Symbol icons only on the menu bar, not on context menus.
116+
**Why**: Tauri doesn't support SF Symbols natively. For the menu bar, we walk `NSApplication.mainMenu()` post-construction via objc2 FFI and set SF Symbols directly on `NSMenuItem` objects — this produces true template images that auto-tint correctly. Context menus don't get icons because Tauri doesn't expose the raw `NSMenu` pointer, and the alternative (rasterized bitmaps via `IconMenuItem`) produces visually poor results (no template tinting, wrong size/weight).
117+
104118
## Gotchas
105119

106120
- **No `Menu::default()`**: Both platforms build from scratch. The old approach inherited system

apps/desktop/src-tauri/src/menu/mod.rs

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ use tauri::{
1111
#[cfg(target_os = "macos")]
1212
use objc2::MainThreadMarker;
1313
#[cfg(target_os = "macos")]
14-
use objc2_app_kit::NSApplication;
14+
use objc2_app_kit::{NSApplication, NSImage, NSMenuItem as NSMenuItemAppKit};
15+
#[cfg(target_os = "macos")]
16+
use objc2_foundation::NSString;
1517

1618
/// Menu item IDs for file actions.
1719
pub const SHOW_HIDDEN_FILES_ID: &str = "show_hidden_files";
@@ -416,6 +418,161 @@ fn cleanup_macos_menus_inner() {
416418
}
417419
}
418420

421+
/// Sets SF Symbol icons on menu items post-construction via native AppKit API.
422+
///
423+
/// Tauri's menu API doesn't support SF Symbols, so we walk the NSMenu hierarchy after
424+
/// construction and call `NSImage(systemSymbolName:accessibilityDescription:)` + `setImage:`
425+
/// on each item, matching by title within each known submenu.
426+
#[cfg(target_os = "macos")]
427+
pub fn set_macos_menu_icons() {
428+
let result = objc2::exception::catch(set_macos_menu_icons_inner);
429+
if let Err(e) = result {
430+
log::warn!("Failed to set macOS menu icons: {e:?}");
431+
}
432+
}
433+
434+
#[cfg(target_os = "macos")]
435+
fn set_macos_menu_icons_inner() {
436+
let mtm = unsafe { MainThreadMarker::new_unchecked() };
437+
let app = NSApplication::sharedApplication(mtm);
438+
let Some(main_menu) = app.mainMenu() else {
439+
return;
440+
};
441+
442+
let count = main_menu.numberOfItems();
443+
for i in 0..count {
444+
let Some(top_item) = main_menu.itemAtIndex(i) else {
445+
continue;
446+
};
447+
let Some(submenu) = top_item.submenu() else {
448+
continue;
449+
};
450+
let title = submenu.title().to_string();
451+
452+
let mappings: &[(&str, &str)] = match title.as_str() {
453+
"cmdr" => &[
454+
("Enter license key\u{2026}", "key"),
455+
("See license details\u{2026}", "key"),
456+
("Settings\u{2026}", "gearshape"),
457+
],
458+
"File" => &[
459+
("Open", "arrow.up.forward"),
460+
("View", "document"),
461+
("Edit in editor", "pencil"),
462+
("Copy\u{2026}", "document.on.document"),
463+
("Move\u{2026}", "folder"),
464+
("New folder", "folder.badge.plus"),
465+
("Delete", "trash"),
466+
("Delete permanently", "trash.slash"),
467+
("Rename", "character.cursor.ibeam"),
468+
("Show in Finder", "arrow.forward.circle"),
469+
("Get info", "info.circle"),
470+
("Quick look", "eye"),
471+
],
472+
"Edit" => &[
473+
("Cut", "scissors"),
474+
("Copy", "document.on.document"),
475+
("Paste", "clipboard"),
476+
("Move here", "document.on.clipboard"),
477+
("Select all", "checkmark.circle"),
478+
("Deselect all", "circle"),
479+
("Copy path", "link"),
480+
("Copy filename", "textformat"),
481+
],
482+
"View" => {
483+
// Also apply icons to the "Sort by" submenu items
484+
apply_sf_symbols_to_nested_submenu(&submenu, "Sort by", &[
485+
("Name", "textformat.alt"),
486+
("Extension", "character.textbox"),
487+
("Size", "ruler"),
488+
("Date modified", "clock"),
489+
("Date created", "calendar"),
490+
("Ascending", "chevron.up"),
491+
("Descending", "chevron.down"),
492+
]);
493+
494+
&[
495+
("Switch pane", "rectangle.2.swap"),
496+
("Swap panes", "arrow.left.arrow.right"),
497+
("Command palette\u{2026}", "command"),
498+
]
499+
}
500+
"Go" => &[
501+
("Back", "chevron.left"),
502+
("Forward", "chevron.right"),
503+
("Parent folder", "arrow.up"),
504+
],
505+
"Tab" => &[
506+
("New tab", "plus"),
507+
("Close tab", "xmark"),
508+
("Next tab", "arrow.right"),
509+
("Previous tab", "arrow.left"),
510+
("Pin tab", "pin"),
511+
("Close other tabs", "xmark.circle"),
512+
],
513+
_ => continue,
514+
};
515+
516+
apply_sf_symbols_to_submenu(&submenu, mappings);
517+
}
518+
}
519+
520+
/// Applies SF Symbol icons to menu items in a submenu, matching by title.
521+
#[cfg(target_os = "macos")]
522+
fn apply_sf_symbols_to_submenu(
523+
submenu: &objc2_app_kit::NSMenu,
524+
mappings: &[(&str, &str)],
525+
) {
526+
let item_count = submenu.numberOfItems();
527+
for j in 0..item_count {
528+
let Some(item) = submenu.itemAtIndex(j) else {
529+
continue;
530+
};
531+
if item.isSeparatorItem() {
532+
continue;
533+
}
534+
let item_title = item.title().to_string();
535+
for &(title, symbol_name) in mappings {
536+
if item_title == title {
537+
set_sf_symbol(&item, symbol_name);
538+
break;
539+
}
540+
}
541+
}
542+
}
543+
544+
/// Applies SF Symbol icons to items inside a nested submenu (e.g. "Sort by" inside "View").
545+
#[cfg(target_os = "macos")]
546+
fn apply_sf_symbols_to_nested_submenu(
547+
parent: &objc2_app_kit::NSMenu,
548+
submenu_title: &str,
549+
mappings: &[(&str, &str)],
550+
) {
551+
let count = parent.numberOfItems();
552+
for i in 0..count {
553+
let Some(item) = parent.itemAtIndex(i) else {
554+
continue;
555+
};
556+
if let Some(child_menu) = item.submenu()
557+
&& child_menu.title().to_string() == submenu_title
558+
{
559+
apply_sf_symbols_to_submenu(&child_menu, mappings);
560+
return;
561+
}
562+
}
563+
}
564+
565+
/// Sets an SF Symbol image on a single NSMenuItem.
566+
#[cfg(target_os = "macos")]
567+
fn set_sf_symbol(item: &NSMenuItemAppKit, symbol_name: &str) {
568+
let name = NSString::from_str(symbol_name);
569+
if let Some(image) = NSImage::imageWithSystemSymbolName_accessibilityDescription(&name, None) {
570+
item.setImage(Some(&image));
571+
} else {
572+
log::warn!("SF Symbol not found: {symbol_name}");
573+
}
574+
}
575+
419576
/// Builds the Sort by submenu (shared between macOS and Linux).
420577
fn build_sort_submenu<R: Runtime>(app: &AppHandle<R>, label: &str) -> tauri::Result<Submenu<R>> {
421578
let sort_by_name = MenuItem::with_id(app, SORT_BY_NAME_ID, "Name", true, None::<&str>)?;

0 commit comments

Comments
 (0)