Files
terminalX/apps/TerminalX/iOS/TXParts.swift
kid 4b5292107b feat: 业务逻辑对齐 + 首页/弹框七项减法 + 终端页四项体验修复
业务逻辑(用户定稿):
- 点主机总是新建会话(会话:主机=多: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>
2026-07-26 17:49:58 +08:00

454 lines
20 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
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 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
}
}