codeburn/mac/Sources/CodeBurnMenubar/StatusItemContextMenuPolicy.swift
Frenky Harry S. Sinaga 8e8b58626a
test(mac): lock status-item context menu policy (#802)
Extract StatusItemContextMenuPolicy (event mask, debounce, presentation
mode) and unit-test it so rightMouseUp + statusItemMenu cannot regress
to the flash/scroll-jump paths without a failing test.
2026-07-24 19:27:25 +07:00

47 lines
2.1 KiB
Swift

import AppKit
import Foundation
/// Pure policy for the status-item right-click menu (#802).
///
/// Keeps the event-mask, debounce, and presentation choices out of AppDelegate
/// so they can be unit-tested without spinning up an NSStatusItem. The bugs this
/// encodes:
///
/// 1. **Flash** presenting on `rightMouseDown` lets the matching `rightMouseUp`
/// dismiss the menu immediately. Monitor (and legacy action) use mouse-*up*.
/// 2. **Jump/scroll** manual `NSMenu.popUp(at:in:)` tracks poorly while the
/// cursor still sits on the status item above the menu. Attach via
/// `statusItem.menu` + `performClick` instead.
/// 3. **Double-present** on macOS 26 both the button action and the global
/// monitor can fire for one click; debounce collapses them.
enum StatusItemContextMenuPolicy {
/// Global-monitor / action event. Must be mouse-up (see flash note above).
static let presentEventMask: NSEvent.EventTypeMask = .rightMouseUp
/// Minimum gap between presents. Covers the dual-path race on macOS 26.
static let presentDebounceSeconds: TimeInterval = 0.3
/// How the menu is shown once a present is accepted.
enum Presentation: Equatable {
/// Assign `statusItem.menu` then `button.performClick`. AppKit positions
/// and tracks the menu under the status item. Clear menu in menuDidClose.
case statusItemMenu
/// Manual `NSMenu.popUp(at:in:)`. Causes scroll-chevron jump on mouse move
/// when the cursor starts above the menu. Kept only as a named anti-pattern
/// so tests can lock that we do *not* use it.
case manualPopUp
}
static let presentation: Presentation = .statusItemMenu
/// Returns true and advances `lastPresentedAt` when a new present is allowed.
static func acceptPresent(
now: Date,
lastPresentedAt: inout Date,
debounce: TimeInterval = presentDebounceSeconds
) -> Bool {
guard now.timeIntervalSince(lastPresentedAt) > debounce else { return false }
lastPresentedAt = now
return true
}
}