diff --git a/README-ZH.md b/README-ZH.md index 2403cfe..7d71a14 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -23,6 +23,8 @@ - [平台支持](#%E5%B9%B3%E5%8F%B0%E6%94%AF%E6%8C%81) - [截图](#%E6%88%AA%E5%9B%BE) + - [菜单项图标(macOS)](#%E8%8F%9C%E5%8D%95%E9%A1%B9%E5%9B%BE%E6%A0%87macos) + - [菜单项快捷键(macOS)](#%E8%8F%9C%E5%8D%95%E9%A1%B9%E5%BF%AB%E6%8D%B7%E9%94%AEmacos) - [已知问题](#%E5%B7%B2%E7%9F%A5%E9%97%AE%E9%A2%98) - [与 app_links 不兼容](#%E4%B8%8E-app_links-%E4%B8%8D%E5%85%BC%E5%AE%B9) - [在 GNOME 中不显示](#%E5%9C%A8-gnome-%E4%B8%AD%E4%B8%8D%E6%98%BE%E7%A4%BA) @@ -34,6 +36,8 @@ - [谁在用使用它?](#%E8%B0%81%E5%9C%A8%E7%94%A8%E4%BD%BF%E7%94%A8%E5%AE%83) - [API](#api) - [TrayManager](#traymanager) +- [菜单项图标(macOS)](#%E8%8F%9C%E5%8D%95%E9%A1%B9%E5%9B%BE%E6%A0%87macos-1) +- [菜单项快捷键(macOS)](#%E8%8F%9C%E5%8D%95%E9%A1%B9%E5%BF%AB%E6%8D%B7%E9%94%AEmacos-1) - [许可证](#%E8%AE%B8%E5%8F%AF%E8%AF%81) @@ -50,6 +54,36 @@ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/macos.png?raw=true) | ![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/linux.png?raw=true) | ![image](https://github.com/leanflutter/tray_manager/blob/main/screenshots/windows.png?raw=true) | +### 菜单项图标(macOS) + +通过 `MenuItem.icon` 渲染的菜单项图标: + +![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/macos_icon.png?raw=true) +![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/macos_icon1.png?raw=true) + +### 菜单项快捷键(macOS) + +使用 `TrayMenuItem.shortcut` 渲染右侧对齐(浅灰色)的快捷键列: + +```dart +import 'package:tray_manager/tray_manager.dart'; + +final menu = Menu( + items: [ + TrayMenuItem( + key: 'assistant', + label: 'Assistant', + shortcut: '⌘⇧Space', + icon: 'images/tray_icon.png', + ), + ], +); + +await trayManager.setContextMenu(menu); +``` + +![MenuItem shortcut on macOS](./screenshots/shortcut.png) + ## 已知问题 ### 与 app_links 不兼容 @@ -218,6 +252,51 @@ class _HomePageState extends State with TrayListener { | popUpContextMenu | 弹出托盘图标的上下文菜单。 | ➖ | ✔️ | ✔️ | | getBounds | 返回 `Rect` 这个托盘图标的边界。 | ➖ | ✔️ | ✔️ | +## 菜单项图标(macOS) + +`MenuItem`(来自 `menu_base`)带有 `icon` 字段。在 **macOS** 上,如果你传入 Flutter assets 路径,就可以显示每个菜单项的图标: + +```dart +Menu menu = Menu( + items: [ + MenuItem( + key: 'show_window', + label: 'Show Window', + icon: 'images/tray_icon.png', + ), + ], +); +await trayManager.setContextMenu(menu); +``` + +说明: + +- 插件会优先从 Flutter assets 加载图标,并以 base64 形式传给原生侧。 +- 如果 assets 加载失败,会回退把 `icon` 当作绝对文件路径来尝试加载(best-effort)。 +- 其他平台目前可能会忽略该字段。 + +## 菜单项快捷键(macOS) + +`TrayMenuItem` 提供了 `shortcut` 字段。在 **macOS** 上该字段会被转换为原生 `NSMenuItem.keyEquivalent` 进行渲染,因此会显示为**右侧对齐、浅灰色**的系统快捷键列: + +```dart +Menu menu = Menu( + items: [ + TrayMenuItem( + key: 'assistant', + label: 'Assistant', + shortcut: '⌘⇧Space', + ), + ], +); +await trayManager.setContextMenu(menu); +``` + +说明: + +- macOS 会按系统规则标准化显示修饰键顺序(例如 `⇧⌘ Space`),不保证与输入字符串顺序一致。 +- 其他平台目前可能会忽略该字段。 + ## 许可证 [MIT](./LICENSE) diff --git a/README.md b/README.md index 53e3fc9..1f7d9f3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ > **⚠️ Migration Notice**: This plugin is being migrated to [libnativeapi/nativeapi-flutter](https://github.com/libnativeapi/nativeapi-flutter) > > The new version is based on a unified C++ core library ([libnativeapi/nativeapi](https://github.com/libnativeapi/nativeapi)), providing more complete and consistent cross-platform native API support. -r [![pub version][pub-image]][pub-url] [![][discord-image]][discord-url] ![][visits-count-image] @@ -22,6 +21,8 @@ English | [简体中文](./README-ZH.md) - [Platform Support](#platform-support) - [Screenshots](#screenshots) + - [MenuItem icon (macOS)](#menuitem-icon-macos) + - [MenuItem shortcut (macOS)](#menuitem-shortcut-macos) - [Known Issues](#known-issues) - [Not Working with app_links](#not-working-with-app_links) - [Not Showing in GNOME](#not-showing-in-gnome) @@ -33,6 +34,8 @@ English | [简体中文](./README-ZH.md) - [Who's using it?](#whos-using-it) - [API](#api) - [TrayManager](#traymanager) +- [MenuItem icon (macOS)](#menuitem-icon-macos-1) +- [MenuItem shortcut (macOS)](#menuitem-shortcut-macos-1) - [License](#license) @@ -49,6 +52,36 @@ English | [简体中文](./README-ZH.md) | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/macos.png?raw=true) | ![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/linux.png?raw=true) | ![image](https://github.com/leanflutter/tray_manager/blob/main/screenshots/windows.png?raw=true) | +### MenuItem icon (macOS) + +Menu item icons rendered via `MenuItem.icon`: + +![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/macos_icon.png?raw=true) +![](https://github.com/leanflutter/tray_manager/blob/main/screenshots/macos_icon1.png?raw=true) + +### MenuItem shortcut (macOS) + +Render a right-aligned (light gray) shortcut column using `TrayMenuItem.shortcut`: + +```dart +import 'package:tray_manager/tray_manager.dart'; + +final menu = Menu( + items: [ + TrayMenuItem( + key: 'assistant', + label: 'Assistant', + shortcut: '⌘⇧Space', + icon: 'images/tray_icon.png', + ), + ], +); + +await trayManager.setContextMenu(menu); +``` + +![MenuItem shortcut on macOS](./screenshots/shortcut.png) + ## Known Issues ### Not Working with app_links @@ -124,11 +157,13 @@ Menu menu = Menu( MenuItem( key: 'show_window', label: 'Show Window', + icon: 'images/tray_icon.png', ), MenuItem.separator(), MenuItem( key: 'exit_app', label: 'Exit App', + icon: 'images/tray_icon.png', ), ], ); @@ -217,6 +252,50 @@ class _HomePageState extends State with TrayListener { | popUpContextMenu | Pops up the context menu of the tray icon. | ➖ | ✔️ | ✔️ | | getBounds | Returns `Rect` The bounds of this tray icon. | ➖ | ✔️ | ✔️ | +## MenuItem icon (macOS) + +`MenuItem` has an `icon` field (from `menu_base`). On **macOS**, per-menu-item icons are supported when you provide an asset path: + +```dart +Menu menu = Menu( + items: [ + MenuItem( + key: 'show_window', + label: 'Show Window', + icon: 'images/tray_icon.png', + ), + ], +); +await trayManager.setContextMenu(menu); +``` + +Notes: + +- The plugin will try to load the icon from Flutter assets and pass it to native code as base64. +- If the icon asset can't be loaded, it will fall back to treating `icon` as an absolute file path (best-effort). +- Other platforms may ignore this field for now. + +## MenuItem shortcut (macOS) + +`TrayMenuItem` provides a `shortcut` field. On **macOS** this is converted into native `NSMenuItem.keyEquivalent` rendering, so it appears as a **right-aligned, light gray** shortcut column: + +```dart +final menu = Menu( + items: [ + TrayMenuItem( + key: 'assistant', + label: 'Assistant', + shortcut: '⌘⇧Space', + ), + ], +); +await trayManager.setContextMenu(menu); +``` + +Notes: +- macOS will standardize modifier display order (e.g. `⇧⌘ Space`), so the visual order may differ from the input string. +- Other platforms may ignore this field for now. + ## License [MIT](./LICENSE) diff --git a/packages/tray_manager/example/lib/pages/home.dart b/packages/tray_manager/example/lib/pages/home.dart index 2d21789..9e23559 100644 --- a/packages/tray_manager/example/lib/pages/home.dart +++ b/packages/tray_manager/example/lib/pages/home.dart @@ -150,30 +150,44 @@ class _HomePageState extends State with TrayListener { onTap: () async { _menu ??= Menu( items: [ + if (Platform.isMacOS) + TrayMenuItem( + label: 'Shortcut', + // macOS only: renders as a right-aligned, light-gray shortcut column. + shortcut: '⌘⇧Space', + icon: 'images/tray_icon.png', + ), MenuItem( label: 'Look Up "LeanFlutter"', + icon: 'images/tray_icon.png', ), MenuItem( label: 'Search with Google', + icon: 'images/tray_icon.png', ), MenuItem.separator(), MenuItem( label: 'Cut', + icon: 'images/tray_icon.png', ), MenuItem( label: 'Copy', + icon: 'images/tray_icon.png', ), MenuItem( label: 'Paste', disabled: true, + icon: 'images/tray_icon.png', ), MenuItem.submenu( label: 'Share', + icon: 'images/tray_icon.png', submenu: Menu( items: [ MenuItem.checkbox( label: 'Item 1', checked: true, + icon: 'images/tray_icon.png', onClick: (menuItem) { if (kDebugMode) { print('click item 1'); @@ -184,6 +198,7 @@ class _HomePageState extends State with TrayListener { MenuItem.checkbox( label: 'Item 2', checked: false, + icon: 'images/tray_icon.png', onClick: (menuItem) { if (kDebugMode) { print('click item 2'); diff --git a/packages/tray_manager/lib/src/tray_manager.dart b/packages/tray_manager/lib/src/tray_manager.dart index f23b61c..053bf7c 100644 --- a/packages/tray_manager/lib/src/tray_manager.dart +++ b/packages/tray_manager/lib/src/tray_manager.dart @@ -180,11 +180,60 @@ class TrayManager { Future setContextMenu(Menu menu) async { _menu = menu; final Map arguments = { - 'menu': menu.toJson(), + 'menu': await _menuToJsonForPlatform(menu), }; await _channel.invokeMethod('setContextMenu', arguments); } + Future> _menuToJsonForPlatform(Menu menu) async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return menu.toJson(); + } + return _menuToJsonWithBase64Icons(menu, {}); + } + + Future> _menuToJsonWithBase64Icons( + Menu menu, + Map iconBase64Cache, + ) async { + final items = menu.items ?? const []; + final jsonItems = >[]; + + for (final item in items) { + final m = Map.from(item.toJson()); + + final iconPath = item.icon; + if (iconPath != null && iconPath.isNotEmpty) { + final cached = iconBase64Cache[iconPath]; + if (cached != null) { + m['base64Icon'] = cached; + } else { + try { + final data = await rootBundle.load(iconPath); + final base64Icon = base64Encode(data.buffer.asUint8List()); + iconBase64Cache[iconPath] = base64Icon; + m['base64Icon'] = base64Icon; + } catch (_) { + // Best-effort: if icon isn't a bundled asset or can't be loaded, + // leave it as-is (platform side may still support file paths). + } + } + } + + final submenu = item.submenu; + if (submenu != null) { + m['submenu'] = + await _menuToJsonWithBase64Icons(submenu, iconBase64Cache); + } + + jsonItems.add(m); + } + + return { + 'items': jsonItems, + }..removeWhere((key, value) => value == null); + } + /// Pops up the context menu of the tray icon. /// /// [bringAppToFront] If true, the app will be brought to the front when the diff --git a/packages/tray_manager/lib/src/tray_menu_item.dart b/packages/tray_manager/lib/src/tray_menu_item.dart new file mode 100644 index 0000000..b469454 --- /dev/null +++ b/packages/tray_manager/lib/src/tray_menu_item.dart @@ -0,0 +1,37 @@ +import 'package:menu_base/menu_base.dart'; + +/// A tray menu item with an explicit shortcut/hotkey hint. +/// +/// On macOS this is rendered using NSMenuItem.keyEquivalent so it appears +/// right-aligned and in the system default (light gray) style. +/// +/// Other platforms may ignore this field. +class TrayMenuItem extends MenuItem { + /// Shortcut hint string (macOS style), e.g. "⌘⇧Space" or "⌘Q". + final String? shortcut; + + TrayMenuItem({ + super.key, + super.type = 'normal', + super.label, + super.toolTip, + super.icon, + super.checked, + super.disabled = false, + super.submenu, + super.onClick, + super.onHighlight, + super.onLoseHighlight, + this.shortcut, + }); + + @override + Map toJson() { + final m = super.toJson(); + final s = shortcut; + if (s != null && s.isNotEmpty) { + m['shortcut'] = s; + } + return m; + } +} diff --git a/packages/tray_manager/lib/tray_manager.dart b/packages/tray_manager/lib/tray_manager.dart index ae4fd5a..1ca2fdc 100644 --- a/packages/tray_manager/lib/tray_manager.dart +++ b/packages/tray_manager/lib/tray_manager.dart @@ -1,4 +1,5 @@ export 'package:menu_base/menu_base.dart'; export 'src/tray_listener.dart'; +export 'src/tray_menu_item.dart'; export 'src/tray_manager.dart'; diff --git a/packages/tray_manager/macos/Classes/TrayMenu.swift b/packages/tray_manager/macos/Classes/TrayMenu.swift index 3119693..65a8360 100644 --- a/packages/tray_manager/macos/Classes/TrayMenu.swift +++ b/packages/tray_manager/macos/Classes/TrayMenu.swift @@ -10,6 +10,87 @@ import AppKit public class TrayMenu: NSMenu, NSMenuDelegate { public var onMenuItemClick:((NSMenuItem) -> Void)? + private static let menuIconSize = NSSize(width: 16, height: 16) + + // Parse a macOS-style shortcut hint like "⌘⇧Space" or "⌘Q" into + // NSMenuItem.keyEquivalent + modifier mask so the system renders it + // right-aligned and in the standard (light gray) style. + private static func parseKeyEquivalent(from hint: String) -> (String, NSEvent.ModifierFlags)? { + var s = hint.trimmingCharacters(in: .whitespacesAndNewlines) + if s.isEmpty { return nil } + + var modifiers: NSEvent.ModifierFlags = [] + + // Consume modifier symbols from the front. + while true { + if s.hasPrefix("⌘") { + modifiers.insert(.command) + s.removeFirst(1) + continue + } + if s.hasPrefix("⇧") { + modifiers.insert(.shift) + s.removeFirst(1) + continue + } + if s.hasPrefix("⌥") { + modifiers.insert(.option) + s.removeFirst(1) + continue + } + if s.hasPrefix("⌃") { + modifiers.insert(.control) + s.removeFirst(1) + continue + } + if s.lowercased().hasPrefix("fn") { + modifiers.insert(.function) + s.removeFirst(2) + continue + } + if s.hasPrefix("⇪") { + modifiers.insert(.capsLock) + s.removeFirst(1) + continue + } + break + } + + let keyLabel = s.trimmingCharacters(in: .whitespacesAndNewlines) + if keyLabel.isEmpty { return nil } + + // Map common keys. + if keyLabel.caseInsensitiveCompare("Space") == .orderedSame { + return (" ", modifiers) + } + + // Single character keys (Q, K, 1, etc). + if keyLabel.count == 1 { + return (keyLabel.lowercased(), modifiers) + } + + // Fallback: unsupported key name. + return nil + } + + private static func loadMenuItemImage(from itemDict: [String: Any]) -> NSImage? { + if let base64Icon = itemDict["base64Icon"] as? String, + let data = Data(base64Encoded: base64Icon), + let image = NSImage(data: data) { + image.size = menuIconSize + return image + } + // Fallback: treat `icon` as an absolute file path if provided. + if let iconPath = itemDict["icon"] as? String, + !iconPath.isEmpty, + FileManager.default.fileExists(atPath: iconPath), + let image = NSImage(contentsOfFile: iconPath) { + image.size = menuIconSize + return image + } + return nil + } + public override init(title: String) { super.init(title: title) } @@ -29,6 +110,7 @@ public class TrayMenu: NSMenu, NSMenuDelegate { let id: Int = itemDict["id"] as! Int let type: String = itemDict["type"] as! String let label: String = itemDict["label"] as? String ?? "" + let shortcut: String = itemDict["shortcut"] as? String ?? "" let toolTip: String = itemDict["toolTip"] as? String ?? "" let checked: Bool? = itemDict["checked"] as? Bool let disabled: Bool = itemDict["disabled"] as? Bool ?? true @@ -42,6 +124,15 @@ public class TrayMenu: NSMenu, NSMenuDelegate { menuItem.tag = id menuItem.title = label menuItem.toolTip = toolTip + if let image = TrayMenu.loadMenuItemImage(from: itemDict) { + menuItem.image = image + } + + if !shortcut.isEmpty, let parsed = TrayMenu.parseKeyEquivalent(from: shortcut) { + menuItem.keyEquivalent = parsed.0 + menuItem.keyEquivalentModifierMask = parsed.1 + } + menuItem.isEnabled = !disabled menuItem.action = !disabled ? #selector(statusItemMenuButtonClicked) : nil menuItem.target = self diff --git a/packages/tray_manager/test/tray_menu_item_test.dart b/packages/tray_manager/test/tray_menu_item_test.dart new file mode 100644 index 0000000..787dbd8 --- /dev/null +++ b/packages/tray_manager/test/tray_menu_item_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tray_manager/tray_manager.dart'; + +void main() { + group('TrayMenuItem', () { + test('toJson includes shortcut when provided', () { + final item = TrayMenuItem( + key: 'show_window', + label: 'Assistant', + shortcut: '⌘⇧Space', + ); + + final json = item.toJson(); + expect(json['label'], 'Assistant'); + expect(json['shortcut'], '⌘⇧Space'); + }); + + test('toJson omits shortcut when null/empty', () { + final item = TrayMenuItem(key: 'x', label: 'X'); + expect(item.toJson().containsKey('shortcut'), isFalse); + }); + }); +} + + diff --git a/screenshots/macos_icon.png b/screenshots/macos_icon.png new file mode 100644 index 0000000..a8802df Binary files /dev/null and b/screenshots/macos_icon.png differ diff --git a/screenshots/macos_icon1.png b/screenshots/macos_icon1.png new file mode 100644 index 0000000..3e6365a Binary files /dev/null and b/screenshots/macos_icon1.png differ diff --git a/screenshots/shortcut.png b/screenshots/shortcut.png new file mode 100644 index 0000000..0a62d55 Binary files /dev/null and b/screenshots/shortcut.png differ