import AppKit /// 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 ausgefahrenen Panels. Ab Phase 2 liefert das die Layout-Engine. public var expandedSize: CGSize { didSet { guard expandedSize != oldValue else { return } presentation.expandedSize = expandedSize layoutPanel() } } public init(metrics: ScreenMetrics, virtualSize: CGSize, expandedSize: CGSize = CGSize(width: 640, height: 320), makeContent: (NotchPresentation) -> NSView) { self.displayID = metrics.displayID self.geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) self.expandedSize = expandedSize self.presentation = NotchPresentation(collapsedSize: geometry.rect.size, expandedSize: expandedSize) self.panel = NotchPanel(contentRect: .zero) panel.contentView = makeContent(presentation) layoutPanel() } public var phase: NotchPhase { machine.phase } public var isVisible: Bool { machine.isVisible } // MARK: - Eingaben /// Zeigerposition in globalen Bildschirmkoordinaten. public func pointerMoved(to point: CGPoint) { // 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) || (presentation.isExpanded && panel.frame.contains(point)) apply(machine.handle(inside ? .pointerEntered : .pointerExited)) } public func click() { apply(machine.handle(.click)) } public func escape() { apply(machine.handle(.escape)) } public func setSuppressed(_ suppressed: Bool) { apply(machine.handle(.suppressed(suppressed))) } /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. public func update(metrics: ScreenMetrics, virtualSize: CGSize) { geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) presentation.collapsedSize = geometry.rect.size layoutPanel() } public func tearDown() { armTimer?.cancel() closeTimer?.cancel() teardownTimer?.cancel() panel.orderOut(nil) } // MARK: - Wirkungen private func apply(_ effects: [NotchEffect]) { for effect in effects { switch effect { case .startArmTimer(let delay): armTimer?.cancel() armTimer = schedule(after: delay) { [weak self] in guard let self else { return } apply(machine.handle(.armTimerFired)) } case .cancelArmTimer: armTimer?.cancel() armTimer = nil case .startCloseTimer(let delay): closeTimer?.cancel() closeTimer = schedule(after: delay) { [weak self] in guard let self else { return } apply(machine.handle(.closeTimerFired)) } case .cancelCloseTimer: closeTimer?.cancel() closeTimer = nil case .show: show() case .hide: hide() } } } 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 } 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) } }