feat: 侧边栏两态重构(统一视图身份)+ 第四轮触控化走查
侧边栏(真机验证): - 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份, 内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画), 图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开 - 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«; 删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸 - 修 GridBox 从不通知 UI:onGridChange→objectWillChange, 标题胶囊格子数随开合实时刷新 触控化走查(上一轮,真机部署验证): - 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮 - TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射) - 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,57 @@ 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
|
||||
@@ -18,6 +69,8 @@ struct TXStatusDot: View {
|
||||
Circle().stroke(punchOut, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
// 链路状态变色(绿 → 琥珀 → 灰)渐变过去,别硬跳。
|
||||
.animation(TX.Motion.quick, value: color)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +108,11 @@ struct TXInitialsAvatar: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组小标题(600 10px, ls .09em, uppercase)。
|
||||
/// 分组小标题(600 10px, ls .09em)。
|
||||
///
|
||||
/// **不做 `.uppercased()`**:这类标签会嵌主机名/会话名/技术名(`tmux · mac-studio · dev`),
|
||||
/// 排版性大写会把它们全改错(曾显示成 `TMUX · MAC-STUDIO · DEV`)。中文本来无大小写,
|
||||
/// 英文标签直接按 `TXTech` 的规范拼写传进来即可。
|
||||
struct TXSectionLabel: View {
|
||||
let text: String
|
||||
/// 右侧计数(如「活动会话 3」的 3 单独右对齐)。
|
||||
@@ -66,7 +123,7 @@ struct TXSectionLabel: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(text.uppercased())
|
||||
Text(text)
|
||||
.font(TX.Font.mono(10, .semibold))
|
||||
.tracking(0.9)
|
||||
.foregroundStyle(color ?? p.textWeak)
|
||||
@@ -80,25 +137,73 @@ struct TXSectionLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 徽标胶囊(主机卡的 mosh / tsnet / SSH / SE key)。
|
||||
/// 技术标签的**拼写与配色**规范(`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(emphasized ? TXAccent.text : p.text4)
|
||||
.foregroundStyle(fg)
|
||||
.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)
|
||||
)
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +228,9 @@ struct TXChip: View {
|
||||
RoundedRectangle(cornerRadius: TX.Radius.badge)
|
||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1)
|
||||
)
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,20 +240,24 @@ struct TXSearchField: View {
|
||||
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(p.textWeak)
|
||||
.foregroundStyle(focused ? TXAccent.text : p.textWeak)
|
||||
TextField(placeholder, text: $text)
|
||||
.font(TX.Font.mono(12))
|
||||
.foregroundStyle(p.text)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
if let shortcut, text.isEmpty {
|
||||
.focused($focused)
|
||||
if let shortcut, text.isEmpty, !focused {
|
||||
Text(shortcut)
|
||||
.font(TX.Font.mono(10.5))
|
||||
.foregroundStyle(p.textFaint)
|
||||
@@ -157,8 +267,10 @@ struct TXSearchField: View {
|
||||
.frame(height: 32)
|
||||
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8).strokeBorder(p.divider, lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(focused ? TXAccent.border : p.divider, lineWidth: 1)
|
||||
)
|
||||
.animation(TX.Motion.quick, value: focused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +301,7 @@ struct TXButton: View {
|
||||
.strokeBorder(emphasized ? TXAccent.border : p.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.txPress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,8 +340,9 @@ struct TXIconButton: View {
|
||||
.frame(width: hitSize ?? max(size, TX.Layout.minTouch),
|
||||
height: hitSize ?? max(size, TX.Layout.minTouch))
|
||||
.contentShape(Rectangle())
|
||||
.animation(TX.Motion.quick, value: isSelected)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.txPressTight)
|
||||
.accessibilityLabel(label ?? icon)
|
||||
}
|
||||
}
|
||||
@@ -251,13 +364,24 @@ struct TXTerminalMirror: View {
|
||||
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) {
|
||||
Spacer(minLength: 0)
|
||||
if lines.isEmpty {
|
||||
Text(placeholder)
|
||||
.font(TX.Font.mono(fontSize))
|
||||
.foregroundStyle(p.text4)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
Text(line.isEmpty ? " " : line)
|
||||
@@ -272,9 +396,6 @@ struct TXTerminalMirror: View {
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, TX.Space.xs)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
.background(background)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
/// 启发式着色:`readViewportText()` 只给纯文本(ANSI 属性已丢),故按行首符号/常见片段上色,
|
||||
|
||||
Reference in New Issue
Block a user