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 und Fensteraufrufe. Deshalb steht hier auch kein `if phase == …`. @MainActor public final class NotchScreenController { public private(set) var geometry: NotchGeometry public let displayID: CGDirectDisplayID private var machine = NotchStateMachine() private let panel: NotchPanel private var armTimer: DispatchWorkItem? private var closeTimer: DispatchWorkItem? /// Größe des ausgefahrenen Panels. Ab Phase 2 liefert das die Layout-Engine. public var expandedSize = CGSize(width: 640, height: 320) { didSet { if machine.isVisible { layoutPanel() } } } public init(metrics: ScreenMetrics, virtualSize: CGSize, content: NSView) { self.displayID = metrics.displayID self.geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) self.panel = NotchPanel(contentRect: .zero) panel.contentView = content 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) { let inside = geometry.triggerRect.contains(point) || (machine.isVisible && 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) layoutPanel() } public func tearDown() { armTimer?.cancel() closeTimer?.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: layoutPanel() panel.orderFrontRegardless() case .hide: 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 } private func layoutPanel() { panel.setFrame(geometry.panelRect(for: expandedSize), display: machine.isVisible) } }