import AppKit import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch") /// 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 { /// Die Auslösefläche für alle Bildschirme. Ändern wirkt sofort. public var trigger: TriggerArea = .standard { didSet { guard trigger != oldValue else { return } controllers.values.forEach { $0.trigger = trigger } } } public var policy: DisplayPolicy { didSet { guard policy != oldValue else { return }; rebuild() } } public var virtualNotchSize: CGSize { didSet { guard virtualNotchSize != oldValue else { return }; rebuild() } } /// Das Maß einer Insel, wenn nichts anderes eingestellt ist. /// /// Die **Höhe** ist hier nur ein Rückfall: auf einem Bildschirm mit /// Menüleiste gibt deren Höhe das Maß vor, damit die Insel darin liegt /// statt darunter herauszuhängen. /// /// Die Breite bestimmt, wie rund die Form *wirkt* — der Radius ist mit der /// halben Höhe ohnehin am Maximum. Bei 150 zu 26 war es ein Balken mit /// runden Enden; 72 zu rund 18 sind etwa 4:1, nah an der Insel am iPhone. /// Inhalt braucht die leere Insel keinen: für jede Live Activity wächst sie /// ohnehin auf deren Maß. public static let defaultVirtualSize = CGSize(width: 72, height: 26) /// Was eine Inselbreite mindestens und höchstens sein darf. /// /// Nach unten, damit die Insel nicht zum Punkt wird, den niemand mehr /// trifft; nach oben, weil sie sonst die halbe Menüleiste verdeckt. public static let widthRange: ClosedRange = 48...320 /// Abweichende Inselbreiten, je Bildschirm. /// /// **Nur die Breite.** Die Höhe ist nicht mehr frei: bei verschmolzener /// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste, /// damit die Insel darin liegt statt darunter herauszuhängen. Ein /// Höhenregler daneben wäre ein Bedienelement, das nichts tut. /// /// Je Bildschirm, weil ein 27-Zoll-Monitor und ein Laptopdeckel dieselbe /// Breite verschieden groß aussehen lassen. public var virtualWidthOverrides: [CGDirectDisplayID: CGFloat] = [:] { didSet { guard virtualWidthOverrides != oldValue else { return }; rebuild() } } /// Die Inselgröße, die für diesen Bildschirm gilt. func virtualSize(for displayID: CGDirectDisplayID) -> CGSize { guard let width = virtualWidthOverrides[displayID] else { return virtualNotchSize } return CGSize(width: width, height: virtualNotchSize.height) } /// Notch oder Insel. Siehe ``NotchStyle``. public var style: NotchStyle = .automatic { didSet { guard style != oldValue else { return } controllers.values.forEach { $0.style = style } } } /// Ob die eingeklappte Insel auf jedem Bildschirm stehen bleibt. /// Siehe ``NotchScreenController/showsIdleIsland``. public var showsIdleIsland = true { didSet { guard showsIdleIsland != oldValue else { return } controllers.values.forEach { $0.showsIdleIsland = showsIdleIsland } } } 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? private var pollInterval: TimeInterval = 0 /// 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, trigger: TriggerArea = .standard, virtualNotchSize: CGSize = NotchCoordinator.defaultVirtualSize, makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) { self.policy = policy self.trigger = trigger self.virtualNotchSize = virtualNotchSize self.makeContent = makeContent } // MARK: - Lebenszyklus public func start() { rebuild() installMonitors() // Sofort anlaufen lassen, nicht erst wenn etwas sichtbar wird: die // Abfrage ist es, die überhaupt etwas sichtbar macht. updatePolling() 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)) log.notice("Bildschirme neu aufgebaut: \(wantedIDs.count, privacy: .public) von \(NSScreen.screens.count, privacy: .public) verwendet") 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: virtualSize(for: metrics.displayID)) } else { let id = metrics.displayID let controller = NotchScreenController( metrics: metrics, virtualSize: virtualSize(for: id), trigger: trigger, style: style, makeContent: { presentation in makeContent(id, presentation) }) controller.showsIdleIsland = showsIdleIsland 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) } // Klicks in der eigenen App: in der Notch fixieren, sonst schließen. if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in MainActor.assumeIsolated { [weak self] in self?.handleClick() } return event }) { monitors.append(clicks) } // Klicks in fremden Programmen schließen ein fixiertes Panel. Ohne das // bleibt es stehen, bis jemand Escape drückt oder die Regel kennt. if let outside = NSEvent.addGlobalMonitorForEvents( matching: [.leftMouseDown, .rightMouseDown], handler: { _ in MainActor.assumeIsolated { [weak self] in self?.handleClick() } }) { monitors.append(outside) } 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) } } /// Ein Klick fixiert nur in der Notch selbst. Überall sonst schließt er — /// auch das eigene Panel, denn wer hineinklickt, bedient ein Widget und /// nicht das Panel. private func handleClick() { let point = NSEvent.mouseLocation for controller in controllers.values where controller.isVisible { if controller.geometry.triggerRect.contains(point) { controller.click() } else if !controller.containsVisiblePoint(point) { controller.clickedOutside() } } } private func pointerMoved() { let point = NSEvent.mouseLocation for controller in controllers.values { controller.pointerMoved(to: point) } } /// Fragt die Zeigerposition ab — immer, nur unterschiedlich oft. /// /// **Warum überhaupt abgefragt wird und nicht nur zugehört:** /// /// Über dem eigenen Panel bekommt Onyx über Ereignisse gar nichts mit: der /// globale Monitor feuert nur für Ereignisse an **fremde** Programme, der /// lokale nur, wenn Onyx das aktive Programm ist — was es als /// `.accessory`-App nie ist. /// /// Und der Auslösebereich liegt in der Menüleiste. Dorthin stellt macOS /// Mausbewegungen nicht verlässlich an fremde Programme zu: mal kommen sie /// an, mal nicht, je nachdem, was gerade den Zeiger führt. Genau daher kam /// das „manchmal geht die Notch nicht auf". Ein Verfahren, das eine /// Zustellung voraussetzt, die es nicht gibt, kann nicht zuverlässig sein. /// /// Deshalb sind die Monitore nur noch Beschleunigung, und die Abfrage ist /// die Wahrheit. Sie kostet einen Rechteckvergleich; bei zehn Abfragen je /// Sekunde im Ruhezustand ist das nicht messbar. private func updatePolling() { let anyVisible = controllers.values.contains { $0.isVisible } let interval = anyVisible ? Self.visiblePollInterval : Self.idlePollInterval guard interval != pollInterval || pointerPollTimer == nil else { return } pointerPollTimer?.invalidate() let timer = Timer(timeInterval: interval, 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 pollInterval = interval log.notice("Zeigerabfrage: \(Int(1 / interval), privacy: .public) Hz, \(self.controllers.count, privacy: .public) Bildschirm(e)") } /// Im Ruhezustand genügt ein Zehntel: dazu kommt ohnehin die Entprellzeit /// von 220 ms, bevor sich etwas rührt. private static let idlePollInterval: TimeInterval = 1.0 / 10.0 /// Offen wird jedes Bild abgefragt, sonst wirkt das Schließen hakelig. private static let visiblePollInterval: TimeInterval = 1.0 / 60.0 private func refreshSuppression() { // Einmal je Durchgang abfragen statt einmal je Bildschirm: die Sperre // gilt für das ganze System. let playing = NotchSuppression.isPreventingDisplaySleep for screen in NSScreen.screens { guard let controller = controllers[screen.displayID] else { continue } let suppress = NotchSuppression.shouldSuppress( hasFullScreenWindow: screen.isShowingFullScreenWindow, preventsDisplaySleep: playing) controller.setSuppressed(suppress) } // Fällt die Unterdrückung weg, während der Zeiger schon in der Notch // steht, käme sonst nichts — die Maschine wartet auf ein Ereignis, das // erst bei der nächsten Bewegung kommt. pointerMoved() } // 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() } }