import AppKit /// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt /// alle mit der Zeigerposition. /// /// Die Zeigerposition kommt aus zwei Quellen, und beide werden gebraucht: /// /// **Ereignismonitor**, solange das Panel zu ist. Ein unsichtbares Fenster über /// der Notch wäre die naheliegende Alternative, müsste dafür aber Mausereignisse /// annehmen — und würde die Menüleiste und die Fensterknöpfe darunter /// unbenutzbar machen. Mausbewegungen global zu beobachten verlangt anders als /// bei Tastatureingaben keine Bedienungshilfen-Berechtigung. /// /// **Abfrage**, solange es offen ist. Über dem eigenen Panel schweigen beide /// Monitore: der globale feuert nur für fremde Programme, der lokale nur wenn /// Onyx aktiv ist — was es als `.accessory`-App nie ist. Siehe `updatePolling()`. @MainActor public final class NotchCoordinator { public var policy: DisplayPolicy { didSet { guard policy != oldValue else { return }; rebuild() } } public var virtualNotchSize: CGSize { didSet { guard virtualNotchSize != oldValue else { return }; rebuild() } } private var controllers: [CGDirectDisplayID: NotchScreenController] = [:] private var monitors: [Any] = [] private var suppressionTimer: Timer? /// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`. private var pointerPollTimer: Timer? /// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene. /// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen /// gehört — sonst müsste jede Aufrufstelle den Animationszustand selbst führen. private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView public init(policy: DisplayPolicy = .builtInOnly, virtualNotchSize: CGSize = CGSize(width: 220, height: 32), makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) { self.policy = policy self.virtualNotchSize = virtualNotchSize self.makeContent = makeContent } // MARK: - Lebenszyklus public func start() { rebuild() installMonitors() NotificationCenter.default.addObserver( self, selector: #selector(screensChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil) // Vollbild lässt sich nicht über eine Benachrichtigung erfragen; es // bleibt nur, gelegentlich nachzusehen. Zwei Sekunden reichen — das // Panel ist ohnehin zu, solange niemand die Notch berührt. suppressionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.refreshSuppression() } } } public func stop() { monitors.forEach(NSEvent.removeMonitor) monitors.removeAll() suppressionTimer?.invalidate() suppressionTimer = nil pointerPollTimer?.invalidate() pointerPollTimer = nil NotificationCenter.default.removeObserver(self) controllers.values.forEach { $0.tearDown() } controllers.removeAll() } // MARK: - Bildschirme @objc private func screensChanged() { rebuild() } private func rebuild() { let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics)) let wantedIDs = Set(wanted.map(\.displayID)) for (id, controller) in controllers where !wantedIDs.contains(id) { controller.tearDown() controllers[id] = nil } for metrics in wanted { if let existing = controllers[metrics.displayID] { // Bestehenden Controller aktualisieren statt neu zu bauen: // ein Auflösungswechsel darf ein offenes Panel nicht wegreißen. existing.update(metrics: metrics, virtualSize: virtualNotchSize) } else { let id = metrics.displayID let controller = NotchScreenController( metrics: metrics, virtualSize: virtualNotchSize, makeContent: { presentation in makeContent(id, presentation) }) controller.onVisibilityChanged = { [weak self] _ in self?.updatePolling() } controllers[id] = controller } } refreshSuppression() } // MARK: - Ereignisse private func installMonitors() { let moved: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged] if let global = NSEvent.addGlobalMonitorForEvents(matching: moved, handler: { _ in MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } }) { monitors.append(global) } // Der globale Monitor schweigt, solange Onyx selbst aktiv ist — dann // greift dieser hier, sonst schließt das Panel unter dem eigenen Zeiger. if let local = NSEvent.addLocalMonitorForEvents(matching: moved, handler: { event in MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } return event }) { monitors.append(local) } if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in MainActor.assumeIsolated { [weak self] in guard let self else { return } let point = NSEvent.mouseLocation for controller in controllers.values where controller.isVisible && controller.geometry.triggerRect.contains(point) { controller.click() } } return event }) { monitors.append(clicks) } if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in guard event.keyCode == 53 else { return event } // esc MainActor.assumeIsolated { [weak self] in self?.controllers.values.forEach { $0.escape() } } return nil }) { monitors.append(keys) } } private func pointerMoved() { let point = NSEvent.mouseLocation for controller in controllers.values { controller.pointerMoved(to: point) } } /// Fragt die Zeigerposition ab, solange ein Panel offen ist. /// /// Über dem eigenen Panel bekommt Onyx sonst gar nichts mit: der globale /// Monitor feuert nur für Ereignisse, die an **fremde** Programme gehen, und /// der lokale nur, wenn Onyx das aktive Programm ist — was es als /// `.accessory`-App nie ist. Genau in dem Moment, in dem der Zeiger das /// Panel wieder verlässt, käme das Ereignis also verspätet oder gar nicht, /// und das Schließen wirkt hakelig. /// /// Abfragen ist hier das ehrlichere Mittel als noch mehr Monitore: es läuft /// nur, während wirklich etwas offen ist, und kostet dabei einen /// Rechteckvergleich pro Bild. private func updatePolling() { let anyVisible = controllers.values.contains { $0.isVisible } if anyVisible, pointerPollTimer == nil { let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } } // `.common` statt der Standard-Schleife: sonst steht die Abfrage, // während ein Menü offen ist oder gescrollt wird. RunLoop.main.add(timer, forMode: .common) pointerPollTimer = timer } else if !anyVisible { pointerPollTimer?.invalidate() pointerPollTimer = nil } } private func refreshSuppression() { for screen in NSScreen.screens { controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow) } } // MARK: - Zugriff für die App public var activeControllers: [NotchScreenController] { Array(controllers.values) } public func controller(for displayID: CGDirectDisplayID) -> NotchScreenController? { controllers[displayID] } /// Öffnet das Panel auf dem Bildschirm, auf dem gerade der Zeiger steht — /// der Weg, über den das Menüleisten-Icon das Panel aufruft. public func togglePanelUnderPointer() { let point = NSEvent.mouseLocation guard let screen = NSScreen.screens.first(where: { $0.frame.contains(point) }) ?? NSScreen.main, let controller = controllers[screen.displayID] else { return } controller.click() } }