feat: 修复 3 个渲染/tmux 显示 bug + UI 产品化第一波(对标 Moshi)
渲染/tmux 修复(e2e 复现+回归,模拟器 idb 点击+截图取证): - 终端底部残影:ghostty iOS 释放 surface 不摘 IOSurfaceLayer(上游缺陷), platformSetup 清现存子层 + TerminalSurfaceCoordinator.onSurfaceFreed 钩子摘孤儿层 - tmux 切 tab 切回旧 tab 内容消失:改所有窗口常驻挂载(ZStack+opacity+hitTesting), surface 不释放、内容保留、后台窗口持续收 %output - tmux pane 内容钉底/顶部残行:pane grid 由容器几何定(tab 条吃高度),75 行快照喂 73 行 溢出上滚;几何测量上移 TmuxTabbedView + attach kickoff 双条件(attachAcked∧containerKnown) + capture-pane 铺快照前剔尾空行 UI 产品化(对标 Moshi getmoshi.app:深蓝黑 + 终端绿 + Catppuccin): - 设计系统 Theme.swift:TXPalette(色角色 struct)+TXThemeManager(环境注入),颜色封装供主题系统 - 统一主题:抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),一套同时驱动 app+终端配色, 设置里选主题 app 与终端一起变色 + UserDefaults 持久化 - 主屏/主机管理页 HomeView:HostCard 主机卡 + FAB 加主机(AddHostSheet) + 设置(主题选择器), HostStore 本地 JSON 持久化(密码暂存/TODO Keychain),点卡片 connect(to:) - 终端外壳 chrome TerminalTopBar:圆形返回钮 + 会话标题 + SSH/mosh 传输徽章 + 状态提示, tmux tab 绿 pill 化,底部工具栏深色自适应,终端默认 Catppuccin Mocha iPad mini(A17 Pro) live 端到端验证:加主机→连接→终端→切 tab→分屏点焦点→主题切换同步。 详见 docs/HANDOFF.md §10。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
_ = state.controller.setTheme(.txDefault) // Catppuccin Mocha 终端配色
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.gate = OutputGate(session: session)
|
||||
@@ -72,10 +73,28 @@ final class TmuxController: ObservableObject {
|
||||
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
|
||||
private var pending: [PendingKind] = []
|
||||
|
||||
// attach kickoff(refresh-client + list-windows + 各 pane capture)必须等"容器真实几何已知"再发:
|
||||
// pane 的 ghostty grid 尺寸由容器像素几何决定(tab 条吃掉顶部高度),一开始就 ≠ raw 全屏格子。
|
||||
// 若先按 raw 尺寸 attach+capture,快照行数 > pane grid 行数 → 铺快照时溢出上滚 → 提示符被拆/内容钉底。
|
||||
// 故 kickoff 双条件:attach 首应答已到 ∧ 容器几何已到(先到者等后到者触发)。
|
||||
private var containerKnown = false
|
||||
private var kickoffDone = false
|
||||
|
||||
init(sendRaw: @escaping @Sendable (Data) -> Void) {
|
||||
self.sendRaw = sendRaw
|
||||
}
|
||||
|
||||
/// attach 序列启动器:仅当 attach 应答与容器几何都就绪时发一次(refresh-client 用容器换算尺寸)。
|
||||
private func kickoffIfReady() {
|
||||
guard attachAcked, containerKnown, !kickoffDone else { return }
|
||||
kickoffDone = true
|
||||
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
|
||||
pending.append(.ignore)
|
||||
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
|
||||
pending.append(.listWindows)
|
||||
}
|
||||
|
||||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
|
||||
|
||||
@@ -94,10 +113,13 @@ final class TmuxController: ObservableObject {
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
|
||||
// 首个真实几何:立即触发 attach kickoff(不 debounce),保证 attach 首帧起 client 格子==容器格子。
|
||||
containerKnown = true
|
||||
if !kickoffDone { kickoffIfReady(); return }
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 合并拖拽期间的连续变化
|
||||
guard !Task.isCancelled, let self, self.attachAcked else { return }
|
||||
guard !Task.isCancelled, let self, self.kickoffDone else { return }
|
||||
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
|
||||
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux 重排 → 回 %layout-change → 更新 frame
|
||||
}
|
||||
@@ -203,11 +225,9 @@ final class TmuxController: ObservableObject {
|
||||
private func handleCommandResponse(_ r: TmuxCommandResponse) {
|
||||
if !attachAcked {
|
||||
attachAcked = true
|
||||
// 先设客户端尺寸(tmux 据此布局),再枚举——枚举到的 layout 已是我方尺寸,避免先渲一帧再重排的闪跳。
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)")
|
||||
pending.append(.ignore)
|
||||
sendCommand("list-windows -F \"#{window_id}\t#{window_name}\t#{window_active}\t#{window_layout}\t#{window_visible_layout}\"")
|
||||
pending.append(.listWindows)
|
||||
// kickoff 延后到容器几何已知(见 kickoffIfReady):避免用 raw 全屏格子 attach 导致
|
||||
// 快照行数 > pane grid 行数、铺快照溢出上滚。几何先到则此处直接触发。
|
||||
kickoffIfReady()
|
||||
return
|
||||
}
|
||||
guard !pending.isEmpty else { return }
|
||||
@@ -224,7 +244,12 @@ final class TmuxController: ObservableObject {
|
||||
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
||||
}
|
||||
case .capturePane(let p):
|
||||
let snapshot = r.lines.joined(separator: "\r\n")
|
||||
// 剔除尾部空行:capture-pane 常返回补满 pane 高度的尾随空行,若行数 > pane grid 行数,
|
||||
// 铺快照(ESC[H + N-1 个 \r\n)会溢出上滚、把顶部内容顶出屏幕(提示符被拆/内容钉底)。
|
||||
// ESC[H 铺法本身正确,前提是行数 ≤ grid 行数;trim 后冷启动近空屏恒成立。
|
||||
var lines = r.lines
|
||||
while lines.last?.isEmpty == true { lines.removeLast() }
|
||||
let snapshot = lines.joined(separator: "\r\n")
|
||||
if let s = surfaceByPane[p] {
|
||||
if !snapshot.isEmpty {
|
||||
// 归位后铺快照(不尾随换行,避免滚屏)。
|
||||
@@ -284,6 +309,11 @@ final class TmuxController: ObservableObject {
|
||||
|
||||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||||
|
||||
/// 切换主题:把新终端配色推给所有已存在的 pane surface。
|
||||
func applyTerminalTheme(_ t: TerminalTheme) {
|
||||
for s in surfaceByPane.values { _ = s.state.controller.setTheme(t) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 传输字节分流器:检测 `tmux -CC` 进入 DCS 前 → 原始终端;进入后 → tmux 网关。
|
||||
|
||||
Reference in New Issue
Block a user