Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Scarriffle 453353de58 Notch öffnet zuverlässig, Einstellungen beenden Onyx nicht mehr
Das Öffnen hing allein an Mausereignissen. Der Auslösebereich liegt aber in
der Menüleiste, und dorthin stellt macOS Bewegungen nicht verlässlich an
fremde Programme zu — mal kommen sie an, mal nicht, je nachdem, was gerade
den Zeiger führt. Ein Verfahren, das eine Zustellung voraussetzt, die es
nicht gibt, kann nicht zuverlässig sein.

Die Zeigerposition wird deshalb jetzt immer abgefragt, im Ruhezustand
zehnmal je Sekunde, bei offenem Panel sechzigmal. Die Monitore sind nur noch
Beschleunigung. Kosten: 0,1 % CPU im Leerlauf, gemessen. Nachgeprüft mit
fünf Anläufen aus verschiedenen Richtungen — fünfmal offen.

Die Unterdrückung war zu grob: jedes Vollbildfenster galt als Grund, das
Panel wegzuhalten. Damit war die Notch tot, sobald Xcode im Vollbild lief.
Gemeint war immer nur der Film über den ganzen Bildschirm, und den erkennt
man daran, dass etwas den Bildschirm wachhält — ein Textfenster tut das
nicht. Beide Bedingungen zusammen, nicht eine davon.

Dazu ein Loch, das dabei auffiel: fiel die Unterdrückung weg, während der
Zeiger schon in der Notch stand, wartete die Maschine auf ein Ereignis, das
erst bei der nächsten Bewegung kam.

Der rote Knopf blendet Fenster jetzt aus, statt sie zu schließen. Onyx hat
kein Hauptfenster; „Fenster zu" heißt hier nie „fertig". Manche
Hilfsprogramme erzwingen aber genau diese Gleichung und beenden eine App,
sobald ihr letztes Fenster verschwindet — dann war Onyx nach einem Blick in
die Einstellungen weg, samt Menüleiste und Notch. Ein Fenster, das nie
wirklich schließt, gibt dafür keinen Anlass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:21:00 +02:00

232 lines
9.9 KiB
Swift

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?
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,
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()
// 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))
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 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
}
/// 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()
}
}