mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 23:14:33 +00:00
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:
parent
2f8e5bddcd
commit
f7eafecb7f
1 changed files with 56 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue