Files
terminalX/apps/TerminalX/iOS/TmuxController.swift
kid 7abe61a71f feat: tmux 动态 resize(iPadOS 拖拽窗口/旋转自适应)
- ContentView 观测 onChange(geo.size)/onAppear → controller.containerResized
- containerResized: 点尺寸 × displayScale ÷ cell 像素 = 目标 cols×rows,去重 + debounce(200ms)
  → refresh-client -C WxH → tmux 重排回 %layout-change → 更新 pane frame
- cell 像素来自 raw 终端/pane surface metrics(InMemoryTerminalViewport.cellWidthPixels);
  surface resize 回调只 noteCellPixels 不驱动 tmux(防反馈环,单向数据流)
- 验证:容器 1032x1280@2x cell16x35→129x73,tmux 根 rect 精确采纳,2 pane 复用不重建

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-24 17:42:45 +08:00

336 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import GhosttyTerminal
import TXCore
/// pane session + surface state +
@MainActor
final class TmuxPaneSurface: ObservableObject, Identifiable {
let id: TmuxPaneID
let state: TerminalViewState
let session: InMemoryTerminalSession
let gate: OutputGate
/// layout rect cols×rows frame
var grid: (cols: Int, rows: Int)
/// `capture-pane` pane %outputtmux 线
var awaitingCapture = true
init(id: TmuxPaneID, cols: Int, rows: Int,
onInput: @escaping @Sendable (Data) -> Void,
onMetrics: @escaping @Sendable (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)) }
)
let state = TerminalViewState()
state.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
self.session = session
self.state = state
self.gate = OutputGate(session: session)
}
}
/// tmux window pane surface + / diff
@MainActor
final class TmuxWindow: ObservableObject, Identifiable {
let id: TmuxWindowID
@Published var title: String
@Published var panes: [TmuxPaneID: TmuxPaneSurface] = [:]
/// zoom zoom pane
@Published var visibleLayout: TmuxLayout?
/// pane diff
var fullLayout: TmuxLayout?
@Published var activePane: TmuxPaneID?
init(id: TmuxWindowID, title: String) { self.id = id; self.title = title }
}
/// tmux control-mode `%`- window tabwindow pane
/// "线 b"app `refresh-client -C WxH` tmux pane surface
/// layout rect%output tmux pane /
@MainActor
final class TmuxController: ObservableObject {
@Published private(set) var windows: [TmuxWindow] = []
@Published var activeWindowID: TmuxWindowID?
private let parser = TmuxControlParser()
private var windowByID: [TmuxWindowID: TmuxWindow] = [:]
private var surfaceByPane: [TmuxPaneID: TmuxPaneSurface] = [:] // %output O(1) pane
private var paneToWindow: [TmuxPaneID: TmuxWindowID] = [:]
private let sendRaw: @Sendable (Data) -> Void
var onExit: (() -> Void)?
private var clientCols = 80, clientRows = 24
private var cellWpx = 0, cellHpx = 0 // cell
private var lastSentCols = 0, lastSentRows = 0
private var resizeDebounce: Task<Void, Never>?
// control-mode FIFO
private var attachAcked = false
private enum PendingKind { case ignore, listWindows, capturePane(TmuxPaneID) }
private var pending: [PendingKind] = []
init(sendRaw: @escaping @Sendable (Data) -> Void) {
self.sendRaw = sendRaw
}
/// model attach tmux refresh-client -C
func setClientSize(cols: Int, rows: Int) { clientCols = max(1, cols); clientRows = max(1, rows) }
/// 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) }
/// iPadOS // + debounce refresh-client -C
/// " × displayScale ÷ cell " surface resize
func containerResized(widthPt: CGFloat, heightPt: CGFloat, scale: CGFloat) {
guard cellWpx > 0, cellHpx > 0, widthPt > 0, heightPt > 0, scale > 0 else { 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
NSLog("TMUXDBG containerResized pt=\(Int(widthPt))x\(Int(heightPt)) scale=\(scale) cell=\(cellWpx)x\(cellHpx) -> \(cols)x\(rows)")
resizeDebounce?.cancel()
resizeDebounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 200_000_000) //
guard !Task.isCancelled, let self, self.attachAcked else { return }
NSLog("TMUXDBG refresh-client -C \(cols)x\(rows)")
self.sendCommand("refresh-client -C \(cols)x\(rows)") // tmux %layout-change frame
}
}
var activeWindow: TmuxWindow? {
guard let id = activeWindowID else { return windows.first }
return windowByID[id]
}
/// control-mode
func feed(_ data: Data) {
for event in parser.feed(data) { apply(event) }
}
// MARK: -
private func apply(_ event: TmuxEvent) {
switch event {
case .windowAdd(let w):
_ = ensureWindow(w) // %layout-change
case .windowClose(let w), .unlinkedWindowClose(let w):
removeWindow(w)
case .windowRenamed(let w, let name):
windowByID[w]?.title = name
case .layoutChange(let w, let layout, let visible):
applyLayout(window: w, full: layout, visible: visible ?? layout)
case .windowPaneChanged(let w, let pane):
paneToWindow[pane] = w
windowByID[w]?.activePane = pane // select-pane
case .output(let pane, let data):
routeOutput(pane, data)
case .extendedOutput(let pane, _, let data):
routeOutput(pane, data)
case .sessionWindowChanged(_, let w):
if windowByID[w] != nil { activeWindowID = w }
case .commandResponse(let response):
handleCommandResponse(response)
case .exit:
onExit?()
default:
break
}
}
private func routeOutput(_ pane: TmuxPaneID, _ data: Data) {
guard let s = surfaceByPane[pane] else { return }
if s.awaitingCapture { return } // capture
s.gate.deliver(data)
}
/// layout reconcile pane surface//
private func applyLayout(window w: TmuxWindowID, full: String, visible: String) {
let win = ensureWindow(w)
guard let fullTree = try? TmuxLayout.parse(full) else { return }
let visTree = (try? TmuxLayout.parse(visible)) ?? fullTree
let newPanes = Set(fullTree.paneIDs)
// pane
for p in Array(win.panes.keys) where !newPanes.contains(p) {
win.panes[p] = nil; surfaceByPane[p] = nil; paneToWindow[p] = nil
}
// pane surface zoomgrid rect
walkLeaves(fullTree.root) { pane, rect in
ensurePane(pane, window: w, cols: rect.width, rows: rect.height)
}
walkLeaves(visTree.root) { pane, rect in
surfaceByPane[pane]?.grid = (rect.width, rect.height)
}
win.fullLayout = fullTree
win.visibleLayout = visTree // SwiftUI
let rr = fullTree.root.rect
NSLog("TMUXDBG layout win=@\(w.raw) root=\(rr.width)x\(rr.height) panes=\(fullTree.paneIDs.count)")
if win.activePane == nil || !newPanes.contains(win.activePane!) {
win.activePane = fullTree.paneIDs.first
}
}
private func walkLeaves(_ node: TmuxLayout.Node, _ visit: (TmuxPaneID, TmuxLayout.Rect) -> Void) {
switch node {
case .leaf(let p, let r): visit(p, r)
case .horizontal(let cs, _), .vertical(let cs, _): cs.forEach { walkLeaves($0, visit) }
}
}
private func ensurePane(_ p: TmuxPaneID, window w: TmuxWindowID, cols: Int, rows: Int) {
paneToWindow[p] = w
guard let win = windowByID[w] else { return }
if let existing = surfaceByPane[p] { existing.grid = (cols, rows); return } //
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) } }
)
surfaceByPane[p] = surface
win.panes[p] = surface
// capture %end %output
pending.append(.capturePane(p))
sendCommand("capture-pane -t %\(p.raw) -p -e -J")
}
/// control-mode attach + windows FIFO
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)
return
}
guard !pending.isEmpty else { return }
switch pending.removeFirst() {
case .ignore:
break
case .listWindows:
for line in r.lines {
let parts = line.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 5, let w = TmuxWindowID.parse(parts[0]) else { continue }
let win = ensureWindow(w)
win.title = String(parts[1])
if parts[2] == "1" { activeWindowID = w }
applyLayout(window: w, full: String(parts[3]), visible: String(parts[4]))
}
case .capturePane(let p):
let snapshot = r.lines.joined(separator: "\r\n")
if let s = surfaceByPane[p] {
if !snapshot.isEmpty {
//
s.gate.deliver(Data(("\u{1b}[H" + snapshot).utf8))
}
s.awaitingCapture = false // %output
}
}
}
@discardableResult
private func ensureWindow(_ w: TmuxWindowID) -> TmuxWindow {
if let existing = windowByID[w] { return existing }
let win = TmuxWindow(id: w, title: "窗口 \(w.raw)")
windowByID[w] = win
windows.append(win)
windows.sort { $0.id.raw < $1.id.raw }
if activeWindowID == nil { activeWindowID = w }
return win
}
private func removeWindow(_ w: TmuxWindowID) {
if let win = windowByID[w] {
for p in win.panes.keys { surfaceByPane[p] = nil }
}
windowByID[w] = nil
windows.removeAll { $0.id == w }
paneToWindow = paneToWindow.filter { $0.value != w }
if activeWindowID == w { activeWindowID = windows.first?.id }
}
// MARK: - /
func sendCommand(_ command: String) {
sendRaw(Data((command + "\n").utf8))
}
/// pane surface send-keys -H pane线hop 线
private nonisolated func sendKeys(pane p: TmuxPaneID, data: Data) {
let hex = data.map { String(format: "%02x", $0) }.joined(separator: " ")
Task { @MainActor in self.sendCommand("send-keys -t %\(p.raw) -H \(hex)") }
}
/// pane + tmux%window-pane-changed
func selectPane(_ p: TmuxPaneID, in w: TmuxWindowID) {
windowByID[w]?.activePane = p
sendCommand("select-pane -t %\(p.raw)")
}
func selectWindow(_ w: TmuxWindowID) {
activeWindowID = w
sendCommand("select-window -t @\(w.raw)")
}
func newWindow() { sendCommand("new-window") }
func killWindow(_ w: TmuxWindowID) { sendCommand("kill-window -t @\(w.raw)") }
/// pane surface
func markPaneReady(_ p: TmuxPaneID) { surfaceByPane[p]?.gate.markReady() }
}
/// `tmux -CC` DCS tmux
/// 线线 rawGate hop 线
final class TmuxRouter: @unchecked Sendable {
private let lock = NSLock()
private let rawGate: OutputGate
private let enterGateway: @Sendable ([UInt8]) -> Void
private let gatewayBytes: @Sendable ([UInt8]) -> Void
private var carry: [UInt8] = []
private var inGateway = false
init(rawGate: OutputGate,
enterGateway: @escaping @Sendable ([UInt8]) -> Void,
gatewayBytes: @escaping @Sendable ([UInt8]) -> Void) {
self.rawGate = rawGate
self.enterGateway = enterGateway
self.gatewayBytes = gatewayBytes
}
func feed(_ data: Data) {
lock.lock(); defer { lock.unlock() }
if inGateway {
gatewayBytes(Array(data))
return
}
let buf = carry + Array(data)
if let idx = TmuxControlSequence.find(TmuxControlSequence.enter, in: buf) {
let before = Array(buf[0 ..< idx])
let after = Array(buf[(idx + TmuxControlSequence.enter.count)...])
if !before.isEmpty { rawGate.deliver(Data(before)) }
inGateway = true
carry = []
enterGateway(after)
} else {
//
let k = TmuxControlSequence.partialTrailingMatchLength(TmuxControlSequence.enter, in: buf)
if buf.count > k {
rawGate.deliver(Data(buf[0 ..< (buf.count - k)]))
}
carry = k > 0 ? Array(buf.suffix(k)) : []
}
}
/// tmux 退
func reset() {
lock.lock(); inGateway = false; carry = []; lock.unlock()
}
}