From 5707d70dff3c1fb3d89c335fb51adc0a5cd0f235 Mon Sep 17 00:00:00 2001 From: kid Date: Sun, 26 Jul 2026 00:55:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BE=A7=E8=BE=B9=E6=A0=8F=E4=B8=A4?= =?UTF-8?q?=E6=80=81=E9=87=8D=E6=9E=84=EF=BC=88=E7=BB=9F=E4=B8=80=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E8=BA=AB=E4=BB=BD=EF=BC=89+=20=E7=AC=AC=E5=9B=9B?= =?UTF-8?q?=E8=BD=AE=E8=A7=A6=E6=8E=A7=E5=8C=96=E8=B5=B0=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 侧边栏(真机验证): - 砍掉悬浮态,只留收起⇄固定展开;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 --- CLAUDE.md | 24 +- apps/TerminalX/iOS/AppRouter.swift | 59 +- apps/TerminalX/iOS/ContentView.swift | 163 ++-- apps/TerminalX/iOS/HomeView.swift | 212 +++-- apps/TerminalX/iOS/HostsView.swift | 2 +- apps/TerminalX/iOS/RailSidebar.swift | 811 ++++++++++---------- apps/TerminalX/iOS/SessionPickerSheet.swift | 6 +- apps/TerminalX/iOS/SettingsView.swift | 2 +- apps/TerminalX/iOS/TXParts.swift | 163 +++- apps/TerminalX/iOS/TerminalChrome.swift | 122 ++- apps/TerminalX/iOS/TerminalDialogs.swift | 19 +- apps/TerminalX/iOS/TerminalSession.swift | 10 + apps/TerminalX/iOS/Theme.swift | 92 ++- apps/TerminalX/iOS/UIPreviewFixture.swift | 3 +- docs/HANDOFF.md | 73 ++ 15 files changed, 1086 insertions(+), 675 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e55f02a..6aa236a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 可用(不再回退文件后端)。给用户体验用 Release(Debug 的 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 [-- <启动参数>]` 一步完成装/启/截图/修正方向。 - **`-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/` / `sidebar/toggle|pin`。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。 +- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/` / `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`**:所有动效 ×12(DEBUG)。`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 layout(zoom 免费);SwiftUI identity 只用 paneID(布局变不重建 surface)。新 pane 在 `capture-pane` %end 前丢弃其 %output(tmux 单线程串行保证快照不缺不重)。 - **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` 为准**。 diff --git a/apps/TerminalX/iOS/AppRouter.swift b/apps/TerminalX/iOS/AppRouter.swift index fac341a..e858803 100644 --- a/apps/TerminalX/iOS/AppRouter.swift +++ b/apps/TerminalX/iOS/AppRouter.swift @@ -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? - /// 状态切换动效:设计定稿 200–260ms easeOut(侧边栏浮层从左滑入 + 遮罩淡入)。 - /// 所有侧边栏状态变更都经这里,键盘快捷键与点击拿到同一条曲线。 - static let sidebarAnimation = Animation.easeOut(duration: 0.24) - - private func animateSidebar(_ change: () -> Void) { - withAnimation(Self.sidebarAnimation, change) + /// 状态切换动效统一走 `TX.Motion.standard`(设计定稿 200–260ms 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 } } /// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 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 } } } diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index 376b4a1..06ce7c5 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -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% 遮罩,点终端任意处收起。 - // 动效:遮罩淡入 + 面板从左滑入(200–260ms 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) // 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 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() }, + ] } } diff --git a/apps/TerminalX/iOS/HomeView.swift b/apps/TerminalX/iOS/HomeView.swift index 26ab866..fb1182e 100644 --- a/apps/TerminalX/iOS/HomeView.swift +++ b/apps/TerminalX/iOS/HomeView.swift @@ -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) } } diff --git a/apps/TerminalX/iOS/HostsView.swift b/apps/TerminalX/iOS/HostsView.swift index 1dec430..4ddbb5d 100644 --- a/apps/TerminalX/iOS/HostsView.swift +++ b/apps/TerminalX/iOS/HostsView.swift @@ -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 { diff --git a/apps/TerminalX/iOS/RailSidebar.swift b/apps/TerminalX/iOS/RailSidebar.swift index 5bea47b..5d44d46 100644 --- a/apps/TerminalX/iOS/RailSidebar.swift +++ b/apps/TerminalX/iOS/RailSidebar.swift @@ -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 gap;window 行 = 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 IP(Up 后缓存的 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 panes(cwd 来自 #{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) } } diff --git a/apps/TerminalX/iOS/SessionPickerSheet.swift b/apps/TerminalX/iOS/SessionPickerSheet.swift index d485931..54280cc 100644 --- a/apps/TerminalX/iOS/SessionPickerSheet.swift +++ b/apps/TerminalX/iOS/SessionPickerSheet.swift @@ -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) } } } diff --git a/apps/TerminalX/iOS/SettingsView.swift b/apps/TerminalX/iOS/SettingsView.swift index 82a10c5..ceb436c 100644 --- a/apps/TerminalX/iOS/SettingsView.swift +++ b/apps/TerminalX/iOS/SettingsView.swift @@ -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) diff --git a/apps/TerminalX/iOS/TXParts.swift b/apps/TerminalX/iOS/TXParts.swift index f769be0..c57b68a 100644 --- a/apps/TerminalX/iOS/TXParts.swift +++ b/apps/TerminalX/iOS/TXParts.swift @@ -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: 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 属性已丢),故按行首符号/常见片段上色, diff --git a/apps/TerminalX/iOS/TerminalChrome.swift b/apps/TerminalX/iOS/TerminalChrome.swift index fde2c63..6ea79ee 100644 --- a/apps/TerminalX/iOS/TerminalChrome.swift +++ b/apps/TerminalX/iOS/TerminalChrome.swift @@ -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: View { @EnvironmentObject var theme: TXThemeManager @ViewBuilder let content: Content @@ -17,14 +21,14 @@ private struct FloatingCapsule: 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) } } } -/// 底部提示条(34pt):接硬件键盘时只留这条快捷键清单;触控时由键盘附加行替换(UI-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) } } diff --git a/apps/TerminalX/iOS/TerminalDialogs.swift b/apps/TerminalX/iOS/TerminalDialogs.swift index b948a81..506b1ca 100644 --- a/apps/TerminalX/iOS/TerminalDialogs.swift +++ b/apps/TerminalX/iOS/TerminalDialogs.swift @@ -7,19 +7,24 @@ import SwiftUI // - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。 /// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。 +/// +/// 遮罩淡入、卡片从 96% 长出来(`TX.Motion.pop`)—— 两段分开写才有层次;整块套一个 +/// `.transition(.opacity)` 的话卡片只是跟着变淡,弹出感全无。 +/// 动画事务由 `AppRouter.present/dismissModal` 提供。 struct TXModalScrim: 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) } } diff --git a/apps/TerminalX/iOS/TerminalSession.swift b/apps/TerminalX/iOS/TerminalSession.swift index 4018d99..05837ca 100644 --- a/apps/TerminalX/iOS/TerminalSession.swift +++ b/apps/TerminalX/iOS/TerminalSession.swift @@ -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 diff --git a/apps/TerminalX/iOS/Theme.swift b/apps/TerminalX/iOS/Theme.swift index f6433cb..1fece8b 100644 --- a/apps/TerminalX/iOS/Theme.swift +++ b/apps/TerminalX/iOS/Theme.swift @@ -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 + } + } + + /// 动效。设计定稿:状态切换 200–260ms `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 值(30–80px / .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 { diff --git a/apps/TerminalX/iOS/UIPreviewFixture.swift b/apps/TerminalX/iOS/UIPreviewFixture.swift index 909cbde..097d113 100644 --- a/apps/TerminalX/iOS/UIPreviewFixture.swift +++ b/apps/TerminalX/iOS/UIPreviewFixture.swift @@ -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 } diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index dce1a31..3b16e27 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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.96→1 缩放+淡入) / `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。提示条 / 侧边栏脚 / 轨道齿轮三者中心线同一条 y(995),上方分割线仍同在 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` 的 overlay(overlay 不参与父视图定尺),尺寸完全由外部决定,溢出的旧行被 `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×34(13pt 视觉不变):关闭这种误触代价高的动作不该给这么小的目标。 +- 搜索框补聚焦态描边(键盘焦点可见,无障碍底线)。 +- 删掉 `HomeView` 里**嵌套重复定义**的 `ConnectingRow`(与文件级那份一字不差,编译能过但是死代码)。 +- 遮罩浓度令牌化 `TX.Scrim`(对话框 .5 / 侧边栏 .42),不再散落 `Color(hex: 0x0A0B12)`。 + +**验收**:iPad 13″ 横屏逐屏对拍(终端 / Pin / 遮罩 / 关闭确认 / 分屏菜单 / 首页)+ iPhone 17 Pro 竖屏(灵动岛 + indicator);像素采样确认 —— 右上角 `(1365,0)` 为终端底色(不再是底色三角)、两态面板脚同在 964/982–991、三张会话卡同为 166..710、遮罩把终端文字亮度 185→97;TXCore 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=' -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→290pt(easeOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。 + ### 12.4 首版明确推迟(Fable 裁决,勿在 UI-3~6 里偷偷做) `mosh ` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。