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 } }