Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Guido Schmit 0297b3ad0d Schließen entruckeln: Material fest, Maske animiert, Zeiger abgefragt
Drei Ursachen, davon zwei echte Fehler.

Die NSVisualEffectView steckte in einem Rahmen, dessen Größe animiert wurde.
Ein Weichzeichner muss dann in jedem Einzelbild neu in anderer Größe gerechnet
werden — am teuersten genau beim Schließen, wo die Fläche am schnellsten
schrumpft. Das Material behält jetzt feste Größe, animiert wird nur die Maske
davor. Tint, Bänderung und Kante sind reine SwiftUI-Formen und dürfen weiter in
der Größe animieren. Auch die Schattendeckkraft ist jetzt konstant statt
animiert; sie aufzublenden kostete pro Bild eine neue Weichzeichnung.

Onyx bekam Mausbewegungen über dem eigenen Panel praktisch nicht mit. Der
globale Monitor feuert nur für Ereignisse an fremde Programme, der lokale nur
wenn Onyx aktiv ist — was es als .accessory-App nie ist. Ausgerechnet beim
Verlassen des Panels kam das Ereignis also verspätet oder gar nicht. Solange
ein Panel offen ist, wird die Zeigerposition jetzt mit 60 Hz abgefragt; der
Timer läuft ausschließlich in dieser Zeit.

Öffnen und Schließen benutzten dieselbe Kurve. Schließen ist jetzt kürzer und
ohne Nachschwingen: beim Öffnen sieht man zu, beim Schließen ist man schon
woanders.

Nebenbefund beim Prüfen: es liefen zwei Onyx-Instanzen aus verschiedenen Builds
gleichzeitig, jede mit eigener Zustandsmaschine auf derselben Notch. Das dürfte
einen guten Teil der Hakeligkeit erklärt haben.
2026-08-10 17:52:42 +02:00

206 lines
8.4 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?
/// 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()
}
}