From f7eafecb7f5890803ba832be0c76c3e537828c37 Mon Sep 17 00:00:00 2001 From: "Frenky Harry S. Sinaga" Date: Fri, 24 Jul 2026 19:22:24 +0700 Subject: [PATCH] fix(mac): keep right-click status-item menu open and stable Present the context menu on rightMouseUp (not down) so the matching mouse-up no longer dismisses it, and open via statusItem.menu + performClick so AppKit tracks the menu under the status item instead of manual popUp (which scrolled the Today row away on mouse move). Clears statusItem.menu in menuDidClose so left-click still opens the popover. Debounce + legacy rightMouseUp path retained for macOS <= 26. Closes #802 --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffac..1fc54e27 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -49,11 +49,14 @@ struct CodeBurnApp: App { } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! private var rightClickMonitor: Any? private var lastContextMenuPresentedAt: Date = .distantPast + /// Held only while the right-click menu is open. Cleared in menuDidClose so + /// left-click goes back to the popover action instead of re-showing the menu. + private var contextMenu: NSMenu? fileprivate let store = AppStore() let updateChecker = UpdateChecker() /// True while the displays are asleep. Refresh ticks skip spawning @@ -909,15 +912,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { button.sendAction(on: [.leftMouseUp, .rightMouseUp]) // macOS 27 no longer routes any right-mouse event to the status-item - // button's target/action. A global monitor still observes right-mouse-down; + // button's target/action. A global monitor still observes right-mouse-up; // we hit-test it against our own status-item window and present the menu - // ourselves. Harmless and stable on 15/26 too (the debounce in - // showContextMenu prevents a double-present if the legacy path also fires). - rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in + // ourselves. + // + // Must be mouse-*up*, not mouse-down: presenting on down starts menu + // tracking while the button is still held, so the matching rightMouseUp + // is treated as an outside click and the menu flashes then dismisses. + // Presenting on up (after the click completes) keeps it open. Harmless + // on 15/26 too (the debounce in showContextMenu prevents a double-present + // if the legacy path also fires). + rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseUp]) { [weak self] _ in guard let self, let button = self.statusItem.button, let window = button.window, window.frame.contains(NSEvent.mouseLocation) else { return } + // Defer one turn so menu presentation is not nested inside the + // monitor callback. Safe on mouse-*up* (the click is already + // complete); on mouse-down a deferred present was killed by the + // matching up event and the menu only flashed. DispatchQueue.main.async { self.showContextMenu(from: button) } } @@ -1130,6 +1143,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return } lastContextMenuPresentedAt = now + // Don't let an open popover steal the click / sit under the menu. + if popover?.isShown == true { + popover.performClose(nil) + } + let menu = NSMenu() // Glanceable "today" usage at the top as a single non-interactive (dimmed) @@ -1160,12 +1178,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { quitItem.target = self menu.addItem(quitItem) - // Present directly. The previous `statusItem.menu = menu; button.performClick` - // trick relies on the click -> action path that macOS 27 changed; popUp is - // version-stable. Open a few px below the status item so the menu clears the - // menu bar: anchoring flush clips the top edge and makes macOS engage menu - // scrolling (a scroll chevron appears and the first row slides up on hover). - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button) + // Present via the status item's own menu slot. AppKit positions and tracks + // that menu correctly under the status item (no scroll chevron / first-row + // jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was + // tracked against a point while the cursor still sat on the status item + // above it, so the first mouse move engaged scroll mode. + // + // #472 dropped this pattern because assigning `statusItem.menu` from the + // right-mouse *action* never ran on macOS 27 (right-clicks no longer reach + // the button action). We still open from our global rightMouseUp monitor + // (or the legacy action on ≤26); once we set `statusItem.menu` ourselves, + // performClick is just "open the attached menu" and works on 27 too. + // + // menuDidClose clears `statusItem.menu` so the next left-click hits our + // action (popover) instead of re-opening this menu. + menu.delegate = self + contextMenu = menu + statusItem.menu = menu + button.performClick(nil) + } + + // MARK: - NSMenuDelegate + + // AppKit invokes menu callbacks on the main thread. Clear the status-item + // menu slot so the next left-click hits our action (popover) again. + nonisolated func menuDidClose(_ menu: NSMenu) { + // Hop explicitly — don't assumeIsolated across the NSMenuDelegate boundary + // under Swift 6 strict concurrency (NSMenu isn't Sendable). + DispatchQueue.main.async { [weak self] in + guard let self else { return } + // Always clear: we only ever attach our own context menu to the item. + self.statusItem.menu = nil + self.contextMenu = nil + } } /// One-line "today" summary for the context menu's usage row.