feat: tmux 多 pane 分屏(pane-per-surface + refresh-client -C 尺寸协调)
- TmuxController 重构为 pane-per-surface:TmuxPaneSurface(session+state+gate/pane) + TmuxWindow(panes 字典/visibleLayout 渲染/fullLayout diff) + surfaceByPane O(1) 路由 - 尺寸走路线 b:attach 发 refresh-client -C WxH 让 tmux 布局,pane surface 尺寸取 layout rect(%output 按 tmux pane 宽高排版,一致才不换行/清屏错乱);surface resize 只断言不反向驱动 - applyLayout reconcile(增删留、复用不重建);新 pane capture-pane %end 前丢弃 %output - 输入绑 pane→send-keys -t %self;tap→select-pane;ContentView 按 rect 绝对定位分屏 + 焦点边框 - 前置修复:tmux 网关字节改 TmuxByteChannel(AsyncStream 单消费者)保序,防多 pane 打碎协议 - 验证(192.168.9.199 直连 tmux -CC attach):左右 pane 各渲染 LEFT/RIGHT 输出、实时、无错乱 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import GhosttyTerminal
|
||||
import TXCore
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SSHTerminalModel()
|
||||
@@ -162,19 +163,75 @@ struct TmuxTabChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
|
||||
struct TmuxWindowView: View {
|
||||
@ObservedObject var window: TmuxWindow
|
||||
let controller: TmuxController
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if let layout = window.visibleLayout {
|
||||
let root = layout.root.rect
|
||||
let cw = geo.size.width / CGFloat(max(1, root.width))
|
||||
let ch = geo.size.height / CGFloat(max(1, root.height))
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(leaves(layout.root), id: \.pane.raw) { item in
|
||||
if let surface = window.panes[item.pane] {
|
||||
TmuxPaneView(
|
||||
surface: surface,
|
||||
isActive: window.activePane == item.pane,
|
||||
onTap: { controller.selectPane(item.pane, in: window.id) },
|
||||
onReady: { controller.markPaneReady(item.pane) }
|
||||
)
|
||||
.frame(width: CGFloat(item.rect.width) * cw,
|
||||
height: CGFloat(item.rect.height) * ch)
|
||||
.position(x: (CGFloat(item.rect.x) + CGFloat(item.rect.width) / 2) * cw,
|
||||
y: (CGFloat(item.rect.y) + CGFloat(item.rect.height) / 2) * ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集布局树叶子(pane + 绝对 rect),identity 只用 paneID → 布局变化不重建 surface view。
|
||||
private func leaves(_ node: TmuxLayout.Node) -> [(pane: TmuxPaneID, rect: TmuxLayout.Rect)] {
|
||||
switch node {
|
||||
case .leaf(let p, let r): return [(p, r)]
|
||||
case .horizontal(let cs, _), .vertical(let cs, _): return cs.flatMap(leaves)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 pane 的终端面 + 焦点边框 + 点击选中。
|
||||
struct TmuxPaneView: View {
|
||||
@ObservedObject var surface: TmuxPaneSurface
|
||||
let isActive: Bool
|
||||
let onTap: () -> Void
|
||||
let onReady: () -> Void
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
TerminalSurfaceView(context: window.state)
|
||||
TerminalSurfaceView(context: surface.state)
|
||||
.terminalFocusOnAppear($focused)
|
||||
.overlay(
|
||||
Rectangle().stroke(isActive ? Color.accentColor : Color.gray.opacity(0.35),
|
||||
lineWidth: isActive ? 2 : 0.5)
|
||||
)
|
||||
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
|
||||
.overlay {
|
||||
if !isActive {
|
||||
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
.onChange(of: isActive) { _, active in focused = active }
|
||||
.task {
|
||||
for _ in 0 ..< 300 where window.state.surfaceSize == nil {
|
||||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||
}
|
||||
controller.markWindowReady(window.id)
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user