按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
345 lines
14 KiB
Swift
345 lines
14 KiB
Swift
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
|
||
)
|
||
}
|
||
}
|