import Foundation /// Sichtbarer Zustand des Notch-Panels. public enum NotchPhase: Equatable, Sendable { /// Panel unsichtbar, nichts läuft. case idle /// Zeiger ist in der Notch, die Entprellzeit läuft. Panel noch unsichtbar. case arming /// Panel sichtbar, folgt dem Zeiger. case open /// Panel sichtbar und festgehalten — der Zeiger darf weg. case pinned /// Zeiger ist draußen, die Nachlauffrist läuft. Panel noch sichtbar. case closing } /// Alles, was die Maschine bewegen kann. Zeit kommt nur als Ereignis herein, /// damit sich jeder Übergang ohne Warten prüfen lässt. public enum NotchInput: Equatable, Sendable { case pointerEntered case pointerExited case click case escape case armTimerFired case closeTimerFired /// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten. case suppressed(Bool) } /// Was der Aufrufer nach einem Übergang zu tun hat. Die Maschine selbst /// berührt weder Fenster noch Timer. public enum NotchEffect: Equatable, Sendable { case startArmTimer(TimeInterval) case cancelArmTimer case startCloseTimer(TimeInterval) case cancelCloseTimer case show case hide } /// Das Öffnungs- und Schließverhalten des Notch-Panels, frei von AppKit. /// /// Zwei Eigenschaften sind wichtiger als sie aussehen: /// /// **Entprellung.** Der Zeiger streift die Notch ständig beiläufig — auf dem Weg /// zur Menüleiste, zu den Fensterknöpfen. Ohne Wartezeit klappt das Panel dabei /// dauernd auf. Deshalb `arming` als eigener Zustand. /// /// **Nachlauffrist.** Wer vom oberen Rand diagonal zu einem Widget zieht, verlässt /// die Notch-Zone kurz. Ohne Frist schließt das Panel genau in dem Moment, in dem /// man es benutzen will. Deshalb `closing` als eigener Zustand. public struct NotchStateMachine: Equatable, Sendable { /// Wartezeit, bevor ein Hover als Absicht gilt. public static let armDelay: TimeInterval = 0.22 /// Nachlauffrist, bevor ein verlassenes Panel schließt. public static let closeDelay: TimeInterval = 0.30 public private(set) var phase: NotchPhase = .idle public private(set) var isSuppressed: Bool = false /// Wo der Zeiger zuletzt war. Die Maschine braucht das, um beim Ablauf der /// Entprellzeit zu entscheiden — ohne diese Angabe würde ein beiläufiges /// Streifen der Notch das Panel kurz aufblitzen lassen: betreten, /// verlassen, Entprellzeit läuft trotzdem ab, Panel auf, Frist abgelaufen, /// Panel zu. private var isPointerInside = false /// Ob die Entprellzeit schon abgelaufen ist, während der Zeiger draußen war. /// Kommt er zurück, wird ohne weiteres Warten geöffnet. private var debounceElapsed = false public init() {} /// Sichtbar, egal aus welchem Grund. public var isVisible: Bool { switch phase { case .open, .pinned, .closing: true case .idle, .arming: false } } @discardableResult public mutating func handle(_ input: NotchInput) -> [NotchEffect] { // Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung. // Ein angeheftetes Panel über einem Vollbildvideo wäre sonst nicht // mehr wegzubekommen, ohne das Video zu verlassen. if case .suppressed(let active) = input { guard active != isSuppressed else { return [] } isSuppressed = active guard active else { return [] } let wasVisible = isVisible phase = .idle return wasVisible ? [.cancelArmTimer, .cancelCloseTimer, .hide] : [.cancelArmTimer, .cancelCloseTimer] } guard !isSuppressed else { return [] } switch input { case .pointerEntered: isPointerInside = true case .pointerExited: isPointerInside = false default: break } switch (phase, input) { // Öffnen case (.idle, .pointerEntered): phase = .arming debounceElapsed = false return [.startArmTimer(Self.armDelay)] case (.arming, .armTimerFired): // Nur öffnen, wenn der Zeiger auch da ist. Sonst weitermerken, // dass die Entprellzeit vorbei ist — kommt er zurück, geht es // sofort auf; bleibt er weg, beendet die Abbruchfrist die Sache. guard isPointerInside else { debounceElapsed = true return [] } phase = .open return [.show] // Kurz herausrutschen bricht nicht ab. // // Ein Zeiger, den eine Hand an den oberen Rand führt, steht dort nicht // still. Jedes Abtastbild außerhalb sofort als Abbruch zu werten hieß: // bei zitternder Hand fing die Entprellzeit dauernd von vorn an und kam // nie ans Ziel — das Panel öffnete „manchmal einfach nicht". // // Der Aufziehzeitgeber läuft weiter; parallel läuft die Frist, nach der // wirklich abgebrochen wird. case (.arming, .pointerExited): return [.startCloseTimer(Self.closeDelay)] case (.arming, .pointerEntered): // Zurück in der Zone: nur die Abbruchfrist stoppen. Die Entprellzeit // **nicht** neu ansetzen, sonst dauert es bei jedem Grenzübertritt // wieder von vorn. guard debounceElapsed else { return [.cancelCloseTimer] } phase = .open return [.cancelCloseTimer, .show] case (.arming, .closeTimerFired): phase = .idle debounceElapsed = false return [.cancelArmTimer] // Schließen case (.open, .pointerExited): phase = .closing return [.startCloseTimer(Self.closeDelay)] case (.closing, .pointerEntered): phase = .open return [.cancelCloseTimer] case (.closing, .closeTimerFired): phase = .idle return [.hide] // Fixieren case (.open, .click), (.closing, .click): phase = .pinned return [.cancelCloseTimer] case (.pinned, .click): // Lösen, aber offen lassen: der Zeiger ist im Panel, sonst wäre der // Klick nicht angekommen. Von hier gelten wieder die normalen Regeln. phase = .open return [] // Beenden case (.open, .escape), (.pinned, .escape), (.closing, .escape): phase = .idle return [.hide] case (.arming, .escape): phase = .idle return [.cancelArmTimer] // Alles andere ist bewusst folgenlos: doppelte Ereignisse aus // überlappenden Tracking-Areas und verspätete Timer, die nach dem // Abbestellen noch feuern. default: return [] } } } extension NotchStateMachine { /// Nur für Tests: setzt die Phase direkt, statt sie über eine Ereigniskette /// aufzubauen. Hält die Tests bei dem Übergang, den sie prüfen. mutating func forcePhase(_ phase: NotchPhase) { self.phase = phase } }