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
This commit is contained in:
Frenky Harry S. Sinaga 2026-07-24 19:22:24 +07:00
parent 2f8e5bddcd
commit f7eafecb7f
No known key found for this signature in database
GPG key ID: 01204C59EE0B058C

View file

@ -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.