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() { 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) } 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) } entry.item.length = view.fittingSize.width > 0 ? view.fittingSize.width : NSStatusItem.variableLength entry.module.activate() } else { entry.module.deactivate() } checkVisibility() } // MARK: - Interaktion @objc private func statusItemClicked(_ sender: NSStatusBarButton) { guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return } if entry.popover.isShown { entry.popover.performClose(nil) return } 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() } 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) } }