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.
163 lines
6.4 KiB
Swift
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()
|
|
}
|
|
}
|