业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连 - 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用 - 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView 首页/弹框减法: - 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」 - 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻) - 会话卡加 ✕ + 复用终端页同款二次确认 - 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近) 终端页修复: - 侧边栏会话列表改创建顺序,切换不再重排抖动 - 焦点高亮上移到 pane 容器边框(四边完整,去负 padding) - 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次 - 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦; vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
454 lines
20 KiB
Swift
454 lines
20 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
|
||
// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。
|
||
|
||
// MARK: - 通用修饰
|
||
|
||
extension View {
|
||
/// 统一走 `TX.Elevation` 的投影(别在视图里手写 `.shadow`,那样迟早各处不一致)。
|
||
func txShadow(_ s: TX.Shadow) -> some View {
|
||
shadow(color: .black.opacity(s.opacity), radius: s.radius, x: s.x, y: s.y)
|
||
}
|
||
}
|
||
|
||
/// 可按下容器的按压反馈:轻微缩放 + 压暗。
|
||
///
|
||
/// `.plain` 在深色界面里**完全没有**按下反馈 —— 触屏上"点了没反应"是最伤手感的一类瑕疵,
|
||
/// 尤其主机卡/会话卡这种点下去要等几百毫秒才有结果的入口。
|
||
struct TXPressStyle: ButtonStyle {
|
||
/// 大块卡片缩得更少(大面积缩放会显得晃);小图标可以缩多一点。
|
||
var scale: CGFloat = 0.985
|
||
var dim: Double = 0.78
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.scaleEffect(configuration.isPressed ? scale : 1)
|
||
.opacity(configuration.isPressed ? dim : 1)
|
||
.animation(TX.Motion.quick, value: configuration.isPressed)
|
||
}
|
||
}
|
||
|
||
extension ButtonStyle where Self == TXPressStyle {
|
||
/// 卡片 / 列表行
|
||
static var txPress: TXPressStyle { TXPressStyle() }
|
||
/// 图标、圆点等小目标
|
||
static var txPressTight: TXPressStyle { TXPressStyle(scale: 0.9, dim: 0.7) }
|
||
}
|
||
|
||
/// 软键盘高度观察者(键盘让位必须**显式且只让一次**)。
|
||
///
|
||
/// 为什么不用系统的隐式 keyboard avoidance:会话画布上它与 `TX.Layout.footBand(safeBottom)`
|
||
/// **双重让位** —— 画布被系统压缩一次、footBand 又把键盘高度算进底栏再压一次,软键盘一弹
|
||
/// 终端只剩 6 行(2026-07-26 真机截图)。故画布 `.ignoresSafeArea(.keyboard)` 关掉隐式让位,
|
||
/// 由 `TerminalScreen` 读这里的高度显式 padding 一次。
|
||
/// 悬浮小键盘(frame 不满屏宽)不占底部空间 → 报 0。
|
||
@MainActor
|
||
final class TXKeyboardObserver: ObservableObject {
|
||
@Published private(set) var height: CGFloat = 0
|
||
private var tokens: [NSObjectProtocol] = []
|
||
|
||
init() {
|
||
let nc = NotificationCenter.default
|
||
for name in [UIResponder.keyboardWillChangeFrameNotification,
|
||
UIResponder.keyboardWillHideNotification] {
|
||
tokens.append(nc.addObserver(forName: name, object: nil, queue: .main) { note in
|
||
let hide = note.name == UIResponder.keyboardWillHideNotification
|
||
let end = (note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?
|
||
.cgRectValue
|
||
Task { @MainActor [weak self] in self?.update(endFrame: end, hidden: hide) }
|
||
})
|
||
}
|
||
}
|
||
|
||
private func update(endFrame: CGRect?, hidden: Bool) {
|
||
guard let endFrame else { return }
|
||
let bounds = UIScreen.main.bounds
|
||
var overlap = hidden ? 0 : max(0, bounds.maxY - endFrame.minY)
|
||
// 悬浮/拆分小键盘悬在内容上方,不参与布局让位。
|
||
if endFrame.width < bounds.width - 1 { overlap = 0 }
|
||
if height != overlap { height = overlap }
|
||
}
|
||
}
|
||
|
||
/// 量一次屏幕度量(安全区 + 可用尺寸),供两件事用:
|
||
///
|
||
/// 1. **沉浸终端**要顶到物理屏幕边缘(`ignoresSafeArea`),于是必须自己让位 —— 底栏在
|
||
/// 「内容带 + 安全区」里居中、浮起胶囊按硬件遮挡下移。SwiftUI 没有读安全区的环境值,
|
||
/// 故必须在 `ignoresSafeArea` **之前**量。
|
||
/// 2. **首页**按屏高收敛会话卡高度(硬写 560pt 在竖屏/分屏/小 iPad 上会把主机网格挤出屏外)。
|
||
///
|
||
/// 旋转、分屏改尺寸都会自动重新量,不需要监听通知。
|
||
struct TXScreenMetrics<Content: View>: View {
|
||
@ViewBuilder let content: (GeometryProxy) -> Content
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
content(geo)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 状态点: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)
|
||
}
|
||
}
|
||
// 链路状态变色(绿 → 琥珀 → 灰)渐变过去,别硬跳。
|
||
.animation(TX.Motion.quick, value: color)
|
||
}
|
||
}
|
||
|
||
/// 两字缩写头像(轨道 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)。
|
||
///
|
||
/// **不做 `.uppercased()`**:这类标签会嵌主机名/会话名/技术名(`tmux · mac-studio · dev`),
|
||
/// 排版性大写会把它们全改错(曾显示成 `TMUX · MAC-STUDIO · DEV`)。中文本来无大小写,
|
||
/// 英文标签直接按 `TXTech` 的规范拼写传进来即可。
|
||
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)
|
||
.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 技术标签的**拼写与配色**规范(`SSH` / `mosh` / `tsnet` / `tmux` / 发行版名)。
|
||
///
|
||
/// **拼写**:一律照各自的官方写法 —— 缩写全大写(`SSH`、`TLS`)、项目名按作者的写法
|
||
/// (`mosh` / `tmux` / `tsnet` 本来就是小写)、产品名照抄(`macOS`、`TrueNAS`)。
|
||
/// 别为了"看起来统一"把 `mosh` 写成 `MOSH` 或把 `SSH` 写成 `ssh` —— 目标用户是开发者,
|
||
/// 写错拼写比大小写不齐更刺眼。同理**分组小标题的排版性大写不施加到名字上**
|
||
/// (`TMUX · MAC-STUDIO · DEV` 把主机名和会话名都改错了)。
|
||
///
|
||
/// **配色**:取各技术自己的"主题色",映射到当前 flavor 的 Catppuccin 调色板。
|
||
/// 只染文字与描边、底色压到 12% —— 强调色不铺面(设计约束)。
|
||
@MainActor
|
||
enum TXTech {
|
||
/// 传输协议:冷色区分,两者互斥所以一行里只出现一个。
|
||
static func transport(_ useMosh: Bool, _ p: TXPalette) -> (text: String, tint: Color) {
|
||
useMosh ? ("mosh", p.mosh) : ("SSH", p.linkPath)
|
||
}
|
||
|
||
/// 发行版 / OS 的品牌色(Ubuntu 橙 → peach、Debian 红 → red、树莓 → red…)。
|
||
/// 认不出来就回中性色,不硬凑。
|
||
static func osTint(_ os: String?, _ p: TXPalette) -> Color {
|
||
let s = (os ?? "").lowercased()
|
||
if s.contains("ubuntu") { return p.reconnecting } // Ubuntu orange
|
||
if s.contains("debian") || s.contains("raspberry") { return p.danger }
|
||
if s.contains("centos") || s.contains("rhel") || s.contains("red hat") { return p.danger }
|
||
if s.contains("fedora") || s.contains("truenas") || s.contains("alpine") { return p.linkPath }
|
||
if s.contains("arch") { return p.mosh }
|
||
if s.contains("suse") { return p.online }
|
||
if s.contains("macos") || s.contains("darwin") { return p.text3 } // Apple 是中性银
|
||
return p.text4
|
||
}
|
||
}
|
||
|
||
/// 徽标胶囊(主机卡的 mosh / SSH / tsnet / 凭据名)。
|
||
///
|
||
/// 样式统一为**淡染底 + 同色文字、无描边**(Shimo 终端右上角的徽标语言,用户裁决全局统一,
|
||
/// 线框样式废除)。`tint` 给技术标签上语义色(见 `TXTech`);`emphasized` 是 app 强调色,
|
||
/// **只留给 tsnet** —— 它是本产品的招牌能力。凭据名是用户自己命的名、不是技术标签,
|
||
/// 保持中性,免得一排徽标全是彩色反而没有重点。
|
||
struct TXBadge: View {
|
||
let text: String
|
||
var tint: Color?
|
||
var emphasized: Bool = false
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var fg: Color { emphasized ? TXAccent.text : (tint ?? p.text4) }
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
.font(TX.Font.mono(11, .medium))
|
||
.foregroundStyle(fg)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background {
|
||
if emphasized {
|
||
Capsule().fill(TXAccent.selectedBg)
|
||
} else if let tint {
|
||
Capsule().fill(tint.opacity(0.14))
|
||
} else {
|
||
Capsule().fill(p.surface)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 筛选 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)
|
||
)
|
||
.animation(TX.Motion.quick, value: isSelected)
|
||
}
|
||
.buttonStyle(.txPress)
|
||
}
|
||
}
|
||
|
||
/// 搜索框(32pt 高、圆角 8、控件底)。右侧可挂快捷键提示。
|
||
struct TXSearchField: View {
|
||
@Binding var text: String
|
||
var placeholder: String
|
||
var shortcut: String?
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
/// 聚焦态:描边转强调色。接妙控键盘用 Tab 走焦点时必须看得出焦点在哪
|
||
/// (无障碍底线:键盘焦点可见)。
|
||
@FocusState private var focused: Bool
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
HStack(spacing: TX.Space.xs) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(focused ? TXAccent.text : p.textWeak)
|
||
TextField(placeholder, text: $text)
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.text)
|
||
.textFieldStyle(.plain)
|
||
.autocorrectionDisabled()
|
||
.textInputAutocapitalization(.never)
|
||
.focused($focused)
|
||
if let shortcut, text.isEmpty, !focused {
|
||
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(focused ? TXAccent.border : p.divider, lineWidth: 1)
|
||
)
|
||
.animation(TX.Motion.quick, value: focused)
|
||
}
|
||
}
|
||
|
||
/// 次要按钮(描边款,如「新建连接」「安装并启用 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(.txPress)
|
||
}
|
||
}
|
||
|
||
/// 图标按钮。两种形态(按设计稿区分,别混):
|
||
/// - **有框**(`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())
|
||
.animation(TX.Motion.quick, value: isSelected)
|
||
}
|
||
.buttonStyle(.txPressTight)
|
||
.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 {
|
||
// 尺寸**完全由外部决定**:行堆叠放进 `Color.clear` 的 overlay —— overlay 不参与父视图定尺,
|
||
// 故行数再多也顶不高卡片。(此前行多的卡比同排的高 20pt,一排卡片上下边对不齐;
|
||
// 外层 `.frame(height:)` 也压不住它,因为 VStack 的最小高度就是各行高之和。)
|
||
// 溢出的旧行被 `clipped()` 切掉,正是想要的——内容底对齐,留住最新输出。
|
||
Color.clear
|
||
// 有输出时贴底(新行在下缘,与真终端的视觉重心一致);空的时候占位文案居中。
|
||
.overlay(alignment: lines.isEmpty ? .center : .bottomLeading) { rows }
|
||
.background(background)
|
||
.clipped()
|
||
}
|
||
|
||
private var rows: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
if lines.isEmpty {
|
||
Text(placeholder)
|
||
.font(TX.Font.mono(fontSize))
|
||
.foregroundStyle(p.text4)
|
||
.frame(maxWidth: .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)
|
||
}
|
||
|
||
/// 启发式着色:`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
|
||
}
|
||
}
|