按 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>
302 lines
12 KiB
Swift
302 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。
|
||
|
||
/// 状态点:8pt 实心 + 2pt 挖底描边(`box-shadow: 0 0 0 2px <底色>`,让点浮在头像/卡片边缘上)。
|
||
struct TXStatusDot: View {
|
||
let color: Color
|
||
var size: CGFloat = 8
|
||
/// 挖底色(点所压的那层的底色);nil = 不挖底。
|
||
var punchOut: Color?
|
||
|
||
var body: some View {
|
||
Circle()
|
||
.fill(color)
|
||
.frame(width: size, height: size)
|
||
.overlay {
|
||
if let punchOut {
|
||
Circle().stroke(punchOut, lineWidth: 2)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 两字缩写头像(轨道 38pt / 侧边栏 26pt / 首页卡 26pt)。选中态用 inset 描边,避免内容位移 1pt。
|
||
struct TXInitialsAvatar: View {
|
||
let initials: String
|
||
var size: CGFloat = 26
|
||
var isSelected: Bool = false
|
||
/// 右上状态点颜色(nil = 不显示)。
|
||
var status: Color?
|
||
/// 状态点的挖底色(所在容器底色)。
|
||
var punchOut: Color = .clear
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var radius: CGFloat { size >= 34 ? 11 : TX.Radius.badge }
|
||
|
||
var body: some View {
|
||
Text(initials)
|
||
.font(TX.Font.mono(size >= 34 ? 13 : 11, .semibold))
|
||
.foregroundStyle(isSelected ? TXAccent.text : p.text3)
|
||
.frame(width: size, height: size)
|
||
.background(isSelected ? TXAccent.selectedBg : p.surface,
|
||
in: RoundedRectangle(cornerRadius: radius))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: radius)
|
||
.strokeBorder(isSelected ? TXAccent.dim : p.border, lineWidth: 1)
|
||
)
|
||
.overlay(alignment: .topTrailing) {
|
||
if let status {
|
||
TXStatusDot(color: status, punchOut: punchOut)
|
||
.offset(x: 3, y: -3)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 分组小标题(600 10px, ls .09em, uppercase)。
|
||
struct TXSectionLabel: View {
|
||
let text: String
|
||
/// 右侧计数(如「活动会话 3」的 3 单独右对齐)。
|
||
var trailing: String?
|
||
var color: Color?
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Text(text.uppercased())
|
||
.font(TX.Font.mono(10, .semibold))
|
||
.tracking(0.9)
|
||
.foregroundStyle(color ?? p.textWeak)
|
||
if let trailing {
|
||
Spacer()
|
||
Text(trailing)
|
||
.font(TX.Font.mono(10, .semibold))
|
||
.foregroundStyle(color ?? p.textWeak)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 徽标胶囊(主机卡的 mosh / tsnet / SSH / SE key)。
|
||
struct TXBadge: View {
|
||
let text: String
|
||
var emphasized: Bool = false
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
.font(TX.Font.mono(11, .medium))
|
||
.foregroundStyle(emphasized ? TXAccent.text : p.text4)
|
||
.padding(.horizontal, 7)
|
||
.padding(.vertical, 3)
|
||
.background(emphasized ? TXAccent.selectedBg : p.surface,
|
||
in: RoundedRectangle(cornerRadius: 5))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.strokeBorder(emphasized ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 筛选 chip(首页「全部 / 生产 / 家里 / 云」)。
|
||
struct TXChip: View {
|
||
let text: String
|
||
let isSelected: Bool
|
||
let action: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Text(text)
|
||
.font(TX.Font.mono(11.5, .medium))
|
||
.foregroundStyle(isSelected ? TXAccent.textBright : p.text4)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(isSelected ? TXAccent.selectedBg : p.surface,
|
||
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.badge)
|
||
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
/// 搜索框(32pt 高、圆角 8、控件底)。右侧可挂快捷键提示。
|
||
struct TXSearchField: View {
|
||
@Binding var text: String
|
||
var placeholder: String
|
||
var shortcut: String?
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
HStack(spacing: TX.Space.xs) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(p.textWeak)
|
||
TextField(placeholder, text: $text)
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.text)
|
||
.textFieldStyle(.plain)
|
||
.autocorrectionDisabled()
|
||
.textInputAutocapitalization(.never)
|
||
if let shortcut, text.isEmpty {
|
||
Text(shortcut)
|
||
.font(TX.Font.mono(10.5))
|
||
.foregroundStyle(p.textFaint)
|
||
}
|
||
}
|
||
.padding(.horizontal, TX.Space.s)
|
||
.frame(height: 32)
|
||
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8).strokeBorder(p.divider, lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 次要按钮(描边款,如「新建连接」「安装并启用 tmux」)。
|
||
struct TXButton: View {
|
||
let title: String
|
||
var icon: String?
|
||
/// 主动作用强调描边 + 强调文字(设计:强调色只做线和文字,不铺底)。
|
||
var emphasized: Bool = false
|
||
let action: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: TX.Space.xs) {
|
||
if let icon {
|
||
Image(systemName: icon).font(.system(size: 12, weight: .medium))
|
||
}
|
||
Text(title).font(TX.Font.mono(12, .medium))
|
||
}
|
||
.foregroundStyle(emphasized ? TXAccent.textBright : p.text2)
|
||
.padding(.horizontal, 14)
|
||
.frame(height: 32)
|
||
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.strokeBorder(emphasized ? TXAccent.border : p.border, lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
/// 图标按钮。两种形态(按设计稿区分,别混):
|
||
/// - **有框**(`bare: false`):控件底 + 常规描边,用于展开键、页头齿轮、Pin 等"控件"。
|
||
/// - **裸图标**(`bare: true`):无底无框,用于轨道里的搜索 / 首页 / 设置——设计稿这三个只有图标。
|
||
struct TXIconButton: View {
|
||
let icon: String
|
||
var size: CGFloat = 34
|
||
var isSelected: Bool = false
|
||
var bare: Bool = false
|
||
/// 命中区(默认补到 44pt 触控下限;轨道里按设计坐标排布时传入实际间距)。
|
||
var hitSize: CGFloat?
|
||
var label: String?
|
||
let action: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: bare ? 17 : 14, weight: .regular))
|
||
.foregroundStyle(isSelected ? TXAccent.text : p.text3)
|
||
.frame(width: size, height: size)
|
||
.background {
|
||
if !bare {
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.fill(isSelected ? TXAccent.selectedBg : p.surface)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
||
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.border,
|
||
lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
.frame(width: hitSize ?? max(size, TX.Layout.minTouch),
|
||
height: hitSize ?? max(size, TX.Layout.minTouch))
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(label ?? icon)
|
||
}
|
||
}
|
||
|
||
/// 终端内容的**文本镜像**(首页会话缩略卡 / 侧边栏预览)。
|
||
///
|
||
/// 为什么不渲染真 surface:一个 ghostty surface 只有一个 IOSurfaceLayer,不能同处两地;
|
||
/// 离屏 Metal 渲染是已修过的泄漏雷区。故用 `readViewportText()` 抓文本,按终端底色排版,
|
||
/// **内容底对齐**(新输出贴着卡片下缘,和真终端的视觉重心一致)。
|
||
struct TXTerminalMirror: View {
|
||
let lines: [String]
|
||
var fontSize: CGFloat = 11
|
||
/// 终端底色(Catppuccin base,跟随所选 flavor 的终端配色而非 chrome)。
|
||
var background: Color
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
/// 无内容时的占位文案(真实会话刚建立、surface 还没 attach 或还没有输出)。
|
||
var placeholder: String = "等待输出…"
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
if lines.isEmpty {
|
||
Text(placeholder)
|
||
.font(TX.Font.mono(fontSize))
|
||
.foregroundStyle(p.text4)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||
}
|
||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||
Text(line.isEmpty ? " " : line)
|
||
.font(TX.Font.mono(fontSize))
|
||
// 设计:终端次要文本 11px / 行高 1.5 → 行距 ≈ 字号 × 0.6。
|
||
.lineSpacing(fontSize * 0.6)
|
||
.foregroundStyle(color(for: line))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, TX.Space.xs)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||
.background(background)
|
||
.clipped()
|
||
}
|
||
|
||
/// 启发式着色:`readViewportText()` 只给纯文本(ANSI 属性已丢),故按行首符号/常见片段上色,
|
||
/// 逼近真实终端的信息密度。不追求语法正确——错判只影响一行的颜色,不影响可读性。
|
||
private func color(for line: String) -> Color {
|
||
let t = line.trimmingCharacters(in: .whitespaces)
|
||
if t.isEmpty { return p.text4 }
|
||
if t.hasPrefix("✓") || t.hasPrefix("✔") || t.hasPrefix("✅") || t.hasPrefix("**") {
|
||
return p.online
|
||
}
|
||
if t.hasPrefix("✗") || t.hasPrefix("❌") { return p.danger }
|
||
let lower = t.lowercased()
|
||
if lower.hasPrefix("error") || lower.contains(" error:") { return p.danger }
|
||
if lower.hasPrefix("warning") || lower.contains(" warning:") { return p.warning }
|
||
if t.hasPrefix("◇") || t.hasPrefix("▸") || t.hasPrefix("↳") || t.hasPrefix("*") {
|
||
return p.mosh
|
||
}
|
||
if t.hasPrefix("●") { return p.online }
|
||
// 提示符行(→ / ➜ / $ 结尾的命令行)用主文字色,命令输出用次文字色 —— 与真实终端观感一致。
|
||
if t.hasPrefix("→") || t.hasPrefix("➜") || t.contains("$ ") { return p.text }
|
||
if t.hasPrefix("[") { return p.offline }
|
||
return p.text3
|
||
}
|
||
}
|