import AppKit /// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt /// alle mit der Zeigerposition. /// /// Die Zeigerposition kommt aus einem globalen Ereignismonitor statt aus einem /// unsichtbaren Fenster über der Notch. Ein solches Fenster müsste Mausereignisse /// annehmen, um sie zu bemerken — und würde damit die Menüleiste und die /// Fensterknöpfe darunter unbenutzbar machen. Mausbewegungen global zu beobachten /// verlangt anders als bei Tastatureingaben keine Bedienungshilfen-Berechtigung. @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? /// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene. private let makeContent: (CGDirectDisplayID) -> NSView public init(policy: DisplayPolicy = .builtInOnly, virtualNotchSize: CGSize = CGSize(width: 220, height: 32), makeContent: @escaping (CGDirectDisplayID) -> 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 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 { controllers[metrics.displayID] = NotchScreenController( metrics: metrics, virtualSize: virtualNotchSize, content: makeContent(metrics.displayID)) } } 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) } } 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() } }