import SwiftUI 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 // 兼容旧名 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 上。 static let footHeight: CGFloat = 34 } 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 ) } }