渲染/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>
608 lines
24 KiB
Swift
608 lines
24 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
import GhosttyTerminal
|
||
import TXCore
|
||
|
||
struct ContentView: View {
|
||
@StateObject private var model = SSHTerminalModel()
|
||
@StateObject private var hostStore = HostStore()
|
||
@StateObject private var theme = TXThemeManager()
|
||
@Environment(\.scenePhase) private var scenePhase
|
||
|
||
var body: some View {
|
||
Group {
|
||
// M1 纯自检分支:仅 -txTsnetKey(无 -txHost)时跑 tsnet 加入+对端枚举自检。
|
||
if let tsnetKey = UserDefaults.standard.string(forKey: "txTsnetKey"), !tsnetKey.isEmpty,
|
||
(UserDefaults.standard.string(forKey: "txHost") ?? "").isEmpty {
|
||
TsnetProbeView(authKey: tsnetKey)
|
||
} else if model.showsTerminal {
|
||
TerminalScreen(model: model)
|
||
} else {
|
||
HomeView(model: model, store: hostStore)
|
||
}
|
||
}
|
||
.environmentObject(theme)
|
||
.preferredColorScheme(theme.palette.isDark ? .dark : .light)
|
||
.onChange(of: theme.palette.id) { _, _ in
|
||
// 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给已连接的 surface。
|
||
model.applyTerminalTheme(theme.palette.terminalTheme)
|
||
}
|
||
.onAppear { model.autoConnectIfConfigured() }
|
||
.onChange(of: scenePhase) { _, phase in
|
||
switch phase {
|
||
case .background: model.enterBackground()
|
||
case .active: model.enterForeground()
|
||
default: break
|
||
}
|
||
}
|
||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||
get: { model.hostKeyMismatch != nil },
|
||
set: { if !$0 { model.dismissHostKeyMismatch() } }
|
||
), presenting: model.hostKeyMismatch) { _ in
|
||
Button("信任新密钥", role: .destructive) { model.trustNewHostKey() }
|
||
Button("取消", role: .cancel) { model.dismissHostKeyMismatch() }
|
||
} message: { info in
|
||
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 主屏:主机列表(对标 Moshi)。卡片式主机 + 右下 FAB 加主机 + 右上设置入口。
|
||
struct HomeView: View {
|
||
@ObservedObject var model: SSHTerminalModel
|
||
@ObservedObject var store: HostStore
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@State private var showAdd = false
|
||
@State private var showSettings = false
|
||
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
p.bg.ignoresSafeArea()
|
||
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: TX.Space.m) {
|
||
sectionHeader("连接")
|
||
if store.hosts.isEmpty {
|
||
emptyState
|
||
} else {
|
||
ForEach(store.hosts) { host in
|
||
HostCard(host: host) { model.connect(to: host) }
|
||
.contextMenu {
|
||
Button(role: .destructive) { store.remove(host) } label: {
|
||
Label("删除", systemImage: "trash")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if isBusy || model.phaseText.hasPrefix("失败") {
|
||
statusRow
|
||
}
|
||
}
|
||
.padding(TX.Space.m)
|
||
.padding(.bottom, 90) // 给 FAB 留空
|
||
}
|
||
|
||
fab
|
||
}
|
||
.background(p.bg)
|
||
.toolbar {
|
||
ToolbarItem(placement: .principal) {
|
||
Text("terminalX")
|
||
.font(TX.Font.mono(17, .semibold))
|
||
.foregroundStyle(p.textPrimary)
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button { showSettings = true } label: {
|
||
Image(systemName: "gearshape")
|
||
.foregroundStyle(p.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
.toolbarBackground(p.bg, for: .navigationBar)
|
||
.toolbarBackground(.visible, for: .navigationBar)
|
||
}
|
||
.tint(p.accent)
|
||
.sheet(isPresented: $showAdd) {
|
||
AddHostSheet { store.add($0) }
|
||
}
|
||
.sheet(isPresented: $showSettings) {
|
||
SettingsStubView()
|
||
}
|
||
}
|
||
|
||
private func sectionHeader(_ text: String) -> some View {
|
||
Text(text)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(p.textSecondary)
|
||
.padding(.leading, 4)
|
||
}
|
||
|
||
private var emptyState: some View {
|
||
VStack(spacing: TX.Space.s) {
|
||
Image(systemName: "server.rack")
|
||
.font(.system(size: 34, weight: .light))
|
||
.foregroundStyle(p.textTertiary)
|
||
Text("还没有主机")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(p.textSecondary)
|
||
Text("点击右下角 + 添加一台服务器")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(p.textTertiary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 48)
|
||
}
|
||
|
||
/// 进行中(连接/认证/重连)——决定是否转圈。
|
||
private var isBusy: Bool {
|
||
model.phaseText.hasSuffix("…") || model.phaseText.contains("重连")
|
||
}
|
||
|
||
private var statusRow: some View {
|
||
HStack(spacing: TX.Space.s) {
|
||
if isBusy { ProgressView().controlSize(.small).tint(p.accent) }
|
||
Text(model.phaseText)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary)
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
|
||
private var fab: some View {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
Button { showAdd = true } label: {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundStyle(p.bg)
|
||
.frame(width: 60, height: 60)
|
||
.background(p.accent, in: Circle())
|
||
.shadow(color: p.accent.opacity(0.35), radius: 12, y: 4)
|
||
}
|
||
.padding(TX.Space.l)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 主机卡片:服务器图标 + 状态点 / 名称 / user@ip:port(等宽)/ chevron。
|
||
struct HostCard: View {
|
||
let host: SavedHost
|
||
let onTap: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Button(action: onTap) {
|
||
HStack(spacing: TX.Space.m) {
|
||
ZStack(alignment: .topTrailing) {
|
||
Image(systemName: "server.rack")
|
||
.font(.system(size: 20, weight: .regular))
|
||
.foregroundStyle(p.textSecondary)
|
||
.frame(width: 44, height: 44)
|
||
.background(p.surfaceElevated, in: RoundedRectangle(cornerRadius: TX.Radius.chip))
|
||
if host.useMosh {
|
||
Circle().fill(p.mosh).frame(width: 9, height: 9)
|
||
.overlay(Circle().stroke(p.surface, lineWidth: 2))
|
||
.offset(x: 3, y: -3)
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(host.name)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(p.textPrimary)
|
||
Text(host.addressLine)
|
||
.font(TX.Font.mono(13))
|
||
.foregroundStyle(p.textSecondary)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(p.textTertiary)
|
||
}
|
||
.padding(TX.Space.m)
|
||
.background(p.surface, in: RoundedRectangle(cornerRadius: TX.Radius.card))
|
||
.overlay(RoundedRectangle(cornerRadius: TX.Radius.card).stroke(p.border, lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
/// 添加主机表单(深色样式)。
|
||
struct AddHostSheet: View {
|
||
let onSave: (SavedHost) -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var name = ""
|
||
@State private var host = ""
|
||
@State private var port = "22"
|
||
@State private var username = ""
|
||
@State private var password = ""
|
||
@State private var useMosh = false
|
||
|
||
private var p: TXPalette { theme.palette }
|
||
private var canSave: Bool { !host.isEmpty && !username.isEmpty }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
p.bg.ignoresSafeArea()
|
||
Form {
|
||
Section {
|
||
field("名称", text: $name, placeholder: host.isEmpty ? "我的服务器" : host)
|
||
field("主机 / IP", text: $host)
|
||
field("端口", text: $port, keyboard: .numberPad)
|
||
field("用户名", text: $username)
|
||
SecureField("密码", text: $password)
|
||
.foregroundStyle(p.textPrimary)
|
||
.listRowBackground(p.surface)
|
||
Toggle("使用 mosh", isOn: $useMosh)
|
||
.tint(p.accent)
|
||
.foregroundStyle(p.textPrimary)
|
||
.listRowBackground(p.surface)
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
}
|
||
.navigationTitle("添加主机")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("取消") { dismiss() }.tint(p.textSecondary)
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("保存") {
|
||
onSave(SavedHost(
|
||
name: name.isEmpty ? host : name,
|
||
host: host, port: Int(port) ?? 22,
|
||
username: username, password: password, useMosh: useMosh))
|
||
dismiss()
|
||
}
|
||
.disabled(!canSave)
|
||
.tint(p.accent)
|
||
}
|
||
}
|
||
.toolbarBackground(p.bg, for: .navigationBar)
|
||
.toolbarBackground(.visible, for: .navigationBar)
|
||
}
|
||
.tint(p.accent)
|
||
}
|
||
|
||
private func field(_ title: String, text: Binding<String>,
|
||
placeholder: String = "", keyboard: UIKeyboardType = .default) -> some View {
|
||
TextField(placeholder.isEmpty ? title : placeholder, text: text)
|
||
.keyboardType(keyboard)
|
||
.autocorrectionDisabled()
|
||
.textInputAutocapitalization(.never)
|
||
.foregroundStyle(p.textPrimary)
|
||
.listRowBackground(p.surface)
|
||
}
|
||
}
|
||
|
||
/// 设置页占位(后续填充:终端主题/字体/工具栏/关于)。
|
||
struct SettingsStubView: View {
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.dismiss) private var dismiss
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
p.bg.ignoresSafeArea()
|
||
List {
|
||
Section("主题(app 与终端统一)") {
|
||
ForEach(TXPalette.all) { pal in
|
||
Button { theme.apply(pal) } label: { themeRow(pal) }
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.listRowBackground(p.surface)
|
||
Section("终端") {
|
||
row("textformat", "字体与大小", value: "系统等宽")
|
||
}
|
||
.listRowBackground(p.surface)
|
||
Section("关于") {
|
||
row("info.circle", "terminalX", value: "0.0.1")
|
||
}
|
||
.listRowBackground(p.surface)
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.foregroundStyle(p.textPrimary)
|
||
}
|
||
.navigationTitle("设置")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("完成") { dismiss() }.tint(p.accent)
|
||
}
|
||
}
|
||
.toolbarBackground(p.bg, for: .navigationBar)
|
||
.toolbarBackground(.visible, for: .navigationBar)
|
||
}
|
||
.tint(p.accent)
|
||
}
|
||
|
||
/// 主题行:色板预览(底/面/强调)+ 名称 + 选中勾。
|
||
private func themeRow(_ pal: TXPalette) -> some View {
|
||
HStack(spacing: TX.Space.m) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 7).fill(pal.bg)
|
||
HStack(spacing: 3) {
|
||
Circle().fill(pal.accent).frame(width: 7, height: 7)
|
||
Circle().fill(pal.mosh).frame(width: 7, height: 7)
|
||
}
|
||
}
|
||
.frame(width: 40, height: 28)
|
||
.overlay(RoundedRectangle(cornerRadius: 7).stroke(p.border, lineWidth: 1))
|
||
|
||
Text(pal.name).foregroundStyle(p.textPrimary)
|
||
Spacer()
|
||
if pal.id == theme.palette.id {
|
||
Image(systemName: "checkmark").foregroundStyle(p.accent).font(.system(size: 14, weight: .semibold))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func row(_ icon: String, _ title: String, value: String) -> some View {
|
||
HStack {
|
||
Image(systemName: icon).foregroundStyle(p.textSecondary).frame(width: 26)
|
||
Text(title).foregroundStyle(p.textPrimary)
|
||
Spacer()
|
||
Text(value).foregroundStyle(p.textSecondary).font(.system(size: 14))
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TerminalScreen: View {
|
||
@ObservedObject var model: SSHTerminalModel
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
TerminalTopBar(model: model)
|
||
Divider().overlay(p.border)
|
||
Group {
|
||
if let tmux = model.tmuxController {
|
||
TmuxTabbedView(controller: tmux) // tmux -CC → 原生 tab
|
||
} else {
|
||
RawTerminalView(model: model) // 普通单终端
|
||
}
|
||
}
|
||
}
|
||
.background(p.bg.ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
/// 终端顶部状态行:返回主屏 · 会话标题 · 状态提示 · 传输徽章(SSH/mosh)。
|
||
struct TerminalTopBar: View {
|
||
@ObservedObject var model: SSHTerminalModel
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
private var connected: Bool { model.phaseText == "已连接" || model.phaseText == "mosh 已连接" }
|
||
|
||
var body: some View {
|
||
HStack(spacing: TX.Space.s) {
|
||
Button { model.close() } label: {
|
||
Image(systemName: "chevron.left")
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(p.textSecondary)
|
||
.frame(width: 34, height: 34)
|
||
.background(p.surface, in: Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(model.connectionTitle.isEmpty ? "终端" : model.connectionTitle)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(p.textPrimary)
|
||
.lineLimit(1)
|
||
if !connected {
|
||
Text(model.phaseText)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(model.phaseText.hasPrefix("失败") ? .red : p.textSecondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
Spacer()
|
||
transportBadge
|
||
}
|
||
.padding(.horizontal, TX.Space.m)
|
||
.padding(.vertical, TX.Space.s)
|
||
.background(p.bg)
|
||
}
|
||
|
||
private var transportBadge: some View {
|
||
let isMosh = model.moshEngaged
|
||
let color = isMosh ? p.mosh : p.accent
|
||
return HStack(spacing: 5) {
|
||
Circle().fill(connected ? color : p.offline).frame(width: 6, height: 6)
|
||
Text(isMosh ? "mosh" : "SSH")
|
||
.font(TX.Font.mono(11, .medium))
|
||
.foregroundStyle(color)
|
||
}
|
||
.padding(.horizontal, 9).padding(.vertical, 5)
|
||
.background(color.opacity(0.13), in: Capsule())
|
||
}
|
||
}
|
||
|
||
/// 普通(非 tmux)单终端视图。
|
||
struct RawTerminalView: View {
|
||
@ObservedObject var model: SSHTerminalModel
|
||
@FocusState private var focused: Bool
|
||
|
||
var body: some View {
|
||
TerminalSurfaceView(context: model.state)
|
||
.terminalFocusOnAppear($focused)
|
||
.task {
|
||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
model.markSurfaceReady()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// tmux control-mode:window 映射为原生 tab 条 + 活动 window 终端。
|
||
struct TmuxTabbedView: View {
|
||
@ObservedObject var controller: TmuxController
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.displayScale) private var displayScale
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: TX.Space.xs) {
|
||
ForEach(controller.windows) { win in
|
||
TmuxTabChip(window: win,
|
||
isActive: win.id == controller.activeWindowID) {
|
||
controller.selectWindow(win.id)
|
||
}
|
||
}
|
||
Button { controller.newWindow() } label: {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(p.textSecondary)
|
||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||
}
|
||
}
|
||
.padding(.horizontal, TX.Space.s).padding(.vertical, TX.Space.xs)
|
||
}
|
||
.background(p.bg)
|
||
Divider().overlay(p.border)
|
||
Group {
|
||
if controller.windows.isEmpty {
|
||
Spacer(); Text("无 tmux 窗口").foregroundStyle(p.textSecondary); Spacer()
|
||
} else {
|
||
// 所有窗口常驻挂载、仅激活窗口可见:切 tab 不销毁其它窗口的 pane 视图,
|
||
// 故 ghostty surface 不被释放、grid 内容保留(否则切回旧 tab 会空白),
|
||
// 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。
|
||
ZStack {
|
||
ForEach(controller.windows) { win in
|
||
TmuxWindowView(window: win, controller: controller)
|
||
.opacity(win.id == controller.activeWindowID ? 1 : 0)
|
||
.allowsHitTesting(win.id == controller.activeWindowID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
// 容器几何测量放在这一层(windows 为空时同样占满剩余空间、尺寸相同):
|
||
// pane grid 由容器像素决定,须在 attach 前拿到真实容器格子;放 TmuxWindowView 会与
|
||
// "windows 靠 list-windows 才有" 形成鸡生蛋死锁(见 TmuxController.kickoffIfReady)。
|
||
.background(GeometryReader { geo in
|
||
Color.clear
|
||
.onAppear { controller.containerResized(widthPt: geo.size.width, heightPt: geo.size.height, scale: displayScale) }
|
||
.onChange(of: geo.size) { _, s in
|
||
controller.containerResized(widthPt: s.width, heightPt: s.height, scale: displayScale)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TmuxTabChip: View {
|
||
@ObservedObject var window: TmuxWindow
|
||
let isActive: Bool
|
||
let onTap: () -> Void
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Button(action: onTap) {
|
||
Text(window.title)
|
||
.font(TX.Font.mono(13, isActive ? .semibold : .regular)).lineLimit(1)
|
||
.foregroundStyle(isActive ? p.accent : p.textSecondary)
|
||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||
.background(isActive ? p.accentSoft : p.surface,
|
||
in: RoundedRectangle(cornerRadius: TX.Radius.chip))
|
||
.overlay(RoundedRectangle(cornerRadius: TX.Radius.chip)
|
||
.stroke(isActive ? p.accent.opacity(0.4) : p.border, lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
|
||
struct TmuxWindowView: View {
|
||
@ObservedObject var window: TmuxWindow
|
||
let controller: TmuxController
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
Group {
|
||
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
|
||
}
|
||
}
|
||
// 容器尺寸测量已上移到 TmuxTabbedView(单一来源),此处不再驱动 containerResized。
|
||
}
|
||
}
|
||
|
||
/// 收集布局树叶子(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: 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 surface.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
onReady()
|
||
}
|
||
}
|
||
}
|