feat: 修复终端换行错乱根因 + 顶带重设计 + 移除移动端分屏
- 换行错乱根因:tmux 客户端格子改以 ghostty 实测为权威(像素除法 偏大 1 行/列 → 远端按更宽排版、本地自动换行掉字符),单 pane 实测 格子直发 refresh-client;kickoff 用 raw surface 真实格子;顶栏 格子数 tmux 模式读 clientGrid(模拟器连本机 sshd 端到端验证) - 顶带落地为真实 chrome 行:✕/⌄ 独立按钮组 + 弱化标题 + TmuxWindowTabGroup window tab 组,pane 不再被悬浮胶囊遮内容 - 放弃移动端分屏:删 SplitMenu/分屏入口,tmux window 切换上移顶部 tab,侧边栏改列活动会话;底部动作条合并状态行 - Info.plist 补 NSLocalNetworkUsageDescription(LAN 直连必需, 缺失时 socket 静默超时) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,13 +16,16 @@ final class TmuxPaneSurface: ObservableObject, Identifiable {
|
||||
|
||||
init(id: TmuxPaneID, cols: Int, rows: Int,
|
||||
onInput: @escaping @Sendable (Data) -> Void,
|
||||
onMetrics: @escaping @Sendable (Int, Int) -> Void) {
|
||||
onViewport: @escaping @Sendable (Int, Int, Int, Int) -> Void) {
|
||||
self.id = id
|
||||
self.grid = (cols, rows)
|
||||
let session = InMemoryTerminalSession(
|
||||
write: { data in onInput(data) }, // 输入直接绑到本 pane → send-keys -t %self
|
||||
// tmux 模式:surface resize 只回报 cell 像素供换算,绝不反向驱动 tmux(防反馈环)。
|
||||
resize: { vp in onMetrics(Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
// tmux 模式:surface resize 上报**真实格子 + cell 像素**(cols/rows/cellW/cellH)。
|
||||
// controller 只在「单 pane window」时把它当客户端尺寸的权威(见 paneViewportChanged),
|
||||
// 多 pane 下仅刷新 cell 缓存 —— 依旧不构成反馈环。
|
||||
resize: { vp in onViewport(Int(vp.columns), Int(vp.rows),
|
||||
Int(vp.cellWidthPixels), Int(vp.cellHeightPixels)) }
|
||||
)
|
||||
let state = TerminalViewState()
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
|
||||
@@ -54,16 +57,6 @@ final class TmuxWindow: ObservableObject, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 tmux 会话的概要(`list-sessions` 结果)。分屏菜单列「分到哪里」用。
|
||||
struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
let windows: Int
|
||||
/// 是否已被某客户端 attach(当前会话即 attached 的那个)。
|
||||
let isAttached: Bool
|
||||
let lastActivity: Date?
|
||||
}
|
||||
|
||||
/// tmux control-mode 网关:消费 `%`-协议流,把 window 映射为原生 tab、window 内多 pane 映射为分屏。
|
||||
/// 尺寸走"路线 b":app 按全屏格子发 `refresh-client -C WxH` 让 tmux 布局,pane surface 尺寸严格取
|
||||
/// layout rect(%output 按 tmux pane 宽高排版,二者一致才不换行/清屏错乱)。
|
||||
@@ -71,8 +64,6 @@ struct TmuxSessionInfo: Identifiable, Equatable {
|
||||
final class TmuxController: ObservableObject {
|
||||
@Published private(set) var windows: [TmuxWindow] = []
|
||||
@Published var activeWindowID: TmuxWindowID?
|
||||
/// 本 tmux server 上的会话(`listSessions()` 填充,分屏菜单用)。
|
||||
@Published private(set) var sessions: [TmuxSessionInfo] = []
|
||||
|
||||
private let parser = TmuxControlParser()
|
||||
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
|
||||
@@ -82,6 +73,8 @@ final class TmuxController: ObservableObject {
|
||||
var onExit: (() -> Void)?
|
||||
|
||||
private var clientCols = 80, clientRows = 24
|
||||
/// 客户端格子对外快照(顶栏胶囊显示用 —— tmux 模式下 raw 的 screenGrid 已随 RawTerminalView 卸载冻结)。
|
||||
@Published private(set) var clientGrid: (cols: Int, rows: Int) = (0, 0)
|
||||
private var cellWpx = 0, cellHpx = 0 // cell 设备像素(换算容器点尺寸→格子)
|
||||
private var lastSentCols = 0, lastSentRows = 0
|
||||
private var resizeDebounce: Task<Void, Never>?
|
||||
@@ -89,12 +82,11 @@ final class TmuxController: ObservableObject {
|
||||
// control-mode 命令应答 FIFO 匹配。
|
||||
private var attachAcked = false
|
||||
private enum PendingKind: CustomStringConvertible {
|
||||
case ignore, listWindows, listSessions, currentSession, capturePane(TmuxPaneID)
|
||||
case ignore, listWindows, currentSession, capturePane(TmuxPaneID)
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ignore: "ignore"
|
||||
case .listWindows: "listWindows"
|
||||
case .listSessions: "listSessions"
|
||||
case .currentSession: "currentSession"
|
||||
case .capturePane(let p): "capturePane(%\(p.raw))"
|
||||
}
|
||||
@@ -122,8 +114,9 @@ final class TmuxController: ObservableObject {
|
||||
private func kickoffIfReady() {
|
||||
guard attachAcked, containerKnown, !kickoffDone else { return }
|
||||
kickoffDone = true
|
||||
lastSentCols = clientCols; lastSentRows = clientRows // 后续实测同值时不再重发
|
||||
NSLog("TMUXDBG kickoff refresh-client -C \(clientCols)x\(clientRows)")
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // 此刻已是容器真实格子
|
||||
sendCommand("refresh-client -C \(clientCols)x\(clientRows)") // raw surface 实测格子(非像素换算)
|
||||
pending.append(.ignore)
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-windows -F \"#{window_id}\(sep)#{window_name}\(sep)#{window_active}\(sep)#{window_layout}\(sep)#{window_visible_layout}\(sep)#{pane_current_path}\(sep)#{window_index}\"")
|
||||
@@ -132,30 +125,64 @@ final class TmuxController: ObservableObject {
|
||||
// 服务器上别的客户端 attach 着其它会话时会猜错(真机实测把 `demo` 当成了当前会话)。
|
||||
sendCommand("display-message -p \"#{session_name}\"")
|
||||
pending.append(.currentSession)
|
||||
listSessions() // 分屏菜单要列全部会话
|
||||
}
|
||||
|
||||
/// 由 model 在 attach 前告知全屏格子(tmux 据此 refresh-client -C 布局)。
|
||||
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
|
||||
func setClientSize(cols: Int, rows: Int) {
|
||||
clientCols = max(1, cols); clientRows = max(1, rows)
|
||||
clientGrid = (clientCols, clientRows)
|
||||
}
|
||||
|
||||
/// cell 设备像素(来自 raw 终端或 pane surface metrics)。
|
||||
func setCellPixels(w: Int, h: Int) { if w > 0 { cellWpx = w }; if h > 0 { cellHpx = h } }
|
||||
/// pane surface metrics 回报(仅刷新 cell 缓存,绝不反向驱动 tmux)。
|
||||
func noteCellPixels(w: Int, h: Int) { setCellPixels(w: w, h: h) }
|
||||
|
||||
/// pane surface 的真实格子上报 —— **客户端尺寸的权威来源**。
|
||||
///
|
||||
/// 像素除法(containerResized)复刻不了 ghostty 内部的取整/内边距:真机与模拟器实测
|
||||
/// 「容器 px ÷ 整数 cell px」比 surface 真实格子**偏大 1 行/列**(如算得 164×51、真实 163×50)。
|
||||
/// 偏大意味着远端按更宽的行排版、surface 按真实宽度自动换行 → 每条满宽行掉字符,
|
||||
/// 全屏 TUI(Claude Code 等)重绘几轮就积累成行首碎片(真机花屏根因)。
|
||||
/// 单 pane window 的 pane 帧恒等于容器帧,其 ghostty 实测格子就是客户端应有的格子 ——
|
||||
/// 用它发 refresh-client。帧尺寸只由容器决定、不受 tmux 布局影响,故没有反馈环。
|
||||
func paneViewportChanged(pane: TmuxPaneID, cols: Int, rows: Int, cellW: Int, cellH: Int) {
|
||||
setCellPixels(w: cellW, h: cellH)
|
||||
guard kickoffDone, cols > 0, rows > 0 else { return }
|
||||
// 多 pane window 的 pane 帧只是容器的比例切片,格子代表不了客户端整体 → 只刷 cell 缓存。
|
||||
guard let w = paneToWindow[pane], windowByID[w]?.panes.count == 1 else { return }
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
clientGrid = (cols, rows)
|
||||
resizeDebounce?.cancel()
|
||||
resizeDebounce = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 120_000_000) // 合并侧边栏动画期间的连续变化
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
NSLog("TMUXDBG pane-measured refresh-client -C \(cols)x\(rows)")
|
||||
self.sendCommand("refresh-client -C \(cols)x\(rows)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 容器尺寸变化(iPadOS 拖拽窗口/旋转/分屏):换算总格子 → 去重 + debounce → refresh-client -C。
|
||||
/// 单向数据流:尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",绝不来自 surface resize 回调(防反馈环)。
|
||||
/// 尺寸只来自"容器点尺寸 × displayScale ÷ cell 像素",**仅作兜底**:有单 pane window 在场时
|
||||
/// 让位给 paneViewportChanged 的实测格子(像素除法会偏大 1 行/列,见其注释)。
|
||||
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
|
||||
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { return }
|
||||
containerKnown = true
|
||||
if !kickoffDone {
|
||||
// kickoff 初值不用像素换算覆盖 —— setClientSize 已带 raw surface 的真实格子
|
||||
// (attach 前 RawTerminalView 与 TmuxStage 同槽同尺寸,ghostty 实测值可直接沿用)。
|
||||
NSLog("TMUXDBG containerKnown pt=\(Int(widthPt))x\(Int(heightPt)) -> kickoff with real grid \(clientCols)x\(clientRows)")
|
||||
kickoffIfReady()
|
||||
return
|
||||
}
|
||||
if windows.contains(where: { $0.panes.count == 1 }) { return } // 实测路径接管
|
||||
let cols = max(1, Int(widthPt * scale / CGFloat(cellWpx)))
|
||||
let rows = max(1, Int(heightPt * scale / CGFloat(cellHpx)))
|
||||
guard cols != lastSentCols || rows != lastSentRows else { return }
|
||||
lastSentCols = cols; lastSentRows = rows
|
||||
clientCols = cols; clientRows = rows
|
||||
clientGrid = (cols, 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) // 合并拖拽期间的连续变化
|
||||
@@ -256,7 +283,9 @@ final class TmuxController: ObservableObject {
|
||||
let surface = TmuxPaneSurface(
|
||||
id: p, cols: cols, rows: rows,
|
||||
onInput: { [weak self] data in self?.sendKeys(pane: p, data: data) },
|
||||
onMetrics: { [weak self] w, h in Task { @MainActor in self?.noteCellPixels(w: w, h: h) } }
|
||||
onViewport: { [weak self] c, r, cw, ch in
|
||||
Task { @MainActor in self?.paneViewportChanged(pane: p, cols: c, rows: r, cellW: cw, cellH: ch) }
|
||||
}
|
||||
)
|
||||
surfaceByPane[p] = surface
|
||||
win.panes[p] = surface
|
||||
@@ -293,22 +322,6 @@ final class TmuxController: ObservableObject {
|
||||
if parts[2] == "1" { activeWindowID = w }
|
||||
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
|
||||
}
|
||||
case .listSessions:
|
||||
sessions = r.lines.compactMap { line in
|
||||
let f = line.components(separatedBy: Self.fieldSep)
|
||||
guard f.count >= 3 else { return nil }
|
||||
let activity = f.count >= 4 ? Double(f[3]).map { Date(timeIntervalSince1970: $0) } : nil
|
||||
return TmuxSessionInfo(name: f[0],
|
||||
windows: Int(f[1]) ?? 1,
|
||||
isAttached: f[2] == "1",
|
||||
lastActivity: activity)
|
||||
}
|
||||
NSLog("TMUXDBG sessions=\(sessions.map { "\($0.name)/w\($0.windows)/a\($0.isAttached)" })")
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
NSLog("TMUXDBG currentSession=\(name)")
|
||||
}
|
||||
case .currentSession:
|
||||
if let name = r.lines.first?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
currentSessionName = name
|
||||
@@ -379,7 +392,10 @@ final class TmuxController: ObservableObject {
|
||||
func newWindow() { sendCommand("new-window") }
|
||||
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
|
||||
|
||||
// MARK: - 关闭 / 分屏 / resize(UI-4)
|
||||
// MARK: - 关闭 / resize(UI-4)
|
||||
|
||||
// 注:app 内主动分屏(split-window / join-pane)已随「放弃移动端分屏」整体移除;
|
||||
// 多 pane 的**被动渲染**保留 —— 别的客户端建好的分屏 attach 进来仍按 layout 正确显示。
|
||||
|
||||
/// 断开 tmux 客户端:**服务端会话继续跑**,下次原样接回。红点确认里的推荐动作。
|
||||
/// tmux 会回 `%exit` → 既有 `onExit` 链路收尾。
|
||||
@@ -388,24 +404,6 @@ final class TmuxController: ObservableObject {
|
||||
/// 结束当前 tmux 会话(服务端进程一并结束)。红点确认里的危险次要动作,需再确认一次。
|
||||
func killSession() { sendCommand("kill-session") }
|
||||
|
||||
/// 分屏。tmux 语义:`-h` 左右并排(竖直分隔线)、`-v` 上下堆叠。
|
||||
/// 只发命令即可 —— tmux 回 `%layout-change`,既有 reconcile 会为新 pane 建 surface
|
||||
/// 并走 capture-pane 快照链路(见 `applyLayout` / `ensurePane`)。
|
||||
func splitWindow(vertical: Bool, joinSession: String? = nil) {
|
||||
if let s = joinSession {
|
||||
// 把另一个会话的当前 window 拉进本 window 当 pane(tmux 原生支持,不嵌套 tmux)。
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(s):")
|
||||
} else {
|
||||
sendCommand("split-window \(vertical ? "-h" : "-v")")
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建 tmux 会话并把它的 window 拉进当前 window 分屏(detached 建,避免抢走客户端)。
|
||||
func splitIntoNewSession(vertical: Bool, name: String) {
|
||||
sendCommand("new-session -d -s \(name)")
|
||||
sendCommand("join-pane \(vertical ? "-h" : "-v") -s \(name):")
|
||||
}
|
||||
|
||||
/// 拖 pane 分隔条**松手后**调整尺寸(拖动中只动视觉、不发命令,见设计约束)。
|
||||
/// 发完等 `%layout-change` 回来再更新真实 frame —— 与"路线 b"一致,客户端不自行重排。
|
||||
func resizePane(_ p: TmuxPaneID, cols: Int? = nil, rows: Int? = nil) {
|
||||
@@ -413,13 +411,6 @@ final class TmuxController: ObservableObject {
|
||||
if let rows, rows > 0 { sendCommand("resize-pane -t %\(p.raw) -y \(rows)") }
|
||||
}
|
||||
|
||||
/// 枚举本 tmux server 上的会话(分屏菜单要列 detached 会话)。结果进 `sessions`。
|
||||
func listSessions() {
|
||||
let sep = Self.fieldSep
|
||||
sendCommand("list-sessions -F \"#{session_name}\(sep)#{session_windows}\(sep)#{session_attached}\(sep)#{session_activity}\"")
|
||||
pending.append(.listSessions)
|
||||
}
|
||||
|
||||
/// 某 pane 的 surface 就绪 → 放行其缓冲输出。
|
||||
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user