import AppKit import SwiftUI @MainActor class MenuController: NSObject { static private(set) weak var shared: MenuController? let statusItem: NSStatusItem let popover: NSPopover var eventMonitor: EventMonitor? init(statusItem: NSStatusItem, popover: NSPopover? = nil) { self.statusItem = statusItem self.popover = popover ?? NSPopover() super.init() if let button = statusItem.button { button.action = #selector(togglePopover(_:)) button.target = self } // Explicitly set content size to match MainView's frame self.popover.contentSize = NSSize(width: 450, height: 640) self.popover.behavior = .transient self.popover.contentViewController = NSHostingController(rootView: MainView()) eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in if let self = self, self.popover.isShown { self.closePopover(sender: event) } } MenuController.shared = self } /// Keep the popover open while a modal window (e.g. an NSOpenPanel) is up. /// A transient popover otherwise dismisses itself the moment another window /// takes focus, which closed the folder picker before it could be used. func beginModalPresentation() { eventMonitor?.stop() popover.behavior = .applicationDefined } func endModalPresentation() { popover.behavior = .transient if popover.isShown { eventMonitor?.start() } } @objc func togglePopover(_ sender: AnyObject?) { if popover.isShown { closePopover(sender: sender) } else { showPopover(sender: sender) } } func showPopover(sender: AnyObject?) { if let button = statusItem.button { popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) eventMonitor?.start() } } func closePopover(sender: AnyObject?) { popover.performClose(sender) eventMonitor?.stop() } } class EventMonitor { private var monitor: Any? private let mask: NSEvent.EventTypeMask let handler: (NSEvent?) -> Void private let addMonitor: (NSEvent.EventTypeMask, @escaping (NSEvent?) -> Void) -> Any? private let removeMonitor: (Any) -> Void init( mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void, addMonitor: @escaping (NSEvent.EventTypeMask, @escaping (NSEvent?) -> Void) -> Any? = NSEvent.addGlobalMonitorForEvents, removeMonitor: @escaping (Any) -> Void = NSEvent.removeMonitor ) { self.mask = mask self.handler = handler self.addMonitor = addMonitor self.removeMonitor = removeMonitor } deinit { stop() } func start() { // Remove any existing monitor before installing a new one. Without this, // repeated start() calls leaked monitors inside AppKit (each // addGlobalMonitorForEvents registers a fresh handler, and overwriting // `self.monitor` discarded the only reference to the prior handle, so // removeMonitor could no longer reach it). if monitor != nil { stop() } monitor = addMonitor(mask, handler) } func stop() { if let monitor = monitor { removeMonitor(monitor) self.monitor = nil } } }