import SwiftUI // 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 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) } } /// 量一次屏幕度量(安全区 + 可用尺寸),供两件事用: /// /// 1. **沉浸终端**要顶到物理屏幕边缘(`ignoresSafeArea`),于是必须自己让位 —— 底栏在 /// 「内容带 + 安全区」里居中、浮起胶囊按硬件遮挡下移。SwiftUI 没有读安全区的环境值, /// 故必须在 `ignoresSafeArea` **之前**量。 /// 2. **首页**按屏高收敛会话卡高度(硬写 560pt 在竖屏/分屏/小 iPad 上会把主机网格挤出屏外)。 /// /// 旋转、分屏改尺寸都会自动重新量,不需要监听通知。 struct TXScreenMetrics: 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 / 凭据名)。 /// /// `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) } private var stroke: Color { if emphasized { return TXAccent.selectedStroke } return tint?.opacity(0.35) ?? .clear } var body: some View { Text(text) .font(TX.Font.mono(11, .medium)) .foregroundStyle(fg) .padding(.horizontal, 7) .padding(.vertical, 3) .background { let shape = RoundedRectangle(cornerRadius: 5) if emphasized { shape.fill(TXAccent.selectedBg) } else if let tint { shape.fill(tint.opacity(0.12)) } else { shape.fill(p.surface) } } .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(stroke, 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) ) .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 } }