From c1be4f0cf82cccb8e8289840477a2c5632b41004 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 2 Oct 2026 07:50:06 +0800 Subject: [PATCH 1/2] Menu bar controller: separators, in-place updates, icon, tooltip, click action Evolves RivetMenuBarController along the lines of #119, keeping the existing install(title:menuItems:) tuple API working: - RivetMenuItem enum (action / separator) with separators rendering as NSMenuItem.separator(); grouping survives into tray menus. - update(items:) rebuilds the menu in place (pause/resume style label swaps no longer reinstall the whole controller); setItem(_:label:) swaps one entry's label without touching the rest. - install(icon:items:) puts a template NSImage in the status bar. - setToolTip(_:) and setClickAction(_:) cover tooltip and the click-without-menu pattern (menu stays reachable via click-and-hold). Covered by RivetSystemTests/MenuBarControllerTests.swift (separator layout, update/replace, setItem label swap, action dispatch). --- .../Sources/RivetSystem/SystemServices.swift | 141 ++++++++++++++++-- .../MenuBarControllerTests.swift | 52 +++++++ 2 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift diff --git a/platform/macos/Sources/RivetSystem/SystemServices.swift b/platform/macos/Sources/RivetSystem/SystemServices.swift index 4c321064..b97e6125 100644 --- a/platform/macos/Sources/RivetSystem/SystemServices.swift +++ b/platform/macos/Sources/RivetSystem/SystemServices.swift @@ -140,36 +140,155 @@ public enum RivetLoginItem { } } +/// One entry of a status-bar menu. `.separator` renders an `NSMenuItem.separator()` +/// and never carries an action; `.action` entries dispatch by `identifier`. +public enum RivetMenuItem { + case action(label: String, identifier: String, handler: () -> Void) + case separator +} + @MainActor public final class RivetMenuBarController: NSObject { private var item: NSStatusItem? private var actions: [String: () -> Void] = [:] + private var currentItems: [RivetMenuItem] = [] + private var currentTitle: String = "" + private var currentIcon: NSImage? + private var currentToolTip: String? + // When set, a plain click fires this handler instead of opening the menu; + // the menu stays reachable through click-and-hold (NSStatusItem behavior). + private var clickHandler: (() -> Void)? public func install(title: String, menuItems: [(String, String, () -> Void)]) { - let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - statusItem.button?.title = title - let menu = NSMenu() - for (label, identifier, action) in menuItems { - actions[identifier] = action - let entry = NSMenuItem(title: label, action: #selector(invoke(_:)), keyEquivalent: "") - entry.representedObject = identifier - entry.target = self - menu.addItem(entry) + install( + title: title, + items: menuItems.map { + .action(label: $0.0, identifier: $0.1, handler: $0.2) + }) + } + + public func install(title: String, items: [RivetMenuItem]) { + installStatusItem { button in + button.title = title + } + currentTitle = title + currentIcon = nil + rebuild(items: items) + } + + /// Template image in place of a text title; rendered as a template so it + /// follows the menu bar's light/dark appearance. + public func install(icon: NSImage, items: [RivetMenuItem]) { + icon.isTemplate = true + installStatusItem { button in + button.image = icon + } + currentTitle = "" + currentIcon = icon + rebuild(items: items) + } + + /// Replace every menu entry in place (labels, handlers, separators), + /// keeping the status item, title/icon, and tooltip. + public func update(items: [RivetMenuItem]) { + rebuild(items: items) + } + + /// Swap one entry's label without rebuilding the whole menu. + public func setItem(_ identifier: String, label: String) { + guard let menu = item?.menu else { return } + for entry in menu.items + where entry.representedObject as? String == identifier { + entry.title = label + } + currentItems = currentItems.map { current in + guard case let .action(_, currentIdentifier, handler) = current, + currentIdentifier == identifier + else { return current } + return .action(label: label, identifier: currentIdentifier, handler: handler) + } + } + + public func setToolTip(_ text: String?) { + currentToolTip = text + item?.button?.toolTip = text + } + + /// Fire this handler on a plain click instead of opening the menu. The + /// menu remains available through click-and-hold. Pass nil to restore + /// menu-at-click. + public func setClickAction(_ handler: (() -> Void)?) { + clickHandler = handler + guard let button = item?.button else { return } + if handler != nil { + button.target = self + button.action = #selector(handleClick(_:)) + } else { + button.target = nil + button.action = nil } - statusItem.menu = menu - item = statusItem } public func remove() { if let item { NSStatusBar.system.removeStatusItem(item) } item = nil actions.removeAll() + currentItems = [] + currentTitle = "" + currentIcon = nil + currentToolTip = nil + clickHandler = nil + } + + /// Test hook: the live NSMenu backing the status item. + var menuForTesting: NSMenu? { item?.menu } + + // MARK: internals + + private func installStatusItem(_ configure: (NSStatusBarButton) -> Void) { + if let existing = item { + NSStatusBar.system.removeStatusItem(existing) + } + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + configure(button) + button.toolTip = currentToolTip + if clickHandler != nil { + button.action = #selector(handleClick(_:)) + button.target = self + } + } + item = statusItem + } + + private func rebuild(items: [RivetMenuItem]) { + let menu = NSMenu() + actions.removeAll() + for entry in items { + switch entry { + case let .action(label, identifier, action): + actions[identifier] = action + let item = NSMenuItem( + title: label, action: #selector(invoke(_:)), keyEquivalent: "") + item.representedObject = identifier + item.target = self + menu.addItem(item) + case .separator: + menu.addItem(NSMenuItem.separator()) + } + } + item?.menu = menu + currentItems = items } @objc private func invoke(_ sender: NSMenuItem) { guard let identifier = sender.representedObject as? String else { return } actions[identifier]?() } + + @objc private func handleClick(_ sender: NSStatusBarButton) { + clickHandler?() + } } public final class RivetActivationRouter { diff --git a/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift new file mode 100644 index 00000000..088bdcdb --- /dev/null +++ b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift @@ -0,0 +1,52 @@ +import AppKit +import Testing +@testable import RivetSystem + +@MainActor +@Test func menuBarMenuContainsSeparatorsAndActions() { + let controller = RivetMenuBarController() + var fired = false + controller.install( + title: "Test", + items: [ + .action(label: "Show", identifier: "show", handler: {}), + .separator, + .action(label: "Quit", identifier: "quit", handler: { fired = true }), + ]) + defer { controller.remove() } + + let menu = controller.menuForTesting! + #expect(menu.numberOfItems == 3) + #expect(menu.item(at: 0)?.title == "Show") + #expect(menu.item(at: 1)?.isSeparatorItem == true) + #expect(menu.item(at: 2)?.title == "Quit") + + // Action dispatch flows through the represented-object identifier. + if let quit = menu.item(at: 2) { + _ = quit.target?.perform(Selector(("invoke:")), with: quit) + } + #expect(fired) +} + +@MainActor +@Test func menuBarUpdateReplacesEntriesAndSetItemSwapsOneLabel() { + let controller = RivetMenuBarController() + controller.install(title: "Test", items: [ + .action(label: "Pause reminders", identifier: "toggle", handler: {}), + ]) + defer { controller.remove() } + + controller.update(items: [ + .action(label: "Resume reminders", identifier: "toggle", handler: {}), + .separator, + .action(label: "Quit", identifier: "quit", handler: {}), + ]) + let menu = controller.menuForTesting! + #expect(menu.numberOfItems == 3) + #expect(menu.item(at: 0)?.title == "Resume reminders") + + controller.setItem("toggle", label: "Pause again") + #expect(menu.item(at: 0)?.title == "Pause again") + // Untouched entries keep their labels. + #expect(menu.item(at: 2)?.title == "Quit") +} From e54cd7359bc60762e151e56bc0654267c111ab3c Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Thu, 8 Oct 2026 09:27:48 +0800 Subject: [PATCH 2/2] Fix menu bar click action mode --- .../Sources/RivetSystem/SystemServices.swift | 31 +++++++++---------- .../MenuBarControllerTests.swift | 26 ++++++++++++++++ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/platform/macos/Sources/RivetSystem/SystemServices.swift b/platform/macos/Sources/RivetSystem/SystemServices.swift index b97e6125..65a3c041 100644 --- a/platform/macos/Sources/RivetSystem/SystemServices.swift +++ b/platform/macos/Sources/RivetSystem/SystemServices.swift @@ -150,13 +150,12 @@ public enum RivetMenuItem { @MainActor public final class RivetMenuBarController: NSObject { private var item: NSStatusItem? + private var menu: NSMenu? private var actions: [String: () -> Void] = [:] private var currentItems: [RivetMenuItem] = [] - private var currentTitle: String = "" - private var currentIcon: NSImage? private var currentToolTip: String? - // When set, a plain click fires this handler instead of opening the menu; - // the menu stays reachable through click-and-hold (NSStatusItem behavior). + // AppKit does not send the button action while NSStatusItem.menu is set. + // Click-action mode therefore detaches (but retains) the menu. private var clickHandler: (() -> Void)? public func install(title: String, menuItems: [(String, String, () -> Void)]) { @@ -171,8 +170,6 @@ public final class RivetMenuBarController: NSObject { installStatusItem { button in button.title = title } - currentTitle = title - currentIcon = nil rebuild(items: items) } @@ -183,8 +180,6 @@ public final class RivetMenuBarController: NSObject { installStatusItem { button in button.image = icon } - currentTitle = "" - currentIcon = icon rebuild(items: items) } @@ -196,7 +191,7 @@ public final class RivetMenuBarController: NSObject { /// Swap one entry's label without rebuilding the whole menu. public func setItem(_ identifier: String, label: String) { - guard let menu = item?.menu else { return } + guard let menu else { return } for entry in menu.items where entry.representedObject as? String == identifier { entry.title = label @@ -214,34 +209,37 @@ public final class RivetMenuBarController: NSObject { item?.button?.toolTip = text } - /// Fire this handler on a plain click instead of opening the menu. The - /// menu remains available through click-and-hold. Pass nil to restore - /// menu-at-click. + /// Switch between click-action and menu modes. AppKit does not dispatch a + /// status-button action while a menu is attached, so a non-nil handler + /// temporarily hides the menu. Pass nil to restore menu-at-click. public func setClickAction(_ handler: (() -> Void)?) { clickHandler = handler - guard let button = item?.button else { return } + guard let item, let button = item.button else { return } if handler != nil { + item.menu = nil button.target = self button.action = #selector(handleClick(_:)) } else { button.target = nil button.action = nil + item.menu = menu } } public func remove() { if let item { NSStatusBar.system.removeStatusItem(item) } item = nil + menu = nil actions.removeAll() currentItems = [] - currentTitle = "" - currentIcon = nil currentToolTip = nil clickHandler = nil } /// Test hook: the live NSMenu backing the status item. var menuForTesting: NSMenu? { item?.menu } + var retainedMenuForTesting: NSMenu? { menu } + var buttonForTesting: NSStatusBarButton? { item?.button } // MARK: internals @@ -277,7 +275,8 @@ public final class RivetMenuBarController: NSObject { menu.addItem(NSMenuItem.separator()) } } - item?.menu = menu + self.menu = menu + item?.menu = clickHandler == nil ? menu : nil currentItems = items } diff --git a/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift index 088bdcdb..d9ac194d 100644 --- a/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift +++ b/platform/macos/Tests/RivetSystemTests/MenuBarControllerTests.swift @@ -50,3 +50,29 @@ import Testing // Untouched entries keep their labels. #expect(menu.item(at: 2)?.title == "Quit") } + +@MainActor +@Test func menuBarClickActionDetachesAndRestoresMenu() { + let controller = RivetMenuBarController() + var clickCount = 0 + controller.install(title: "Test", items: [ + .action(label: "Quit", identifier: "quit", handler: {}), + ]) + defer { controller.remove() } + + let installedMenu = controller.menuForTesting + #expect(installedMenu != nil) + + controller.setClickAction { clickCount += 1 } + #expect(controller.menuForTesting == nil) + #expect(controller.retainedMenuForTesting === installedMenu) + + if let button = controller.buttonForTesting, let action = button.action { + _ = button.target?.perform(action, with: button) + } + #expect(clickCount == 1) + + controller.setClickAction(nil) + #expect(controller.menuForTesting === installedMenu) + #expect(controller.buttonForTesting?.action == nil) +}