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) } public init(metrics: ScreenMetrics, virtualSize: CGSize, contentSize: CGSize = CGSize(width: 640, height: 320), makeContent: (NotchPresentation) -> NSView) { self.displayID = metrics.displayID let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) 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) self.panel = NotchPanel(contentRect: .zero) panel.contentView = makeContent(presentation) layoutPanel() } public var phase: NotchPhase { machine.phase } public var isVisible: Bool { machine.isVisible } /// 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. 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) <= geometry.rect.width / 2 } public func escape() { send(.escape) } 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)) } /// 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 // 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() } 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.isVisible 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.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) } } 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() } } } 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) } }