From 8da725114fc3208322281b4cbe5f39e51aba282d Mon Sep 17 00:00:00 2001 From: kid Date: Sun, 26 Jul 2026 16:52:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B6=E5=B8=A6=20Shimo=20=E5=8C=96?= =?UTF-8?q?=E9=87=8D=E8=AE=BE=E8=AE=A1=20+=20=E7=AC=AC=E4=B8=80=E7=89=88?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=87=8F=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶带:实心圆窗口按钮、无边框 tab 胶囊、右上 mosh/SSH 协议徽标 - 减法:删「新建 window/回首页」按钮、tab 组 +、pane 拖拽调宽, window/pane 管理交还 tmux(连带清理 resizePane/newWindow/ui-resize) - 底部状态行去重:只留链路 + tmux 概览,协议由顶带徽标独占 - 徽标全局统一:淡染底+同色文字胶囊(线框废除),拼写走 TXTech 规范 - CLAUDE.md 记录无头截图横屏帧缓冲右侧裁切大坑 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- apps/TerminalX/iOS/ContentView.swift | 142 +-------------- apps/TerminalX/iOS/HomeView.swift | 2 +- apps/TerminalX/iOS/TXParts.swift | 21 +-- apps/TerminalX/iOS/TerminalChrome.swift | 204 ++++++++-------------- apps/TerminalX/iOS/Theme.swift | 2 - apps/TerminalX/iOS/TmuxController.swift | 16 +- apps/TerminalX/iOS/UIPreviewFixture.swift | 11 +- 8 files changed, 97 insertions(+), 305 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3b5e7e6..f243a1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 - **M4 host key mismatch 测试**:`-txHostKeyPinOverrideBase64 `(播种假 pin → 真 host key 不符 → 弹告警) **UI 改造期无头验证(推荐走脚本)**:`scripts/tx-shot.sh [-- <启动参数>]` 一步完成装/启/截图/修正方向。 -- **`-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` 转回来(内容已横向渲染)。**⚠️ 大坑(2026-07-26):帧缓冲不随窗口变横**——窗口按 1376×1032 排版但帧缓冲只显示**左边 1032pt**,右侧 ~344pt 直接落在屏外:顶带 trailing 的徽标/按钮在截图里"消失",看起来像没渲染(曾为此追了 6 轮假 bug:不是布局/条件/z 序问题,是根本没拍到)。**验证贴右边缘的 UI 必须去掉 `-txLandscape` 用竖屏**(窗口 1032×1376 完整落在帧缓冲内);横屏全宽对拍只能真机/带 GUI 环境做。 - **`-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`(直接关) / `dismiss` / `session/` / `sidebar/toggle`(`sidebar/pin` 兼容保留=toggle,悬浮态已砍;`split*` 已随「放弃移动端分屏」删除)。比坐标点击稳(不依赖 idb companion,不随布局失效)。模态状态放在 `AppRouter.modal` 而非视图 `@State`,快捷键与 URL 才能共用同一入口。 - **对拍走查法(还原度靠这个,别靠目测)**:截图 → 用 python+PIL 把设计稿 PNG 与截图统一缩放到 1366×1024 → 裁同区域并排 → 再对元素列做垂直投影扫描量化 y 坐标。缩略图里 `#1E1E2E` 与 `#11121C` 目测分不出,必须采样像素。 @@ -88,7 +88,7 @@ sleep N; xcrun simctl io $UDID screenshot /tmp/x.png # 然后用 Read 工具 ## 仓库结构 - `packages/TXCore` — 零依赖纯逻辑:tmux 解析器/layout/`SessionMachine`(会话状态机+退避重连)/`TmuxControlSequence`(DCS 检测)。 - `packages/TXTransport` — `SSHSession`(libssh2, 支持外部 fd)/`MoshSession`(mosh_main 桥)/`Transport` 协议;C shim: `CSSH`+`CSSHCore`(libssh2)、`CMosh`+`MoshCore`(mosh)。 -- `apps/TerminalX/iOS` — `TerminalSession`(**一实例=一条会话**:连接编排+tsnet+tmux 路由+mosh 编排+viewport 快照)/`SessionManager`(多会话持有+最小化/关闭语义+生命周期广播)/`AppRouter`(AppRoute .home|.terminal(id) + SidebarState 两态 collapsed|pinned)/`ContentView`(RootView+**SessionCanvas 会话常驻挂载**+TerminalScreen 沉浸轨道页+TmuxStage)/`HomeView`(首页会话卡+主机网格)/`RailSidebar`(`TerminalSidebar` 两态唯一视图:288pt 全宽排版+railWidth 图标列,收起=左侧裁切;列活动会话,tmux window 列表已上移为顶部 Tab)/`TerminalChrome`(`TerminalTopBar` 落地顶带:✕⌄独立按钮组+弱化标题+`TmuxWindowTabGroup` window tab 组,终端 pane 从带下开始不再被悬浮胶囊遮内容;底部动作条含合并状态行;分屏已整体移除)/`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 图标列,收起=左侧裁切;列活动会话,tmux window 列表已上移为顶部 Tab)/`TerminalChrome`(`TerminalTopBar` 落地顶带:实心圆✕⌄按钮+弱化标题+`TmuxWindowTabGroup` 无边框 tab 胶囊+右上 mosh/SSH 协议徽标(协议唯一出口,底部状态行不重复);底部只剩状态行=链路+tmux 概览,动作按钮已删;**window/pane 管理交还 tmux 自己**:无新建 window 入口、无 pane 拖拽调宽(连同 `ui/resize` 无头命令、`newWindow`/`resizePane` 一并移除),app 只做切换与被动渲染;分屏更早已整体移除)/`TXParts`(设计令牌小件+终端文本镜像)/`TmuxController`(tmux 网关)/`TsnetProbe`(M1 自检)/`Theme`(TXAccent 恒定 blurple + TXChrome 中性阶×4 + TXFlavor 终端配色)/`HostStore`(主机列表 JSON 持久化)/`UIPreviewFixture`(DEBUG 无头 UI 验证脚手架)。 - UI 已从 Demo 转入**商业化改造**:设计定稿见 `docs/design/UI-DESIGN-HANDOFF.md`(Nocturne chrome + Catppuccin 终端 + 沉浸轨道),路线图与进度见 `docs/HANDOFF.md §12`。 - `vendor/` — 所有非 Swift 依赖本地化:`libghostty-spm`(GhosttyKit)/`MSDisplayLink`/`libssh2`/`mbedtls`/`tsnet-bridge`(Go)/`mosh`(blinksh/ios 分支)/`protobuf`(3.21.12);`build/mosh/`(手写 config.h+CMakeLists)。 - `artifacts/` — 预编译 xcframework(**有意提交**,非 gitignore):`CSSHCore`/`TsnetBridge`/`MoshCore`。 diff --git a/apps/TerminalX/iOS/ContentView.swift b/apps/TerminalX/iOS/ContentView.swift index bd5f7ba..64debd7 100644 --- a/apps/TerminalX/iOS/ContentView.swift +++ b/apps/TerminalX/iOS/ContentView.swift @@ -282,7 +282,7 @@ struct TerminalScreen: View { // 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。 // 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。 .padding(.leading, TX.Layout.paneGap) - TerminalActionBar(session: session, actions: barActions, safeBottom: safeBottom) + TerminalActionBar(session: session, safeBottom: safeBottom) } .background(p.bgDeep) } @@ -348,18 +348,6 @@ struct TerminalScreen: View { router.goHome() } - /// 底部动作(**触控可点**,不是快捷键提示)。新建 window 在原生窗口下灰显。 - /// (分屏入口已随「放弃移动端分屏」移除;左侧状态行见 `TerminalActionBar.statusLine`。) - private var barActions: [TerminalActionBar.Action] { - let tmux = session.tmuxController - return [ - .init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window", - enabled: tmux != nil) { - tmux?.newWindow() - }, - .init(id: "home", icon: "house", title: "回首页") { router.goHome() }, - ] - } } /// 普通(非 tmux)单终端视图。 @@ -424,7 +412,9 @@ struct TmuxStage: View { } } -/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)+ pane 间拖拽条。 +/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。 +/// pane 尺寸调整**不做 app 侧 UI**(第一版减法,用户裁决):交给 tmux 自己(prefix+方向键等), +/// app 只被动跟随 `%layout-change`。 struct TmuxWindowView: View { @ObservedObject var window: TmuxWindow let controller: TmuxController @@ -455,10 +445,6 @@ struct TmuxWindowView: View { y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch) } } - // pane 之间的拖拽条(在 pane 之上,热区 12pt)。 - ForEach(splitters(layout.root), id: \.id) { s in - PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller) - } } } else { Color.clear @@ -476,126 +462,6 @@ struct TmuxWindowView: View { } } - /// 收集所有相邻子节点之间的分隔位置。tmux 的 `resize-pane -x/-y` 作用于**包含该 pane 的那层分割**, - /// 故 resize 目标取"前一个子节点里的任一 pane"即可。 - private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] { - var out: [SplitterSpec] = [] - func walk(_ n: TmuxLayout.Node) { - switch n { - case .leaf: - break - case .horizontal(let children, _): - // 左右并排 → 竖直分隔条,拖动改前一个子节点的**宽度**。 - for (i, child) in children.enumerated() where i < children.count - 1 { - let r = child.rect - if let pane = firstPane(child) { - out.append(SplitterSpec( - id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane, - x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width)) - } - } - children.forEach(walk) - case .vertical(let children, _): - // 上下堆叠 → 水平分隔条,拖动改前一个子节点的**高度**。 - for (i, child) in children.enumerated() where i < children.count - 1 { - let r = child.rect - if let pane = firstPane(child) { - out.append(SplitterSpec( - id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane, - x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height)) - } - } - children.forEach(walk) - } - } - walk(node) - return out - } - - private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? { - switch node { - case .leaf(let p, _): return p - case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first - } - } -} - -/// 一条分隔位置(格子坐标)。 -struct SplitterSpec { - let id: String - /// true = 竖直条(左右分,拖动改宽);false = 水平条(上下分,拖动改高) - let isVertical: Bool - /// resize 目标 pane(前一个子节点里的任一 pane) - let pane: TmuxPaneID - let x: Int, y: Int - /// 条的长度(格子) - let length: Int - /// 前一个子节点当前的 cols / rows - let currentSize: Int -} - -/// pane 拖拽条:热区 12pt、把手 44×3pt。 -/// -/// **拖动中只动视觉**(把手位移 + 高亮线),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**, -/// 然后等 tmux 回 `%layout-change` 更新真实 frame —— 与"路线 b"一致(客户端不自行重排)。 -struct PaneSplitter: View { - let splitter: SplitterSpec - let cellW: CGFloat - let cellH: CGFloat - let controller: TmuxController - @State private var drag: CGFloat = 0 - @State private var dragging = false - @EnvironmentObject var theme: TXThemeManager - private var p: TXPalette { theme.palette } - - private var hit: CGFloat { TX.Layout.paneDragHit } - - var body: some View { - Group { - if splitter.isVertical { - bar(width: hit, height: CGFloat(splitter.length) * cellH) - .position(x: CGFloat(splitter.x) * cellW + drag, - y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH) - } else { - bar(width: CGFloat(splitter.length) * cellW, height: hit) - .position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW, - y: CGFloat(splitter.y) * cellH + drag) - } - } - .gesture( - DragGesture(minimumDistance: 2) - .onChanged { v in - dragging = true - drag = splitter.isVertical ? v.translation.width : v.translation.height - } - .onEnded { _ in - // 位移 → 格子增量;松手发一次命令,视觉位移立刻归零(等 tmux 回布局)。 - let cell = splitter.isVertical ? cellW : cellH - let delta = cell > 0 ? Int((drag / cell).rounded()) : 0 - let newSize = max(1, splitter.currentSize + delta) - if delta != 0 { - controller.resizePane(splitter.pane, - cols: splitter.isVertical ? newSize : nil, - rows: splitter.isVertical ? nil : newSize) - } - drag = 0 - dragging = false - } - ) - } - - /// 热区透明 + 居中 44×3pt 把手(拖动中把手转强调色)。 - private func bar(width: CGFloat, height: CGFloat) -> some View { - ZStack { - Color.clear.frame(width: width, height: height) - RoundedRectangle(cornerRadius: 2) - .fill(dragging ? TXAccent.base : p.borderStrong) - .frame(width: splitter.isVertical ? 3 : 44, - height: splitter.isVertical ? 44 : 3) - .opacity(dragging ? 1 : 0.55) - } - .contentShape(Rectangle()) - } } /// 单个 pane 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。 diff --git a/apps/TerminalX/iOS/HomeView.swift b/apps/TerminalX/iOS/HomeView.swift index fb1182e..47e0c7c 100644 --- a/apps/TerminalX/iOS/HomeView.swift +++ b/apps/TerminalX/iOS/HomeView.swift @@ -461,7 +461,7 @@ struct SessionCard: View { @ViewBuilder private var linkTag: some View { switch session.link { case .direct: - tag("direct", p.online) + tag("直连", p.online) case .relay: tag("tsnet", p.online) case .connecting: diff --git a/apps/TerminalX/iOS/TXParts.swift b/apps/TerminalX/iOS/TXParts.swift index c57b68a..fcb4a9d 100644 --- a/apps/TerminalX/iOS/TXParts.swift +++ b/apps/TerminalX/iOS/TXParts.swift @@ -171,9 +171,10 @@ enum TXTech { /// 徽标胶囊(主机卡的 mosh / SSH / tsnet / 凭据名)。 /// -/// `tint` 给技术标签上语义色(见 `TXTech`);`emphasized` 是 app 强调色,**只留给 tsnet** -/// —— 它是本产品的招牌能力。凭据名是用户自己命的名、不是技术标签,保持中性, -/// 免得一排徽标全是彩色反而没有重点。 +/// 样式统一为**淡染底 + 同色文字、无描边**(Shimo 终端右上角的徽标语言,用户裁决全局统一, +/// 线框样式废除)。`tint` 给技术标签上语义色(见 `TXTech`);`emphasized` 是 app 强调色, +/// **只留给 tsnet** —— 它是本产品的招牌能力。凭据名是用户自己命的名、不是技术标签, +/// 保持中性,免得一排徽标全是彩色反而没有重点。 struct TXBadge: View { let text: String var tint: Color? @@ -182,28 +183,22 @@ struct TXBadge: View { private var p: TXPalette { theme.palette } private var fg: Color { emphasized ? TXAccent.text : (tint ?? p.text4) } - private var stroke: Color { - if emphasized { return TXAccent.selectedStroke } - return tint?.opacity(0.35) ?? .clear - } var body: some View { Text(text) .font(TX.Font.mono(11, .medium)) .foregroundStyle(fg) - .padding(.horizontal, 7) + .padding(.horizontal, 8) .padding(.vertical, 3) .background { - let shape = RoundedRectangle(cornerRadius: 5) if emphasized { - shape.fill(TXAccent.selectedBg) + Capsule().fill(TXAccent.selectedBg) } else if let tint { - shape.fill(tint.opacity(0.12)) + Capsule().fill(tint.opacity(0.14)) } else { - shape.fill(p.surface) + Capsule().fill(p.surface) } } - .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(stroke, lineWidth: 1)) } } diff --git a/apps/TerminalX/iOS/TerminalChrome.swift b/apps/TerminalX/iOS/TerminalChrome.swift index bbc8b4c..2cb14f5 100644 --- a/apps/TerminalX/iOS/TerminalChrome.swift +++ b/apps/TerminalX/iOS/TerminalChrome.swift @@ -3,17 +3,17 @@ import SwiftUI // 终端顶带(`1b` 的浮起胶囊已按用户裁决落地为**真实 chrome 行**):按钮、标题、tab 全在 // 终端**外**的 bgDeep 带上,终端 pane 从带下方开始 —— 不再有 86pt 预留带、不再遮内容。 // -// 顶带层级(一眼一个主角): -// [✕ ⌄ 独立按钮组] · 弱化标题行(纯文本,逐级降灰) · [tab 组 —— 唯一有容器感的元素] -// tab 组 = tmux window 的段式切换槽(window 映射原生 tab 的惯例);活动会话切换在侧边栏; -// 状态文字在底部动作条的合并状态行(见 TerminalActionBar.statusLine)。 +// 视觉语言对齐 Shimo 参考(用户第二轮裁决):**实心软圆按钮 + 无边框淡染胶囊**, +// 全带不再出现描边方框 —— 分组靠留白与底色深浅,不靠画框。 +// [● ● 实心圆按钮] · 弱化标题(· 分隔的一句话) · [无边框 tab 胶囊组] …… [Mosh/SSH 徽标] +// tab 组 = tmux window 的切换胶囊(window 映射原生 tab 的惯例);活动会话切换在侧边栏。 -/// 终端顶带:窗口按钮组 + 弱化标题 + tmux window tab 组。 +/// 终端顶带:实心圆窗口按钮 + 弱化标题 + tmux window tab 胶囊 + 传输徽标。 /// -/// 与旧「标题胶囊」的三点不同(用户定稿):① ✕/⌄ **分离成独立悬浮按钮组**,不再和标题挤在 -/// 一个胶囊里 —— 危险操作(关闭)与身份信息(主机名)不该共用一个容器;② 标题**弱化**为 -/// 纯文本行(顶带在终端外,无需胶囊底衬托对比度);③ tab 组是顶带唯一带容器的元素, -/// 视觉重心让给「切 window」这个高频动作。 +/// 要点(Shimo 化第三轮):① ✕/⌄ 是**实心圆**(macOS 交通灯的色语义 + 常显深色字形), +/// 不再是描边方块;② 标题弱化成 `主机名 · cwd · 格子数` 一句话,各段逐级降灰、`·` 分隔; +/// ③ tab 胶囊无槽无描边,选中 = accent 淡染胶囊浮起;④ 尾部 Mosh/SSH 淡染徽标 +/// —— 一眼确认传输方式(mosh 断线可存活)就在手边。 struct TerminalTopBar: View { @ObservedObject var session: TerminalSession let onClose: () -> Void @@ -24,7 +24,7 @@ struct TerminalTopBar: View { var body: some View { HStack(spacing: TX.Space.m) { // 顺序沿用 macOS 的肌肉记忆(关闭在左、最小化在右)。 - HStack(spacing: TX.Space.xs) { + HStack(spacing: TX.Space.s) { windowButton(icon: "xmark", tint: TXWindowDot.close, label: "关闭会话", action: onClose) // 向下的 chevron:对上「下滑最小化 = 缩回首页卡片」的手势语义。 @@ -36,23 +36,28 @@ struct TerminalTopBar: View { Spacer(minLength: 0) } .frame(height: 40) + // 徽标走 overlay 而不是 HStack 尾巴:tab 组的 ScrollView 会贪掉剩余宽度,让尾巴上的 + // 兄弟布局不稳;悬浮在右端与 tab 数量无关,chip 多了从徽标下面滚过(徽标带实底)。 + .overlay(alignment: .trailing) { transportBadge } } - /// 弱化标题行:状态点 + 主机名(text2) + 「window title · cwd」(textWeak) + 格子数(textFaint)。 - /// 主机身份已由侧边栏选中行承担,这里只是就近确认,故整行不超过 text2 的强度。 + /// 弱化标题行:状态点 + `主机名 · cwd · 格子数`,一句话逐级降灰、`·` 分隔(Shimo 的 + /// 状态文案写法)。主机身份已由侧边栏选中行承担,这里只是就近确认。 private var titleLine: some View { HStack(spacing: TX.Space.xs) { TXStatusDot(color: RailSidebarStyle.statusColor(session, p), size: 5) Text(session.displayName) .font(TX.Font.mono(12, .medium)) - .foregroundStyle(p.text2) + .foregroundStyle(p.text3) .lineLimit(1) if !contextLine.isEmpty { + dotSeparator Text(contextLine) .font(TX.Font.mono(11)) .foregroundStyle(p.textWeak) .lineLimit(1) } + dotSeparator // 终端格子数。侧边栏开合会 resize —— 数字经 GridBox.onGridChange → // session.objectWillChange 跟着刷新。 Text(gridLabel) @@ -61,6 +66,30 @@ struct TerminalTopBar: View { } } + private var dotSeparator: some View { + Text("·") + .font(TX.Font.mono(11)) + .foregroundStyle(p.textFaint) + } + + /// 传输协议徽标(Shimo 右上角同款:淡染底+同色文字、无描边)。这是 mosh/SSH 的 + /// **唯一**出口——底部状态行不再重复(协议是连接的静态属性,链路动态归左下)。 + /// 拼写与配色走 `TXTech`(mosh 小写、SSH 全大写)。连接建立前不显示(还不知道走什么)。 + @ViewBuilder private var transportBadge: some View { + if session.isLive || session.moshEngaged { + let t = TXTech.transport(session.moshEngaged, p) + Text(t.text) + .font(TX.Font.mono(11.5, .medium)) + .foregroundStyle(t.tint) + .padding(.horizontal, 12) + .frame(height: 26) + // 双层底:tint 淡染在上、bgDeep 实底垫底(badge 悬浮在 tab 滚动区之上, + // 不垫实底会透出滚过的 chip)。 + .background(t.tint.opacity(0.14), in: Capsule()) + .background(p.bgDeep, in: Capsule()) + } + } + /// 标题的上下文只留 cwd —— 当前 window title 已由 tab 组的选中 chip 承担,写两遍是重复。 /// 无 tmux(没有 tab 组)时退回连接状态文案。 private var contextLine: String { @@ -93,19 +122,17 @@ struct TerminalTopBar: View { return "\(g.cols)×\(g.rows)" } - /// 关闭 / 最小化按钮:28pt 圆角方块 + **常显字形**(触屏没有 hover,纯色点说明不了自己), - /// 颜色语义保留(红=关闭、琥珀=最小化),底色只上 14% —— 与全 app 的圆角矩形控件同语汇。 + /// 关闭 / 最小化按钮:**实心圆**(Shimo 同款)+ 常显深色字形(触屏没有 hover, + /// 纯色点说明不了自己)。颜色语义保留(红=关闭、琥珀=最小化),饱和圆面自己就是容器, + /// 不再描边画框。 private func windowButton(icon: String, tint: Color, label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) - .font(.system(size: 10.5, weight: .bold)) - .foregroundStyle(tint) - .frame(width: 28, height: 28) - .background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8).strokeBorder(tint.opacity(0.45), lineWidth: 1) - ) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color.black.opacity(0.55)) + .frame(width: 26, height: 26) + .background(tint, in: Circle()) .frame(width: 30, height: 40) .contentShape(Rectangle()) } @@ -114,10 +141,11 @@ struct TerminalTopBar: View { } } -/// tmux window 的 **tab 组**:一条 surfaceDark 圆角槽,window 是槽内的段式 chip。 +/// tmux window 的 **tab 组**:无槽无描边的胶囊排(Shimo 化第三轮 —— 旧「描边槽 + 描边 +/// chip」被用户裁决砍掉:暗底上层层画框显脏)。 /// -/// 「组」的语义:这些 tab 同属当前 tmux 会话 —— 容器即分组,不再各自悬浮。选中 chip 用 -/// accent 底+描边浮起,未选 chip 透明贴槽底;尾部 `+` 新建 window(浏览器 tab 组惯例)。 +/// 「组」的语义靠**相邻**表达:选中 chip 是唯一的 accent 淡染胶囊(浮起),未选 chip 纯文字 +/// 贴底;尾部 `+` 新建 window(浏览器 tab 组惯例)。 /// controller 未 attach 但已知 tmux 概览时按占位渲染(接回前 / fixture),原生窗口不显示。 struct TmuxWindowTabGroup: View { @ObservedObject var session: TerminalSession @@ -142,47 +170,28 @@ struct TmuxWindowTabGroup: View { /// 真实 tmux 的 tab 组行。独立视图直接订阅 controller(嵌套 ObservableObject 不冒泡)。 private struct WindowTabGroupRow: View { @ObservedObject var controller: TmuxController - @EnvironmentObject var theme: TXThemeManager - private var p: TXPalette { theme.palette } var body: some View { + // 无 `+` 新建入口(第一版减法,用户裁决):window 的增删都交给 tmux 自己 + // (prefix+c / prefix+&),tab 只做**切换与反映**。 TabGroupSlot { ForEach(controller.windows) { win in WindowTabChip(window: win, isSelected: win.id == controller.activeWindowID) { controller.selectWindow(win.id) } } - // 组内 `+`:新建 window 的就近入口(底部动作条的大按钮仍在)。 - Button { controller.newWindow() } label: { - Image(systemName: "plus") - .font(.system(size: 10.5, weight: .medium)) - .foregroundStyle(p.text4) - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - } - .buttonStyle(.txPressTight) - .accessibilityLabel("新建 window") } } } -/// tab 组的槽体:内衬 3pt,chip 挤在同一条容器里 —— 「同组」的视觉本体。条目多时横向滚动。 +/// tab 组的容器:**无槽体**,chip 靠相邻成组(Shimo 语言:分组靠留白,不靠画框)。 +/// 条目多时横向滚动。 private struct TabGroupSlot: View { @ViewBuilder let content: Content - @EnvironmentObject var theme: TXThemeManager - private var p: TXPalette { theme.palette } var body: some View { - // 槽体(底+描边)挂在**内容层**而不是 ScrollView 上:ScrollView 恒占满剩余宽度, - // 挂它身上就是一条拖到屏幕边的空槽 —— 槽必须紧贴 chip 才读作「一组」。 ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { content } - .padding(3) - .background(p.surfaceDark, in: RoundedRectangle(cornerRadius: TX.Radius.capsule)) - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.capsule) - .strokeBorder(p.border, lineWidth: 1) - ) + HStack(spacing: TX.Space.hairline) { content } } .frame(height: 36) } @@ -203,7 +212,8 @@ private struct WindowTabChip: View { } } -/// chip 的视觉本体(真实 window 与占位共用):`编号 标题`,选中浮起、未选贴槽底。 +/// chip 的视觉本体(真实 window 与占位共用):`编号 标题` 胶囊。选中 = accent 淡染胶囊 +/// 浮起(无描边,Shimo 的徽标语言),未选 = 纯文字贴底。 private struct WindowTabChipBody: View { let index: String let title: String @@ -218,17 +228,12 @@ private struct WindowTabChipBody: View { .foregroundStyle(isSelected ? TXAccent.text : p.textFaint) Text(title) .font(TX.Font.mono(12, isSelected ? .medium : .regular)) - .foregroundStyle(isSelected ? p.text : p.text3) + .foregroundStyle(isSelected ? TXAccent.textBright : p.textWeak) .lineLimit(1) } - .padding(.horizontal, TX.Space.s) + .padding(.horizontal, 12) .frame(height: 28) - .background(isSelected ? TXAccent.selectedBg : .clear, - in: RoundedRectangle(cornerRadius: TX.Radius.capsule - 3)) - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.capsule - 3) - .strokeBorder(isSelected ? TXAccent.selectedStroke : .clear, lineWidth: 1) - ) + .background(isSelected ? TXAccent.selectedBg : .clear, in: Capsule()) .contentShape(Rectangle()) .animation(TX.Motion.quick, value: isSelected) } @@ -424,82 +429,39 @@ struct TmuxInstallSheet: View { } } -/// 舞台底部动作条 + **左下状态行**。 +/// 舞台底部**状态行**(动作按钮已按第一版减法裁掉:window 管理交给 tmux 自己, +/// 回首页走侧边栏 / 顶带 ⌄ 最小化)。 /// -/// 左下是合并后的**唯一**状态出口(原右上状态胶囊的三格分层徽标 + 原左下上下文文字, -/// 二者信息重复,用户裁决合并到这里):`[dot 链路] | 传输 | 会话概览`。 -/// **只染出问题的那层**:网络层不可达 → 第一格转琥珀/红;重连中 → 第二格转琥珀; -/// tmux 缺失 → 第三格写「无 tmux · 客户端窗口」而非报错。主机名不再重复出现 -/// (标题胶囊与当前 tab 已有)。 +/// 左下是**网络/会话**状态的唯一出口:`[dot 链路] | tmux 概览`。传输协议(mosh/SSH) +/// **不在这里**——它是连接的静态属性,由顶带右上的协议徽标独占(原先两处都写 mosh, +/// 用户裁决去重);重连计数并入第一格(重连是链路层的事)。 +/// **只染出问题的那层**:链路断/重连 → 第一格转琥珀/灰;tmux 缺失 → 第二格写 +/// 「无 tmux · 客户端窗口」而非报错。主机名不再重复出现(顶带标题已有)。 /// -/// **不是快捷键清单,而是真按钮**:iPad 大概率没接键盘,把「新 window / 回首页」 -/// 只写成 `⌘T` 之类的文字提示等于这些功能对触控用户不存在。硬件键盘接上后这几个动作 -/// 仍然可以走快捷键(UI-5),按钮照旧留着 —— 常驻可点是底线。 -/// -/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚、轨道齿轮 +/// 高度 = 内容带 34pt + home indicator 安全区,内容在其中**居中** —— 与侧边栏脚 /// 同一条中心线。(此前只有 34pt 且被安全区顶起,屏幕底下留一条 24pt 的空带,看着像"没铺到底"。) struct TerminalActionBar: View { @ObservedObject var session: TerminalSession - let actions: [Action] let safeBottom: CGFloat @EnvironmentObject var theme: TXThemeManager - @Environment(\.horizontalSizeClass) private var hSize private var p: TXPalette { theme.palette } - struct Action: Identifiable { - let id: String - let icon: String - let title: String - /// 不可用时灰显但仍在(如原生窗口不能新建 window)——不隐藏,免得布局跳。 - var enabled: Bool = true - let run: () -> Void - } - - /// 窄屏只留前两个:多了在 iPhone 上放不下,会折行把 34pt 的带撑破。 - private var shown: [Action] { hSize == .compact ? Array(actions.prefix(2)) : actions } - var body: some View { HStack(spacing: TX.Space.s) { statusLine Spacer(minLength: TX.Space.s) - ForEach(shown) { action in - Button(action: action.run) { - HStack(spacing: 5) { - Image(systemName: action.icon) - .font(.system(size: 10.5, weight: .medium)) - Text(action.title) - .font(TX.Font.mono(11.5, .medium)) - } - .foregroundStyle(action.enabled ? p.text2 : p.textFaint) - .padding(.horizontal, TX.Space.xs) - .frame(height: 26) - .background(action.enabled ? p.surface : .clear, - in: RoundedRectangle(cornerRadius: TX.Radius.badge)) - .overlay( - RoundedRectangle(cornerRadius: TX.Radius.badge) - .strokeBorder(action.enabled ? p.border : p.divider, lineWidth: 1) - ) - // 动作条永远**单行**:宁可少放一个也不许折行(折行会溢出底部带)。 - .lineLimit(1) - .fixedSize() - } - .buttonStyle(.txPress) - .disabled(!action.enabled) - } } .padding(.horizontal, TX.Space.m) .frame(height: TX.Layout.footBand(safeBottom)) .animation(TX.Motion.standard, value: contextText) } - // MARK: - 状态行(原右上状态胶囊与旧左下文字的合并体) + // MARK: - 状态行 private var statusLine: some View { HStack(spacing: 0) { cell(egressText, color: egressColor, dot: true) separator - cell(transportText, color: transportColor, dot: false) - separator cell(contextText, color: p.text3, dot: false) } } @@ -519,13 +481,13 @@ struct TerminalActionBar: View { } } - // 第一格:网络可达性(tsnet 出口 / 直连) + // 第一格:链路(tsnet 出口 / 直连 / 重连计数)。拼写按 TXTech 规范(tsnet 官方小写)。 private var egressText: String { switch session.link { case .relay: "tsnet" - case .direct: "direct" + case .direct: "直连" case .connecting: "连接中" - case .reconnecting: "tsnet" + case .reconnecting(let n): "重连中 · 第 \(n) 次" case .offline: "离线" } } @@ -539,21 +501,7 @@ struct TerminalActionBar: View { } } - // 第二格:传输层(mosh / SSH) - private var transportText: String { - if case .reconnecting(let n) = session.link { - return "\(session.moshEngaged ? "mosh" : "ssh") 重连 \(n)" - } - return session.moshEngaged ? "mosh" : "ssh" - } - - private var transportColor: Color { - if case .reconnecting = session.link { return p.reconnecting } - // 设计:这一格的文字是中性色,只有 RTT 数字才染绿/琥珀(首版无 RTT → 全中性)。 - return p.text3 - } - - // 第三格:会话复用层概览(旧左下「会话状态一句话」并入这里,windows 数保留)。 + // 第二格:会话复用层概览(windows 数保留)。 private var contextText: String { if let t = session.tmuxSummary { let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)" diff --git a/apps/TerminalX/iOS/Theme.swift b/apps/TerminalX/iOS/Theme.swift index 5dcd59e..e1182f3 100644 --- a/apps/TerminalX/iOS/Theme.swift +++ b/apps/TerminalX/iOS/Theme.swift @@ -289,8 +289,6 @@ enum TX { static let sidebarWidth: CGFloat = 288 /// pane 之间的缝 static let paneGap: CGFloat = 2 - /// 拖拽热区 - static let paneDragHit: CGFloat = 12 /// 触控命中区下限 static let minTouch: CGFloat = 44 /// 底部区**内容带**高度:舞台提示条 / 侧边栏脚 / 轨道齿轮共用,故三者中心线与上方那条 diff --git a/apps/TerminalX/iOS/TmuxController.swift b/apps/TerminalX/iOS/TmuxController.swift index 182fac2..4bff123 100644 --- a/apps/TerminalX/iOS/TmuxController.swift +++ b/apps/TerminalX/iOS/TmuxController.swift @@ -389,12 +389,11 @@ final class TmuxController: ObservableObject { sendCommand("select-window -t @\(w.raw)") } - func newWindow() { sendCommand("new-window") } - func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") } + // MARK: - 关闭(UI-4) - // MARK: - 关闭 / resize(UI-4) - - // 注:app 内主动分屏(split-window / join-pane)已随「放弃移动端分屏」整体移除; + // 注:window 的增删(new-window / kill-window)与 pane resize 已按「第一版减法」移除 + // —— window/pane 管理完全交给 tmux 自己(prefix+c / prefix+& / prefix+方向键),app 只做 + // 切换与被动渲染;app 内主动分屏(split-window / join-pane)更早已随「放弃移动端分屏」移除。 // 多 pane 的**被动渲染**保留 —— 别的客户端建好的分屏 attach 进来仍按 layout 正确显示。 /// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。 @@ -404,13 +403,6 @@ final class TmuxController: ObservableObject { /// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。 func killSession() { sendCommand("kill-session") } - /// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。 - /// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。 - func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) { - if let cols, cols > 0 { sendCommand("resize-pane -t %\(p.raw) -x \(cols)") } - if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") } - } - /// 某 pane 的 surface 就绪 → 放行其缓冲输出。 func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() } diff --git a/apps/TerminalX/iOS/UIPreviewFixture.swift b/apps/TerminalX/iOS/UIPreviewFixture.swift index 97c5a78..f470376 100644 --- a/apps/TerminalX/iOS/UIPreviewFixture.swift +++ b/apps/TerminalX/iOS/UIPreviewFixture.swift @@ -174,9 +174,10 @@ enum UIPreviewFixture { /// 为什么不用坐标点击:本环境没有 GUI,idb companion 不可用;而坐标点击还会随布局改动失效。 /// 走 URL 直接驱动 `AppRouter`/`SessionManager` 的公开语义,既稳定又正是要验的那条路径。 /// - /// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) · `resize/`(resize-pane) + /// 支持:`home` · `minimize` · `close`(弹确认) · `close-now`(直接关) /// · `pick/<会话名>` · `pick-new/<名>` · `pick-native`(连接前的会话选择器) /// · `dismiss` · `session/<序号>`(1 起,按首页卡片顺序)· `sidebar/toggle|pin` + /// (`resize/` 已随「pane 拖拽调宽」一起删除 —— pane 尺寸交给 tmux 自己。) @MainActor static func handle(uiCommand path: [String], manager: SessionManager, router: AppRouter) -> Bool { switch path.first { @@ -207,14 +208,6 @@ enum UIPreviewFixture { guard let s = manager.sessionAwaitingChoice ?? router.route.sessionID.flatMap(manager.session) else { return false } s.useNativeTerminal() - case "resize": - // 验证 resize-pane 链路(拖拽手势本身是标准 DragGesture):`ui/resize/`。 - guard let id = router.route.sessionID, let s = manager.session(id), - let tmux = s.tmuxController, - let win = tmux.windows.first(where: { $0.id == tmux.activeWindowID }), - let pane = win.activePane, - let cols = path.dropFirst().first.flatMap(Int.init) else { return false } - tmux.resizePane(pane, cols: cols) case "session": guard let n = path.dropFirst().first.flatMap(Int.init), let s = manager.session(atShortcutIndex: n - 1) else { return false }