MenuController.swift

3.1 KB · raw

1import AppKit2import SwiftUI34@MainActor5class MenuController: NSObject {6  static private(set) weak var shared: MenuController?78  let statusItem: NSStatusItem9  let popover: NSPopover10  var eventMonitor: EventMonitor?1112  init(statusItem: NSStatusItem, popover: NSPopover? = nil) {13    self.statusItem = statusItem14    self.popover = popover ?? NSPopover()15    super.init()1617    if let button = statusItem.button {18      button.action = #selector(togglePopover(_:))19      button.target = self20    }2122    // Explicitly set content size to match MainView's frame23    self.popover.contentSize = NSSize(width: 450, height: 640)24    self.popover.behavior = .transient25    self.popover.contentViewController = NSHostingController(rootView: MainView())2627    eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in28      if let self = self, self.popover.isShown {29        self.closePopover(sender: event)30      }31    }3233    MenuController.shared = self34  }3536  /// Keep the popover open while a modal window (e.g. an NSOpenPanel) is up.37  /// A transient popover otherwise dismisses itself the moment another window38  /// takes focus, which closed the folder picker before it could be used.39  func beginModalPresentation() {40    eventMonitor?.stop()41    popover.behavior = .applicationDefined42  }4344  func endModalPresentation() {45    popover.behavior = .transient46    if popover.isShown {47      eventMonitor?.start()48    }49  }5051  @objc func togglePopover(_ sender: AnyObject?) {52    if popover.isShown {53      closePopover(sender: sender)54    } else {55      showPopover(sender: sender)56    }57  }5859  func showPopover(sender: AnyObject?) {60    if let button = statusItem.button {61      popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)62      eventMonitor?.start()63    }64  }6566  func closePopover(sender: AnyObject?) {67    popover.performClose(sender)68    eventMonitor?.stop()69  }70}7172class EventMonitor {73  private var monitor: Any?74  private let mask: NSEvent.EventTypeMask75  let handler: (NSEvent?) -> Void76  private let addMonitor: (NSEvent.EventTypeMask, @escaping (NSEvent?) -> Void) -> Any?77  private let removeMonitor: (Any) -> Void7879  init(80    mask: NSEvent.EventTypeMask,81    handler: @escaping (NSEvent?) -> Void,82    addMonitor: @escaping (NSEvent.EventTypeMask, @escaping (NSEvent?) -> Void) -> Any? =83      NSEvent.addGlobalMonitorForEvents,84    removeMonitor: @escaping (Any) -> Void = NSEvent.removeMonitor85  ) {86    self.mask = mask87    self.handler = handler88    self.addMonitor = addMonitor89    self.removeMonitor = removeMonitor90  }9192  deinit {93    stop()94  }9596  func start() {97    // Remove any existing monitor before installing a new one. Without this,98    // repeated start() calls leaked monitors inside AppKit (each99    // addGlobalMonitorForEvents registers a fresh handler, and overwriting100    // `self.monitor` discarded the only reference to the prior handle, so101    // removeMonitor could no longer reach it).102    if monitor != nil {103      stop()104    }105    monitor = addMonitor(mask, handler)106  }107108  func stop() {109    if let monitor = monitor {110      removeMonitor(monitor)111      self.monitor = nil112    }113  }114}