diff --git a/.changes/menu-item-secondary-label.md b/.changes/menu-item-secondary-label.md new file mode 100644 index 00000000..ec5c57af --- /dev/null +++ b/.changes/menu-item-secondary-label.md @@ -0,0 +1,5 @@ +--- +"muda": minor +--- + +Add `MenuItem::set_text_with_secondary` and `IconMenuItem::set_text_with_secondary` (macOS) for rendering Finder-style "primary (secondary)" labels where the secondary part uses `NSColor.secondaryLabelColor`. Useful for "default" markers, "currently selected" indicators, etc. On other platforms, falls back to plain text concatenation. diff --git a/Cargo.toml b/Cargo.toml index 768244e9..7b522736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ objc2-foundation = { version = "0.3.0", default-features = false, features = [ "NSData", "NSDictionary", "NSGeometry", + "NSRange", "NSString", "NSThread", ] } @@ -66,8 +67,11 @@ objc2-app-kit = { version = "0.3.0", default-features = false, features = [ "std", "objc2-core-foundation", "NSApplication", + "NSAttributedString", "NSCell", + "NSColor", "NSEvent", + "NSFont", "NSImage", "NSMenu", "NSMenuItem", diff --git a/src/items/icon.rs b/src/items/icon.rs index d1ce5432..b4545a7f 100644 --- a/src/items/icon.rs +++ b/src/items/icon.rs @@ -157,6 +157,35 @@ impl IconMenuItem { self.inner.borrow_mut().set_text(text.as_ref()) } + /// Set the item's label as a Finder-style two-part string: `primary` rendered in the + /// default menu label color, optionally followed by `secondary` rendered in + /// `NSColor.secondaryLabelColor`. Both parts use the standard menu font. + /// Pass `None` for `secondary` to clear back to plain `setTitle:` styling. + /// + /// Useful for labels like `Preview (default)`, `Speakers (current)`, or + /// `Folder (3 items selected)`. + /// + /// ## Platform-specific: + /// + /// - **Windows / Linux**: Concatenates `primary` and `secondary` and falls back to + /// [`IconMenuItem::set_text`]; the secondary part is not visually distinguished. + pub fn set_text_with_secondary(&self, primary: &str, secondary: Option<&str>) { + #[cfg(target_os = "macos")] + { + self.inner + .borrow_mut() + .set_text_with_secondary(primary, secondary); + } + #[cfg(not(target_os = "macos"))] + { + let combined = match secondary { + Some(sec) => format!("{primary}{sec}"), + None => primary.to_string(), + }; + self.inner.borrow_mut().set_text(&combined); + } + } + /// Get whether this check menu item is enabled or not. pub fn is_enabled(&self) -> bool { self.inner.borrow().is_enabled() diff --git a/src/items/mod.rs b/src/items/mod.rs index 5dd90e0f..d7b89c7f 100644 --- a/src/items/mod.rs +++ b/src/items/mod.rs @@ -83,4 +83,31 @@ mod test { let item = PredefinedMenuItem::separator(); assert_eq!(item.id().clone(), item.into_id()); } + + #[test] + #[cfg_attr( + all( + miri, + not(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + )) + ), + ignore + )] + fn set_text_with_secondary_concatenates_label() { + let item = MenuItem::new("Preview", true, None); + item.set_text_with_secondary("Preview", Some(" (default)")); + assert_eq!(item.text(), "Preview (default)"); + + item.set_text_with_secondary("Preview", None); + assert_eq!(item.text(), "Preview"); + + let icon_item = IconMenuItem::new("Preview", true, None, None); + icon_item.set_text_with_secondary("Speakers", Some(" (current)")); + assert_eq!(icon_item.text(), "Speakers (current)"); + } } diff --git a/src/items/normal.rs b/src/items/normal.rs index 581dc524..1a05fb38 100644 --- a/src/items/normal.rs +++ b/src/items/normal.rs @@ -88,6 +88,35 @@ impl MenuItem { self.inner.borrow_mut().set_text(text.as_ref()) } + /// Set the item's label as a Finder-style two-part string: `primary` rendered in the + /// default menu label color, optionally followed by `secondary` rendered in + /// `NSColor.secondaryLabelColor`. Both parts use the standard menu font. + /// Pass `None` for `secondary` to clear back to plain `setTitle:` styling. + /// + /// Useful for labels like `Preview (default)`, `Speakers (current)`, or + /// `Folder (3 items selected)`. + /// + /// ## Platform-specific: + /// + /// - **Windows / Linux**: Concatenates `primary` and `secondary` and falls back to + /// [`MenuItem::set_text`]; the secondary part is not visually distinguished. + pub fn set_text_with_secondary(&self, primary: &str, secondary: Option<&str>) { + #[cfg(target_os = "macos")] + { + self.inner + .borrow_mut() + .set_text_with_secondary(primary, secondary); + } + #[cfg(not(target_os = "macos"))] + { + let combined = match secondary { + Some(sec) => format!("{primary}{sec}"), + None => primary.to_string(), + }; + self.inner.borrow_mut().set_text(&combined); + } + } + /// Get whether this menu item is enabled or not. pub fn is_enabled(&self) -> bool { self.inner.borrow().is_enabled() diff --git a/src/platform_impl/macos/mod.rs b/src/platform_impl/macos/mod.rs index 4fe71efd..dfd3eaed 100644 --- a/src/platform_impl/macos/mod.rs +++ b/src/platform_impl/macos/mod.rs @@ -20,17 +20,18 @@ use objc2::{ define_class, msg_send, rc::Retained, runtime::{AnyObject, NSObjectProtocol, ProtocolObject, Sel}, - sel, DeclaredClass, MainThreadOnly, Message, + sel, AnyThread, DeclaredClass, MainThreadOnly, Message, }; use objc2_app_kit::{ NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, NSAboutPanelOptionCredits, NSAboutPanelOptionVersion, - NSApplication, NSControlStateValueOff, NSControlStateValueOn, NSEvent, NSEventModifierFlags, - NSImage, NSImageName, NSMenu, NSMenuDelegate, NSMenuItem, NSRunningApplication, NSView, + NSApplication, NSColor, NSControlStateValueOff, NSControlStateValueOn, NSEvent, + NSEventModifierFlags, NSFont, NSFontAttributeName, NSForegroundColorAttributeName, NSImage, + NSImageName, NSMenu, NSMenuDelegate, NSMenuItem, NSRunningApplication, NSView, }; use objc2_foundation::{ - ns_string, MainThreadMarker, NSAttributedString, NSDictionary, NSInteger, NSObject, NSPoint, - NSSize, NSString, + ns_string, MainThreadMarker, NSAttributedString, NSDictionary, NSInteger, + NSMutableAttributedString, NSObject, NSPoint, NSRange, NSSize, NSString, }; use self::util::strip_mnemonic; @@ -245,6 +246,9 @@ pub struct MenuChild { ns_menu_items: HashMap>>, + /// Set by `set_text_with_secondary`; reapplied at lazy `NSMenuItem` creation. + attributed_title_parts: Option<(String, String)>, + // menu item fields key_accelerator: Option, @@ -315,6 +319,7 @@ impl MenuChild { native_icon: None, ns_menu: None, ns_menu_items: HashMap::new(), + attributed_title_parts: None, ns_menus: None, predefined_item_type: None, } @@ -343,6 +348,7 @@ impl MenuChild { icon: None, native_icon: None, ns_menu_items: HashMap::new(), + attributed_title_parts: None, ns_menus: Some(HashMap::new()), predefined_item_type: None, } @@ -379,6 +385,7 @@ impl MenuChild { native_icon: None, ns_menu: None, ns_menu_items: HashMap::new(), + attributed_title_parts: None, ns_menus: None, } } @@ -402,6 +409,7 @@ impl MenuChild { native_icon: None, ns_menu: None, ns_menu_items: HashMap::new(), + attributed_title_parts: None, ns_menus: None, predefined_item_type: None, } @@ -426,6 +434,7 @@ impl MenuChild { native_icon: None, ns_menu: None, ns_menu_items: HashMap::new(), + attributed_title_parts: None, ns_menus: None, predefined_item_type: None, } @@ -450,6 +459,7 @@ impl MenuChild { icon: None, ns_menu: None, ns_menu_items: HashMap::new(), + attributed_title_parts: None, ns_menus: None, predefined_item_type: None, } @@ -472,10 +482,12 @@ impl MenuChild { pub fn set_text(&mut self, text: &str) { self.text = strip_mnemonic(text); + self.attributed_title_parts = None; unsafe { let title = NSString::from_str(&self.text); for ns_items in self.ns_menu_items.values() { for ns_item in ns_items { + ns_item.setAttributedTitle(None); ns_item.setTitle(&title); if let Some(submenu) = ns_item.submenu() { submenu.setTitle(&title); @@ -485,6 +497,61 @@ impl MenuChild { } } + pub fn set_text_with_secondary(&mut self, primary: &str, secondary: Option<&str>) { + let primary = strip_mnemonic(primary); + let secondary = secondary.map(strip_mnemonic); + let combined = match &secondary { + Some(sec) => { + let mut s = String::with_capacity(primary.len() + sec.len()); + s.push_str(&primary); + s.push_str(sec); + s + } + None => primary.clone(), + }; + self.text = combined; + + self.attributed_title_parts = secondary.as_ref().map(|sec| (primary.clone(), sec.clone())); + + unsafe { + let title = NSString::from_str(&self.text); + match &secondary { + Some(sec) => { + let attributed = build_attributed_secondary_title(&primary, sec); + for ns_items in self.ns_menu_items.values() { + for ns_item in ns_items { + ns_item.setAttributedTitle(Some(&attributed)); + ns_item.setTitle(&title); + if let Some(submenu) = ns_item.submenu() { + submenu.setTitle(&title); + } + } + } + } + None => { + for ns_items in self.ns_menu_items.values() { + for ns_item in ns_items { + ns_item.setAttributedTitle(None); + ns_item.setTitle(&title); + if let Some(submenu) = ns_item.submenu() { + submenu.setTitle(&title); + } + } + } + } + } + } + } + + fn apply_attributed_title_if_any(&self, ns_menu_item: &NSMenuItem) { + if let Some((primary, secondary)) = &self.attributed_title_parts { + unsafe { + let attributed = build_attributed_secondary_title(primary, secondary); + ns_menu_item.setAttributedTitle(Some(&attributed)); + } + } + } + pub fn is_enabled(&self) -> bool { self.enabled } @@ -844,6 +911,8 @@ impl MenuChild { ns_menu_item.setEnabled(self.enabled); } + self.apply_attributed_title_if_any(&ns_menu_item); + self.ns_menu_items .entry(menu_id) .or_default() @@ -955,6 +1024,8 @@ impl MenuChild { } } + self.apply_attributed_title_if_any(&ns_menu_item); + self.ns_menu_items .entry(menu_id) .or_default() @@ -1158,6 +1229,38 @@ impl MenuItem { } } +/// Builds a two-run attributed title: primary in default color, secondary in `NSColor.secondaryLabelColor`. +fn build_attributed_secondary_title( + primary: &str, + secondary: &str, +) -> Retained { + unsafe { + let combined = NSString::from_str(&format!("{primary}{secondary}")); + let attributed = NSMutableAttributedString::initWithString( + NSMutableAttributedString::alloc(), + &combined, + ); + + let font = NSFont::menuFontOfSize(0.0); + let full_range = NSRange::new(0, combined.length()); + attributed.addAttribute_value_range(NSFontAttributeName, &font, full_range); + + let primary_utf16_len = NSString::from_str(primary).length(); + let secondary_utf16_len = combined.length().saturating_sub(primary_utf16_len); + if secondary_utf16_len > 0 { + let secondary_range = NSRange::new(primary_utf16_len, secondary_utf16_len); + let color = NSColor::secondaryLabelColor(); + attributed.addAttribute_value_range( + NSForegroundColorAttributeName, + &color, + secondary_range, + ); + } + + Retained::cast_unchecked::(attributed) + } +} + fn menuitem_set_icon(menuitem: &NSMenuItem, icon: Option<&Icon>) { if let Some(icon) = icon { unsafe { @@ -1281,3 +1384,38 @@ impl NativeIcon { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg_attr(miri, ignore = "calls into the ObjC runtime, which Miri can't emulate")] + fn attributed_title_parts_round_trip() { + let mut child = MenuChild::new("placeholder", true, None, None); + assert!(child.attributed_title_parts.is_none()); + + child.set_text_with_secondary("Preview", Some(" (default)")); + assert_eq!( + child + .attributed_title_parts + .as_ref() + .map(|(p, s)| (p.as_str(), s.as_str())), + Some(("Preview", " (default)")) + ); + assert_eq!(child.text, "Preview (default)"); + + // Calling with `None` reverts to plain text and clears the remembered pair. + child.set_text_with_secondary("Preview", None); + assert!(child.attributed_title_parts.is_none()); + assert_eq!(child.text, "Preview"); + + // Setting it again, then `set_text` should also clear it (so a later append doesn't + // re-attribute a plain title). + child.set_text_with_secondary("Preview", Some(" (default)")); + assert!(child.attributed_title_parts.is_some()); + child.set_text("Plain again"); + assert!(child.attributed_title_parts.is_none()); + assert_eq!(child.text, "Plain again"); + } +}