Die Silhouette bestand aus zwei überlappenden Teilpfaden. Gefüllt stimmte die Vereinigung, aber die Kontur zeichnete jeden Teilpfad vollständig nach — auch dort, wo er innen lag. Dazu kippte die Form ab einer Breite in einen anderen Zweig, und die beiden Zweige waren nicht dieselbe Geometrie. Zusammen war das das Flackern an den Rundungen. Jetzt ein durchgehender Umriss, dessen Zweige ineinander übergehen statt zu springen. Neu ist die Wahl zwischen verschmolzen und schwebend. Eine echte Notch **muss** an der Kante kleben und oben eckig sein, sonst klafft ein Spalt zum Gehäuse; eine Insel **muss** Abstand halten und rundum gerundet sein, sonst sieht sie aus wie ein abgeschnittenes Rechteck. Beides zu mischen geht nicht, deshalb eine Wahl und keine zwei Schalter. Ihre Höhe rät Onyx nicht mehr, sondern misst die Menüleiste: 24 Punkte minus Luft oben und unten. Vorher hing ein festes Maß unten heraus. Dazu „nur der Hauptbildschirm" als dritte Anzeigewahl — gemeint ist der mit der Menüleiste, nicht `NSScreen.main`, das mit dem aktiven Fenster mitwandert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
14 KiB
Swift
321 lines
14 KiB
Swift
import AppKit
|
|
import OSLog
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
|
|
|
|
/// 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 {
|
|
|
|
/// Die Auslösefläche für alle Bildschirme. Ändern wirkt sofort.
|
|
public var trigger: TriggerArea = .standard {
|
|
didSet {
|
|
guard trigger != oldValue else { return }
|
|
controllers.values.forEach { $0.trigger = trigger }
|
|
}
|
|
}
|
|
|
|
public var policy: DisplayPolicy {
|
|
didSet { guard policy != oldValue else { return }; rebuild() }
|
|
}
|
|
|
|
public var virtualNotchSize: CGSize {
|
|
didSet { guard virtualNotchSize != oldValue else { return }; rebuild() }
|
|
}
|
|
|
|
/// Das Maß einer Insel, wenn nichts anderes eingestellt ist.
|
|
///
|
|
/// Die **Höhe** ist hier nur ein Rückfall: auf einem Bildschirm mit
|
|
/// Menüleiste gibt deren Höhe das Maß vor, damit die Insel darin liegt
|
|
/// statt darunter herauszuhängen.
|
|
///
|
|
/// Die Breite bestimmt, wie rund die Form *wirkt* — der Radius ist mit der
|
|
/// halben Höhe ohnehin am Maximum. Bei 150 zu 26 war es ein Balken mit
|
|
/// runden Enden; 72 zu rund 18 sind etwa 4:1, nah an der Insel am iPhone.
|
|
/// Inhalt braucht die leere Insel keinen: für jede Live Activity wächst sie
|
|
/// ohnehin auf deren Maß.
|
|
public static let defaultVirtualSize = CGSize(width: 72, height: 26)
|
|
|
|
/// Was eine Inselbreite mindestens und höchstens sein darf.
|
|
///
|
|
/// Nach unten, damit die Insel nicht zum Punkt wird, den niemand mehr
|
|
/// trifft; nach oben, weil sie sonst die halbe Menüleiste verdeckt.
|
|
public static let widthRange: ClosedRange<CGFloat> = 48...320
|
|
|
|
/// Abweichende Inselbreiten, je Bildschirm.
|
|
///
|
|
/// **Nur die Breite.** Die Höhe ist nicht mehr frei: bei verschmolzener
|
|
/// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste,
|
|
/// damit die Insel darin liegt statt darunter herauszuhängen. Ein
|
|
/// Höhenregler daneben wäre ein Bedienelement, das nichts tut.
|
|
///
|
|
/// Je Bildschirm, weil ein 27-Zoll-Monitor und ein Laptopdeckel dieselbe
|
|
/// Breite verschieden groß aussehen lassen.
|
|
public var virtualWidthOverrides: [CGDirectDisplayID: CGFloat] = [:] {
|
|
didSet { guard virtualWidthOverrides != oldValue else { return }; rebuild() }
|
|
}
|
|
|
|
/// Die Inselgröße, die für diesen Bildschirm gilt.
|
|
func virtualSize(for displayID: CGDirectDisplayID) -> CGSize {
|
|
guard let width = virtualWidthOverrides[displayID] else { return virtualNotchSize }
|
|
return CGSize(width: width, height: virtualNotchSize.height)
|
|
}
|
|
|
|
/// Notch oder Insel. Siehe ``NotchStyle``.
|
|
public var style: NotchStyle = .automatic {
|
|
didSet {
|
|
guard style != oldValue else { return }
|
|
controllers.values.forEach { $0.style = style }
|
|
}
|
|
}
|
|
|
|
/// Ob die eingeklappte Insel auf jedem Bildschirm stehen bleibt.
|
|
/// Siehe ``NotchScreenController/showsIdleIsland``.
|
|
public var showsIdleIsland = true {
|
|
didSet {
|
|
guard showsIdleIsland != oldValue else { return }
|
|
controllers.values.forEach { $0.showsIdleIsland = showsIdleIsland }
|
|
}
|
|
}
|
|
|
|
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,
|
|
trigger: TriggerArea = .standard,
|
|
virtualNotchSize: CGSize = NotchCoordinator.defaultVirtualSize,
|
|
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
|
|
self.policy = policy
|
|
self.trigger = trigger
|
|
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))
|
|
log.notice("Bildschirme neu aufgebaut: \(wantedIDs.count, privacy: .public) von \(NSScreen.screens.count, privacy: .public) verwendet")
|
|
|
|
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: virtualSize(for: metrics.displayID))
|
|
} else {
|
|
let id = metrics.displayID
|
|
let controller = NotchScreenController(
|
|
metrics: metrics,
|
|
virtualSize: virtualSize(for: id),
|
|
trigger: trigger,
|
|
style: style,
|
|
makeContent: { presentation in makeContent(id, presentation) })
|
|
controller.showsIdleIsland = showsIdleIsland
|
|
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) }
|
|
|
|
// Klicks in der eigenen App: in der Notch fixieren, sonst schließen.
|
|
if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in
|
|
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
|
|
return event
|
|
}) { monitors.append(clicks) }
|
|
|
|
// Klicks in fremden Programmen schließen ein fixiertes Panel. Ohne das
|
|
// bleibt es stehen, bis jemand Escape drückt oder die Regel kennt.
|
|
if let outside = NSEvent.addGlobalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown], handler: { _ in
|
|
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
|
|
}) { monitors.append(outside) }
|
|
|
|
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) }
|
|
}
|
|
|
|
/// Ein Klick fixiert nur in der Notch selbst. Überall sonst schließt er —
|
|
/// auch das eigene Panel, denn wer hineinklickt, bedient ein Widget und
|
|
/// nicht das Panel.
|
|
private func handleClick() {
|
|
let point = NSEvent.mouseLocation
|
|
for controller in controllers.values where controller.isVisible {
|
|
if controller.geometry.triggerRect.contains(point) {
|
|
controller.click()
|
|
} else if !controller.containsVisiblePoint(point) {
|
|
controller.clickedOutside()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
log.notice("Zeigerabfrage: \(Int(1 / interval), privacy: .public) Hz, \(self.controllers.count, privacy: .public) Bildschirm(e)")
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|