Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift
T
ScarriffleandClaude Opus 5 c05ccb8ae1 Die Notch wird zur Insel, wo es keine Hardware gibt
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>
2026-09-05 21:05:13 +02:00

361 lines
15 KiB
Swift

import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
/// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen.
///
/// Die Entscheidungen trifft `NotchStateMachine`; diese Klasse übersetzt sie nur
/// in Timer, Fensteraufrufe und Animationszustände. Deshalb steht hier auch kein
/// `if phase == …`.
@MainActor
public final class NotchScreenController {
public private(set) var geometry: NotchGeometry
public let displayID: CGDirectDisplayID
public let presentation: NotchPresentation
private var machine = NotchStateMachine()
private let panel: NotchPanel
private var armTimer: DispatchWorkItem?
private var closeTimer: DispatchWorkItem?
/// Entfernt das Fenster erst, wenn das Einfahren zu Ende ist.
private var teardownTimer: DispatchWorkItem?
/// Größe des **Inhalts**, nicht des Fensters. Die Layout-Engine liefert sie;
/// den Freiraum für die Notch schlägt der Controller selbst auf, damit keine
/// Aufrufstelle daran denken muss.
public var contentSize: CGSize {
didSet {
guard contentSize != oldValue else { return }
presentation.expandedSize = expandedSize
layoutPanel()
}
}
/// Fenstergröße: Inhalt plus der von der Notch verdeckte Streifen.
private var expandedSize: CGSize {
CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height)
}
/// Die eingestellte Auslösefläche. Ändern wirkt sofort.
public var trigger: TriggerArea {
didSet {
guard trigger != oldValue else { return }
geometry.trigger = trigger
}
}
/// Notch oder Insel. Ändern baut die Geometrie neu — die Form hängt daran,
/// aber auch die Lage des Fensters.
public var style: NotchStyle {
didSet {
guard style != oldValue, let metrics = lastMetrics else { return }
update(metrics: metrics, virtualSize: lastVirtualSize)
}
}
/// Was zuletzt gemessen wurde — damit ein Stilwechsel die Geometrie ohne
/// Umweg über den Koordinator neu rechnen kann.
private var lastMetrics: ScreenMetrics?
private var lastVirtualSize: CGSize
public init(metrics: ScreenMetrics,
virtualSize: CGSize,
trigger: TriggerArea = .standard,
style: NotchStyle = .automatic,
contentSize: CGSize = CGSize(width: 640, height: 320),
makeContent: (NotchPresentation) -> NSView) {
self.displayID = metrics.displayID
self.trigger = trigger
self.style = style
self.lastMetrics = metrics
self.lastVirtualSize = virtualSize
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger, style: style)
self.geometry = geometry
self.contentSize = contentSize
self.presentation = NotchPresentation(
collapsedSize: geometry.rect.size,
expandedSize: CGSize(width: contentSize.width,
height: contentSize.height + geometry.rect.height),
contentTopInset: geometry.rect.height)
presentation.isDetached = geometry.isDetached
self.panel = NotchPanel(contentRect: .zero)
panel.contentView = makeContent(presentation)
layoutPanel()
applyIdleIsland()
}
public var phase: NotchPhase { machine.phase }
/// Ausgefahren. **Nicht** „auf dem Bildschirm": eine eingeklappte Live
/// Activity soll den Koordinator nicht dazu bringen, den Zeiger zu
/// verfolgen, als stünde ein Panel offen.
public var isVisible: Bool { machine.isExpanded }
/// Meldet, wenn das Panel sichtbar wird oder verschwindet. Der Koordinator
/// schaltet daran die Zeigerabfrage an und aus.
public var onVisibilityChanged: ((Bool) -> Void)?
// MARK: - Eingaben
/// Zeigerposition in globalen Bildschirmkoordinaten.
/// Wo der Zeiger beim letzten Verlassen stand — nur fürs Protokoll.
private var lastPoint: CGPoint = .zero
public func pointerMoved(to point: CGPoint) {
lastPoint = point
// Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so
// groß wie das ausgefahrene Panel, damit die Animation Platz hat —
// würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren
// Fläche das Panel offen.
let inside = geometry.triggerRect.contains(point) || containsVisiblePoint(point)
send(inside ? .pointerEntered : .pointerExited)
}
public func click() { send(.click) }
public func clickedOutside() { send(.clickedOutside) }
/// Ob ein Punkt auf der **sichtbaren** Fläche des Panels liegt.
///
/// Nicht `panel.frame`: das Fenster reicht bis an die Bildschirmoberkante
/// und ist links und rechts der Notch dort durchsichtig — dieser Streifen
/// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick
/// auf ein fremdes Statuselement für einen Klick ins Panel.
///
/// Der oberste Streifen zählt genau so weit, wie er auch **gezeichnet**
/// wird — sonst gilt der Zeiger auf dem eigenen Band als „außerhalb", und
/// das Panel fährt unter dem Finger ein, während man auf sein Akkusymbol
/// zeigt.
public func containsVisiblePoint(_ point: CGPoint) -> Bool {
guard presentation.isExpanded, panel.frame.contains(point) else { return false }
let menuBarBand = panel.frame.maxY - geometry.rect.height
guard point.y > menuBarBand else { return true }
return abs(point.x - panel.frame.midX) <= presentation.topStripWidth / 2
}
public func escape() { send(.escape) }
/// Was eine Live Activity gerade unter der Notch beansprucht. `nil` = keine.
///
/// Meldet der Presenter, wenn sich seine Warteschlange ändert. Das Panel
/// fährt davon nicht aus — es bleibt oder kommt nur eingeklappt auf den
/// Schirm.
///
/// **Die Reihenfolge ist der ganze Trick.** Erst das Fenster zeigen, dann
/// im nächsten Durchlauf wachsen lassen: steht die Endgröße schon im ersten
/// Bild, gibt es nichts zu animieren, und die Fläche springt auf. Beim
/// Ausfahren macht `show()` seit jeher genau das — für die Activity fehlte
/// es.
public func setAmbient(_ size: CGSize?) {
guard let size else {
// Umgekehrt beim Verschwinden: erst schrumpfen, dann fortnehmen.
// Das Fenster geht ohnehin erst nach der Einfahrfrist.
presentation.ambientContentSize = nil
send(.ambient(false))
return
}
send(.ambient(true))
DispatchQueue.main.async { [weak self] in
self?.presentation.ambientContentSize = size
}
}
/// Ob die eingeklappte Fläche stehen bleibt, wenn nichts anliegt.
///
/// **Damit das Panel aus etwas herauswächst.** Auf dem eingebauten Display
/// wächst es aus der Notch — die ist immer da. Auf einem externen gab es
/// bisher nichts: das Fenster kam erst mit dem Inhalt auf den Schirm, und
/// eine schwarze Fläche, die im Nichts aufgeht, liest sich als Fehler und
/// nicht als Bewegung. Bleibt die eingeklappte Insel stehen, ist der
/// Ausgangspunkt sichtbar — und die Bewegung hat einen Ursprung.
///
/// Auf dem eingebauten Display kostet das nichts: dort liegt die Fläche
/// deckungsgleich unter der schwarzen Hardware.
public var showsIdleIsland = true {
didSet {
guard showsIdleIsland != oldValue else { return }
applyIdleIsland()
}
}
/// Unterdrückung schlägt die Insel: bei einem Vollbildvideo hat auch sie
/// zu schweigen.
private var mayShowIdleIsland: Bool { showsIdleIsland && !machine.isSuppressed }
/// Bringt das Fenster in den Zustand, den die Insel gerade verlangt.
///
/// Rührt nichts an, solange etwas anderes das Fenster braucht — ein
/// ausgefahrenes Panel oder eine Live Activity haben Vorrang.
private func applyIdleIsland() {
guard !presentation.isExpanded, !machine.hasAmbient else { return }
if mayShowIdleIsland {
panel.orderFrontRegardless()
} else {
panel.orderOut(nil)
}
}
public func setSuppressed(_ suppressed: Bool) {
guard suppressed != machine.isSuppressed else { return }
log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)")
send(.suppressed(suppressed))
applyIdleIsland()
}
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
lastMetrics = metrics
lastVirtualSize = virtualSize
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger, style: style)
presentation.isDetached = geometry.isDetached
presentation.collapsedSize = geometry.rect.size
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
// (virtueller Balken) ändert sich der Freiraum mit.
presentation.contentTopInset = geometry.rect.height
presentation.expandedSize = expandedSize
layoutPanel()
applyIdleIsland()
}
public func tearDown() {
armTimer?.cancel()
closeTimer?.cancel()
teardownTimer?.cancel()
panel.orderOut(nil)
}
// MARK: - Wirkungen
/// Der einzige Weg, die Maschine zu bewegen.
///
/// Sichtbarkeit wird hier verglichen und nicht in `apply`: `handle` hat den
/// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück.
private func send(_ input: NotchInput) {
let wasVisible = machine.isExpanded
let before = machine.phase
apply(machine.handle(input))
// Jeden Zustandswechsel protokollieren — außer den beiden, die
// sekündlich hunderte Male kommen. Ohne diese Spur ist ein Panel, das
// sich „manchmal nicht öffnet", nicht zu untersuchen: hinterher weiß
// niemand mehr, ob der Zeiger erkannt wurde, ob die Maschine
// umgeschaltet hat oder ob die Unterdrückung dazwischenkam.
if machine.phase != before {
log.notice("Notch \(self.displayID, privacy: .public): \(String(describing: before), privacy: .public) → \(String(describing: self.machine.phase), privacy: .public) durch \(String(describing: input), privacy: .public)")
// Beim Schließen zusätzlich, **warum** der Zeiger als draußen galt.
// Ohne diese drei Zahlen bleibt „es ging zu, obwohl ich noch drin
// war" eine Behauptung gegen eine andere.
if before == .open, self.machine.phase == .closing {
log.notice(" Zeiger \(Int(self.lastPoint.x), privacy: .public)/\(Int(self.lastPoint.y), privacy: .public), Auslösefläche \(String(describing: self.geometry.triggerRect), privacy: .public), Fenster \(String(describing: self.panel.frame), privacy: .public), ausgefahren \(self.presentation.isExpanded, privacy: .public)")
}
}
if machine.isExpanded != wasVisible { onVisibilityChanged?(machine.isExpanded) }
}
private func apply(_ effects: [NotchEffect]) {
for effect in effects {
switch effect {
case .startArmTimer(let delay):
armTimer?.cancel()
armTimer = schedule(after: delay) { [weak self] in
self?.send(.armTimerFired)
}
case .cancelArmTimer:
armTimer?.cancel()
armTimer = nil
case .startCloseTimer(let delay):
closeTimer?.cancel()
closeTimer = schedule(after: delay) { [weak self] in
self?.send(.closeTimerFired)
}
case .cancelCloseTimer:
closeTimer?.cancel()
closeTimer = nil
case .show:
show()
case .hide:
hide()
case .showAmbient:
showAmbient()
case .hideAmbient:
hideAmbient()
}
}
}
private func show() {
// Ein noch laufendes Einfahren abbrechen: wer sofort zurückkehrt, soll
// das Panel wieder aufgehen sehen und nicht dabei zusehen, wie es
// mittendrin verschwindet.
teardownTimer?.cancel()
teardownTimer = nil
layoutPanel()
panel.orderFrontRegardless()
// Erst im nächsten Durchlauf ausfahren. Im selben Durchlauf wie
// `orderFrontRegardless` sieht SwiftUI keinen Zustandswechsel, sondern
// nur den Endzustand — das Panel wäre schlagartig da.
DispatchQueue.main.async { [weak self] in
self?.presentation.isExpanded = true
}
}
private func hide() {
presentation.isExpanded = false
teardownTimer?.cancel()
teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in
guard let self, !presentation.isExpanded else { return }
// Liegt eine Live Activity an, bleibt das Fenster stehen — nur
// eingeklappt. Es fortzunehmen hieße, sie mit dem Panel zu
// beerdigen, obwohl sie mit ihm nichts zu tun hat.
guard !machine.hasAmbient, !mayShowIdleIsland else { return }
panel.orderOut(nil)
}
}
private func schedule(after delay: TimeInterval, _ body: @escaping @MainActor () -> Void)
-> DispatchWorkItem {
let item = DispatchWorkItem { MainActor.assumeIsolated { body() } }
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
return item
}
/// Das Fenster hat immer die Größe des ausgefahrenen Panels — die Animation
/// findet im Inhalt statt, nicht am Fenster. Fensterrahmen zu animieren
/// überlässt die Interpolation dem Fensterserver und ruckelt sichtbar.
private func layoutPanel() {
panel.setFrame(geometry.panelRect(for: expandedSize), display: presentation.isExpanded)
}
}
// MARK: - Live Activities
private extension NotchScreenController {
/// Fenster auf den Schirm, ohne auszufahren.
func showAmbient() {
teardownTimer?.cancel()
teardownTimer = nil
panel.orderFrontRegardless()
presentation.isExpanded = false
}
/// Nichts mehr zu zeigen — Fenster fort, sofern es nicht ausgefahren ist.
func hideAmbient() {
guard !presentation.isExpanded else { return }
teardownTimer?.cancel()
teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in
guard let self, !presentation.isExpanded,
!machine.hasAmbient, !mayShowIdleIsland else { return }
panel.orderOut(nil)
}
}
}