Files
terminalX/apps/TerminalX/iOS/Theme.swift
kid 28e9cfc207 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>
2026-07-25 22:59:35 +08:00

345 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 // warninggit
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 Monochrome 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
)
}
}