import SwiftUI import UIKit import GhosttyTerminal import GhosttyTheme // MARK: - 设计令牌(对齐 docs/design/UI-DESIGN-HANDOFF.md) // // 三层分工,别混: // TXAccent —— 唯一强调色(Nocturne blurple),**恒定不随主题变**。只做线、点、选中底和文字,绝不大面积铺色。 // TXChrome —— app chrome 的中性阶。切 Catppuccin flavor 时这一层随之推导(4 张预生成表,见下)。 // TXFlavor —— Catppuccin flavor 本体:驱动**终端内容区**(libghostty 渲染)+ 提供状态语义色。 // TXPalette 是三者的门面,视图只吃 palette。 /// 唯一强调色:Nocturne blurple 阶。恒定 —— 换主题只换中性阶与终端配色,强调色不动。 enum TXAccent { /// `--color-accent`:主强调(线、点、选中描边的源色) static let base = Color(hex: 0x9184D9) /// accent-600:强调描边(如主题卡选中框) static let border = Color(hex: 0x796CBF) /// accent-900:选中底(列表行、轨道头像) static let selectedBg = Color(hex: 0x2B2741) /// accent-800:选中描边(配 inset stroke 用,避免选中时内容位移 1pt) static let selectedStroke = Color(hex: 0x423A6A) /// accent-400:强调文字 static let text = Color(hex: 0xB5ABFC) /// accent-300:强调文字(更亮,用于 hover/主按钮标题) static let textBright = Color(hex: 0xD2CEFD) /// accent-500:弱化强调文字(分组小标题) static let mid = Color(hex: 0x968AE0) /// accent-700:极弱强调(轨道头像 inset 描边) static let dim = Color(hex: 0x5D5294) } /// macOS 窗口按钮沿用系统色(关闭/最小化圆点),不进主题体系。 enum TXWindowDot { static let close = Color(hex: 0xFF5F57) static let minimize = Color(hex: 0xFEBC2E) } /// app chrome 的中性阶。**不含强调色**(那是 TXAccent 的事)。 struct TXChrome: Equatable { /// 舞台底 / pane 之间的缝(最深) let bgDeep: UInt32 /// 画布底 / 屏幕底 let bg: UInt32 /// 侧边栏 / 轨道 / 卡片底 let surfaceDark: UInt32 /// 控件底(输入框、次要按钮、徽标) let surface: UInt32 /// 分隔线(弱) let divider: UInt32 /// 描边(常规) let border: UInt32 /// 描边(强调 / 对话框) let borderStrong: UInt32 /// 描边(对话框内层 / 中等) let borderDialog: UInt32 /// 主文字 let text: UInt32 /// 次文字(neutral-300) let text2: UInt32 /// 次文字(neutral-400) let text3: UInt32 /// 三级文字(neutral-500) let text4: UInt32 /// 弱文字 / 占位(neutral-600) let textWeak: UInt32 /// 极弱(提示、注脚,neutral-700) let textFaint: UInt32 } extension TXChrome { /// Mocha 表 = 设计交付的**精确锚点值**,其余 flavor 由此按 HSL 平移推导(见 docs/design/render-screens.py 同目录笔记)。 static let mocha = TXChrome( bgDeep: 0x11121C, bg: 0x161826, surfaceDark: 0x1B1D2B, surface: 0x232532, divider: 0x2C2F3D, border: 0x3F424D, borderStrong: 0x595D6C, borderDialog: 0x4A4D5E, text: 0xE9E9ED, text2: 0xCFD3E5, text3: 0xB2B6CA, text4: 0x9397AB, textWeak: 0x75798C, textFaint: 0x4A4D5E) /// Macchiato:按 flavor base 相对 Mocha 的色相/明度差平移(文字阶抬升幅度收一半,保住对比度)。 static let macchiato = TXChrome( bgDeep: 0x171B28, bg: 0x1B2133, surfaceDark: 0x212737, surface: 0x292F3E, divider: 0x323949, border: 0x464C58, borderStrong: 0x606877, borderDialog: 0x515869, text: 0xEDEEF1, text2: 0xD3DAE9, text3: 0xB6BDCF, text4: 0x969EB0, textWeak: 0x788091, textFaint: 0x515869) static let frappe = TXChrome( bgDeep: 0x222735, bg: 0x272E3F, surfaceDark: 0x2D3443, surface: 0x363C49, divider: 0x3F4754, border: 0x535963, borderStrong: 0x6D7582, borderDialog: 0x5E6574, text: 0xF4F4F5, text2: 0xDCE1EB, text3: 0xBEC5D1, text4: 0x9FA6B2, textWeak: 0x818793, textFaint: 0x5E6574) /// Latte 是唯一浅色 flavor:中性阶整体反转(底浅字深),不走平移。仍遵守"不用纯黑纯白"。 static let latte = TXChrome( bgDeep: 0xE4E6ED, bg: 0xF3F4F8, surfaceDark: 0xFAFBFD, surface: 0xEBEDF3, divider: 0xDCDEE7, border: 0xC7CAD6, borderStrong: 0xA9ADBD, borderDialog: 0xB8BCCA, text: 0x2A2C3A, text2: 0x43465A, text3: 0x5A5E73, text4: 0x757994, textWeak: 0x8E92A6, textFaint: 0xB0B4C4) } /// Catppuccin flavor:驱动终端内容区配色 + 提供状态语义色(徽标、状态点)。 struct TXFlavor: Equatable { let id: String let name: String let isDark: Bool /// 对应 GhosttyTheme 目录名,驱动终端配色。 let ghosttyName: String // 语义色(Catppuccin 命名) let green: UInt32 // 成功、提示符、在线 let red: UInt32 // error let yellow: UInt32 // warning、git 标记 let blue: UInt32 // 路径、字段 let teal: UInt32 // 勾选、mosh let peach: UInt32 // 重连中 let overlay: UInt32 // 次要输出、离线 /// 终端内容区底色(Catppuccin base)。沉浸态胶囊下的预留带、首页缩略卡都铺这个色, /// 与 libghostty 实际渲染的 pane 底色一致,视觉才连续。 let terminalBg: UInt32 } // MARK: - Palette(视图唯一入口) /// 统一主题门面:一套选择同时驱动 **app chrome**(中性阶)与 **终端**(`ghosttyName` → GhosttyTheme), /// 强调色恒为 Nocturne blurple。新增主题 = 再写一个 `static let`,视图零改动。 struct TXPalette: Equatable, Identifiable { let flavor: TXFlavor let chrome: TXChrome var id: String { flavor.id } var name: String { flavor.name } var isDark: Bool { flavor.isDark } var ghosttyName: String { flavor.ghosttyName } /// 该主题的终端配色(GhosttyTheme → TerminalTheme)。找不到回退系统默认。 var terminalTheme: TerminalTheme { GhosttyThemeCatalog.theme(named: flavor.ghosttyName)?.toTerminalTheme() ?? .default } // MARK: chrome 取色(设计 token 直通) var bgDeep: Color { Color(hex: chrome.bgDeep) } var bg: Color { Color(hex: chrome.bg) } var surfaceDark: Color { Color(hex: chrome.surfaceDark) } var surface: Color { Color(hex: chrome.surface) } var divider: Color { Color(hex: chrome.divider) } var border: Color { Color(hex: chrome.border) } var borderStrong: Color { Color(hex: chrome.borderStrong) } var borderDialog: Color { Color(hex: chrome.borderDialog) } var text: Color { Color(hex: chrome.text) } var text2: Color { Color(hex: chrome.text2) } var text3: Color { Color(hex: chrome.text3) } var text4: Color { Color(hex: chrome.text4) } var textWeak: Color { Color(hex: chrome.textWeak) } var textFaint: Color { Color(hex: chrome.textFaint) } // MARK: 状态语义色(随 flavor) /// 在线 / 成功 / 运行中 var online: Color { Color(hex: flavor.green) } /// error var danger: Color { Color(hex: flavor.red) } /// 重连中 var reconnecting: Color { Color(hex: flavor.peach) } var linkGood: Color { Color(hex: flavor.green) } var linkPath: Color { Color(hex: flavor.blue) } /// 终端内容区底色(沉浸预留带 / 缩略卡背景)。 var terminalBackground: Color { Color(hex: flavor.terminalBg) } // MARK: 兼容旧取色点 // 各屏按设计稿重写时逐步改用上面的 chrome/TXAccent 直通名,重写完这一段就删。 var surfaceElevated: Color { divider } var accent: Color { TXAccent.base } var accentSoft: Color { TXAccent.selectedBg } var textPrimary: Color { text } var textSecondary: Color { text3 } var textTertiary: Color { textWeak } var running: Color { online } var mosh: Color { Color(hex: flavor.teal) } var warning: Color { Color(hex: flavor.yellow) } var offline: Color { Color(hex: flavor.overlay) } } extension TXPalette { static let catppuccinMocha = TXPalette( flavor: TXFlavor( id: "catppuccin-mocha", name: "Mocha", isDark: true, ghosttyName: "Catppuccin Mocha", green: 0xA6E3A1, red: 0xF38BA8, yellow: 0xF9E2AF, blue: 0x89B4FA, teal: 0x94E2D5, peach: 0xFAB387, overlay: 0x6C7086, terminalBg: 0x1E1E2E), chrome: .mocha) static let catppuccinMacchiato = TXPalette( flavor: TXFlavor( id: "catppuccin-macchiato", name: "Macchiato", isDark: true, ghosttyName: "Catppuccin Macchiato", green: 0xA6DA95, red: 0xED8796, yellow: 0xEED49F, blue: 0x8AADF4, teal: 0x8BD5CA, peach: 0xF5A97F, overlay: 0x6E738D, terminalBg: 0x24273A), chrome: .macchiato) static let catppuccinFrappe = TXPalette( flavor: TXFlavor( id: "catppuccin-frappe", name: "Frappé", isDark: true, ghosttyName: "Catppuccin Frappe", green: 0xA6D189, red: 0xE78284, yellow: 0xE5C890, blue: 0x8CAAEE, teal: 0x81C8BE, peach: 0xEF9F76, overlay: 0x737994, terminalBg: 0x303446), chrome: .frappe) static let catppuccinLatte = TXPalette( flavor: TXFlavor( id: "catppuccin-latte", name: "Latte", isDark: false, ghosttyName: "Catppuccin Latte", green: 0x40A02B, red: 0xD20F39, yellow: 0xDF8E1D, blue: 0x1E66F5, teal: 0x179299, peach: 0xFE640B, overlay: 0x9CA0B0, terminalBg: 0xEFF1F5), chrome: .latte) /// 可选主题(设置里列出)。默认第一个。 static let all: [TXPalette] = [catppuccinMocha, catppuccinMacchiato, catppuccinFrappe, catppuccinLatte] static let `default` = catppuccinMocha static func byID(_ id: String?) -> TXPalette { all.first { $0.id == id } ?? .default } } /// 当前主题的持有者(app + 终端统一)。选择持久化到 UserDefaults,终端侧经 `TerminalTheme.txDefault` 同源读取。 /// 主题选择的持久化键(文件级 nonisolated,供 app 与终端两侧读取)。 let txThemeStorageKey = "txThemeID" @MainActor final class TXThemeManager: ObservableObject { @Published var palette: TXPalette init() { palette = TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey)) } /// 切换主题:持久化 + 更新(app 视图响应式;终端由 ContentView 观察 palette.id 变化后推送)。 func apply(_ palette: TXPalette) { self.palette = palette UserDefaults.standard.set(palette.id, forKey: txThemeStorageKey) } } extension TerminalTheme { /// 当前所选主题的终端配色(与 app 同源)。model / tmux pane 新建时读取,保证新面板也用当前主题。 static var txDefault: TerminalTheme { TXPalette.byID(UserDefaults.standard.string(forKey: txThemeStorageKey)).terminalTheme } } // MARK: - 结构性令牌(不随主题变) /// 圆角 / 间距 / 字体 / 布局基准,数值取自设计交付文档。 enum TX { enum Radius { /// 徽标 / 胶囊小件 static let badge: CGFloat = 6 /// 控件(输入框、按钮) static let control: CGFloat = 9 /// 卡片 static let card: CGFloat = 12 /// 对话框 static let dialog: CGFloat = 15 /// 浮起 chrome(标题/状态胶囊) static let capsule: CGFloat = 11 /// 终端 pane 容器。**只用在朝 app chrome 的那几个角**——贴着屏幕物理边缘的角必须留直角, /// 否则会与设备自身的大圆角打架(详见 `TerminalScreen.paneShape`)。 static let pane: CGFloat = 6 // 兼容旧名 static let chip: CGFloat = 9 static let field: CGFloat = 9 } /// 设计间距阶:2 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 26 / 34 / 40 enum Space { static let hairline: CGFloat = 2 static let xs: CGFloat = 6 static let s: CGFloat = 10 static let m: CGFloat = 16 static let l: CGFloat = 20 static let xl: CGFloat = 26 static let xxl: CGFloat = 34 /// 屏幕左右留白(首页 / 设置内容区) static let screenGutter: CGFloat = 40 } /// 布局基准(pt)。竖屏值见 `.compact` 变体。 enum Layout { /// 折叠轨道宽(竖屏 48) static let railWidth: CGFloat = 56 static let railWidthCompact: CGFloat = 48 /// 展开侧边栏宽 static let sidebarWidth: CGFloat = 288 /// pane 之间的缝 static let paneGap: CGFloat = 2 /// 拖拽热区 static let paneDragHit: CGFloat = 12 /// 触控命中区下限 static let minTouch: CGFloat = 44 /// 终端主 pane 顶部给浮起胶囊预留 static let capsuleInset: CGFloat = 86 /// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条 /// 分割线都落在同一条 y 上。真机上还要在下面叠 home indicator 的安全区 /// —— 沉浸页把 chrome 铺到物理边缘,安全区由底栏自己让出(见 `TX.Layout.footBand`)。 static let footHeight: CGFloat = 34 /// 底部区总高 = 内容带 + 安全区。内容在其中**居中**:既贴住物理底边不留悬空的死带, /// 又不会压到 home indicator 上。 static func footBand(_ safeBottom: CGFloat) -> CGFloat { footHeight + safeBottom } /// 沉浸态浮起胶囊距**物理**顶边的距离。 /// /// iPad 隐藏状态栏后顶边没有遮挡 → 用设计值 14。灵动岛/刘海机型的安全区读数很大 /// (≥30),此时按硬件让位,避免胶囊钻到岛下面。 static func capsuleTop(_ safeTop: CGFloat) -> CGFloat { safeTop > 30 ? safeTop - 12 : 14 } } /// 动效。设计定稿:状态切换 200–260ms `easeOut`。 /// /// 所有过渡都从这里取曲线 —— 点击、快捷键、无头 URL 驱动的同一个状态变更拿到同一条动画, /// 并统一响应系统「减弱动态」(位移退化为纯淡入淡出)。 enum Motion { /// 常规状态切换:路由、侧边栏三态、模态浮层。 @MainActor static var standard: Animation { .easeOut(duration: (reduceMotion ? 0.12 : 0.24) * slowFactor) } /// 轻量反馈:选中态、状态点变色、按压回弹。短一点,避免拖泥带水。 @MainActor static var quick: Animation { .easeOut(duration: (reduceMotion ? 0.08 : 0.14) * slowFactor) } @MainActor static var reduceMotion: Bool { UIAccessibility.isReduceMotionEnabled } /// 无头验证用的慢放(`-txSlowMotion 1` → ×12)。 /// /// `simctl io screenshot` 本身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明 /// "动效真的在跑"(而不是又退化成硬切)就得先把它放慢。仅 DEBUG。 private static var slowFactor: Double { #if DEBUG UserDefaults.standard.bool(forKey: "txSlowMotion") ? 12 : 1 #else 1 #endif } /// 有位移的转场(侧边栏浮层从左滑入)。开了「减弱动态」只淡入淡出。 @MainActor static func slide(_ edge: Edge) -> AnyTransition { reduceMotion ? .opacity : .move(edge: edge) } /// 浮层弹出:淡入 + 极轻的缩放(96% → 100%),让对话框"长出来"而不是硬切。 @MainActor static var pop: AnyTransition { reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.96)) } /// 首页 ⇄ 终端:首页略微推远、终端从 97% 放大到位,读作"推进到会话里"。 @MainActor static var pushBack: AnyTransition { reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 1.02)) } } /// 阴影。**刻意比设计初稿扁平**:暗色 chrome 靠描边分层,阴影只做极轻的浮起暗示。 /// 初稿那套 CSS 值(30–80px / .55–.7 黑)在深色底上会糊成一团脏斑,浮起胶囊尤其明显 /// —— 终端文字从投影里穿过去,观感是"胶囊在漏墨"。故统一收到小半径、低透明度。 struct Shadow { let opacity: Double let radius: CGFloat var x: CGFloat = 0 var y: CGFloat = 0 } enum Elevation { /// 浮起 chrome(标题 / 状态胶囊):贴在终端内容之上,投影必须几乎不可见。 static let floating = Shadow(opacity: 0.22, radius: 7, y: 2) /// 侧边栏浮层:唯一带方向感的投影(往右推),让面板与被压暗的终端分层。 static let panel = Shadow(opacity: 0.28, radius: 16, x: 4) /// 对话框 / 菜单。 static let dialog = Shadow(opacity: 0.34, radius: 16, y: 6) /// 会话缩略卡(聚焦时略强)。 static let card = Shadow(opacity: 0.16, radius: 4, y: 2) static let cardFocused = Shadow(opacity: 0.24, radius: 8, y: 3) } /// 浮层遮罩:对话框与侧边栏浮层共用同一底色,只有浓度不同。 enum Scrim { static let color = Color(hex: 0x0A0B12) /// 对话框:要求"必须先处理它",压得更实。 static let dialog: Double = 0.5 /// 侧边栏浮层:面板本身已占掉一大片,遮罩轻一点。 static let sidebar: Double = 0.42 } enum Font { /// JetBrains Mono(chrome 与终端同源)。字体缺失时 SwiftUI 自动回退系统字体,中文走 PingFang SC。 static func mono(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font { .custom(psName(weight), fixedSize: size) } /// 随动态字体缩放的变体(正文型文案用这个,终端内容不用)。 static func monoScaled(_ size: CGFloat, _ weight: SwiftUI.Font.Weight = .regular) -> SwiftUI.Font { .custom(psName(weight), size: size) } private static func psName(_ weight: SwiftUI.Font.Weight) -> String { switch weight { case .bold, .heavy, .black: return "JetBrainsMono-Bold" case .semibold: return "JetBrainsMono-SemiBold" case .medium: return "JetBrainsMono-Medium" default: return "JetBrainsMono-Regular" } } /// 分组小标题(600 10px, ls .09em, uppercase) static let sectionLabel = mono(10, .semibold) /// 屏幕主标题(500 34px) static let screenTitle = mono(34, .medium) /// 页面标题(500 24px) static let pageTitle = mono(24, .medium) /// 列表主行(500 13px) static let listRow = mono(13, .medium) /// 列表副行 / 元信息(400 11px) static let listMeta = mono(11) /// 徽标 / 胶囊(500 12px) static let badge = mono(12, .medium) } } extension Color { /// 0xRRGGBB 十六进制构造。 init(hex: UInt32) { self.init( .sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255, opacity: 1 ) } }