Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Guido Schmit 715031f153 Panel wächst aus der Notch, statt einzublenden
Die Fläche wird nicht eingeblendet — sie beginnt exakt in der Notch-Silhouette
und wächst von dort nach unten und zur Seite, deckungsgleich mit der schwarzen
Hardware darüber. Eingeblendetes Panel wirkt wie Glas; Onyx soll sich wie ein
fester Körper anfühlen, der herausfährt.

Der Inhalt hat eine eigene Kurve: beim Öffnen um 90 ms verzögert, damit die
Fläche vorweg läuft und kein Text sich mitdehnt; beim Schließen sofort weg,
damit beim Einfahren nichts gestaucht wird. Die Eckenrundung wandert mit
(10 → 26), sonst sieht die kleine Form aufgeblasen aus.

Drei Fallstricke, die im Code stehen:

Der Fensterschatten folgt dem Fensterrahmen, nicht der gezeichneten Form. Das
Fenster hat immer volle Panelgröße, damit die Animation Platz hat — der
Systemschatten läge also die ganze Zeit um eine unsichtbare Fläche. Deshalb
hasShadow = false und Schatten aus SwiftUI.

Ausgefahren wird erst im nächsten Runloop-Durchlauf. Im selben Durchlauf wie
orderFrontRegardless sieht SwiftUI keinen Zustandswechsel, sondern nur den
Endzustand, und das Panel wäre schlagartig da.

NotchHostingView nimmt Klicks nur dort an, wo etwas zu sehen ist. Sonst
verschluckt das volle Fenster während des Aus- und Einfahrens Klicks in seiner
leeren Fläche.
2026-08-10 17:25:27 +02:00

163 lines
6.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 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.
/// 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
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
controllers[id] = NotchScreenController(
metrics: metrics,
virtualSize: virtualNotchSize,
makeContent: { presentation in makeContent(id, presentation) })
}
}
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()
}
}