feat: UI 商业化改造 + 连接前 tmux 会话选择(真机验证)
按 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>
This commit is contained in:
@@ -2,86 +2,210 @@ import SwiftUI
|
||||
import GhosttyTerminal
|
||||
import GhosttyTheme
|
||||
|
||||
/// 统一主题:一套配色同时驱动 **app UI**(下面的颜色角色)和 **终端**(`ghosttyName` → GhosttyTheme)。
|
||||
/// 在设置里选主题后 app 与终端一起变色。新增主题 = 再写一个 `static let`,视图零改动。
|
||||
struct TXPalette: Equatable, Identifiable {
|
||||
// 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 目录名,驱动终端配色(与 app 配色取自同一 Catppuccin flavor,视觉统一)。
|
||||
/// 对应 GhosttyTheme 目录名,驱动终端配色。
|
||||
let ghosttyName: String
|
||||
|
||||
let bg: Color
|
||||
let surface: Color
|
||||
let surfaceElevated: Color
|
||||
let accent: Color
|
||||
let accentSoft: Color
|
||||
let textPrimary: Color
|
||||
let textSecondary: Color
|
||||
let textTertiary: Color
|
||||
let border: Color
|
||||
let borderStrong: Color
|
||||
let running: Color
|
||||
let mosh: Color
|
||||
let warning: Color
|
||||
let offline: Color
|
||||
// 语义色(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: ghosttyName)?.toTerminalTheme() ?? .default
|
||||
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 {
|
||||
/// 用一套 Catppuccin flavor 的核心色构造统一 palette(app 与终端同源)。
|
||||
private static func catppuccin(
|
||||
id: String, name: String, ghostty: String, isDark: Bool,
|
||||
base: UInt32, surface0: UInt32, surface1: UInt32,
|
||||
text: UInt32, subtext0: UInt32, overlay0: UInt32,
|
||||
green: UInt32, teal: UInt32, yellow: UInt32
|
||||
) -> TXPalette {
|
||||
TXPalette(
|
||||
id: id, name: name, isDark: isDark, ghosttyName: ghostty,
|
||||
bg: Color(hex: base),
|
||||
surface: Color(hex: surface0),
|
||||
surfaceElevated: Color(hex: surface1),
|
||||
accent: Color(hex: green),
|
||||
accentSoft: Color(hex: green).opacity(0.16),
|
||||
textPrimary: Color(hex: text),
|
||||
textSecondary: Color(hex: subtext0),
|
||||
textTertiary: Color(hex: overlay0),
|
||||
border: (isDark ? Color.white : Color.black).opacity(0.08),
|
||||
borderStrong: (isDark ? Color.white : Color.black).opacity(0.14),
|
||||
running: Color(hex: green),
|
||||
mosh: Color(hex: teal),
|
||||
warning: Color(hex: yellow),
|
||||
offline: Color(hex: overlay0)
|
||||
)
|
||||
}
|
||||
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 catppuccinMocha = catppuccin(
|
||||
id: "catppuccin-mocha", name: "Catppuccin Mocha", ghostty: "Catppuccin Mocha", isDark: true,
|
||||
base: 0x1E1E2E, surface0: 0x313244, surface1: 0x45475A,
|
||||
text: 0xCDD6F4, subtext0: 0xA6ADC8, overlay0: 0x6C7086,
|
||||
green: 0xA6E3A1, teal: 0x94E2D5, yellow: 0xF9E2AF)
|
||||
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 catppuccinMacchiato = catppuccin(
|
||||
id: "catppuccin-macchiato", name: "Catppuccin Macchiato", ghostty: "Catppuccin Macchiato", isDark: true,
|
||||
base: 0x24273A, surface0: 0x363A4F, surface1: 0x494D64,
|
||||
text: 0xCAD3F5, subtext0: 0xA5ADCB, overlay0: 0x6E738D,
|
||||
green: 0xA6DA95, teal: 0x8BD5CA, yellow: 0xEED49F)
|
||||
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 catppuccinFrappe = catppuccin(
|
||||
id: "catppuccin-frappe", name: "Catppuccin Frappé", ghostty: "Catppuccin Frappe", isDark: true,
|
||||
base: 0x303446, surface0: 0x414559, surface1: 0x51576D,
|
||||
text: 0xC6D0F5, subtext0: 0xA5ADCE, overlay0: 0x737994,
|
||||
green: 0xA6D189, teal: 0x81C8BE, yellow: 0xE5C890)
|
||||
|
||||
static let catppuccinLatte = catppuccin(
|
||||
id: "catppuccin-latte", name: "Catppuccin Latte", ghostty: "Catppuccin Latte", isDark: false,
|
||||
base: 0xEFF1F5, surface0: 0xCCD0DA, surface1: 0xBCC0CC,
|
||||
text: 0x4C4F69, subtext0: 0x6C6F85, overlay0: 0x9CA0B0,
|
||||
green: 0x40A02B, teal: 0x179299, yellow: 0xDF8E1D)
|
||||
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]
|
||||
@@ -118,24 +242,91 @@ extension TerminalTheme {
|
||||
}
|
||||
}
|
||||
|
||||
/// 结构性设计令牌(圆角/间距/字体)——不随主题变。
|
||||
// MARK: - 结构性令牌(不随主题变)
|
||||
|
||||
/// 圆角 / 间距 / 字体 / 布局基准,数值取自设计交付文档。
|
||||
enum TX {
|
||||
enum Radius {
|
||||
static let card: CGFloat = 16
|
||||
static let chip: CGFloat = 10
|
||||
static let field: CGFloat = 12
|
||||
/// 徽标 / 胶囊小件
|
||||
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 = 22
|
||||
static let xl: CGFloat = 32
|
||||
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 {
|
||||
.system(size: size, weight: weight, design: .monospaced)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user