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:
kid
2026-07-26 00:55:53 +08:00
parent 28e9cfc207
commit 5707d70dff
15 changed files with 1086 additions and 675 deletions

View File

@@ -42,6 +42,18 @@ xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
-clonedSourcePackagesDirPath /tmp/tx-spm build
```
## 真机部署用户体验用iPad Pro 13″ M5
```sh
IPAD=454D7605-FA87-51F4-BA62-5E58B640188E # 变了用 xcrun devicectl list devices
cd apps/TerminalX && xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
-configuration Release -destination "id=$IPAD" \
-clonedSourcePackagesDirPath /tmp/tx-spm -allowProvisioningUpdates build
APP=~/Library/Developer/Xcode/DerivedData/TerminalX-*/Build/Products/Release-iphoneos/TerminalX.app
xcrun devicectl device install app --device $IPAD "$APP"
xcrun devicectl device process launch --device $IPAD --terminate-existing ai.athom.terminalx
```
自动签名已配好Team J75A6S5938。**Release 把所有 `#if DEBUG` 编译掉** → fixture/`terminalx://ui`/`-txSlowMotion` 都不存在,真机跑的必然是真实数据路径;签名后 Keychain/SE 可用(不再回退文件后端)。给用户体验用 ReleaseDebug 的 SwiftUI 开销会让动效发闷)。
## 无头验证(本环境无 GUI用 simctl 截图取证)
iPad Pro 13(M5) 模拟器 UDID`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。
```sh
@@ -65,9 +77,10 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
**UI 改造期无头验证(推荐走脚本)**`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
- **`-txLandscape 1`**app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。
- **`-txUIFixture 1`**:造 3 条**假会话**mac-studio/ubuntu-cn2/nas-home含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`DEBUG only`-txHost` 互斥。
- **`terminalx://ui/<动作>`**`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle|pin`。比坐标点击稳(不依赖 idb companion不随布局失效。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
- **`terminalx://ui/<动作>`**`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle``sidebar/pin` 兼容保留=toggle悬浮态已砍。比坐标点击稳(不依赖 idb companion不随布局失效。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E``#11121C` 目测分不出,必须采样像素。
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView``router.route` 判断。
- **`-txSlowMotion 1`**:所有动效 ×12DEBUG`simctl io screenshot` 自身要几百毫秒,抓不到 240ms 的过渡中间帧 —— 想用截图证明「动效真的在跑」(而不是又退化成硬切)必须先开它。判定法:连发 3 张截图采样同一像素,应得到**三个不同的中间值**;硬切时三张一模一样。
- 设计稿本体在 `docs/design/``UI-DESIGN-HANDOFF.md`(token/规格/交互全表) + `terminalX-iPadOS.dc.html`(13 屏画板) + `render-screens.py`(拆屏,配 headless Chrome 逐屏截 PNG 对拍)。
**M3 挂起→恢复无头验证**:退后台 `simctl openurl $UDID https://apple.com`;冻结 `kill -STOP $(pgrep -x TerminalX)`;解冻 `kill -CONT`;前台 `simctl openurl $UDID "terminalx://resume"`URL scheme 已注册)。日志 `log stream | grep -E 'MOSHDBG|TXM3'``wake pulse``TXM3 recovered` 时间差判 <3s。**模拟器看门狗会杀掉被 STOP 冻结过久(>~30s)的 app且模拟器不复现 socket defunct/WG 过期,深挂起需真机锁屏补测。**
@@ -75,7 +88,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
## 仓库结构
- `packages/TXCore` — 零依赖纯逻辑tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
- `packages/TXTransport``SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
- `apps/TerminalX/iOS``TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 三态)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(56pt 轨道+288pt 面板三态)/`TerminalChrome`(浮起标题/状态胶囊+提示条)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。
- `apps/TerminalX/iOS``TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(`TerminalSidebar` 两态唯一视图288pt 全宽排版+railWidth 图标列,收起=左侧裁切)/`TerminalChrome`(浮起标题/状态胶囊+提示条)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。
- UI 已从 Demo 转入**商业化改造**:设计定稿见 `docs/design/UI-DESIGN-HANDOFF.md`Nocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 `docs/HANDOFF.md §12`
- `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)/`mosh`(blinksh/ios 分支)/`protobuf`(3.21.12)`build/mosh/`(手写 config.h+CMakeLists)。
- `artifacts/` — 预编译 xcframework**有意提交**,非 gitignore`CSSHCore`/`TsnetBridge`/`MoshCore`
@@ -98,6 +111,13 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
- **tmux 多 pane 尺寸走"路线 b"**app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局pane surface 尺寸严格取 layout rect%output 按 tmux pane 宽高排版,二者不一致必换行/清屏错乱pane surface 的 resize 回调**只断言不反向驱动 tmux**防反馈环。diff 用 full layout、渲染用 visible layoutzoom 免费SwiftUI identity 只用 paneID布局变不重建 surface。新 pane 在 `capture-pane` %end 前丢弃其 %outputtmux 单线程串行保证快照不缺不重)。
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除ghostty 释放 grid内容全丢② matchedGeometry最小化缩回首页卡要求两端同层级独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
- **侧边栏两态(收起⇄固定展开,悬浮态已被用户裁决砍掉)必须是同一个视图身份**`TerminalSidebar`):内容恒按 288pt 全宽排版,收起只是容器宽度收到 railWidth + `clipped()`"收起=展开在左侧裁切");最左 railWidth 是图标列,两态同尺寸同位置,明细随展开淡入;视图直接参与布局,收起=明细淡出+终端从右盖上来(用户定稿效果)。开合入口只有两个:**Logo 位**(收起态显 » 展开键,展开态还原 Logo——将来 App Logo 设计与展开按钮一体)与头部 « 收起键。曾经"轨道+面板两套视图 + overlay/pinned 两个面板实例",三态切换视图身份互换 → 动画混乱硬切。
- **终端格子数不是"首次布局后稳定"**:侧边栏开合就会 resize。读 `screenGrid` 的视图(标题胶囊 `164×50`)靠 `GridBox.onGridChange → session.objectWillChange` 转发刷新,否则数字停在旧值(嵌套可观察不冒泡这坑的又一面)。
- **`clipped()` 只裁绘制不裁触摸**:收起态下侧边栏被裁掉的明细区(整行按钮)会伸进终端里抢点击 → 整行按钮用 `LeadingHitShape``contentShape` 限制命中区到当前可见宽度Animatable纯明细控件再加 `allowsHitTesting(expanded)`
- **状态变更必须包在 `TX.Motion` 的动画事务里**`AppRouter` 的 route/modal/sidebar 全走 `animated{}`。裸改 `@Published` → 视图上写好的 `.transition` 拿不到事务,**静默退化成硬切**(首页⇄终端、关闭确认、分屏菜单都曾是这么"啪"地跳出来)。收在 router 里点击/⌘快捷键/无头 URL 才是同一手感。阴影同理只走 `View.txShadow(TX.Elevation.*)`,别手写 `.shadow`(深色底上大半径高透明度会糊成脏斑)。
- **贴屏幕边缘的容器不许收小圆角**:设备屏幕本身是 ~22pt 大圆角,在那儿再画 6pt 小角必被"吃掉"(小角整段落在屏幕圆角之外,切口露出底色三角)。终端 pane 用 `UnevenRoundedRectangle` **只在朝 chrome 的角收圆角**,顶边/右边铺满物理边缘、不收角不描边(描边用负 padding `top:-1/trailing:-1` 推出屏外。Stage Manager 小窗同理。
- **沉浸页的安全区由底栏自己让位**:整屏 `ignoresSafeArea()`,底部区高 = `TX.Layout.footBand(safeBottom)` = 34 + 安全区,内容**居中**(贴住物理底边又让开 home indicator。**坑**:只放开顶边时,颜色型 `.background` 会溢进底部安全区、内容却停在上沿 → 屏幕最下面一条 24pt 空带 + 轨道底色铺满,一眼没对齐;遮罩态/Pin 态面板脚还会差 24pt一钉住就跳。安全区读数用 `TXScreenMetrics``GeometryReader` 必须在 `ignoresSafeArea` **之前**,否则读到 0
- **`.frame(height:)` 压不住内容驱动的最小高度**:它只是给了个框,不声明撑满的子视图按自身理想高度**居中**放进去 → 一排卡片高矮不齐。要么子视图自己 `.frame(maxHeight: .infinity)`,要么(内容天生比框高时,如 `TXTerminalMirror` 的行堆叠)把内容放进 `Color.clear``overlay` —— overlay 不参与父视图定尺,尺寸完全由外部决定,溢出交给 `clipped()`
- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()``readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。
- **嵌套 ObservableObject 不冒泡**`SessionManager``@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。

View File

@@ -14,21 +14,19 @@ enum AppRoute: Equatable {
}
}
///
///
enum SidebarState: Equatable {
/// 56pt
case collapsed
/// 288pt **** reflow PTY resize
case overlay
/// 288pt **/ resize **
/// 288pt resize
case pinned
var isExpanded: Bool { self != .collapsed }
var isExpanded: Bool { self == .pinned }
/// overlay
///
var layoutWidth: CGFloat {
switch self {
case .collapsed, .overlay: TX.Layout.railWidth
case .collapsed: TX.Layout.railWidth
case .pinned: TX.Layout.sidebarWidth
}
}
@@ -51,47 +49,34 @@ final class AppRouter: ObservableObject {
/// /
@Published var modal: AppModal?
/// 稿 200260ms easeOut +
/// 线
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
private func animateSidebar(_ change: () -> Void) {
withAnimation(Self.sidebarAnimation, change)
/// `TX.Motion.standard`稿 200260ms easeOut
///
/// ** / / 线** `transition`
/// 退 ""
/// router URL
private func animated(_ change: () -> Void) {
withAnimation(TX.Motion.standard, change)
}
/// / /
func enter(_ sessionID: UUID) {
route = .terminal(sessionID)
animated { route = .terminal(sessionID) }
}
/// H /
/// H /
func goHome() {
modal = nil
route = .home
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
/// \ / Pin
func toggleSidebar() {
animateSidebar {
switch sidebar {
case .collapsed: sidebar = .overlay
case .overlay, .pinned: sidebar = .collapsed
}
animated {
modal = nil
route = .home
}
}
/// Pin / resize
func togglePin() {
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
/// \ / Logo /
func toggleSidebar() {
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
}
/// /WDD URL
func present(_ modal: AppModal) { self.modal = modal }
func dismissModal() { modal = nil }
/// Pin
func dismissOverlaySidebar() {
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
}
func present(_ modal: AppModal) { animated { self.modal = modal } }
func dismissModal() { animated { modal = nil } }
}

View File

@@ -94,7 +94,8 @@ struct RootView: View {
if router.route == .home {
HomeView(manager: manager, router: router, hostStore: hostStore,
credStore: credStore, tailscaleStore: tailscaleStore)
.transition(.opacity)
// 97% ""
.transition(TX.Motion.pushBack)
}
}
.background(p.bgDeep.ignoresSafeArea())
@@ -142,6 +143,7 @@ struct SessionCanvas: View {
@ObservedObject var hostStore: HostStore
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
@@ -153,61 +155,55 @@ struct SessionCanvas: View {
// ""
// `scaleEffect` frame ** PTY resize**
// frame tmux
.scaleEffect(visible ? 1 : 0.97)
.animation(AppRouter.sidebarAnimation, value: visible)
.scaleEffect(visible || reduceMotion ? 1 : 0.97)
.animation(TX.Motion.standard, value: visible)
.allowsHitTesting(visible)
}
}
}
}
/// · 稿 `1b` + `3a`
/// · 稿 `1b` + `3a`/
///
/// 56pt Pin 288pt + 86pt
/// 34pt **** reflow resize
/// Pin resize
/// `TerminalSidebar`56 288+ 86pt
///
/// resize
///
/// **** chrome ****`ignoresSafeArea()`
/// / `TX.Layout.footBand`线 y
struct TerminalScreen: View {
@ObservedObject var session: TerminalSession
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var sidebarSearch = ""
/// tmux
@State private var showTmuxInstall = false
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 0) {
if router.sidebar == .pinned {
sidebarPanel
} else {
RailView(manager: manager, router: router, session: session,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost)
}
stage
}
.background(p.bgDeep.ignoresSafeArea())
// 稿 1b/3a /logo RootView
// SessionCanvas hide
.ignoresSafeArea(edges: .top)
// 50%
// + 200260ms easeOut AppRouter
.overlay(alignment: .leading) {
if router.sidebar == .overlay {
ZStack(alignment: .leading) {
Color(hex: 0x0A0B12).opacity(0.5)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { router.dismissOverlaySidebar() }
.transition(.opacity)
sidebarPanel
.transition(.move(edge: .leading))
}
// safe area
// TXScreenMetrics
TXScreenMetrics { geo in
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
.ignoresSafeArea()
}
}
}
private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
HStack(spacing: 0) {
// **** TerminalSidebar
// 56 288
// resize
TerminalSidebar(manager: manager, router: router, session: session,
tailscaleStore: tailscaleStore,
railWidth: railWidth, safeBottom: safeBottom,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
search: $sidebarSearch)
stage(safeTop: safeTop, safeBottom: safeBottom)
}
.background(p.bgDeep)
// 3c3b alert/sheet
.overlay {
switch router.modal {
@@ -230,10 +226,10 @@ struct TerminalScreen: View {
if showTmuxInstall {
TmuxInstallSheet(
onRun: { cmd in
showTmuxInstall = false
withAnimation(TX.Motion.standard) { showTmuxInstall = false }
session.runInTerminal(cmd)
},
onCancel: { showTmuxInstall = false })
onCancel: { withAnimation(TX.Motion.standard) { showTmuxInstall = false } })
}
}
// host key 使
@@ -248,11 +244,9 @@ struct TerminalScreen: View {
}
}
private var sidebarPanel: SidebarPanel {
SidebarPanel(manager: manager, router: router, session: session,
tailscaleStore: tailscaleStore,
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
search: $sidebarSearch)
/// / 48
private var railWidth: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
/// + / 齿
@@ -263,13 +257,15 @@ struct TerminalScreen: View {
// MARK: -
private var stage: some View {
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
// tmux 2c pane
if session.tmuxUnavailable {
NoTmuxBanner(onInstall: { showTmuxInstall = true },
onDismiss: { session.dismissTmuxBanner() })
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
onDismiss: {
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
})
.padding(.top, TX.Layout.capsuleInset)
.transition(.opacity)
}
@@ -280,20 +276,35 @@ struct TerminalScreen: View {
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.terminalBackground)
.clipShape(RoundedRectangle(cornerRadius: 6))
.clipShape(paneShape)
.overlay(
RoundedRectangle(cornerRadius: 6).strokeBorder(p.border, lineWidth: 1)
paneShape.strokeBorder(p.border, lineWidth: 1)
// /
// 线
.padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1))
)
// pane 线
.padding([.leading, .trailing, .top], TX.Layout.paneGap)
TerminalHintBar(context: hintContext, hints: hints)
// **** 2pt /
// pane 线
.padding(.leading, TX.Layout.paneGap)
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
}
capsules
capsules(safeTop: safeTop)
}
.background(p.bgDeep)
.contentShape(Rectangle())
// Pin
.simultaneousGesture(TapGesture().onEnded { router.dismissOverlaySidebar() })
}
/// pane ** app chrome **
///
/// iPad/iPhone 6pt
/// ""
/// /****
/// pane chrome Stage Manager
private var paneShape: UnevenRoundedRectangle {
UnevenRoundedRectangle(
topLeadingRadius: TX.Radius.pane,
bottomLeadingRadius: TX.Radius.pane,
bottomTrailingRadius: TX.Radius.pane,
topTrailingRadius: 0)
}
@ViewBuilder private var paneArea: some View {
@@ -312,17 +323,20 @@ struct TerminalScreen: View {
router.goHome()
manager.close(id)
})
//
.animation(TX.Motion.standard, value: session.phaseText)
}
}
private var capsules: some View {
private func capsules(safeTop: CGFloat) -> some View {
HStack(alignment: .top) {
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
Spacer(minLength: TX.Space.m)
TerminalStatusCapsule(session: session)
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 14)
// iPad 14/
.padding(.top, TX.Layout.capsuleTop(safeTop))
}
/// **** `closeSession(kill:)`
@@ -337,7 +351,7 @@ struct TerminalScreen: View {
if let tmux = session.tmuxController {
if kill { tmux.killSession() } else { tmux.detach() }
}
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
router.goHome() // router TX.Motion.standard
// detach/kill tmux %exit onExit
Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
@@ -348,7 +362,7 @@ struct TerminalScreen: View {
private func minimizeSession() {
session.captureSnapshot() //
manager.minimize(session.id)
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
router.goHome()
}
///
@@ -373,20 +387,31 @@ struct TerminalScreen: View {
router.present(.splitMenu(vertical: vertical))
}
private var hintContext: String {
switch router.sidebar {
case .pinned: "Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次"
case .overlay: "遮罩态 · 点终端任意处收起,终端不 reflow"
case .collapsed: session.isLive ? "已连接妙控键盘 · 附加行已收起" : session.phaseText
/// **** ·
///
private var barContext: String {
if !session.isLive { return session.phaseText }
if let t = session.tmuxSummary {
let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)"
return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")"
}
if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" }
return session.displayName
}
private var hints: [(key: String, label: String)] {
switch router.sidebar {
case .pinned: [("Pin", "解除钉住"), ("\\", "收起为轨道"), ("⌘⇧H", "回首页")]
case .overlay: [("\\", "收起"), ("Pin", "钉在左侧"), ("⌘⇧H", "回首页")]
case .collapsed: [("⌘K", "命令面板"), ("⌘D", "竖分屏"), ("⌘T", "新 window"), ("⌘⇧H", "回首页")]
}
/// ****tmux
private var barActions: [TerminalActionBar.Action] {
let tmux = session.tmuxController
return [
.init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) {
requestSplit(vertical: true)
},
.init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window",
enabled: tmux != nil) {
tmux?.newWindow()
},
.init(id: "home", icon: "house", title: "回首页") { router.goHome() },
]
}
}

View File

@@ -11,6 +11,7 @@ struct HomeView: View {
@ObservedObject var credStore: FileCredentialStore
@ObservedObject var tailscaleStore: TailscaleStore
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
@State private var search = ""
@State private var groupFilter: String? = nil
@@ -41,6 +42,31 @@ struct HomeView: View {
}
var body: some View {
//
TXScreenMetrics { geo in
content(safeBottom: geo.safeAreaInsets.bottom)
}
.background(p.bg.ignoresSafeArea())
//
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
private func content(safeBottom: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 0) {
header
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
@@ -71,29 +97,16 @@ struct HomeView: View {
hostGrid
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.bottom, TX.Space.xxl)
.padding(.horizontal, gutter)
// home indicator
.padding(.bottom, TX.Space.xxl + safeBottom)
}
//
.ignoresSafeArea(edges: .bottom)
// /线/
.animation(TX.Motion.standard, value: manager.sessions.count)
}
}
.background(p.bg.ignoresSafeArea())
//
.task(id: manager.sessions.count) {
while !Task.isCancelled {
for s in manager.sessions { s.captureSnapshot() }
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}
.sheet(isPresented: $showEditor) {
HostEditorView(existing: editing, credStore: credStore, tailscaleStore: tailscaleStore,
existingGroups: groups) { host in
if hostStore.hosts.contains(where: { $0.id == host.id }) { hostStore.update(host) }
else { hostStore.add(host) }
}
}
.sheet(isPresented: $showSettings) {
SettingsHost(credStore: credStore, tailscaleStore: tailscaleStore)
}
}
// MARK: -
@@ -178,22 +191,30 @@ struct HomeView: View {
.font(TX.Font.mono(12))
.foregroundStyle(p.textWeak)
}
Spacer()
Spacer(minLength: TX.Space.s)
HStack(spacing: TX.Space.s) {
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
.frame(width: 220)
TXSearchField(text: $search, placeholder: "搜索主机")
// 220 / iPhone
//
.frame(minWidth: 120, maxWidth: 220)
TXButton(title: "新建连接", emphasized: true) {
editing = nil
showEditor = true
}
.fixedSize()
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
}
}
.padding(.horizontal, TX.Space.screenGutter)
.padding(.horizontal, gutter)
.padding(.top, TX.Space.l)
.padding(.bottom, TX.Space.m)
}
/// iPad 40pt 20pt
private var gutter: CGFloat {
hSize == .compact ? TX.Space.l : TX.Space.screenGutter
}
private var subtitle: String {
var parts: [String] = []
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
@@ -213,60 +234,7 @@ struct HomeView: View {
}
}
/// tmux
/// `SessionManager` manager
struct ConnectingRow: View {
@ObservedObject var session: TerminalSession
let onCancel: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var failed: Bool { session.phaseText.hasPrefix("失败") }
var body: some View {
HStack(spacing: TX.Space.s) {
if failed {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 13))
.foregroundStyle(p.danger)
} else {
ProgressView().controlSize(.small).tint(TXAccent.base)
}
VStack(alignment: .leading, spacing: 2) {
Text(failed ? "连接 \(session.displayName) 失败" : "正在连接 \(session.displayName)")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Text(detail)
.font(TX.Font.mono(11))
.foregroundStyle(failed ? p.danger : p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.s)
if failed {
TXButton(title: "重试") { session.retry() }
}
TXButton(title: failed ? "移除" : "取消", action: onCancel)
}
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.card)
.strokeBorder(failed ? p.danger.opacity(0.4) : p.border, lineWidth: 1)
)
.padding(.top, TX.Space.xs)
}
private var detail: String {
if failed { return String(session.phaseText.dropFirst(3)) }
if session.pendingSessionChoice != nil { return "等待选择 tmux 会话" }
if !session.banner.isEmpty { return session.banner }
return session.phaseText
}
}
// MARK: - 3
// MARK: -
/// **** ConnectingRow
/// tmux
@@ -277,8 +245,15 @@ struct ConnectingRow: View {
}
}
/// ** 3 ** 3 **4:3**
///
/// 3
/// 3
private var sessionCards: some View {
HStack(alignment: .top, spacing: TX.Space.m) {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 3),
spacing: TX.Space.m
) {
ForEach(liveSessions) { s in
SessionCard(
session: s,
@@ -288,15 +263,17 @@ struct ConnectingRow: View {
manager.focus(s)
router.enter(s.id)
}
.aspectRatio(4.0 / 3.0, contentMode: .fit)
}
}
.frame(height: 560)
}
// MARK: - 4
private var hostGrid: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m), count: 4),
// 4 iPad 2
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: TX.Space.m),
count: hSize == .compact ? 2 : 4),
spacing: TX.Space.m) {
ForEach(filteredHosts) { host in
HostTile(host: host,
@@ -336,7 +313,7 @@ struct ConnectingRow: View {
.foregroundStyle(p.border)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
private func credName(for host: SavedHost) -> String? {
@@ -428,27 +405,36 @@ struct SessionCard: View {
}
}
/// **** Button
/// affordance
var body: some View {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 线/ + blur(1.5px) + rgba(24,26,40,.72)
//
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
Button(action: onOpen) {
VStack(spacing: 0) {
cardHead
Rectangle().fill(p.divider).frame(height: 1)
// 线/ + blur(1.5px) + rgba(24,26,40,.72)
//
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
.blur(radius: isTroubled ? 1.5 : 0)
.overlay { if isTroubled { troubleVeil } }
Rectangle().fill(p.divider).frame(height: 1)
cardFoot
}
// ****`.frame(height:)` 560
// 10pt
// LazyVGrid HStack
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.txShadow(isFocused ? TX.Elevation.cardFocused : TX.Elevation.card)
.animation(TX.Motion.quick, value: isFocused)
.contentShape(Rectangle())
}
.background(p.surfaceDark)
.clipShape(RoundedRectangle(cornerRadius: 13))
.overlay(
RoundedRectangle(cornerRadius: 13)
.strokeBorder(isFocused ? TXAccent.selectedStroke : p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(isFocused ? 0.4 : 0.2), radius: isFocused ? 9 : 5, y: 6)
.contentShape(Rectangle())
.onTapGesture(perform: onOpen)
.buttonStyle(.txPress)
}
private var cardHead: some View {
@@ -528,12 +514,9 @@ struct SessionCard: View {
Text("\(shortcutIndex)")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
Button(action: onOpen) {
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.buttonStyle(.plain)
Text(isTroubled ? "立即重试" : "回到会话 →")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(TXAccent.textBright)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 38)
@@ -590,9 +573,11 @@ struct HostTile: View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: TX.Space.s) {
HStack(alignment: .top, spacing: TX.Space.s) {
// Ubuntu / Debian / macOS
// OS
Image(systemName: icon)
.font(.system(size: 15, weight: .light))
.foregroundStyle(p.text4)
.foregroundStyle(TXTech.osTint(host.os, p))
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
@@ -614,9 +599,12 @@ struct HostTile: View {
}
}
HStack(spacing: 5) {
if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") }
if hasTsnet { TXBadge(text: "tsnet") }
if let credName { TXBadge(text: credName, emphasized: true) }
// tsnet app
// TXTech
let transport = TXTech.transport(host.useMosh, p)
TXBadge(text: transport.text, tint: transport.tint)
if hasTsnet { TXBadge(text: "tsnet", emphasized: true) }
if let credName { TXBadge(text: credName) }
}
}
.padding(TX.Space.s)
@@ -627,6 +615,6 @@ struct HostTile: View {
.strokeBorder(p.border, lineWidth: 1)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}

View File

@@ -146,7 +146,7 @@ struct HostCard: View {
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
private func badge(_ icon: String, _ text: String, _ color: Color) -> some View {

View File

@@ -1,158 +1,380 @@
import SwiftUI
// 稿 `1b -` 56pt + `3a -/Pin ` 288pt
// 56pt 288pt****
//
// ****`` App logo
// »«Pin
//
// " + "
// 288pt railWidth `clipped()`
// =
// railWidth ****logo() / / / / window /
// / 齿****
//
// /
// 稿Logo =»«
//
// ****`clipped()` //window
// `LeadingHitShape`
// Pin window
// `allowsHitTesting(expanded)`
/// 56pt 48pt线
struct RailView: View {
/// `width`
private struct LeadingHitShape: Shape {
var width: CGFloat
var animatableData: CGFloat {
get { width }
set { width = newValue }
}
func path(in rect: CGRect) -> Path {
Path(CGRect(x: 0, y: 0, width: width, height: rect.height))
}
}
struct TerminalSidebar: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
/// TMUX window
/// tmux window
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
/// 48 = 线
let railWidth: CGFloat
/// home indicator
let safeBottom: CGFloat
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
private var width: CGFloat {
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
}
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
private var expanded: Bool { router.sidebar.isExpanded }
private var currentWidth: CGFloat { expanded ? TX.Layout.sidebarWidth : railWidth }
// 稿 `1b` 1366×1024
// logo 28..62 · 75..109 · 118..152 · 160 · 174/220/266 8
// · 线+ 313..351 · 370 · TMUX 382 · window 408 7
// · 齿 985..1019 · 932..967 · 914
var body: some View {
VStack(spacing: 0) {
logo
.padding(.top, 28)
.padding(.bottom, 13)
// / / 稿**** logo
content
.frame(width: TX.Layout.sidebarWidth, alignment: .leading)
.background(p.surfaceDark)
.frame(width: currentWidth, alignment: .leading)
.clipped()
}
private var content: some View {
VStack(alignment: .leading, spacing: 0) {
header
searchRow
.padding(.bottom, TX.Space.s)
homeRow
sectionRow(label: "活动会话", trailing: "\(manager.sessions.count)")
sessionRows
newHostRow
tmuxDivider
tmuxLabelRow
tmuxRows
Spacer(minLength: TX.Space.s)
tailscaleRow
footRow
}
}
// MARK: -
/// Logo ****稿 App Logo
/// » Logo``
private var header: some View {
HStack(spacing: 0) {
ZStack {
logoBox { Image(systemName: "chevron.right.2").font(.system(size: 13, weight: .semibold)) }
.opacity(expanded ? 0 : 1)
logoBox { Text("").font(TX.Font.mono(15, .bold)) }
.opacity(expanded ? 1 : 0)
}
.frame(width: railWidth)
.contentShape(Rectangle())
.onTapGesture { if !expanded { router.toggleSidebar() } }
.accessibilityLabel(expanded ? "terminalX" : "展开侧边栏")
.accessibilityAddTraits(expanded ? [] : .isButton)
HStack(spacing: TX.Space.s) {
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer(minLength: 0)
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起侧边栏") {
router.toggleSidebar()
}
}
.padding(.trailing, TX.Space.s)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
.padding(.top, 14)
.padding(.bottom, 10)
}
/// Logo / 30×30
private func logoBox(@ViewBuilder _ inner: () -> some View) -> some View {
inner()
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
}
/// = =
/// 12 + 10 + 6 railWidth/2""
private var searchRow: some View {
ZStack(alignment: .leading) {
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
router.toggleSidebar()
}
.padding(.bottom, 9)
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
.padding(.bottom, 8)
divider
.padding(.bottom, 13)
hostAvatars
newHostButton
divider
.padding(.top, 9)
.padding(.bottom, 12)
tmuxSection
Spacer(minLength: TX.Space.s)
TXStatusDot(color: linkColor, punchOut: p.surfaceDark)
.padding(.bottom, 11)
// 稿****
TXIconButton(icon: "chevron.right.2", hitSize: 34, label: "展开侧边栏") {
router.toggleSidebar()
}
.padding(.bottom, 18)
TXIconButton(icon: "gearshape", bare: true, hitSize: 34, label: "设置", action: onSettings)
.padding(.bottom, 5)
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded)
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.leading, 12)
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
.frame(width: width)
.background(p.surfaceDark)
}
/// App logo``****
private var logo: some View {
Text("")
.font(TX.Font.mono(16, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 34, height: 34)
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
.accessibilityLabel("terminalX")
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: 0) {
Image(systemName: "house")
.font(.system(size: 17))
.foregroundStyle(p.text3)
.frame(width: railWidth)
HStack {
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer(minLength: 0)
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 36)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("首页")
}
private var divider: some View {
// MARK: - 线
/// = = 24pt 线""
private func sectionRow(label: String, trailing: String? = nil) -> some View {
ZStack(alignment: .leading) {
shortDivider
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
TXSectionLabel(text: label, trailing: trailing)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 26)
}
private var shortDivider: some View {
Rectangle()
.fill(p.divider)
.frame(width: 24, height: 1)
}
private var hostAvatars: some View {
VStack(spacing: 8) {
ForEach(manager.activeSessions) { s in
Button {
manager.focus(s)
router.enter(s.id)
} label: {
TXInitialsAvatar(initials: s.initials,
size: avatarSize,
isSelected: router.route.sessionID == s.id,
status: RailSidebarStyle.statusColor(s, p),
punchOut: p.surfaceDark)
}
.buttonStyle(.plain)
// MARK: -
@ViewBuilder private var sessionRows: some View {
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, railWidth)
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s,
isSelected: router.route.sessionID == s.id,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
manager.focus(s)
router.enter(s.id)
}
}
}
/// 线 +
private var newHostButton: some View {
/// 线 +
private var newHostRow: some View {
Button(action: onNewHost) {
Image(systemName: "plus")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: avatarSize, height: avatarSize)
.background(
RoundedRectangle(cornerRadius: 11)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
HStack(spacing: 0) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(p.textWeak)
.frame(width: 30, height: 30)
.background(
RoundedRectangle(cornerRadius: 9)
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
.foregroundStyle(p.border)
)
.frame(width: railWidth)
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.plain)
.padding(.top, 9)
.buttonStyle(.txPress)
.accessibilityLabel("连接新主机")
}
/// TMUX + window tmux
private var tmuxSection: some View {
VStack(spacing: 7) {
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
// MARK: - tmux
/// = 3a = 线
private var tmuxDivider: some View {
ZStack(alignment: .leading) {
shortDivider
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.padding(.vertical, TX.Space.s)
}
/// = accent TXTech tmux
/// = tmux · ·
private var tmuxLabelRow: some View {
ZStack(alignment: .leading) {
Text(session.tmuxSummary == nil ? "窗口" : "tmux")
.font(TX.Font.mono(9, .semibold))
.tracking(0.9)
// 稿 TMUX accent-500
.foregroundStyle(TXAccent.mid)
.padding(.bottom, 9)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
Button { tmux.selectWindow(win.id) } label: {
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
}
.buttonStyle(.plain)
}
} else {
// attach window
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
windowNumber("\(i)", isActive: i == 0)
.frame(width: railWidth)
.opacity(expanded ? 0 : 1)
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.frame(height: 22)
.padding(.bottom, 4)
}
@ViewBuilder private var tmuxRows: some View {
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID,
expanded: expanded, railWidth: railWidth,
hitWidth: currentWidth) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// tmux controller attach
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
WindowRowBody(
index: "\(i)",
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
subtitle: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "1 pane",
isSelected: i == 0,
expanded: expanded, railWidth: railWidth)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, railWidth)
.frame(height: 32, alignment: .leading)
.opacity(expanded ? 1 : 0)
}
}
/// window 34×30 +
private func windowNumber(_ text: String, isActive: Bool) -> some View {
Text(text)
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
.foregroundStyle(isActive ? p.text : p.text4)
.frame(width: 34, height: 30)
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
)
/// window T
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 0) {
Color.clear.frame(width: railWidth, height: 1)
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer(minLength: 0)
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
.padding(.trailing, TX.Space.m)
}
.frame(height: 32)
}
.buttonStyle(.txPress)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
}
private var linkColor: Color { RailSidebarStyle.statusColor(session, p) }
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "tmux · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "tmux · \(host)"
}
// MARK: - tailnet · 线 + 齿
/// tailnet
private var tailscaleRow: some View {
HStack(spacing: 0) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online,
punchOut: p.surfaceDark)
.frame(width: railWidth)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: 40)
}
/// 线线 y footBand 齿
private var footRow: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
Button(action: onSettings) {
HStack(spacing: 0) {
Image(systemName: "gearshape")
.font(.system(size: 17))
.foregroundStyle(p.text3)
.frame(width: railWidth)
Text("设置")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.opacity(expanded ? 1 : 0)
Spacer(minLength: 0)
}
.frame(height: TX.Layout.footBand(safeBottom))
.contentShape(LeadingHitShape(width: currentWidth))
}
.buttonStyle(.txPress)
.accessibilityLabel("设置")
}
}
}
///
@@ -168,283 +390,60 @@ enum RailSidebarStyle {
}
}
// MARK: - 288pt
// MARK: -
/// / Pin
///
/// = 26pt + 10pt gapwindow = 26×22pt + 10pt gap
/// **inset **`strokeBorder` border 1pt
struct SidebarPanel: View {
@ObservedObject var manager: SessionManager
@ObservedObject var router: AppRouter
@ObservedObject var session: TerminalSession
@ObservedObject var tailscaleStore: TailscaleStore
let onNewHost: () -> Void
let onSettings: () -> Void
@Binding var search: String
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
.padding(.horizontal, TX.Space.m)
.padding(.bottom, TX.Space.s)
homeRow
sessionsSection
// 1pt ****线
LinearGradient(colors: [p.divider.opacity(0), p.divider, p.divider.opacity(0)],
startPoint: .leading, endPoint: .trailing)
.frame(height: 1)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.s)
tmuxSection
Spacer(minLength: 0)
tailscaleFoot
}
.frame(width: TX.Layout.sidebarWidth)
.background(p.surfaceDark)
}
private var header: some View {
HStack(spacing: TX.Space.s) {
Text("")
.font(TX.Font.mono(15, .bold))
.foregroundStyle(TXAccent.text)
.frame(width: 30, height: 30)
.background(p.surface, in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(p.border, lineWidth: 1))
Text("terminalX")
.font(TX.Font.mono(14, .medium))
.foregroundStyle(p.text)
Spacer()
// Pin
TXIconButton(icon: "pin", size: 28, isSelected: router.sidebar == .pinned,
label: "钉在左侧") { router.togglePin() }
//
TXIconButton(icon: "chevron.left.2", size: 28, label: "收起") { router.toggleSidebar() }
}
.padding(.horizontal, TX.Space.m)
.padding(.top, 11)
.padding(.bottom, 6)
}
private var homeRow: some View {
Button { router.goHome() } label: {
HStack(spacing: TX.Space.s) {
Image(systemName: "house")
.font(.system(size: 13))
.foregroundStyle(p.text2)
.frame(width: 26)
Text("首页")
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
Spacer()
Text("⌘⇧H")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.horizontal, TX.Space.m)
.frame(height: 36)
}
.buttonStyle(.plain)
}
private var sessionsSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: "活动会话", trailing: "\(manager.sessions.count)")
.padding(.horizontal, TX.Space.m)
.padding(.top, TX.Space.s)
.padding(.bottom, 2)
if manager.activeSessions.isEmpty {
Text("没有在跑的会话")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.leading, 53)
.frame(height: 32, alignment: .leading)
}
ForEach(manager.activeSessions) { s in
SidebarSessionRow(session: s, isSelected: router.route.sessionID == s.id) {
manager.focus(s)
router.enter(s.id)
}
}
Button(action: onNewHost) {
Text("连接新主机…")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
.padding(.leading, 53) //
.frame(height: 32, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
private var tmuxSection: some View {
VStack(alignment: .leading, spacing: 2) {
TXSectionLabel(text: tmuxHeader)
.padding(.horizontal, TX.Space.m)
.padding(.bottom, 2)
if let tmux = session.tmuxController {
ForEach(tmux.windows) { win in
SidebarWindowRow(window: win,
isSelected: win.id == tmux.activeWindowID) {
tmux.selectWindow(win.id)
}
}
newWindowRow { tmux.newWindow() }
} else if let summary = session.tmuxSummary {
// tmux controller attach
ForEach(0 ..< max(1, summary.windows), id: \.self) { i in
placeholderWindowRow(
index: i,
title: i == 0 && !summary.currentTitle.isEmpty ? summary.currentTitle : "window \(i)",
detail: i == 0 && summary.panes > 0
? "\(summary.panes) pane\(summary.panes == 1 ? "" : "s")" : "",
isSelected: i == 0)
}
newWindowRow {}
} else {
Text("无 tmux · 客户端窗口")
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.padding(.horizontal, TX.Space.m)
.padding(.vertical, TX.Space.xs)
}
}
}
/// window T window x=53
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text("新建 window")
.font(TX.Font.mono(12))
.foregroundStyle(p.text4)
Spacer()
Text("⌘T")
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textFaint)
}
.padding(.leading, 53)
.padding(.trailing, TX.Space.m)
.frame(height: 32)
}
.buttonStyle(.plain)
}
/// controller window SidebarWindowRow
private func placeholderWindowRow(index: Int, title: String, detail: String,
isSelected: Bool) -> some View {
HStack(spacing: TX.Space.s) {
Text("\(index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
if !detail.isEmpty {
Text(detail)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
}
}
Spacer(minLength: 0)
if isSelected { TXStatusDot(color: p.online, size: 7) }
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
}
private var tmuxHeader: String {
let host = session.displayName
if let t = session.tmuxSummary, !t.name.isEmpty { return "TMUX · \(host) · \(t.name)" }
return session.tmuxSummary == nil ? "窗口 · \(host)" : "TMUX · \(host)"
}
/// tailnet 绿 + tailnet + IP
///
/// **** `TX.Layout.footHeight`线 y
/// 线 1pt divider稿
private var tailscaleFoot: some View {
VStack(spacing: 0) {
Rectangle().fill(p.divider).frame(height: 1)
HStack(spacing: TX.Space.s) {
TXStatusDot(color: tailscaleStore.connections.isEmpty ? p.offline : p.online)
VStack(alignment: .leading, spacing: 1) {
Text(tailscaleStore.connections.isEmpty ? "Tailscale 未配置" : "Tailscale 已连接")
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text2)
if let c = tailscaleStore.connections.first {
// tailnet + tailnet IPUp SelfIP
Text([c.hostname, c.lastSelfIP].compactMap { $0 }.joined(separator: " · "))
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
}
}
}
/// 26pt + ·window title + /
/// 30pt + ·window title +
///
struct SidebarSessionRow: View {
@ObservedObject var session: TerminalSession
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
let hitWidth: CGFloat
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
Button(action: onTap) {
HStack(spacing: TX.Space.s) {
TXInitialsAvatar(initials: session.initials, size: 26,
HStack(spacing: 0) {
TXInitialsAvatar(initials: session.initials, size: 30,
isSelected: isSelected,
status: RailSidebarStyle.statusColor(session, p),
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
punchOut: isSelected && expanded ? TXAccent.selectedBg : p.surfaceDark)
.frame(width: railWidth)
HStack(spacing: TX.Space.s) {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(TX.Font.mono(13, .medium))
.foregroundStyle(p.text)
.lineLimit(1)
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
Spacer(minLength: TX.Space.xs)
trailingTag
}
Spacer(minLength: TX.Space.xs)
trailingTag
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 48)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
// inset border 1pt
.overlay(
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
.fill(TXAccent.selectedBg)
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.opacity(isSelected && expanded ? 1 : 0)
)
.padding(.horizontal, TX.Space.s)
.contentShape(LeadingHitShape(width: hitWidth))
.animation(TX.Motion.quick, value: isSelected)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
private var title: String {
@@ -473,13 +472,14 @@ struct SidebarSessionRow: View {
}
}
/// window 26×22pt + title + cwd/panes
/// window 26×22 + title + cwd/panes
struct SidebarWindowRow: View {
@ObservedObject var window: TmuxWindow
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
let hitWidth: CGFloat
let onTap: () -> Void
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
private var subtitle: String {
var parts: [String] = []
@@ -491,19 +491,44 @@ struct SidebarWindowRow: View {
var body: some View {
Button(action: onTap) {
WindowRowBody(index: "\(window.index)", title: window.title, subtitle: subtitle,
isSelected: isSelected, expanded: expanded, railWidth: railWidth)
.contentShape(LeadingHitShape(width: hitWidth))
}
.buttonStyle(.txPress)
}
}
/// window SidebarWindowRow
struct WindowRowBody: View {
let index: String
let title: String
let subtitle: String
let isSelected: Bool
let expanded: Bool
let railWidth: CGFloat
@EnvironmentObject var theme: TXThemeManager
private var p: TXPalette { theme.palette }
var body: some View {
HStack(spacing: 0) {
Text(index)
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.frame(width: railWidth)
HStack(spacing: TX.Space.s) {
Text("\(window.index)")
.font(TX.Font.mono(11, .semibold))
.foregroundStyle(isSelected ? TXAccent.text : p.text4)
.frame(width: 26, height: 22)
.background(isSelected ? TXAccent.selectedBg : .clear,
in: RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 1) {
Text(window.title)
Text(title)
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? p.text : p.text2)
.lineLimit(1)
// cwd · n panescwd #{pane_current_path}
Text(subtitle)
.font(TX.Font.mono(10.5))
.foregroundStyle(p.textWeak)
@@ -514,16 +539,20 @@ struct SidebarWindowRow: View {
TXStatusDot(color: p.online, size: 7)
}
}
.padding(.horizontal, TX.Space.s)
.frame(height: 44)
.background(isSelected ? TXAccent.selectedBg.opacity(0.75) : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.control))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1)
)
.padding(.horizontal, TX.Space.s)
.padding(.trailing, TX.Space.m)
.opacity(expanded ? 1 : 0)
}
.buttonStyle(.plain)
.frame(height: 44)
.background(
RoundedRectangle(cornerRadius: TX.Radius.control)
.fill(TXAccent.selectedBg.opacity(0.75))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.control)
.strokeBorder(TXAccent.selectedStroke, lineWidth: 1)
)
.padding(.horizontal, TX.Space.xs)
.opacity(isSelected && expanded ? 1 : 0)
)
.animation(TX.Motion.quick, value: isSelected)
}
}

View File

@@ -84,7 +84,7 @@ struct SessionPickerSheet: View {
.background(p.surface, in: Capsule())
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
.accessibilityLabel("直接连接原生终端,不经过 tmux")
}
}
@@ -115,7 +115,7 @@ struct SessionPickerSheet: View {
Button { onAttach(s.name) } label: {
sessionRow(s)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
if index < choice.sessions.count - 1 {
Rectangle().fill(p.divider).frame(height: 1)
.padding(.horizontal, TX.Space.m)
@@ -200,7 +200,7 @@ struct SessionPickerSheet: View {
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
}

View File

@@ -69,7 +69,7 @@ struct SettingsView: View {
Section("主题app 与终端统一)") {
ForEach(TXPalette.all) { pal in
Button { theme.apply(pal) } label: { themeRow(pal) }
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
.listRowBackground(p.surface)

View File

@@ -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 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 /
///
/// `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 /

View File

@@ -6,6 +6,10 @@ import SwiftUI
// 86pt padding clipped
/// blur + + + 11
///
/// ****`TX.Elevation.floating`
/// `0 10px 30px rgba(0,0,0,.55)`
/// "" .92
private struct FloatingCapsule<Content: View>: View {
@EnvironmentObject var theme: TXThemeManager
@ViewBuilder let content: Content
@@ -17,14 +21,14 @@ private struct FloatingCapsule<Content: View>: View {
.frame(height: 34)
.background {
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.fill(p.surfaceDark.opacity(0.88))
.fill(p.surfaceDark.opacity(0.92))
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
}
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.capsule)
.strokeBorder(p.border, lineWidth: 1)
)
.shadow(color: .black.opacity(0.55), radius: 15, y: 10)
.txShadow(TX.Elevation.floating)
}
}
@@ -39,10 +43,17 @@ struct TerminalTitleCapsule: View {
var body: some View {
FloatingCapsule {
HStack(spacing: TX.Space.xs) {
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
// 沿 macOS
// windowButton
windowButton(icon: "xmark", tint: TXWindowDot.close,
label: "关闭会话", action: onClose)
// chevron 9.5pt
//
windowButton(icon: "chevron.down", tint: TXWindowDot.minimize,
label: "最小化到首页", action: onMinimize)
Rectangle().fill(p.border).frame(width: 1, height: 14)
.padding(.horizontal, 2)
.padding(.leading, 4)
.padding(.trailing, 2)
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
Text(session.displayName)
.font(TX.Font.mono(12.5, .medium))
@@ -54,7 +65,8 @@ struct TerminalTitleCapsule: View {
.foregroundStyle(p.textWeak)
.lineLimit(1)
}
// 稿 08 · 168×44 @Published
// 稿 08 · 168×44 resize
// GridBox.onGridChange session.objectWillChange
Text(gridLabel)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textFaint)
@@ -91,16 +103,30 @@ struct TerminalTitleCapsule: View {
return "\(g.cols)×\(g.rows)"
}
private func windowDot(_ color: Color, _ label: String, action: @escaping () -> Void) -> some View {
/// /
///
/// macOS 13pt ****
/// mac hover hover 13pt
/// "" mac app
///
///
/// 22pt + ****`` / `` =
/// == 14% / 26×34
private func windowButton(icon: String, tint: Color, label: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
Circle()
.fill(color)
.frame(width: 13, height: 13)
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
.frame(width: 22, height: 30)
Image(systemName: icon)
.font(.system(size: 9.5, weight: .bold))
.foregroundStyle(tint)
.frame(width: 22, height: 22)
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 7))
.overlay(
RoundedRectangle(cornerRadius: 7).strokeBorder(tint.opacity(0.45), lineWidth: 1)
)
.frame(width: 26, height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPressTight)
.accessibilityLabel(label)
}
}
@@ -287,6 +313,8 @@ struct TerminalStateOverlay: View {
}
.padding(TX.Space.l)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// "" paneArea phaseText
.transition(.opacity)
}
}
@@ -316,7 +344,7 @@ struct NoTmuxBanner: View {
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPressTight)
}
.padding(.horizontal, TX.Space.s)
.frame(height: 40)
@@ -369,38 +397,74 @@ struct TmuxInstallSheet: View {
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
.txShadow(TX.Elevation.dialog)
}
}
}
/// 34ptUI-5
struct TerminalHintBar: View {
/// Pin · / resize
///
///
/// ****iPad / window /
/// `D`
/// UI-5 线
///
/// = 34pt + home indicator **** 齿
/// 线 34pt 24pt ""
struct TerminalActionBar: View {
/// /
let context: String
let hints: [(key: String, label: String)]
let actions: [Action]
let safeBottom: CGFloat
@EnvironmentObject var theme: TXThemeManager
@Environment(\.horizontalSizeClass) private var hSize
private var p: TXPalette { theme.palette }
struct Action: Identifiable {
let id: String
let icon: String
let title: String
/// tmux
var enabled: Bool = true
let run: () -> Void
}
/// iPhone 34pt
private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions }
var body: some View {
HStack(spacing: TX.Space.s) {
Text(context)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
.lineLimit(1)
Spacer(minLength: TX.Space.m)
ForEach(hints, id: \.key) { hint in
HStack(spacing: 5) {
Text(hint.key)
.font(TX.Font.mono(11.5, .medium))
.foregroundStyle(p.text4)
Text(hint.label)
.font(TX.Font.mono(11.5))
.foregroundStyle(p.textWeak)
Spacer(minLength: TX.Space.s)
ForEach(shown) { action in
Button(action: action.run) {
HStack(spacing: 5) {
Image(systemName: action.icon)
.font(.system(size: 10.5, weight: .medium))
Text(action.title)
.font(TX.Font.mono(11.5, .medium))
}
.foregroundStyle(action.enabled ? p.text2 : p.textFaint)
.padding(.horizontal, TX.Space.xs)
.frame(height: 26)
.background(action.enabled ? p.surface : .clear,
in: RoundedRectangle(cornerRadius: TX.Radius.badge))
.overlay(
RoundedRectangle(cornerRadius: TX.Radius.badge)
.strokeBorder(action.enabled ? p.border : p.divider, lineWidth: 1)
)
// ****
.lineLimit(1)
.fixedSize()
}
.buttonStyle(.txPress)
.disabled(!action.enabled)
}
}
.padding(.horizontal, TX.Space.m)
.frame(height: TX.Layout.footHeight)
.frame(height: TX.Layout.footBand(safeBottom))
.animation(TX.Motion.standard, value: context)
}
}

View File

@@ -7,19 +7,24 @@ import SwiftUI
// - ** / W = **`esc` ``
/// + /
///
/// 96% `TX.Motion.pop`
/// `.transition(.opacity)`
/// `AppRouter.present/dismissModal`
struct TXModalScrim<Content: View>: View {
let onDismiss: () -> Void
@ViewBuilder let content: Content
var body: some View {
ZStack {
Color(hex: 0x0A0B12).opacity(0.42)
TX.Scrim.color.opacity(TX.Scrim.dialog)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
.transition(.opacity)
content
.transition(TX.Motion.pop)
}
.transition(.opacity)
}
}
@@ -60,7 +65,7 @@ struct CloseConfirmDialog: View {
RoundedRectangle(cornerRadius: TX.Radius.dialog)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
.txShadow(TX.Elevation.dialog)
}
}
@@ -102,7 +107,7 @@ struct CloseConfirmDialog: View {
.font(TX.Font.mono(12, confirmingKill ? .semibold : .regular))
.foregroundStyle(p.danger)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
Spacer()
TXButton(title: "取消", action: onCancel)
@@ -129,7 +134,7 @@ struct CloseConfirmDialog: View {
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
)
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
}
}
@@ -201,7 +206,7 @@ struct SplitMenuView: View {
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
.strokeBorder(p.borderStrong, lineWidth: 1)
)
.shadow(color: .black.opacity(0.7), radius: 40, y: 30)
.txShadow(TX.Elevation.dialog)
}
}
@@ -273,7 +278,7 @@ struct SplitMenuView: View {
.padding(.horizontal, TX.Space.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.txPress)
.disabled(disabled)
}
}

View File

@@ -128,15 +128,20 @@ final class TmuxByteChannel: @unchecked Sendable {
final class GridBox: @unchecked Sendable {
private let lock = NSLock()
private var cols = 80, rows = 24, cellW = 0, cellH = 0
/// **** resize
/// TerminalSession objectWillChange
var onGridChange: (@Sendable () -> Void)?
var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
}
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
lock.lock()
let changed = self.cols != max(1, cols) || self.rows != max(1, rows)
self.cols = max(1, cols); self.rows = max(1, rows)
if cellW > 0 { self.cellW = cellW } // cell
if cellH > 0 { self.cellH = cellH }
lock.unlock()
if changed { onGridChange?() }
}
}
@@ -371,6 +376,11 @@ final class TerminalSession: ObservableObject, Identifiable {
self.state = state
self.gate = OutputGate(session: session)
// 线 screenGrid
screenGrid.onGridChange = { [weak self] in
Task { @MainActor in self?.objectWillChange.send() }
}
// tmux enter controller+bytes
let ch = tmuxChannel
Task { @MainActor [weak self] in

View File

@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
import GhosttyTerminal
import GhosttyTheme
@@ -257,6 +258,9 @@ enum TX {
static let dialog: CGFloat = 15
/// chrome/
static let capsule: CGFloat = 11
/// pane ** app chrome **
/// `TerminalScreen.paneShape`
static let pane: CGFloat = 6
//
static let chip: CGFloat = 9
@@ -291,8 +295,94 @@ enum TX {
static let minTouch: CGFloat = 44
/// pane
static let capsuleInset: CGFloat = 86
/// ****线 y
/// **** / / 齿线
/// 线 y home indicator
/// chrome `TX.Layout.footBand`
static let footHeight: CGFloat = 34
/// = + ****
/// home indicator
static func footBand(_ safeBottom: CGFloat) -> CGFloat { footHeight + safeBottom }
/// ****
///
/// iPad 14/
/// 30
static func capsuleTop(_ safeTop: CGFloat) -> CGFloat {
safeTop > 30 ? safeTop - 12 : 14
}
}
/// 稿 200260ms `easeOut`
///
/// 线 URL
/// 退
enum Motion {
///
@MainActor static var standard: Animation {
.easeOut(duration: (reduceMotion ? 0.12 : 0.24) * slowFactor)
}
///
@MainActor static var quick: Animation {
.easeOut(duration: (reduceMotion ? 0.08 : 0.14) * slowFactor)
}
@MainActor static var reduceMotion: Bool { UIAccessibility.isReduceMotionEnabled }
/// `-txSlowMotion 1` ×12
///
/// `simctl io screenshot` 240ms
/// ""退 DEBUG
private static var slowFactor: Double {
#if DEBUG
UserDefaults.standard.bool(forKey: "txSlowMotion") ? 12 : 1
#else
1
#endif
}
///
@MainActor static func slide(_ edge: Edge) -> AnyTransition {
reduceMotion ? .opacity : .move(edge: edge)
}
/// + 96% 100%""
@MainActor static var pop: AnyTransition {
reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.96))
}
/// 97% ""
@MainActor static var pushBack: AnyTransition {
reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 1.02))
}
}
/// **稿** chrome
/// 稿 CSS 3080px / .55.7
/// 穿""
struct Shadow {
let opacity: Double
let radius: CGFloat
var x: CGFloat = 0
var y: CGFloat = 0
}
enum Elevation {
/// chrome /
static let floating = Shadow(opacity: 0.22, radius: 7, y: 2)
///
static let panel = Shadow(opacity: 0.28, radius: 16, x: 4)
/// /
static let dialog = Shadow(opacity: 0.34, radius: 16, y: 6)
///
static let card = Shadow(opacity: 0.16, radius: 4, y: 2)
static let cardFocused = Shadow(opacity: 0.24, radius: 8, y: 3)
}
///
enum Scrim {
static let color = Color(hex: 0x0A0B12)
/// ""
static let dialog: Double = 0.5
///
static let sidebar: Double = 0.42
}
enum Font {

View File

@@ -242,7 +242,8 @@ enum UIPreviewFixture {
manager.focus(s)
router.enter(s.id)
case "sidebar":
if path.dropFirst().first == "pin" { router.togglePin() } else { router.toggleSidebar() }
// pin toggle
router.toggleSidebar()
default:
return false
}

View File

@@ -286,6 +286,79 @@
- **原生终端** 状态胶囊 `direct | ssh | 原生窗口 · 不经 tmux`
- **完整真实路径**`-txOpenAsHost`)→ 首页正在连接 192.168.99.83」→ 首页上弹选择器标题连接到 …」)→ `main` 进终端页 `tmux main` + 轨道 window `0/1/2/3/4`
### 12.3h 质感走查:动效 / 阴影 / 圆角 / 安全区用户第三轮走查2026-07-25
用户反馈四类瑕疵**风格保持不变只修质感**浮层"特别难展开和消失"缺过渡胶囊阴影太重不够扁平终端小圆角与 iPad 屏幕大圆角打架全屏时右上角被吃掉)、底部混乱没考虑安全区逐条修完并补了同类问题
**1. 动效令牌化 + 补齐缺失过渡(`TX.Motion`**
- `TX.Motion``standard`(0.24 easeOut) / `quick`(0.14) / `slide(edge)` / `pop`(0.961 缩放+淡入) / `pushBack`(首页推远)。**统一响应减弱动态」**`UIAccessibility.isReduceMotionEnabled` 时长减半 + 位移退化为纯淡入淡出)。
- **根因**`AppRouter` 里只有侧边栏包了 `withAnimation``route` / `modal` 的变更**裸着改** 视图上写好的 `.transition` 拿不到动画事务全退化成硬切现在 `enter`/`goHome`/`present`/`dismissModal`/`toggleSidebar`/`togglePin` **全部经 `animated{}`**点击 / 快捷键 / 无头 URL 三条入口自动同一手感
- `TXModalScrim` 拆成两段转场遮罩 `.opacity` + 卡片 `TX.Motion.pop`整块套一个 `.transition(.opacity)` 只是一起变淡没有弹出感
- tmux 横幅显隐`TmuxInstallSheet``TerminalStateOverlay`连上后淡出)、提示条文案随三态切换会话卡增减选中态/状态点变色
- **按压反馈** `TXPressStyle`缩放 + 压暗 140ms替换全部 `.buttonStyle(.plain)`深色界面里 `.plain` 完全没有按下反馈主机卡/会话卡这种"点下去要等几百毫秒"的入口尤其伤手感
- 会话卡改成**整张一个 Button**卡脚回到会话 →」退化为文字提示同一动作做成嵌套 Button 只会抢命中区 + 两套按压反馈)。
- **`-txSlowMotion 1`**DEBUG把所有动效 ×12`simctl io screenshot` 自身要几百毫秒抓不到 240ms 的中间帧没这个开关就无法用截图证明"动效真的在跑"。验证方法连发三张截图采样同一像素应得到**三个不同的中间值**硬切时三张完全一样)。
**2. 阴影扁平化(`TX.Elevation`,刻意偏离设计初稿)**
设计稿给的是 CSS 胶囊 `0 10px 30px rgba(0,0,0,.55)`对话框 `0 30px 80px .7`在深色底上会糊成一团脏斑 —— 半透胶囊叠重投影像"漏墨"终端文字从投影里穿过去改为`floating`(.22/7/y2) · `panel`(.28/16/x4) · `dialog`(.34/16/y6) · `card`(.16/4/y2) · `cardFocused`(.24/8/y3)分层交给描边与材质胶囊底不透明度提到 .92 补偿统一经 `View.txShadow(_:)`视图里不许再手写 `.shadow`
顺带补上**遮罩态侧边栏浮层一直漏掉的投影**设计要求 `24px 0` `panel` 是唯一带 x 位移的)。
**3. 圆角不再与屏幕圆角打架(`paneShape`**
终端右上角贴着屏幕物理边缘那里设备本身是 ~22pt 大圆角 —— 再画 6pt 小圆角必然被"吃掉"小圆角整段落在屏幕圆角之外切口处露出底色三角)。改为 `UnevenRoundedRectangle`**只在朝 chrome 的角收圆角**左上/左下/右下 6pt顶边与右边**直接铺到物理边缘不收角不描边**由屏幕自己的圆角收口Stage Manager 小窗同理窗圆角接管)。
- 描边用负 padding`top:-1, trailing:-1`把顶边/右边那两条**推出屏外**留着就是贴在屏幕边上的一圈多余框线
- 顺带取消顶边/右边的 2pt 2pt **pane 之间**的缝不该出现在屏幕边缘终端格子从 163×48 涨到 164×50
**4. 安全区chrome 铺满物理屏,底栏自己让位(`TX.Layout.footBand` / `capsuleTop`**
- 原来 `.ignoresSafeArea(edges: .top)` 只放开顶边 底部内容停在安全区上沿屏幕最下面留一条 24pt 的空带"没铺到底"**颜色型 `.background` 默认会溢进安全区**于是轨道底色铺满内容却停在 24pt 之上一眼就是没对齐
- 现在整屏 `.ignoresSafeArea()`底部区高度 = `footHeight(34) + safeBottom`,内容**在其中居中** 既贴住物理底边又让开 home indicator提示条 / 侧边栏脚 / 轨道齿轮三者中心线同一条 y995上方分割线仍同在 964
- **顺带修掉一个状态跳变**遮罩态面板铺满全高Pin 态被安全区推高 24pt一钉住面板脚就跳一下现在两态完全一致
- 安全区读数经新 `TXScreenMetrics``GeometryReader` 必须在 `ignoresSafeArea` **之前**否则读到 0)。
- 胶囊顶距`capsuleTop(safeTop)` —— iPad 隐藏状态栏后无遮挡用设计值 14灵动岛/刘海机型safeTop>30按硬件下移。**iPhone 17 Pro 模拟器实测**胶囊落在岛下方、底栏在 indicator 之上。
**5. 同类问题(走查中发现,一并修)**
- **会话卡不等高**(一排卡上下边对不齐):`TXTerminalMirror` 的行堆叠有内容驱动的最小高度,外层 `.frame(height:)` 压不住它 —— `.frame(height:)` 只是给了个框,**不想撑满的子视图会按自身理想高度居中放进去**。改为把行堆叠放进 `Color.clear` 的 overlayoverlay 不参与父视图定尺),尺寸完全由外部决定,溢出的旧行被 `clipped()` 切掉(正是想要的:底对齐留最新输出)。
- 会话卡从不换行的 `HStack``LazyVGrid`(最多 3 列):第 4 条会话进来原先会把所有卡压扁。高度由死写 560 改为 `min(560, max(300, 可用高度×0.56))`,竖屏/分屏/小 iPad 上不再把「全部主机」挤出屏外。
- 首页滚动内容铺到物理底边(`ignoresSafeArea(.bottom)` + 内容 padding 补 safeBottom末行能完整滚到 indicator 之上。
- 窄窗口适配:主机网格 4→2 列、左右留白 40→20、搜索框 220 固定改可伸缩、提示条只留 2 条4 条在 iPhone 上折行把 34pt 带撑破,且强制单行 `lineLimit(1).fixedSize()`)。
- 胶囊红/黄圆点命中区 22×30 → 26×3413pt 视觉不变):关闭这种误触代价高的动作不该给这么小的目标。
- 搜索框补聚焦态描边(键盘焦点可见,无障碍底线)。
- 删掉 `HomeView` 里**嵌套重复定义**的 `ConnectingRow`(与文件级那份一字不差,编译能过但是死代码)。
- 遮罩浓度令牌化 `TX.Scrim`(对话框 .5 / 侧边栏 .42),不再散落 `Color(hex: 0x0A0B12)`
**验收**iPad 13″ 横屏逐屏对拍(终端 / Pin / 遮罩 / 关闭确认 / 分屏菜单 / 首页)+ iPhone 17 Pro 竖屏(灵动岛 + indicator像素采样确认 —— 右上角 `(1365,0)` 为终端底色(不再是底色三角)、两态面板脚同在 964/982991、三张会话卡同为 166..710、遮罩把终端文字亮度 185→97TXCore 45 tests 绿。
### 12.3i 触控化与标签规范(用户第四轮走查 + **真机部署**2026-07-26
1. **首页会话卡固定 3 格 4:3**`LazyVGrid` 列数写死 3不再 `min(3, count)`),不足 3 条自然从左往右排、卡片尺寸不随会话数跳动;高度改 `.aspectRatio(4/3, contentMode: .fit)`,删掉按屏高算高度那段。
2. **技术标签规范(新 `TXTech`**
- **拼写**:照各自官方写法 —— 缩写全大写(`SSH`)、项目名按作者写法(`mosh`/`tmux`/`tsnet` 本来小写)、产品名照抄(`macOS`/`TrueNAS`)。**`TXSectionLabel` 不再 `.uppercased()`** —— 它会嵌主机名/会话名,之前渲染成 `TMUX · MAC-STUDIO · DEV` 把名字全改错了;轨道标签同步改 `tmux`
- **配色**:传输协议冷色区分(`mosh` teal / `SSH` blue二者互斥所以一行只出现一个`tsnet` 用 app 强调色(招牌能力);凭据名回中性(那是用户命的名,不是技术标签,避免一排全彩色)。主机卡图标染**发行版品牌色**Ubuntu 橙→peach、Debian/树莓→red、Fedora/TrueNAS→blue、Arch→teal、macOS→中性银认不出的回中性。徽标只染文字+描边、底色 12%。
3. **关闭/最小化按钮重做**:原 macOS 交通灯式 13pt 纯色圆点三处不合适 —— 纯色块不说明自己干什么mac 靠 hover 浮字形,触屏没有 hover、13pt 命中目标太小、圆点是 mac 窗口语汇与本 app 圆角矩形语汇不搭。改 **22pt 圆角方块 + 常显字形**`✕` / `⌄`),保留红=关闭、琥珀=最小化的颜色语义,底色 14% 不铺满,命中 26×34间距放宽。`⌄` 而非双向内收箭头:后者在 9.5pt 下糊成一团,且向下正好对上设计的「下滑最小化」手势。
4. **侧边栏自身的平移动画(新 `leadingColumn`**:原来是 `if pinned { 面板 } else { 轨道 }` —— 两者**互换**只能硬切,用户看到面板"消失"、轨道"出现",没有平移。改为轨道**常驻**在底、面板叠在上列宽随三态动画56/56/288+ `clipped()`:收起时面板向左滑出并被裁掉、轨道从背后露出。慢放实测 1.2s 处列宽正处于中间态≈140pt确认逐帧插值而非硬切。
5. **去掉命令面板**:底部 `⌘K 命令面板` 与首页搜索框的 `⌘K` 提示一并删除(快捷键体系属 UI-5未实现的提示等于假承诺
6. **底部改动作条 `TerminalActionBar`**(原 `TerminalHintBar`iPad 大概率没接键盘,「竖分屏 / 新建 window / 回首页」只写成 `⌘D` 文字提示等于这些功能对触控用户不存在 → 改成**常驻可点按钮**(图标 + 文案26pt 高胶囊)。非 tmux 会话下前两个灰显但保留(不隐藏,免得布局跳)。左侧上下文文案改为**真实会话状态**`mac-studio · tmux dev · 3 windows`)—— 此前写死「已连接妙控键盘 · 附加行已收起」,没接键盘时那是假话。措辞与侧边栏统一为「新建 window」。
**真机部署(用户体验用)**`-configuration Release -destination 'id=<iPad UDID>' -allowProvisioningUpdates` 自动签名Apple Development: JIANKUAN YANG / Team J75A6S5938`xcrun devicectl device install app` + `process launch`。**Release 里所有 `#if DEBUG` 的 fixture / `terminalx://ui` / `-txSlowMotion` 全部编译不进去**,所以真机跑的必然是真实数据路径(首次运行落在「还没有主机」空状态);签名后 Keychain/Secure Enclave 可用,凭据走系统钥匙串而非文件回退。
### 12.3j 侧边栏三态动画重构统一视图身份用户第五轮反馈「动画混乱」2026-07-26
用户判词:三态切换动画混乱;收起态应理解为「展开态在左侧裁切一部分」,允许改 UI 让两态最左侧图标同尺寸同位置。
**根源**12.3i 的 leadingColumn 只修了一半):① 轨道 `RailView` 与面板 `SidebarPanel` 是两套视图,元素尺寸/位置互不对应logo 34/30、头像 38/26、徽标 34×30/26×22纵向节奏也不同→ 就算列宽在动画,内容仍是两张不相干的画面交叠;② 遮罩态面板挂全屏 overlay、Pin 态面板是 leadingColumn 里**另一个实例** → overlay⇄pinned 切换视图身份互换,一个滑出一个滑入互相打架;③ 遮罩/投影靠 `if` 插拔。
**重构**`RailSidebar.swift` 整文件重写为 `TerminalSidebar``ContentView.screen` 重组):
1. **三态唯一视图**:内容恒按 288pt 全宽排版;收起 = 容器宽度收到 railWidth + `clipped()`(字面实现「左侧裁切」)。最左 railWidth 为**图标列**logo 30 / 裸图标 17pt / 头像 30 / window 徽标 26×22 / 状态点 / 开合键 / 齿轮,两态同尺寸同位置;右侧明细(标题/副行/快捷键/选中底)`opacity(expanded)` 淡入淡出。
2. **行对应关系**:轨道的 24pt 短分隔线 ⇄ 面板的分组标题(同一行两种密度,交叉淡化);轨道 `tmux` 迷你标签 ⇄ `tmux · 主机 · 会话` 分组标题;搜索裸图标 ⇄ 搜索框放大镜位置几乎重合底部统一为「tailnet 状态行 / 开合键行 / 分割线+齿轮 footBand」两态元素不增不减。
3. **组合**:舞台前的占位列只在 Pin 态推到 288保持「钉住/解除各 resize 一次」);遮罩与侧边栏**常驻**在同一 overlay 里,遮罩动 opacity、投影(`Elevation.panel`)动 opacity —— 遮罩⇄Pin 时侧边栏本体**一动不动**,只有遮罩淡出+舞台平移。
4. **新坑:`clipped()` 只裁绘制不裁触摸** —— 收起态下整行按钮被裁掉的明细区会伸进终端抢点击。整行按钮用 `LeadingHitShape`Animatable `contentShape`,命中区=当前可见宽度);纯明细控件(搜索框/Pin/新建 window再加 `allowsHitTesting(expanded)`
5. **验证**fixture + ×12 慢放 + 逐帧像素采样):收起→遮罩(遮罩逐帧变暗 30→29→22、遮罩→收起面板前沿扫过采样点+遮罩渐浅、遮罩→Pin胶囊中间帧 709px vs 终值 826px舞台平移中、Pin→收起427→362均有中间帧无硬切。
**用户复核后追加裁决(同日)——砍掉悬浮态,只留两态**Pin→收起的"明细淡出 + 终端从右盖上来"效果被点名保留,展开即反向。
- `SidebarState` 只剩 `collapsed|pinned``togglePin`/`dismissOverlaySidebar`/遮罩/浮层投影全删;`TerminalSidebar` 直接进 HStack 参与布局无占位列开合就是它的宽度动画56⇄288各触发一次 resize点终端**不再**收起侧边栏(固定是持久态)。
- **开合入口收敛**(此前顶部/底部两个收起键冲突、底部那枚缩在左下角不协调Logo 位=开合主入口(收起态显 » 展开键、展开态还原 `` Logo同框同位交叉淡化——用户定稿将来 App Logo 设计与展开按钮一体);头部 « 收起键保留底部开合行删除Pin 键删除(两态下无意义)。`terminalx://ui/sidebar/pin` 兼容保留=toggle。
- **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox``onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。
- 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pteaseOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。
### 12.4 首版明确推迟Fable 裁决,勿在 UI-3~6 里偷偷做)
`mosh <RTT>` 实时数字(需改 vendor/mosh iosclient 导出 SRTT属传输层· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」交给 tmux relayout· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关ghostty 无运行时 mutation灰显占位
</content>