cmux/Sources/Panels/Panel.swift
2026-03-20 20:36:08 -07:00

310 lines
8.9 KiB
Swift

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<UUID> = []
var focusedReadPanelID: UUID?
var manualUnreadPanelIDs: Set<UUID> = []
var indicatorPanelIDs: Set<UUID> {
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..<stepCount).map { index in
let startTime = keyTimes[index]
let endTime = keyTimes[index + 1]
return FocusFlashSegment(
delay: startTime * duration,
duration: (endTime - startTime) * duration,
targetOpacity: values[index + 1],
curve: curves[index]
)
}
}
static func opacity(at elapsed: TimeInterval) -> Double {
guard elapsed >= 0, elapsed <= duration else { return 0 }
for index in 0..<segments.count {
let startTime = keyTimes[index] * duration
let endTime = keyTimes[index + 1] * duration
if elapsed > 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)
}
}