Files
terminalX/apps/TerminalX/iOS/TXParts.swift
kid 8da725114f feat: 顶带 Shimo 化重设计 + 第一版功能减法
- 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标
- 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽,
  window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize)
- 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占
- 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范
- CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:52:19 +08:00

418 lines
18 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
// 稿 / / 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<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 peachDebian 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
}
}