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>
361 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|