import Foundation import Combine import AppKit /// Type of panel content public enum PanelType: String, Codable, Sendable { case terminal case browser case markdown } public enum TerminalPanelFocusIntent: Equatable { case surface case findField } public enum BrowserPanelFocusIntent: Equatable { case webView case addressBar case findField } public enum PanelFocusIntent: Equatable { case panel case terminal(TerminalPanelFocusIntent) case browser(BrowserPanelFocusIntent) } public enum WorkspaceAttentionFlashReason: String, Equatable, Sendable { case navigation case notificationArrival case notificationDismiss case manualUnreadDismiss case debug } enum WorkspaceAttentionFlashAccent: Equatable, Sendable { case notificationBlue case navigationTeal var strokeColor: NSColor { switch self { case .notificationBlue: return .systemBlue case .navigationTeal: return .systemTeal } } } struct WorkspaceAttentionFlashPresentation: Equatable, Sendable { let accent: WorkspaceAttentionFlashAccent let glowOpacity: Double let glowRadius: CGFloat } struct WorkspaceAttentionPersistentState: Equatable, Sendable { var unreadPanelIDs: Set = [] var focusedReadPanelID: UUID? var manualUnreadPanelIDs: Set = [] var indicatorPanelIDs: Set { var ids = unreadPanelIDs.union(manualUnreadPanelIDs) if let focusedReadPanelID { ids.insert(focusedReadPanelID) } return ids } func hasCompetingIndicator(for panelID: UUID) -> Bool { indicatorPanelIDs.contains(where: { $0 != panelID }) } } struct WorkspaceAttentionFlashDecision: Equatable, Sendable { let panelID: UUID let reason: WorkspaceAttentionFlashReason let isAllowed: Bool } enum WorkspaceAttentionCoordinator { static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> WorkspaceAttentionFlashPresentation { switch reason { case .navigation: return WorkspaceAttentionFlashPresentation( accent: .navigationTeal, glowOpacity: 0.14, glowRadius: 3 ) case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: return WorkspaceAttentionFlashPresentation( accent: .notificationBlue, glowOpacity: 0.6, glowRadius: 6 ) } } static func decideFlash( targetPanelID: UUID, reason: WorkspaceAttentionFlashReason, persistentState: WorkspaceAttentionPersistentState ) -> WorkspaceAttentionFlashDecision { let isAllowed: Bool switch reason { case .navigation: isAllowed = !persistentState.hasCompetingIndicator(for: targetPanelID) case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug: isAllowed = true } return WorkspaceAttentionFlashDecision( panelID: targetPanelID, reason: reason, isAllowed: isAllowed ) } } enum FocusFlashCurve: Equatable { case easeIn case easeOut } enum PanelOverlayRingMetrics { static let inset: CGFloat = 2 static let cornerRadius: CGFloat = 6 static let lineWidth: CGFloat = 2.5 static func pathRect(in bounds: CGRect) -> CGRect { bounds.insetBy(dx: inset, dy: inset) } } #if DEBUG func cmuxFlashDebugID(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(6)) } func cmuxFlashDebugRect(_ rect: CGRect?) -> String { guard let rect else { return "nil" } return String( format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height ) } func cmuxFlashDebugBool(_ value: Bool) -> Int { value ? 1 : 0 } #endif struct FocusFlashSegment: Equatable { let delay: TimeInterval let duration: TimeInterval let targetOpacity: Double let curve: FocusFlashCurve } enum FocusFlashPattern { static let values: [Double] = [0, 1, 0, 1, 0] static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] static let duration: TimeInterval = 0.9 static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] static let ringInset: Double = Double(PanelOverlayRingMetrics.inset) static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius) static var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) return (0.. Double { guard elapsed >= 0, elapsed <= duration else { return 0 } for index in 0.. endTime { continue } let segmentDuration = max(endTime - startTime, 0.0001) let rawProgress = max(0, min(1, (elapsed - startTime) / segmentDuration)) let curvedProgress = interpolatedProgress(rawProgress, curve: curves[index]) let startOpacity = values[index] let endOpacity = values[index + 1] return startOpacity + ((endOpacity - startOpacity) * curvedProgress) } return values.last ?? 0 } private static func interpolatedProgress(_ progress: Double, curve: FocusFlashCurve) -> Double { switch curve { case .easeIn: return progress * progress case .easeOut: let inverse = 1 - progress return 1 - (inverse * inverse) } } } /// Protocol for all panel types (terminal, browser, etc.) @MainActor public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID { /// Unique identifier for this panel var id: UUID { get } /// The type of panel var panelType: PanelType { get } /// Display title shown in tab bar var displayTitle: String { get } /// Optional SF Symbol icon name for the tab var displayIcon: String? { get } /// Whether the panel has unsaved changes var isDirty: Bool { get } /// Close the panel and clean up resources func close() /// Focus the panel for input func focus() /// Unfocus the panel func unfocus() /// Trigger a focus flash animation for this panel. func triggerFlash(reason: WorkspaceAttentionFlashReason) /// Capture the panel-local focus target that should be restored later. func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent /// Return the best focus target to restore when this panel becomes active again. func preferredFocusIntentForActivation() -> PanelFocusIntent /// Prime panel-local focus state before activation side effects run. func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) /// Restore a previously captured focus target. @discardableResult func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool /// Return the semantic focus target currently owned by this panel, if any. func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? /// Explicitly yield a previously owned focus target before another panel restores focus. @discardableResult func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool } /// Extension providing default implementations extension Panel { public var displayIcon: String? { nil } public var isDirty: Bool { false } func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { _ = window return preferredFocusIntentForActivation() } func preferredFocusIntentForActivation() -> PanelFocusIntent { .panel } func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) { _ = intent } @discardableResult func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool { guard intent == .panel else { return false } focus() return true } func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? { _ = responder _ = window return nil } @discardableResult func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool { _ = intent _ = window return false } func triggerFlash() { triggerFlash(reason: .navigation) } }