Phase 1: Notch-Shell, Design-System, Menüleisten-Infrastruktur
Zustandsmaschine und Geometrie sind testgetrieben entstanden und tragen 40 Tests — sie sind frei von AppKit, damit jeder Übergang ohne Fenster und ohne Warten prüfbar ist. Zeit kommt nur als Ereignis herein. Zwei Entscheidungen, die im Code begründet sind: Der Zeiger wird über einen globalen Ereignismonitor verfolgt, nicht über ein unsichtbares Fenster auf der Notch. Ein solches Fenster müsste Mausereignisse annehmen, um sie zu bemerken, und würde damit Menüleiste und Fensterknöpfe darunter unbenutzbar machen. "Nur internes Display" fällt auf ein externes zurück, wenn kein eingebautes da ist. Am Dock mit geschlossenem Deckel hieße die Einstellung wörtlich genommen, dass Onyx unerreichbar wird. Design: NSVisualEffectView mit eigenem Tint statt Liquid Glass — Onyx ist Stein, kein Glas. Alle Farben und Maße liegen als Tokens in OnyxDesign. Menüleiste: jedes Modul bekommt ein eigenes NSStatusItem. Elemente, denen macOS mangels Platz keine Breite gibt, werden erkannt und gemeldet, statt still zu verschwinden. App-Target über XcodeGen, damit die Projektdefinition im Diff lesbar bleibt. Nicht sandboxed, mit App-Group-Entitlement — baut, startet, signiert mit PP34X97WS3.
This commit is contained in:
159
Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Normal file
159
Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
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.
|
||||
private let makeContent: (CGDirectDisplayID) -> NSView
|
||||
|
||||
public init(policy: DisplayPolicy = .builtInOnly,
|
||||
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
|
||||
makeContent: @escaping (CGDirectDisplayID) -> 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 {
|
||||
controllers[metrics.displayID] = NotchScreenController(
|
||||
metrics: metrics,
|
||||
virtualSize: virtualNotchSize,
|
||||
content: makeContent(metrics.displayID))
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user