feat: 侧边栏两态重构(统一视图身份)+ 第四轮触控化走查
侧边栏(真机验证): - 砍掉悬浮态,只留收起⇄固定展开;TerminalSidebar 单一视图身份, 内容恒按 288pt 排版、收起=左侧裁切(clipped 宽度动画), 图标列两态同尺寸同位置,明细淡入淡出,终端随宽度盖上/让开 - 开合入口收敛:Logo 位(收起态=»展开键/展开态=Logo)+ 头部«; 删底部开合键与 Pin;LeadingHitShape 修 clipped 不裁触摸 - 修 GridBox 从不通知 UI:onGridChange→objectWillChange, 标题胶囊格子数随开合实时刷新 触控化走查(上一轮,真机部署验证): - 窗口按钮 13pt 色点→22pt 圆角方块常显字形;动作条改可点按钮 - TXTech 拼写/配色规范(SSH/mosh/tmux 官方写法+品牌色映射) - 首页会话卡固定 3 列 4:3;去掉未实现的 ⌘K 提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
24
CLAUDE.md
24
CLAUDE.md
@@ -42,6 +42,18 @@ xcodebuild -project TerminalX.xcodeproj -scheme TerminalX \
|
|||||||
-clonedSourcePackagesDirPath /tmp/tx-spm build
|
-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 截图取证)
|
## 无头验证(本环境无 GUI,用 simctl 截图取证)
|
||||||
iPad Pro 13(M5) 模拟器 UDID:`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。
|
iPad Pro 13(M5) 模拟器 UDID:`2EA9055E-4557-4B20-82AF-56A68AC1D454`(变了用 `xcrun simctl list devices`)。
|
||||||
```sh
|
```sh
|
||||||
@@ -65,9 +77,10 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具
|
|||||||
**UI 改造期无头验证(推荐走脚本)**:`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
|
**UI 改造期无头验证(推荐走脚本)**:`scripts/tx-shot.sh <out.png> [-- <启动参数>]` 一步完成装/启/截图/修正方向。
|
||||||
- **`-txLandscape 1`**:app 侧 `requestGeometryUpdate` 转横屏(设计基准 iPad Pro 13″ 1366×1024)。无 GUI 时模拟器帧缓冲恒竖屏,故脚本截完 `sips -r 90` 转回来(内容已横向渲染)。
|
- **`-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` 互斥。
|
- **`-txUIFixture 1`**:造 3 条**假会话**(mac-studio/ubuntu-cn2/nas-home,含 direct/relay/重连三种状态)灌示意输出进 in-memory 终端,**不建任何传输连接**。设计稿的首页会话卡/轨道多头像/状态徽标都要多会话才有内容,用真机连接验证太慢。见 `UIPreviewFixture.swift`(DEBUG only),与 `-txHost` 互斥。
|
||||||
- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle|pin`。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
|
- **`terminalx://ui/<动作>`**(`xcrun simctl openurl`):无头驱动导航与浮层——`home` / `minimize` / `close`(弹确认) / `close-now`(直接关) / `split/v|h`(分屏菜单) / `dismiss` / `session/<n>` / `sidebar/toggle`(`sidebar/pin` 兼容保留=toggle,悬浮态已砍)。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。
|
||||||
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E` 与 `#11121C` 目测分不出,必须采样像素。
|
- **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E` 与 `#11121C` 目测分不出,必须采样像素。
|
||||||
- **坑:`statusBarHidden` 不能挂在 `TerminalScreen`**(它常驻挂载,会连首页一起隐藏)→ 挂 `RootView` 按 `router.route` 判断。
|
- **坑:`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 对拍)。
|
- 设计稿本体在 `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 过期,深挂起需真机锁屏补测。**
|
**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/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。
|
||||||
- `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。
|
- `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`。
|
- 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)。
|
- `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`。
|
- `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 多 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) 单消费者。
|
- **tmux 网关字节必须单消费者保序**:多个 `Task{@MainActor}` 到主线程顺序无保证,多 pane 高吞吐会打碎 % 协议 → 用 `TmuxByteChannel`(AsyncStream) 单消费者。
|
||||||
- **导航不得销毁会话**:终端**不能**走 `fullScreenCover`——① surface 一旦从视图树摘除,ghostty 释放 grid,内容全丢;② matchedGeometry(最小化缩回首页卡)要求两端同层级,独立 presentation 层做不到。故 `SessionCanvas` 把所有会话 ZStack 常驻挂载、只切 opacity + `allowsHitTesting`(沿用 tmux 切 tab 已验证的手法),画布恒全屏 → 切路由不触发 PTY resize。
|
- **导航不得销毁会话**:终端**不能**走 `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 颜色。
|
- **首页缩略卡不许再开 surface**:一个 ghostty surface 只有一个 IOSurfaceLayer,同处两地/离屏 Metal 渲染是已修过的泄漏雷区。缩略卡走 `TerminalSession.captureSnapshot()`(`readViewportText()` 抓尾部若干行)+ `TXTerminalMirror` 文本渲染(底对齐),代价只是丢 ANSI 颜色。
|
||||||
- **嵌套 ObservableObject 不冒泡**:`SessionManager` 的 `@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
|
- **嵌套 ObservableObject 不冒泡**:`SessionManager` 的 `@Published sessions` 不会因某条会话内部 `@Published` 变化而发布。凡显示会话状态的行/卡片都要抽成独立视图用 `@ObservedObject var session:` 直接订阅(见 `SessionSidebarRow`/`SessionStatusRow`)。
|
||||||
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
|
- SourceKit "No such module" 是索引噪声,**以 `swift build`/`xcodebuild` 为准**。
|
||||||
|
|||||||
@@ -14,21 +14,19 @@ enum AppRoute: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 侧边栏三态。同一条栏,元素不增不减,只是展示密度不同。
|
/// 侧边栏两态(用户裁决砍掉悬浮态:展开即固定)。同一条栏,元素不增不减,只是展示密度不同。
|
||||||
enum SidebarState: Equatable {
|
enum SidebarState: Equatable {
|
||||||
/// 56pt 轨道:只显图标与两字缩写
|
/// 56pt 轨道:只显图标与两字缩写
|
||||||
case collapsed
|
case collapsed
|
||||||
/// 288pt 面板**盖住**轨道,右侧终端压遮罩;点终端任意处收起。终端不 reflow、不触发 PTY resize。
|
/// 288pt 固定在左侧:终端变窄。开合各触发一次 resize。
|
||||||
case overlay
|
|
||||||
/// 288pt 钉在左侧:遮罩消失,终端变窄。**只在钉住/解除时各 resize 一次**。
|
|
||||||
case pinned
|
case pinned
|
||||||
|
|
||||||
var isExpanded: Bool { self != .collapsed }
|
var isExpanded: Bool { self == .pinned }
|
||||||
|
|
||||||
/// 该状态下侧边栏占据的布局宽度(overlay 是浮层,不占位)。
|
/// 该状态下侧边栏占据的布局宽度。
|
||||||
var layoutWidth: CGFloat {
|
var layoutWidth: CGFloat {
|
||||||
switch self {
|
switch self {
|
||||||
case .collapsed, .overlay: TX.Layout.railWidth
|
case .collapsed: TX.Layout.railWidth
|
||||||
case .pinned: TX.Layout.sidebarWidth
|
case .pinned: TX.Layout.sidebarWidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,47 +49,34 @@ final class AppRouter: ObservableObject {
|
|||||||
/// 当前模态浮层(关闭确认 / 分屏菜单)。
|
/// 当前模态浮层(关闭确认 / 分屏菜单)。
|
||||||
@Published var modal: AppModal?
|
@Published var modal: AppModal?
|
||||||
|
|
||||||
/// 状态切换动效:设计定稿 200–260ms easeOut(侧边栏浮层从左滑入 + 遮罩淡入)。
|
/// 状态切换动效统一走 `TX.Motion.standard`(设计定稿 200–260ms easeOut,响应「减弱动态」)。
|
||||||
/// 所有侧边栏状态变更都经这里,键盘快捷键与点击拿到同一条曲线。
|
///
|
||||||
static let sidebarAnimation = Animation.easeOut(duration: 0.24)
|
/// **路由 / 侧边栏 / 浮层的每一次变更都必须包在这条曲线里**,否则视图上的 `transition`
|
||||||
|
/// 拿不到动画事务就退化成硬切 —— 首页 ⇄ 终端、关闭确认、分屏菜单曾都是这么"啪"地跳出来的。
|
||||||
private func animateSidebar(_ change: () -> Void) {
|
/// 收在 router 里的另一个好处:点击、⌘ 快捷键、无头 URL 三条入口天然同一手感。
|
||||||
withAnimation(Self.sidebarAnimation, change)
|
private func animated(_ change: () -> Void) {
|
||||||
|
withAnimation(TX.Motion.standard, change)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。
|
/// 进入某会话(点会话卡 / 侧边栏会话行 / 新建连接后)。恢复焦点由会话自身持有。
|
||||||
func enter(_ sessionID: UUID) {
|
func enter(_ sessionID: UUID) {
|
||||||
route = .terminal(sessionID)
|
animated { route = .terminal(sessionID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。
|
/// 回首页:会话完全不动(⌘⇧H / 侧边栏首页行)。展开态跨导航保留。
|
||||||
func goHome() {
|
func goHome() {
|
||||||
modal = nil
|
animated {
|
||||||
route = .home
|
modal = nil
|
||||||
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
|
route = .home
|
||||||
}
|
|
||||||
|
|
||||||
/// 开合侧边栏(⌘\ / 开合键)。折叠 ⇄ 遮罩;Pin 态视作展开,收起回折叠。
|
|
||||||
func toggleSidebar() {
|
|
||||||
animateSidebar {
|
|
||||||
switch sidebar {
|
|
||||||
case .collapsed: sidebar = .overlay
|
|
||||||
case .overlay, .pinned: sidebar = .collapsed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pin 切换(只在展开态出现)。钉住/解除各触发一次终端 resize。
|
/// 开合侧边栏(⌘\ / Logo 位展开键 / 头部收起键)。收起 ⇄ 固定展开。
|
||||||
func togglePin() {
|
func toggleSidebar() {
|
||||||
animateSidebar { sidebar = sidebar == .pinned ? .overlay : .pinned }
|
animated { sidebar = sidebar == .collapsed ? .pinned : .collapsed }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
|
/// 打开/关闭模态浮层(红点、⌘W、⌘D、⌘⇧D 与无头 URL 共用同一入口)。
|
||||||
func present(_ modal: AppModal) { self.modal = modal }
|
func present(_ modal: AppModal) { animated { self.modal = modal } }
|
||||||
func dismissModal() { modal = nil }
|
func dismissModal() { animated { modal = nil } }
|
||||||
|
|
||||||
/// 点终端任意处:遮罩态收起(Pin 态不受影响)。
|
|
||||||
func dismissOverlaySidebar() {
|
|
||||||
if sidebar == .overlay { animateSidebar { sidebar = .collapsed } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ struct RootView: View {
|
|||||||
if router.route == .home {
|
if router.route == .home {
|
||||||
HomeView(manager: manager, router: router, hostStore: hostStore,
|
HomeView(manager: manager, router: router, hostStore: hostStore,
|
||||||
credStore: credStore, tailscaleStore: tailscaleStore)
|
credStore: credStore, tailscaleStore: tailscaleStore)
|
||||||
.transition(.opacity)
|
// 首页略微推远后淡出,与终端从 97% 放大到位对偶 —— 读作"推进到会话里"。
|
||||||
|
.transition(TX.Motion.pushBack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(p.bgDeep.ignoresSafeArea())
|
.background(p.bgDeep.ignoresSafeArea())
|
||||||
@@ -142,6 +143,7 @@ struct SessionCanvas: View {
|
|||||||
@ObservedObject var hostStore: HostStore
|
@ObservedObject var hostStore: HostStore
|
||||||
@ObservedObject var credStore: FileCredentialStore
|
@ObservedObject var credStore: FileCredentialStore
|
||||||
@ObservedObject var tailscaleStore: TailscaleStore
|
@ObservedObject var tailscaleStore: TailscaleStore
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -153,61 +155,55 @@ struct SessionCanvas: View {
|
|||||||
// 离开终端时轻微缩小淡出,观感上"缩回首页卡片"。
|
// 离开终端时轻微缩小淡出,观感上"缩回首页卡片"。
|
||||||
// `scaleEffect` 只是渲染变换、不改 frame → **不触发 PTY resize**,
|
// `scaleEffect` 只是渲染变换、不改 frame → **不触发 PTY resize**,
|
||||||
// 故不能用 frame 动画替代(那会让 tmux 反复重排)。
|
// 故不能用 frame 动画替代(那会让 tmux 反复重排)。
|
||||||
.scaleEffect(visible ? 1 : 0.97)
|
.scaleEffect(visible || reduceMotion ? 1 : 0.97)
|
||||||
.animation(AppRouter.sidebarAnimation, value: visible)
|
.animation(TX.Motion.standard, value: visible)
|
||||||
.allowsHitTesting(visible)
|
.allowsHitTesting(visible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 终端主界面 · 沉浸轨道(设计稿 `1b` + `3a`)。
|
/// 终端主界面 · 沉浸轨道(设计稿 `1b` + `3a`,用户裁决后侧边栏只剩收起/固定两态)。
|
||||||
///
|
///
|
||||||
/// 布局:左 56pt 轨道(或 Pin 态的 288pt 面板)+ 右舞台。舞台顶部留 86pt 预留带给浮起胶囊,
|
/// 布局:左侧 `TerminalSidebar`(56 ⇄ 288,直接参与布局)+ 右舞台。舞台顶部留 86pt
|
||||||
/// 底部 34pt 提示条。遮罩态的面板是**浮层**,盖住轨道、不参与布局 → 终端不 reflow、不 resize;
|
/// 预留带给浮起胶囊,底部一条提示条。开合就是侧边栏的宽度动画:收起时明细淡出、
|
||||||
/// 只有 Pin 切换才改变舞台宽度(各 resize 一次,符合设计约束)。
|
/// 终端从右侧盖上来;展开反向。开合各触发一次终端 resize。
|
||||||
|
///
|
||||||
|
/// **安全区**:整屏 chrome 铺到**物理**边缘(`ignoresSafeArea()`),安全区由两处底栏
|
||||||
|
/// (提示条 / 侧边栏脚)自己让出 —— 见 `TX.Layout.footBand`,两侧分割线落在同一条 y 上。
|
||||||
struct TerminalScreen: View {
|
struct TerminalScreen: View {
|
||||||
@ObservedObject var session: TerminalSession
|
@ObservedObject var session: TerminalSession
|
||||||
@ObservedObject var manager: SessionManager
|
@ObservedObject var manager: SessionManager
|
||||||
@ObservedObject var router: AppRouter
|
@ObservedObject var router: AppRouter
|
||||||
@ObservedObject var tailscaleStore: TailscaleStore
|
@ObservedObject var tailscaleStore: TailscaleStore
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
@Environment(\.horizontalSizeClass) private var hSize
|
||||||
@State private var sidebarSearch = ""
|
@State private var sidebarSearch = ""
|
||||||
/// 「安装并启用 tmux」引导弹层。
|
/// 「安装并启用 tmux」引导弹层。
|
||||||
@State private var showTmuxInstall = false
|
@State private var showTmuxInstall = false
|
||||||
private var p: TXPalette { theme.palette }
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
// 先量安全区,再铺满物理屏(顺序反了就量不到,见 TXScreenMetrics)。
|
||||||
if router.sidebar == .pinned {
|
TXScreenMetrics { geo in
|
||||||
sidebarPanel
|
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
|
||||||
} 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 推低。
|
|
||||||
.ignoresSafeArea()
|
.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 —— 设计给的是精确尺寸的卡片。
|
// 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||||||
.overlay {
|
.overlay {
|
||||||
switch router.modal {
|
switch router.modal {
|
||||||
@@ -230,10 +226,10 @@ struct TerminalScreen: View {
|
|||||||
if showTmuxInstall {
|
if showTmuxInstall {
|
||||||
TmuxInstallSheet(
|
TmuxInstallSheet(
|
||||||
onRun: { cmd in
|
onRun: { cmd in
|
||||||
showTmuxInstall = false
|
withAnimation(TX.Motion.standard) { showTmuxInstall = false }
|
||||||
session.runInTerminal(cmd)
|
session.runInTerminal(cmd)
|
||||||
},
|
},
|
||||||
onCancel: { showTmuxInstall = false })
|
onCancel: { withAnimation(TX.Motion.standard) { showTmuxInstall = false } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。
|
// host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。
|
||||||
@@ -248,11 +244,9 @@ struct TerminalScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sidebarPanel: SidebarPanel {
|
/// 折叠轨道宽(竖屏/窄窗口 48)。占位列宽度必须与侧边栏收起宽一致,否则多出一条底色缝。
|
||||||
SidebarPanel(manager: manager, router: router, session: session,
|
private var railWidth: CGFloat {
|
||||||
tailscaleStore: tailscaleStore,
|
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
|
||||||
onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost,
|
|
||||||
search: $sidebarSearch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。
|
/// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。
|
||||||
@@ -263,13 +257,15 @@ struct TerminalScreen: View {
|
|||||||
|
|
||||||
// MARK: - 舞台
|
// MARK: - 舞台
|
||||||
|
|
||||||
private var stage: some View {
|
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
// 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。
|
||||||
if session.tmuxUnavailable {
|
if session.tmuxUnavailable {
|
||||||
NoTmuxBanner(onInstall: { showTmuxInstall = true },
|
NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } },
|
||||||
onDismiss: { session.dismissTmuxBanner() })
|
onDismiss: {
|
||||||
|
withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() }
|
||||||
|
})
|
||||||
.padding(.top, TX.Layout.capsuleInset)
|
.padding(.top, TX.Layout.capsuleInset)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
@@ -280,20 +276,35 @@ struct TerminalScreen: View {
|
|||||||
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
|
.padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(p.terminalBackground)
|
.background(p.terminalBackground)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(paneShape)
|
||||||
.overlay(
|
.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 底边即与侧边栏脚同高的那条分割线。
|
// 只在**朝轨道那一侧**留 2pt 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。
|
||||||
.padding([.leading, .trailing, .top], TX.Layout.paneGap)
|
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||||||
TerminalHintBar(context: hintContext, hints: hints)
|
.padding(.leading, TX.Layout.paneGap)
|
||||||
|
TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom)
|
||||||
}
|
}
|
||||||
capsules
|
capsules(safeTop: safeTop)
|
||||||
}
|
}
|
||||||
.background(p.bgDeep)
|
.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 {
|
@ViewBuilder private var paneArea: some View {
|
||||||
@@ -312,17 +323,20 @@ struct TerminalScreen: View {
|
|||||||
router.goHome()
|
router.goHome()
|
||||||
manager.close(id)
|
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) {
|
HStack(alignment: .top) {
|
||||||
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||||||
Spacer(minLength: TX.Space.m)
|
Spacer(minLength: TX.Space.m)
|
||||||
TerminalStatusCapsule(session: session)
|
TerminalStatusCapsule(session: session)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.m)
|
.padding(.horizontal, TX.Space.m)
|
||||||
.padding(.top, 14)
|
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||||||
|
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
||||||
@@ -337,7 +351,7 @@ struct TerminalScreen: View {
|
|||||||
if let tmux = session.tmuxController {
|
if let tmux = session.tmuxController {
|
||||||
if kill { tmux.killSession() } else { tmux.detach() }
|
if kill { tmux.killSession() } else { tmux.detach() }
|
||||||
}
|
}
|
||||||
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
|
router.goHome() // 动画在 router 里(TX.Motion.standard)
|
||||||
// 给 detach/kill 命令一点时间落地再拆传输层(tmux 会回 %exit 走既有 onExit 收尾)。
|
// 给 detach/kill 命令一点时间落地再拆传输层(tmux 会回 %exit 走既有 onExit 收尾)。
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
@@ -348,7 +362,7 @@ struct TerminalScreen: View {
|
|||||||
private func minimizeSession() {
|
private func minimizeSession() {
|
||||||
session.captureSnapshot() // 缩回首页前抓一次镜像,卡片立刻有内容
|
session.captureSnapshot() // 缩回首页前抓一次镜像,卡片立刻有内容
|
||||||
manager.minimize(session.id)
|
manager.minimize(session.id)
|
||||||
withAnimation(AppRouter.sidebarAnimation) { router.goHome() }
|
router.goHome()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 分屏菜单选择后立即分屏(设计:选完不再问方向)。
|
/// 分屏菜单选择后立即分屏(设计:选完不再问方向)。
|
||||||
@@ -373,20 +387,31 @@ struct TerminalScreen: View {
|
|||||||
router.present(.splitMenu(vertical: vertical))
|
router.present(.splitMenu(vertical: vertical))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hintContext: String {
|
/// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」——
|
||||||
switch router.sidebar {
|
/// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。
|
||||||
case .pinned: "Pin 态 · 终端变窄,仅在钉住/解除时 resize 一次"
|
private var barContext: String {
|
||||||
case .overlay: "遮罩态 · 点终端任意处收起,终端不 reflow"
|
if !session.isLive { return session.phaseText }
|
||||||
case .collapsed: session.isLive ? "已连接妙控键盘 · 附加行已收起" : 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)] {
|
/// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。
|
||||||
switch router.sidebar {
|
private var barActions: [TerminalActionBar.Action] {
|
||||||
case .pinned: [("Pin", "解除钉住"), ("⌘\\", "收起为轨道"), ("⌘⇧H", "回首页")]
|
let tmux = session.tmuxController
|
||||||
case .overlay: [("⌘\\", "收起"), ("Pin", "钉在左侧"), ("⌘⇧H", "回首页")]
|
return [
|
||||||
case .collapsed: [("⌘K", "命令面板"), ("⌘D", "竖分屏"), ("⌘T", "新 window"), ("⌘⇧H", "回首页")]
|
.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() },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct HomeView: View {
|
|||||||
@ObservedObject var credStore: FileCredentialStore
|
@ObservedObject var credStore: FileCredentialStore
|
||||||
@ObservedObject var tailscaleStore: TailscaleStore
|
@ObservedObject var tailscaleStore: TailscaleStore
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
@Environment(\.horizontalSizeClass) private var hSize
|
||||||
|
|
||||||
@State private var search = ""
|
@State private var search = ""
|
||||||
@State private var groupFilter: String? = nil
|
@State private var groupFilter: String? = nil
|
||||||
@@ -41,6 +42,31 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some 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) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header
|
header
|
||||||
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
|
if hostStore.hosts.isEmpty && manager.sessions.isEmpty {
|
||||||
@@ -71,29 +97,16 @@ struct HomeView: View {
|
|||||||
hostGrid
|
hostGrid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.screenGutter)
|
.padding(.horizontal, gutter)
|
||||||
.padding(.bottom, TX.Space.xxl)
|
// 末行要能完整滚到安全区之上(否则最后一排主机卡永远被 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: - 空状态
|
// MARK: - 空状态
|
||||||
@@ -178,22 +191,30 @@ struct HomeView: View {
|
|||||||
.font(TX.Font.mono(12))
|
.font(TX.Font.mono(12))
|
||||||
.foregroundStyle(p.textWeak)
|
.foregroundStyle(p.textWeak)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer(minLength: TX.Space.s)
|
||||||
HStack(spacing: TX.Space.s) {
|
HStack(spacing: TX.Space.s) {
|
||||||
TXSearchField(text: $search, placeholder: "搜索", shortcut: "⌘K")
|
TXSearchField(text: $search, placeholder: "搜索主机")
|
||||||
.frame(width: 220)
|
// 可伸缩而非死死 220:窄窗口(分屏 / iPhone)下固定宽度会把「新建连接」
|
||||||
|
// 挤出屏幕外。
|
||||||
|
.frame(minWidth: 120, maxWidth: 220)
|
||||||
TXButton(title: "新建连接", emphasized: true) {
|
TXButton(title: "新建连接", emphasized: true) {
|
||||||
editing = nil
|
editing = nil
|
||||||
showEditor = true
|
showEditor = true
|
||||||
}
|
}
|
||||||
|
.fixedSize()
|
||||||
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
|
TXIconButton(icon: "gearshape", label: "设置") { showSettings = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.screenGutter)
|
.padding(.horizontal, gutter)
|
||||||
.padding(.top, TX.Space.l)
|
.padding(.top, TX.Space.l)
|
||||||
.padding(.bottom, TX.Space.m)
|
.padding(.bottom, TX.Space.m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 屏幕左右留白:设计基准(iPad 横屏)40pt;窄窗口收到 20pt,否则内容区被吃掉一大截。
|
||||||
|
private var gutter: CGFloat {
|
||||||
|
hSize == .compact ? TX.Space.l : TX.Space.screenGutter
|
||||||
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
|
if !manager.sessions.isEmpty { parts.append("\(manager.sessions.count) 个会话在跑") }
|
||||||
@@ -213,60 +234,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 首页的「正在连接」行:点主机后到进终端之间的反馈(探测 tmux、等选择、失败)。
|
// MARK: - 活动会话卡
|
||||||
/// 独立视图以直接订阅该会话(`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 列等宽)
|
|
||||||
|
|
||||||
/// 已建立的会话。**排除正在连接的**(它们走 ConnectingRow)—— 否则同一会话既有进度行又有卡片,
|
/// 已建立的会话。**排除正在连接的**(它们走 ConnectingRow)—— 否则同一会话既有进度行又有卡片,
|
||||||
/// 而且卡片里的 tmux 信息此刻还没定(会先显示成「原生窗口」再跳变)。
|
/// 而且卡片里的 tmux 信息此刻还没定(会先显示成「原生窗口」再跳变)。
|
||||||
@@ -277,8 +245,15 @@ struct ConnectingRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 会话卡:**一排固定 3 格**,不足 3 条从左往右排(不拉伸、不居中),每格 **4:3**。
|
||||||
|
///
|
||||||
|
/// 固定 3 列的好处:卡片尺寸与位置不随会话数跳动 —— 连上第二台主机时第一张卡不该改变大小。
|
||||||
|
/// 超过 3 条自动换行到下一排。
|
||||||
private var sessionCards: some View {
|
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
|
ForEach(liveSessions) { s in
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session: s,
|
session: s,
|
||||||
@@ -288,15 +263,17 @@ struct ConnectingRow: View {
|
|||||||
manager.focus(s)
|
manager.focus(s)
|
||||||
router.enter(s.id)
|
router.enter(s.id)
|
||||||
}
|
}
|
||||||
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 560)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 全部主机(4 列,按内容高度)
|
// MARK: - 全部主机(4 列,按内容高度)
|
||||||
|
|
||||||
private var hostGrid: some View {
|
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) {
|
spacing: TX.Space.m) {
|
||||||
ForEach(filteredHosts) { host in
|
ForEach(filteredHosts) { host in
|
||||||
HostTile(host: host,
|
HostTile(host: host,
|
||||||
@@ -336,7 +313,7 @@ struct ConnectingRow: View {
|
|||||||
.foregroundStyle(p.border)
|
.foregroundStyle(p.border)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func credName(for host: SavedHost) -> String? {
|
private func credName(for host: SavedHost) -> String? {
|
||||||
@@ -428,27 +405,36 @@ struct SessionCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 整张卡是**一个**按钮:卡脚的「回到会话 →」与点卡片是同一个动作,做成嵌套 Button 只会
|
||||||
|
/// 抢命中区、还各自一套按压反馈。故卡脚那行只作为文字提示(affordance),不再是控件。
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
Button(action: onOpen) {
|
||||||
cardHead
|
VStack(spacing: 0) {
|
||||||
Rectangle().fill(p.divider).frame(height: 1)
|
cardHead
|
||||||
// 离线/重连时缓存内容轻微模糊 + 压遮罩(设计:blur(1.5px) + rgba(24,26,40,.72)),
|
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)
|
TXTerminalMirror(lines: session.snapshotLines, background: p.terminalBackground)
|
||||||
.overlay { if isTroubled { troubleVeil } }
|
.blur(radius: isTroubled ? 1.5 : 0)
|
||||||
Rectangle().fill(p.divider).frame(height: 1)
|
.overlay { if isTroubled { troubleVeil } }
|
||||||
cardFoot
|
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)
|
.buttonStyle(.txPress)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cardHead: some View {
|
private var cardHead: some View {
|
||||||
@@ -528,12 +514,9 @@ struct SessionCard: View {
|
|||||||
Text("⌘\(shortcutIndex)")
|
Text("⌘\(shortcutIndex)")
|
||||||
.font(TX.Font.mono(10.5))
|
.font(TX.Font.mono(10.5))
|
||||||
.foregroundStyle(p.textFaint)
|
.foregroundStyle(p.textFaint)
|
||||||
Button(action: onOpen) {
|
Text(isTroubled ? "立即重试" : "回到会话 →")
|
||||||
Text(isTroubled ? "立即重试" : "回到会话 →")
|
.font(TX.Font.mono(11.5, .medium))
|
||||||
.font(TX.Font.mono(11.5, .medium))
|
.foregroundStyle(TXAccent.textBright)
|
||||||
.foregroundStyle(TXAccent.textBright)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.s)
|
.padding(.horizontal, TX.Space.s)
|
||||||
.frame(height: 38)
|
.frame(height: 38)
|
||||||
@@ -590,9 +573,11 @@ struct HostTile: View {
|
|||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
VStack(alignment: .leading, spacing: TX.Space.s) {
|
VStack(alignment: .leading, spacing: TX.Space.s) {
|
||||||
HStack(alignment: .top, spacing: TX.Space.s) {
|
HStack(alignment: .top, spacing: TX.Space.s) {
|
||||||
|
// 图标染发行版品牌色(Ubuntu 橙 / Debian 红 / macOS 中性银…),
|
||||||
|
// 一眼扫过去就能按 OS 分辨主机;认不出的发行版回中性。
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 15, weight: .light))
|
.font(.system(size: 15, weight: .light))
|
||||||
.foregroundStyle(p.text4)
|
.foregroundStyle(TXTech.osTint(host.os, p))
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -614,9 +599,12 @@ struct HostTile: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
if host.useMosh { TXBadge(text: "mosh") } else { TXBadge(text: "SSH") }
|
// 传输协议(互斥)着语义冷色;tsnet 是招牌能力,用 app 强调色;
|
||||||
if hasTsnet { TXBadge(text: "tsnet") }
|
// 凭据名是用户自己命的名、不是技术标签 → 中性(详见 TXTech)。
|
||||||
if let credName { TXBadge(text: credName, emphasized: true) }
|
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)
|
.padding(TX.Space.s)
|
||||||
@@ -627,6 +615,6 @@ struct HostTile: View {
|
|||||||
.strokeBorder(p.border, lineWidth: 1)
|
.strokeBorder(p.border, lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ struct HostCard: View {
|
|||||||
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||||
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
|
.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 {
|
private func badge(_ icon: String, _ text: String, _ color: Color) -> some View {
|
||||||
|
|||||||
@@ -1,158 +1,380 @@
|
|||||||
import SwiftUI
|
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)。元素中心线统一在轨道正中。
|
/// 整行按钮的命中形状:只有左起 `width` 宽度可点(随开合动画走)。
|
||||||
struct RailView: View {
|
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 manager: SessionManager
|
||||||
@ObservedObject var router: AppRouter
|
@ObservedObject var router: AppRouter
|
||||||
/// 当前会话(TMUX 段列它的 window)。
|
/// 当前会话(tmux 段列它的 window)。
|
||||||
@ObservedObject var session: TerminalSession
|
@ObservedObject var session: TerminalSession
|
||||||
|
@ObservedObject var tailscaleStore: TailscaleStore
|
||||||
|
/// 收起态宽度(竖屏 48)。图标列宽 = 这个值,两态图标中心线才重合。
|
||||||
|
let railWidth: CGFloat
|
||||||
|
/// home indicator 安全区(沉浸页铺满物理屏,由底栏自己让位)。
|
||||||
|
let safeBottom: CGFloat
|
||||||
let onNewHost: () -> Void
|
let onNewHost: () -> Void
|
||||||
let onSettings: () -> Void
|
let onSettings: () -> Void
|
||||||
|
@Binding var search: String
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
@Environment(\.horizontalSizeClass) private var hSize
|
|
||||||
private var p: TXPalette { theme.palette }
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
private var width: CGFloat {
|
private var expanded: Bool { router.sidebar.isExpanded }
|
||||||
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
|
private var currentWidth: CGFloat { expanded ? TX.Layout.sidebarWidth : railWidth }
|
||||||
}
|
|
||||||
private var avatarSize: CGFloat { hSize == .compact ? 34 : 38 }
|
|
||||||
|
|
||||||
// 纵向节奏取自设计稿 `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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
content
|
||||||
logo
|
.frame(width: TX.Layout.sidebarWidth, alignment: .leading)
|
||||||
.padding(.top, 28)
|
.background(p.surfaceDark)
|
||||||
.padding(.bottom, 13)
|
.frame(width: currentWidth, alignment: .leading)
|
||||||
// 搜索 / 首页 / 设置在设计稿里是**裸图标**(无底无框),只有 logo 与展开键有框。
|
.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: "搜索") {
|
TXIconButton(icon: "magnifyingglass", bare: true, hitSize: 34, label: "搜索") {
|
||||||
router.toggleSidebar()
|
router.toggleSidebar()
|
||||||
}
|
}
|
||||||
.padding(.bottom, 9)
|
.frame(width: railWidth)
|
||||||
TXIconButton(icon: "house", bare: true, hitSize: 34, label: "首页") { router.goHome() }
|
.opacity(expanded ? 0 : 1)
|
||||||
.padding(.bottom, 8)
|
.allowsHitTesting(!expanded)
|
||||||
divider
|
TXSearchField(text: $search, placeholder: "搜索主机 · 会话")
|
||||||
.padding(.bottom, 13)
|
.padding(.leading, 12)
|
||||||
hostAvatars
|
.padding(.trailing, TX.Space.m)
|
||||||
newHostButton
|
.opacity(expanded ? 1 : 0)
|
||||||
divider
|
.allowsHitTesting(expanded)
|
||||||
.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: width)
|
|
||||||
.background(p.surfaceDark)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App logo(`›`)。**不可点** —— 开合是另一枚按钮的职责。
|
private var homeRow: some View {
|
||||||
private var logo: some View {
|
Button { router.goHome() } label: {
|
||||||
Text("›")
|
HStack(spacing: 0) {
|
||||||
.font(TX.Font.mono(16, .bold))
|
Image(systemName: "house")
|
||||||
.foregroundStyle(TXAccent.text)
|
.font(.system(size: 17))
|
||||||
.frame(width: 34, height: 34)
|
.foregroundStyle(p.text3)
|
||||||
.background(p.surface, in: RoundedRectangle(cornerRadius: 11))
|
.frame(width: railWidth)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border, lineWidth: 1))
|
HStack {
|
||||||
.accessibilityLabel("terminalX")
|
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()
|
Rectangle()
|
||||||
.fill(p.divider)
|
.fill(p.divider)
|
||||||
.frame(width: 24, height: 1)
|
.frame(width: 24, height: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var hostAvatars: some View {
|
// MARK: - 会话
|
||||||
VStack(spacing: 8) {
|
|
||||||
ForEach(manager.activeSessions) { s in
|
@ViewBuilder private var sessionRows: some View {
|
||||||
Button {
|
if manager.activeSessions.isEmpty {
|
||||||
manager.focus(s)
|
Text("没有在跑的会话")
|
||||||
router.enter(s.id)
|
.font(TX.Font.mono(11.5))
|
||||||
} label: {
|
.foregroundStyle(p.textWeak)
|
||||||
TXInitialsAvatar(initials: s.initials,
|
.padding(.leading, railWidth)
|
||||||
size: avatarSize,
|
.frame(height: 32, alignment: .leading)
|
||||||
isSelected: router.route.sessionID == s.id,
|
.opacity(expanded ? 1 : 0)
|
||||||
status: RailSidebarStyle.statusColor(s, p),
|
}
|
||||||
punchOut: p.surfaceDark)
|
ForEach(manager.activeSessions) { s in
|
||||||
}
|
SidebarSessionRow(session: s,
|
||||||
.buttonStyle(.plain)
|
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) {
|
Button(action: onNewHost) {
|
||||||
Image(systemName: "plus")
|
HStack(spacing: 0) {
|
||||||
.font(.system(size: 13, weight: .medium))
|
Image(systemName: "plus")
|
||||||
.foregroundStyle(p.textWeak)
|
.font(.system(size: 12, weight: .medium))
|
||||||
.frame(width: avatarSize, height: avatarSize)
|
.foregroundStyle(p.textWeak)
|
||||||
.background(
|
.frame(width: 30, height: 30)
|
||||||
RoundedRectangle(cornerRadius: 11)
|
.background(
|
||||||
.strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
|
RoundedRectangle(cornerRadius: 9)
|
||||||
.foregroundStyle(p.border)
|
.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)
|
.buttonStyle(.txPress)
|
||||||
.padding(.top, 9)
|
.accessibilityLabel("连接新主机")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TMUX 段:标签 + window 数字。无 tmux 的主机换成「窗口」段(客户端窗口)。
|
// MARK: - tmux 段
|
||||||
private var tmuxSection: some View {
|
|
||||||
VStack(spacing: 7) {
|
/// 展开 = 渐隐分隔(设计 3a);收起 = 图标列短线。
|
||||||
Text(session.tmuxSummary == nil ? "窗口" : "TMUX")
|
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))
|
.font(TX.Font.mono(9, .semibold))
|
||||||
.tracking(0.9)
|
.tracking(0.9)
|
||||||
// 设计稿:轨道的 TMUX 段标签用 accent-500(弱化强调),不是中性灰。
|
|
||||||
.foregroundStyle(TXAccent.mid)
|
.foregroundStyle(TXAccent.mid)
|
||||||
.padding(.bottom, 9)
|
.frame(width: railWidth)
|
||||||
if let tmux = session.tmuxController {
|
.opacity(expanded ? 0 : 1)
|
||||||
ForEach(tmux.windows) { win in
|
TXSectionLabel(text: tmuxHeader)
|
||||||
Button { tmux.selectWindow(win.id) } label: {
|
.padding(.horizontal, TX.Space.m)
|
||||||
windowNumber("\(win.index)", isActive: win.id == tmux.activeWindowID)
|
.opacity(expanded ? 1 : 0)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.frame(height: 22)
|
||||||
}
|
.padding(.bottom, 4)
|
||||||
} else {
|
}
|
||||||
// 还没 attach(或假会话)时按概览里的 window 数占位,保持轨道高度稳定。
|
|
||||||
ForEach(0 ..< max(1, session.tmuxSummary?.windows ?? 1), id: \.self) { i in
|
@ViewBuilder private var tmuxRows: some View {
|
||||||
windowNumber("\(i)", isActive: i == 0)
|
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,选中态填控件底 + 中等描边)。
|
/// 「新建 window ⌘T」:纯明细行 —— 收起态整行淡出但保留高度,两态节奏一致。
|
||||||
private func windowNumber(_ text: String, isActive: Bool) -> some View {
|
private func newWindowRow(_ action: @escaping () -> Void) -> some View {
|
||||||
Text(text)
|
Button(action: action) {
|
||||||
.font(TX.Font.mono(12, isActive ? .semibold : .regular))
|
HStack(spacing: 0) {
|
||||||
.foregroundStyle(isActive ? p.text : p.text4)
|
Color.clear.frame(width: railWidth, height: 1)
|
||||||
.frame(width: 34, height: 30)
|
Text("新建 window")
|
||||||
.background(isActive ? p.surface : .clear, in: RoundedRectangle(cornerRadius: 8))
|
.font(TX.Font.mono(12))
|
||||||
.overlay(
|
.foregroundStyle(p.text4)
|
||||||
RoundedRectangle(cornerRadius: 8)
|
Spacer(minLength: 0)
|
||||||
.strokeBorder(isActive ? p.borderDialog : .clear, lineWidth: 1)
|
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 共用同一套内容)。
|
/// 会话行:图标列 30pt 头像(两态同位)+ 明细(名称·window title + 链路标)。
|
||||||
///
|
/// 选中底随明细淡入淡出 —— 收起态由头像自身的选中样式表达,避免高亮块被裁出直边。
|
||||||
/// 行规格:会话行 = 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 + 右侧链路/重连标。
|
|
||||||
struct SidebarSessionRow: View {
|
struct SidebarSessionRow: View {
|
||||||
@ObservedObject var session: TerminalSession
|
@ObservedObject var session: TerminalSession
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
let expanded: Bool
|
||||||
|
let railWidth: CGFloat
|
||||||
|
let hitWidth: CGFloat
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
private var p: TXPalette { theme.palette }
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
Button(action: onTap) {
|
||||||
HStack(spacing: TX.Space.s) {
|
HStack(spacing: 0) {
|
||||||
TXInitialsAvatar(initials: session.initials, size: 26,
|
TXInitialsAvatar(initials: session.initials, size: 30,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
status: RailSidebarStyle.statusColor(session, p),
|
status: RailSidebarStyle.statusColor(session, p),
|
||||||
punchOut: isSelected ? TXAccent.selectedBg : p.surfaceDark)
|
punchOut: isSelected && expanded ? TXAccent.selectedBg : p.surfaceDark)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
.frame(width: railWidth)
|
||||||
Text(title)
|
HStack(spacing: TX.Space.s) {
|
||||||
.font(TX.Font.mono(13, .medium))
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
.foregroundStyle(p.text)
|
Text(title)
|
||||||
.lineLimit(1)
|
.font(TX.Font.mono(13, .medium))
|
||||||
Text(subtitle)
|
.foregroundStyle(p.text)
|
||||||
.font(TX.Font.mono(10.5))
|
.lineLimit(1)
|
||||||
.foregroundStyle(p.textWeak)
|
Text(subtitle)
|
||||||
.lineLimit(1)
|
.font(TX.Font.mono(10.5))
|
||||||
|
.foregroundStyle(p.textWeak)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer(minLength: TX.Space.xs)
|
||||||
|
trailingTag
|
||||||
}
|
}
|
||||||
Spacer(minLength: TX.Space.xs)
|
.padding(.trailing, TX.Space.m)
|
||||||
trailingTag
|
.opacity(expanded ? 1 : 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.s)
|
.frame(height: 44)
|
||||||
.frame(height: 48)
|
.background(
|
||||||
.background(isSelected ? TXAccent.selectedBg : .clear,
|
|
||||||
in: RoundedRectangle(cornerRadius: TX.Radius.control))
|
|
||||||
// inset 描边而非 border:选中时内容不位移 1pt。
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: TX.Radius.control)
|
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 {
|
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 {
|
struct SidebarWindowRow: View {
|
||||||
@ObservedObject var window: TmuxWindow
|
@ObservedObject var window: TmuxWindow
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
let expanded: Bool
|
||||||
|
let railWidth: CGFloat
|
||||||
|
let hitWidth: CGFloat
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
|
||||||
private var p: TXPalette { theme.palette }
|
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
@@ -491,19 +491,44 @@ struct SidebarWindowRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTap) {
|
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) {
|
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) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(window.title)
|
Text(title)
|
||||||
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
|
.font(TX.Font.mono(13, isSelected ? .medium : .regular))
|
||||||
.foregroundStyle(isSelected ? p.text : p.text2)
|
.foregroundStyle(isSelected ? p.text : p.text2)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
// 设计:cwd · n panes(cwd 来自 #{pane_current_path})
|
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(TX.Font.mono(10.5))
|
.font(TX.Font.mono(10.5))
|
||||||
.foregroundStyle(p.textWeak)
|
.foregroundStyle(p.textWeak)
|
||||||
@@ -514,16 +539,20 @@ struct SidebarWindowRow: View {
|
|||||||
TXStatusDot(color: p.online, size: 7)
|
TXStatusDot(color: p.online, size: 7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.s)
|
.padding(.trailing, TX.Space.m)
|
||||||
.frame(height: 44)
|
.opacity(expanded ? 1 : 0)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ struct SessionPickerSheet: View {
|
|||||||
.background(p.surface, in: Capsule())
|
.background(p.surface, in: Capsule())
|
||||||
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
|
.overlay(Capsule().strokeBorder(p.border, lineWidth: 1))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
.accessibilityLabel("直接连接原生终端,不经过 tmux")
|
.accessibilityLabel("直接连接原生终端,不经过 tmux")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ struct SessionPickerSheet: View {
|
|||||||
Button { onAttach(s.name) } label: {
|
Button { onAttach(s.name) } label: {
|
||||||
sessionRow(s)
|
sessionRow(s)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
if index < choice.sessions.count - 1 {
|
if index < choice.sessions.count - 1 {
|
||||||
Rectangle().fill(p.divider).frame(height: 1)
|
Rectangle().fill(p.divider).frame(height: 1)
|
||||||
.padding(.horizontal, TX.Space.m)
|
.padding(.horizontal, TX.Space.m)
|
||||||
@@ -200,7 +200,7 @@ struct SessionPickerSheet: View {
|
|||||||
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
.background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct SettingsView: View {
|
|||||||
Section("主题(app 与终端统一)") {
|
Section("主题(app 与终端统一)") {
|
||||||
ForEach(TXPalette.all) { pal in
|
ForEach(TXPalette.all) { pal in
|
||||||
Button { theme.apply(pal) } label: { themeRow(pal) }
|
Button { theme.apply(pal) } label: { themeRow(pal) }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(p.surface)
|
.listRowBackground(p.surface)
|
||||||
|
|||||||
@@ -2,6 +2,57 @@ import SwiftUI
|
|||||||
|
|
||||||
// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。
|
// 设计稿反复出现的小件(首页 / 轨道 / 侧边栏共用)。数值取自 docs/design/UI-DESIGN-HANDOFF.md。
|
||||||
|
|
||||||
|
// MARK: - 通用修饰
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// 统一走 `TX.Elevation` 的投影(别在视图里手写 `.shadow`,那样迟早各处不一致)。
|
||||||
|
func txShadow(_ s: TX.Shadow) -> some View {
|
||||||
|
shadow(color: .black.opacity(s.opacity), radius: s.radius, x: s.x, y: s.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可按下容器的按压反馈:轻微缩放 + 压暗。
|
||||||
|
///
|
||||||
|
/// `.plain` 在深色界面里**完全没有**按下反馈 —— 触屏上"点了没反应"是最伤手感的一类瑕疵,
|
||||||
|
/// 尤其主机卡/会话卡这种点下去要等几百毫秒才有结果的入口。
|
||||||
|
struct TXPressStyle: ButtonStyle {
|
||||||
|
/// 大块卡片缩得更少(大面积缩放会显得晃);小图标可以缩多一点。
|
||||||
|
var scale: CGFloat = 0.985
|
||||||
|
var dim: Double = 0.78
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? scale : 1)
|
||||||
|
.opacity(configuration.isPressed ? dim : 1)
|
||||||
|
.animation(TX.Motion.quick, value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ButtonStyle where Self == TXPressStyle {
|
||||||
|
/// 卡片 / 列表行
|
||||||
|
static var txPress: TXPressStyle { TXPressStyle() }
|
||||||
|
/// 图标、圆点等小目标
|
||||||
|
static var txPressTight: TXPressStyle { TXPressStyle(scale: 0.9, dim: 0.7) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 量一次屏幕度量(安全区 + 可用尺寸),供两件事用:
|
||||||
|
///
|
||||||
|
/// 1. **沉浸终端**要顶到物理屏幕边缘(`ignoresSafeArea`),于是必须自己让位 —— 底栏在
|
||||||
|
/// 「内容带 + 安全区」里居中、浮起胶囊按硬件遮挡下移。SwiftUI 没有读安全区的环境值,
|
||||||
|
/// 故必须在 `ignoresSafeArea` **之前**量。
|
||||||
|
/// 2. **首页**按屏高收敛会话卡高度(硬写 560pt 在竖屏/分屏/小 iPad 上会把主机网格挤出屏外)。
|
||||||
|
///
|
||||||
|
/// 旋转、分屏改尺寸都会自动重新量,不需要监听通知。
|
||||||
|
struct TXScreenMetrics<Content: View>: View {
|
||||||
|
@ViewBuilder let content: (GeometryProxy) -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
content(geo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 状态点:8pt 实心 + 2pt 挖底描边(`box-shadow: 0 0 0 2px <底色>`,让点浮在头像/卡片边缘上)。
|
/// 状态点:8pt 实心 + 2pt 挖底描边(`box-shadow: 0 0 0 2px <底色>`,让点浮在头像/卡片边缘上)。
|
||||||
struct TXStatusDot: View {
|
struct TXStatusDot: View {
|
||||||
let color: Color
|
let color: Color
|
||||||
@@ -18,6 +69,8 @@ struct TXStatusDot: View {
|
|||||||
Circle().stroke(punchOut, lineWidth: 2)
|
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 {
|
struct TXSectionLabel: View {
|
||||||
let text: String
|
let text: String
|
||||||
/// 右侧计数(如「活动会话 3」的 3 单独右对齐)。
|
/// 右侧计数(如「活动会话 3」的 3 单独右对齐)。
|
||||||
@@ -66,7 +123,7 @@ struct TXSectionLabel: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(text.uppercased())
|
Text(text)
|
||||||
.font(TX.Font.mono(10, .semibold))
|
.font(TX.Font.mono(10, .semibold))
|
||||||
.tracking(0.9)
|
.tracking(0.9)
|
||||||
.foregroundStyle(color ?? p.textWeak)
|
.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 {
|
struct TXBadge: View {
|
||||||
let text: String
|
let text: String
|
||||||
|
var tint: Color?
|
||||||
var emphasized: Bool = false
|
var emphasized: Bool = false
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
private var p: TXPalette { theme.palette }
|
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 {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(TX.Font.mono(11, .medium))
|
.font(TX.Font.mono(11, .medium))
|
||||||
.foregroundStyle(emphasized ? TXAccent.text : p.text4)
|
.foregroundStyle(fg)
|
||||||
.padding(.horizontal, 7)
|
.padding(.horizontal, 7)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
.background(emphasized ? TXAccent.selectedBg : p.surface,
|
.background {
|
||||||
in: RoundedRectangle(cornerRadius: 5))
|
let shape = RoundedRectangle(cornerRadius: 5)
|
||||||
.overlay(
|
if emphasized {
|
||||||
RoundedRectangle(cornerRadius: 5)
|
shape.fill(TXAccent.selectedBg)
|
||||||
.strokeBorder(emphasized ? TXAccent.selectedStroke : .clear, lineWidth: 1)
|
} 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)
|
RoundedRectangle(cornerRadius: TX.Radius.badge)
|
||||||
.strokeBorder(isSelected ? TXAccent.selectedStroke : p.divider, lineWidth: 1)
|
.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 placeholder: String
|
||||||
var shortcut: String?
|
var shortcut: String?
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
/// 聚焦态:描边转强调色。接妙控键盘用 Tab 走焦点时必须看得出焦点在哪
|
||||||
|
/// (无障碍底线:键盘焦点可见)。
|
||||||
|
@FocusState private var focused: Bool
|
||||||
private var p: TXPalette { theme.palette }
|
private var p: TXPalette { theme.palette }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: TX.Space.xs) {
|
HStack(spacing: TX.Space.xs) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(p.textWeak)
|
.foregroundStyle(focused ? TXAccent.text : p.textWeak)
|
||||||
TextField(placeholder, text: $text)
|
TextField(placeholder, text: $text)
|
||||||
.font(TX.Font.mono(12))
|
.font(TX.Font.mono(12))
|
||||||
.foregroundStyle(p.text)
|
.foregroundStyle(p.text)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
if let shortcut, text.isEmpty {
|
.focused($focused)
|
||||||
|
if let shortcut, text.isEmpty, !focused {
|
||||||
Text(shortcut)
|
Text(shortcut)
|
||||||
.font(TX.Font.mono(10.5))
|
.font(TX.Font.mono(10.5))
|
||||||
.foregroundStyle(p.textFaint)
|
.foregroundStyle(p.textFaint)
|
||||||
@@ -157,8 +267,10 @@ struct TXSearchField: View {
|
|||||||
.frame(height: 32)
|
.frame(height: 32)
|
||||||
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
.background(p.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||||
.overlay(
|
.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)
|
.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),
|
.frame(width: hitSize ?? max(size, TX.Layout.minTouch),
|
||||||
height: hitSize ?? max(size, TX.Layout.minTouch))
|
height: hitSize ?? max(size, TX.Layout.minTouch))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.animation(TX.Motion.quick, value: isSelected)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPressTight)
|
||||||
.accessibilityLabel(label ?? icon)
|
.accessibilityLabel(label ?? icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,13 +364,24 @@ struct TXTerminalMirror: View {
|
|||||||
var placeholder: String = "等待输出…"
|
var placeholder: String = "等待输出…"
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Spacer(minLength: 0)
|
|
||||||
if lines.isEmpty {
|
if lines.isEmpty {
|
||||||
Text(placeholder)
|
Text(placeholder)
|
||||||
.font(TX.Font.mono(fontSize))
|
.font(TX.Font.mono(fontSize))
|
||||||
.foregroundStyle(p.text4)
|
.foregroundStyle(p.text4)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
}
|
||||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
Text(line.isEmpty ? " " : line)
|
Text(line.isEmpty ? " " : line)
|
||||||
@@ -272,9 +396,6 @@ struct TXTerminalMirror: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, TX.Space.xs)
|
.padding(.vertical, TX.Space.xs)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
|
||||||
.background(background)
|
|
||||||
.clipped()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启发式着色:`readViewportText()` 只给纯文本(ANSI 属性已丢),故按行首符号/常见片段上色,
|
/// 启发式着色:`readViewportText()` 只给纯文本(ANSI 属性已丢),故按行首符号/常见片段上色,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import SwiftUI
|
|||||||
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
// 终端内容容器顶部留 86pt 预留带给胶囊,预留带用 padding 实现(容器 clipped 防向上溢出)。
|
||||||
|
|
||||||
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
/// 浮起胶囊的共用外观:blur 材质 + 半透底 + 常规描边 + 圆角 11。
|
||||||
|
///
|
||||||
|
/// **扁平**:分层交给描边和材质,投影只留一点点(`TX.Elevation.floating`)。原先那道
|
||||||
|
/// `0 10px 30px rgba(0,0,0,.55)` 在终端文字上糊出一大团脏斑 —— 胶囊底本身是半透的,
|
||||||
|
/// 投影一重就成了"漏墨"。底色相应提到 .92,少了投影仍然读得清。
|
||||||
private struct FloatingCapsule<Content: View>: View {
|
private struct FloatingCapsule<Content: View>: View {
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
@ViewBuilder let content: Content
|
@ViewBuilder let content: Content
|
||||||
@@ -17,14 +21,14 @@ private struct FloatingCapsule<Content: View>: View {
|
|||||||
.frame(height: 34)
|
.frame(height: 34)
|
||||||
.background {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
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))
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: TX.Radius.capsule))
|
||||||
}
|
}
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
RoundedRectangle(cornerRadius: TX.Radius.capsule)
|
||||||
.strokeBorder(p.border, lineWidth: 1)
|
.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 {
|
var body: some View {
|
||||||
FloatingCapsule {
|
FloatingCapsule {
|
||||||
HStack(spacing: TX.Space.xs) {
|
HStack(spacing: TX.Space.xs) {
|
||||||
windowDot(TXWindowDot.close, "关闭会话", action: onClose)
|
// 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右),但形态改成带字形的按钮,
|
||||||
windowDot(TXWindowDot.minimize, "最小化", action: onMinimize)
|
// 详见 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)
|
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)
|
TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 6)
|
||||||
Text(session.displayName)
|
Text(session.displayName)
|
||||||
.font(TX.Font.mono(12.5, .medium))
|
.font(TX.Font.mono(12.5, .medium))
|
||||||
@@ -54,7 +65,8 @@ struct TerminalTitleCapsule: View {
|
|||||||
.foregroundStyle(p.textWeak)
|
.foregroundStyle(p.textWeak)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
// 终端格子数(设计稿 08 的「· 168×44」)。首次布局后稳定,故不必 @Published。
|
// 终端格子数(设计稿 08 的「· 168×44」)。侧边栏开合会 resize ——
|
||||||
|
// 数字经 GridBox.onGridChange → session.objectWillChange 跟着刷新。
|
||||||
Text(gridLabel)
|
Text(gridLabel)
|
||||||
.font(TX.Font.mono(11.5))
|
.font(TX.Font.mono(11.5))
|
||||||
.foregroundStyle(p.textFaint)
|
.foregroundStyle(p.textFaint)
|
||||||
@@ -91,16 +103,30 @@ struct TerminalTitleCapsule: View {
|
|||||||
return "\(g.cols)×\(g.rows)"
|
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) {
|
Button(action: action) {
|
||||||
Circle()
|
Image(systemName: icon)
|
||||||
.fill(color)
|
.font(.system(size: 9.5, weight: .bold))
|
||||||
.frame(width: 13, height: 13)
|
.foregroundStyle(tint)
|
||||||
.overlay(Circle().strokeBorder(Color.black.opacity(0.25), lineWidth: 0.5))
|
.frame(width: 22, height: 22)
|
||||||
.frame(width: 22, height: 30)
|
.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())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPressTight)
|
||||||
.accessibilityLabel(label)
|
.accessibilityLabel(label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +313,8 @@ struct TerminalStateOverlay: View {
|
|||||||
}
|
}
|
||||||
.padding(TX.Space.l)
|
.padding(TX.Space.l)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
// 连上后这一层要淡出去,别"啪"地掀掉(动画由 paneArea 按 phaseText 驱动)。
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +344,7 @@ struct NoTmuxBanner: View {
|
|||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPressTight)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, TX.Space.s)
|
.padding(.horizontal, TX.Space.s)
|
||||||
.frame(height: 40)
|
.frame(height: 40)
|
||||||
@@ -369,38 +397,74 @@ struct TmuxInstallSheet: View {
|
|||||||
RoundedRectangle(cornerRadius: TX.Radius.dialog)
|
RoundedRectangle(cornerRadius: TX.Radius.dialog)
|
||||||
.strokeBorder(p.borderStrong, lineWidth: 1)
|
.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 context: String
|
||||||
let hints: [(key: String, label: String)]
|
let actions: [Action]
|
||||||
|
let safeBottom: CGFloat
|
||||||
@EnvironmentObject var theme: TXThemeManager
|
@EnvironmentObject var theme: TXThemeManager
|
||||||
|
@Environment(\.horizontalSizeClass) private var hSize
|
||||||
private var p: TXPalette { theme.palette }
|
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 {
|
var body: some View {
|
||||||
HStack(spacing: TX.Space.s) {
|
HStack(spacing: TX.Space.s) {
|
||||||
Text(context)
|
Text(context)
|
||||||
.font(TX.Font.mono(11.5))
|
.font(TX.Font.mono(11.5))
|
||||||
.foregroundStyle(p.textWeak)
|
.foregroundStyle(p.textWeak)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: TX.Space.m)
|
Spacer(minLength: TX.Space.s)
|
||||||
ForEach(hints, id: \.key) { hint in
|
ForEach(shown) { action in
|
||||||
HStack(spacing: 5) {
|
Button(action: action.run) {
|
||||||
Text(hint.key)
|
HStack(spacing: 5) {
|
||||||
.font(TX.Font.mono(11.5, .medium))
|
Image(systemName: action.icon)
|
||||||
.foregroundStyle(p.text4)
|
.font(.system(size: 10.5, weight: .medium))
|
||||||
Text(hint.label)
|
Text(action.title)
|
||||||
.font(TX.Font.mono(11.5))
|
.font(TX.Font.mono(11.5, .medium))
|
||||||
.foregroundStyle(p.textWeak)
|
}
|
||||||
|
.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)
|
.padding(.horizontal, TX.Space.m)
|
||||||
.frame(height: TX.Layout.footHeight)
|
.frame(height: TX.Layout.footBand(safeBottom))
|
||||||
|
.animation(TX.Motion.standard, value: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,24 @@ import SwiftUI
|
|||||||
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
|
// - **红点 / ⌘W = 关闭**:弹一次确认,按主机能力两套文案;`esc` 取消、`⏎` 选推荐项。
|
||||||
|
|
||||||
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
|
/// 浮层容器:遮罩 + 居中卡片(对话框 / 菜单共用)。
|
||||||
|
///
|
||||||
|
/// 遮罩淡入、卡片从 96% 长出来(`TX.Motion.pop`)—— 两段分开写才有层次;整块套一个
|
||||||
|
/// `.transition(.opacity)` 的话卡片只是跟着变淡,弹出感全无。
|
||||||
|
/// 动画事务由 `AppRouter.present/dismissModal` 提供。
|
||||||
struct TXModalScrim<Content: View>: View {
|
struct TXModalScrim<Content: View>: View {
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
@ViewBuilder let content: Content
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: 0x0A0B12).opacity(0.42)
|
TX.Scrim.color.opacity(TX.Scrim.dialog)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture(perform: onDismiss)
|
.onTapGesture(perform: onDismiss)
|
||||||
|
.transition(.opacity)
|
||||||
content
|
content
|
||||||
|
.transition(TX.Motion.pop)
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ struct CloseConfirmDialog: View {
|
|||||||
RoundedRectangle(cornerRadius: TX.Radius.dialog)
|
RoundedRectangle(cornerRadius: TX.Radius.dialog)
|
||||||
.strokeBorder(p.borderStrong, lineWidth: 1)
|
.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))
|
.font(TX.Font.mono(12, confirmingKill ? .semibold : .regular))
|
||||||
.foregroundStyle(p.danger)
|
.foregroundStyle(p.danger)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
TXButton(title: "取消", action: onCancel)
|
TXButton(title: "取消", action: onCancel)
|
||||||
@@ -129,7 +134,7 @@ struct CloseConfirmDialog: View {
|
|||||||
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
|
.strokeBorder(Color(hex: 0x8D5560), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +206,7 @@ struct SplitMenuView: View {
|
|||||||
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
|
RoundedRectangle(cornerRadius: TX.Radius.dialog - 1)
|
||||||
.strokeBorder(p.borderStrong, lineWidth: 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)
|
.padding(.horizontal, TX.Space.xs)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.txPress)
|
||||||
.disabled(disabled)
|
.disabled(disabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,15 +128,20 @@ final class TmuxByteChannel: @unchecked Sendable {
|
|||||||
final class GridBox: @unchecked Sendable {
|
final class GridBox: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var cols = 80, rows = 24, cellW = 0, cellH = 0
|
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) {
|
var value: (cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||||
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
|
lock.lock(); defer { lock.unlock() }; return (cols, rows, cellW, cellH)
|
||||||
}
|
}
|
||||||
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
func set(cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
|
let changed = self.cols != max(1, cols) || self.rows != max(1, rows)
|
||||||
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 cellW > 0 { self.cellW = cellW } // cell 尺寸稳定,保留最后有效值
|
||||||
if cellH > 0 { self.cellH = cellH }
|
if cellH > 0 { self.cellH = cellH }
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
|
if changed { onGridChange?() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +376,11 @@ final class TerminalSession: ObservableObject, Identifiable {
|
|||||||
self.state = state
|
self.state = state
|
||||||
self.gate = OutputGate(session: session)
|
self.gate = OutputGate(session: session)
|
||||||
|
|
||||||
|
// 格子数变化 → 主线程转发发布(标题胶囊等读 screenGrid 的视图才会刷新)。
|
||||||
|
screenGrid.onGridChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.objectWillChange.send() }
|
||||||
|
}
|
||||||
|
|
||||||
// tmux 网关字节:单消费者按序处理(enter 建 controller+喂初始,bytes 续喂)。
|
// tmux 网关字节:单消费者按序处理(enter 建 controller+喂初始,bytes 续喂)。
|
||||||
let ch = tmuxChannel
|
let ch = tmuxChannel
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
import GhosttyTerminal
|
import GhosttyTerminal
|
||||||
import GhosttyTheme
|
import GhosttyTheme
|
||||||
|
|
||||||
@@ -257,6 +258,9 @@ enum TX {
|
|||||||
static let dialog: CGFloat = 15
|
static let dialog: CGFloat = 15
|
||||||
/// 浮起 chrome(标题/状态胶囊)
|
/// 浮起 chrome(标题/状态胶囊)
|
||||||
static let capsule: CGFloat = 11
|
static let capsule: CGFloat = 11
|
||||||
|
/// 终端 pane 容器。**只用在朝 app chrome 的那几个角**——贴着屏幕物理边缘的角必须留直角,
|
||||||
|
/// 否则会与设备自身的大圆角打架(详见 `TerminalScreen.paneShape`)。
|
||||||
|
static let pane: CGFloat = 6
|
||||||
|
|
||||||
// 兼容旧名
|
// 兼容旧名
|
||||||
static let chip: CGFloat = 9
|
static let chip: CGFloat = 9
|
||||||
@@ -291,8 +295,94 @@ enum TX {
|
|||||||
static let minTouch: CGFloat = 44
|
static let minTouch: CGFloat = 44
|
||||||
/// 终端主 pane 顶部给浮起胶囊预留
|
/// 终端主 pane 顶部给浮起胶囊预留
|
||||||
static let capsuleInset: CGFloat = 86
|
static let capsuleInset: CGFloat = 86
|
||||||
/// 底部区高度:**舞台提示条与侧边栏脚共用**,故两侧的分割线落在同一条 y 上。
|
/// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条
|
||||||
|
/// 分割线都落在同一条 y 上。真机上还要在下面叠 home indicator 的安全区
|
||||||
|
/// —— 沉浸页把 chrome 铺到物理边缘,安全区由底栏自己让出(见 `TX.Layout.footBand`)。
|
||||||
static let footHeight: CGFloat = 34
|
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 {
|
enum Font {
|
||||||
|
|||||||
@@ -242,7 +242,8 @@ enum UIPreviewFixture {
|
|||||||
manager.focus(s)
|
manager.focus(s)
|
||||||
router.enter(s.id)
|
router.enter(s.id)
|
||||||
case "sidebar":
|
case "sidebar":
|
||||||
if path.dropFirst().first == "pin" { router.togglePin() } else { router.toggleSidebar() }
|
// 悬浮态已砍(用户裁决):只剩收起⇄固定,pin 与 toggle 同义(兼容旧脚本)。
|
||||||
|
router.toggleSidebar()
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,79 @@
|
|||||||
- **原生终端** → 状态胶囊 `direct | ssh | 原生窗口 · 不经 tmux`
|
- **原生终端** → 状态胶囊 `direct | ssh | 原生窗口 · 不经 tmux`
|
||||||
- **完整真实路径**(`-txOpenAsHost`)→ 首页「正在连接 192.168.99.83」→ 首页上弹选择器(标题「连接到 …」)→ 选 `main` → 进终端页 `tmux main` + 轨道 window `0/1/2/3/4`
|
- **完整真实路径**(`-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=<iPad UDID>' -allowProvisioningUpdates` 自动签名(Apple Development: JIANKUAN YANG / Team J75A6S5938)→ `xcrun devicectl device install app` + `process launch`。**Release 里所有 `#if DEBUG` 的 fixture / `terminalx://ui` / `-txSlowMotion` 全部编译不进去**,所以真机跑的必然是真实数据路径(首次运行落在「还没有主机」空状态);签名后 Keychain/Secure Enclave 可用,凭据走系统钥匙串而非文件回退。
|
||||||
|
|
||||||
|
### 12.3j 侧边栏三态动画重构:统一视图身份(用户第五轮反馈「动画混乱」,2026-07-26)
|
||||||
|
|
||||||
|
用户判词:三态切换动画混乱;收起态应理解为「展开态在左侧裁切一部分」,允许改 UI 让两态最左侧图标同尺寸同位置。
|
||||||
|
|
||||||
|
**根源**(12.3i 的 leadingColumn 只修了一半):① 轨道 `RailView` 与面板 `SidebarPanel` 是两套视图,元素尺寸/位置互不对应(logo 34/30、头像 38/26、徽标 34×30/26×22,纵向节奏也不同)→ 就算列宽在动画,内容仍是两张不相干的画面交叠;② 遮罩态面板挂全屏 overlay、Pin 态面板是 leadingColumn 里**另一个实例** → overlay⇄pinned 切换视图身份互换,一个滑出一个滑入互相打架;③ 遮罩/投影靠 `if` 插拔。
|
||||||
|
|
||||||
|
**重构**(`RailSidebar.swift` 整文件重写为 `TerminalSidebar`,`ContentView.screen` 重组):
|
||||||
|
1. **三态唯一视图**:内容恒按 288pt 全宽排版;收起 = 容器宽度收到 railWidth + `clipped()`(字面实现「左侧裁切」)。最左 railWidth 为**图标列**:logo 30 / 裸图标 17pt / 头像 30 / window 徽标 26×22 / 状态点 / 开合键 / 齿轮,两态同尺寸同位置;右侧明细(标题/副行/快捷键/选中底)`opacity(expanded)` 淡入淡出。
|
||||||
|
2. **行对应关系**:轨道的 24pt 短分隔线 ⇄ 面板的分组标题(同一行两种密度,交叉淡化);轨道 `tmux` 迷你标签 ⇄ `tmux · 主机 · 会话` 分组标题;搜索裸图标 ⇄ 搜索框(放大镜位置几乎重合);底部统一为「tailnet 状态行 / 开合键行 / 分割线+齿轮 footBand」,两态元素不增不减。
|
||||||
|
3. **组合**:舞台前的占位列只在 Pin 态推到 288(保持「钉住/解除各 resize 一次」);遮罩与侧边栏**常驻**在同一 overlay 里,遮罩动 opacity、投影(`Elevation.panel`)动 opacity —— 遮罩⇄Pin 时侧边栏本体**一动不动**,只有遮罩淡出+舞台平移。
|
||||||
|
4. **新坑:`clipped()` 只裁绘制不裁触摸** —— 收起态下整行按钮被裁掉的明细区会伸进终端抢点击。整行按钮用 `LeadingHitShape`(Animatable `contentShape`,命中区=当前可见宽度);纯明细控件(搜索框/Pin/新建 window)再加 `allowsHitTesting(expanded)`。
|
||||||
|
5. **验证**(fixture + ×12 慢放 + 逐帧像素采样):收起→遮罩(遮罩逐帧变暗 30→29→22)、遮罩→收起(面板前沿扫过采样点+遮罩渐浅)、遮罩→Pin(胶囊中间帧 709px vs 终值 826px,舞台平移中)、Pin→收起(427→362)均有中间帧,无硬切。
|
||||||
|
|
||||||
|
**用户复核后追加裁决(同日)——砍掉悬浮态,只留两态**:Pin→收起的"明细淡出 + 终端从右盖上来"效果被点名保留,展开即反向。
|
||||||
|
- `SidebarState` 只剩 `collapsed|pinned`;`togglePin`/`dismissOverlaySidebar`/遮罩/浮层投影全删;`TerminalSidebar` 直接进 HStack 参与布局(无占位列),开合就是它的宽度动画(56⇄288),各触发一次 resize;点终端**不再**收起侧边栏(固定是持久态)。
|
||||||
|
- **开合入口收敛**(此前顶部/底部两个收起键冲突、底部那枚缩在左下角不协调):Logo 位=开合主入口(收起态显 » 展开键、展开态还原 `›` Logo,同框同位交叉淡化——用户定稿:将来 App Logo 设计与展开按钮一体);头部 « 收起键保留;底部开合行删除;Pin 键删除(两态下无意义)。`terminalx://ui/sidebar/pin` 兼容保留=toggle。
|
||||||
|
- **顺带修掉暴露的旧缺陷**:标题胶囊的格子数读 `screenGrid.value` 却从不刷新(旧注释"首次布局后稳定,不必 @Published"在"开合即 resize"下不成立,表现为开合后数字停在旧值)→ `GridBox` 加 `onGridChange`(去重)、`TerminalSession` 转发 `objectWillChange`。验证:展开 135×50 / 收起 164×50 实时跟随。
|
||||||
|
- 验证:展开动画 pane 左描边逐帧 58→198.5→261→290pt(easeOut 先快后慢),无硬切;收起态 Logo 位为 »、底部只剩状态点/分割线/齿轮;展开态头部仅 Logo+名称+«。
|
||||||
|
|
||||||
### 12.4 首版明确推迟(Fable 裁决,勿在 UI-3~6 里偷偷做)
|
### 12.4 首版明确推迟(Fable 裁决,勿在 UI-3~6 里偷偷做)
|
||||||
`mosh <RTT>` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。
|
`mosh <RTT>` 实时数字(需改 vendor/mosh iosclient 导出 SRTT,属传输层)· ⌘K 命令面板 · 无 tmux 主机的多客户端窗口(保留横幅引导)· iPad 竖屏 pane 强制上下堆叠(会推翻「路线 b」,交给 tmux relayout)· 缩略卡 ANSI 彩色(用 `readViewportText()` 文本镜像,**绝不做离屏 Metal/双 surface**——IOSurfaceLayer 泄漏史)· 设置页连字/CJK 开关(ghostty 无运行时 mutation,灰显占位)。
|
||||||
</content>
|
</content>
|
||||||
|
|||||||
Reference in New Issue
Block a user