import AppKit import SwiftUI /// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul. /// /// Die Reihenfolge merkt sich macOS selbst über `autosaveName`; Onyx speichert sie /// nicht doppelt. Was Onyx speichert, ist welche Module an sind und wie sie /// aussehen sollen. @MainActor public final class MenuBarController { /// Ein Modul, dessen Element keine Breite bekommen hat. /// /// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte /// zusätzlich blockiert. Ein Element, das dort nicht mehr hinpasst, wird von /// macOS kommentarlos weggelassen — für den Nutzer sieht das aus, als wäre /// die Einstellung wirkungslos. Deshalb wird der Fall erkannt und gemeldet. public private(set) var hiddenModuleIDs: Set = [] /// Wird gerufen, wenn sich `hiddenModuleIDs` ändert — die Einstellungen /// zeigen daraufhin einen Hinweis am betroffenen Modul. public var onVisibilityChanged: ((Set) -> Void)? private struct Entry { let module: any MenuBarModule let item: NSStatusItem let popover: NSPopover var settings: MenuBarModuleSettings } private var entries: [String: Entry] = [:] private var visibilityTimer: Timer? public init() {} public func start() { // Ob ein Element sichtbar ist, meldet AppKit nicht. Es bleibt nur, // gelegentlich nachzusehen. visibilityTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.checkVisibility() } } } public func stop() { closeOpenPopover() visibilityTimer?.invalidate() visibilityTimer = nil for id in entries.keys { setEnabled(false, forModuleID: id) } } // MARK: - Module public func register(_ module: any MenuBarModule, settings: MenuBarModuleSettings) { guard entries[module.id] == nil else { return } let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) item.autosaveName = "onyx.menubar.\(module.id)" item.isVisible = false let popover = NSPopover() popover.behavior = .transient popover.animates = true entries[module.id] = Entry(module: module, item: item, popover: popover, settings: settings) apply(settings, toModuleID: module.id) } public func setEnabled(_ enabled: Bool, forModuleID id: String) { guard var entry = entries[id] else { return } entry.settings.isEnabled = enabled entries[id] = entry apply(entry.settings, toModuleID: id) } public func setPresentation(_ presentation: MenuBarPresentation, forModuleID id: String) { guard var entry = entries[id] else { return } entry.settings.presentation = presentation entries[id] = entry apply(entry.settings, toModuleID: id) } /// Welches Popover gerade offen ist. Höchstens eines. private var openPopoverID: String? private var globalMonitor: Any? private var localMonitor: Any? private var keyMonitor: Any? public func settings(forModuleID id: String) -> MenuBarModuleSettings? { entries[id]?.settings } private func apply(_ settings: MenuBarModuleSettings, toModuleID id: String) { guard let entry = entries[id] else { return } entry.item.isVisible = settings.isEnabled if settings.isEnabled { let view = entry.module.makeStatusView(presentation: settings.presentation) if let button = entry.item.button { button.subviews.forEach { $0.removeFromSuperview() } view.frame = button.bounds view.autoresizingMask = [.width, .height] button.addSubview(view) button.target = self button.action = #selector(statusItemClicked(_:)) // Beide Tasten annehmen: rechts öffnet später das Kontextmenü. button.sendAction(on: [.leftMouseUp, .rightMouseUp]) button.identifier = NSUserInterfaceItemIdentifier(id) } applyWidth(view.intrinsicContentSize.width, to: entry) // Die nötige Breite steht nicht von Anfang an fest: die Kernzahl // kennt das Modul erst nach der ersten Messung, und ein Moduswechsel // ändert „100 %" in „100 °C". (view as? WidthReporting)?.onWidthChange = { [weak self] width in guard let self, let entry = entries[id] else { return } applyWidth(width, to: entry) } entry.module.activate() } else { entry.module.deactivate() } checkVisibility() } private func applyWidth(_ width: CGFloat, to entry: Entry) { entry.item.length = width > 0 ? width : NSStatusItem.variableLength } // MARK: - Interaktion @objc private func statusItemClicked(_ sender: NSStatusBarButton) { guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return } // Immer nur eines offen. Zwei Popover nebeneinander sehen aus wie ein // Fehler, und das zweite verdeckt oft das erste. if openPopoverID == id { closeOpenPopover() return } closeOpenPopover() entry.popover.contentViewController = NSHostingController( rootView: entry.module.makePopoverView()) entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) // Ohne das bleibt das Popover hinter dem aktiven Fenster. entry.popover.contentViewController?.view.window?.makeKey() openPopoverID = id startWatchingForOutsideClicks() } // MARK: - Schließen bei Klick daneben /// `NSPopover.behavior = .transient` genügt hier **nicht**. /// /// Es schließt zuverlässig, solange die eigene App aktiv ist. Onyx läuft /// aber als `.accessory` und wird durch einen Klick auf ein Statuselement /// nicht aktiviert — ein Klick in ein fremdes Fenster erreicht das Popover /// dann gar nicht, und es bleibt offen stehen. Ein globaler Beobachter sieht /// solche Klicks trotzdem. private func startWatchingForOutsideClicks() { guard globalMonitor == nil else { return } globalMonitor = NSEvent.addGlobalMonitorForEvents( matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in Task { @MainActor in self?.closeOpenPopover() } } // Der globale Beobachter sieht keine Klicks in die eigene App — dafür // der lokale. Er reicht das Ereignis weiter, damit Knöpfe im Popover // weiterhin funktionieren. localMonitor = NSEvent.addLocalMonitorForEvents( matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in guard let self, let id = openPopoverID, let entry = entries[id] else { return event } // Klicks **im** Popover schließen es nicht. if event.window !== entry.popover.contentViewController?.view.window, event.window !== entry.item.button?.window { closeOpenPopover() } return event } // Auch die Escape-Taste und ein Bildschirmwechsel beenden es. keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if event.keyCode == 53 { self?.closeOpenPopover(); return nil } return event } } private func closeOpenPopover() { if let id = openPopoverID, let entry = entries[id] { entry.popover.performClose(nil) } openPopoverID = nil globalMonitor.map(NSEvent.removeMonitor) localMonitor.map(NSEvent.removeMonitor) keyMonitor.map(NSEvent.removeMonitor) globalMonitor = nil localMonitor = nil keyMonitor = nil } private func checkVisibility() { var hidden = Set() for (id, entry) in entries where entry.settings.isEnabled { // `isVisible` bleibt true, auch wenn das Element mangels Platz nicht // gezeichnet wird. Der verlässliche Hinweis ist ein Knopf ohne Fenster. if entry.item.button?.window == nil { hidden.insert(id) } } guard hidden != hiddenModuleIDs else { return } hiddenModuleIDs = hidden onVisibilityChanged?(hidden) } }