业务逻辑(用户定稿): - 点主机总是新建会话(会话:主机=多:1);activate 选中断线会话自动重连 - 「直接输入」凭据保存时自动落 Keychain(去重复用),主机只存引用 - 主机编辑页可就地新建 Tailscale 连接;删无人引用的旧 HostsListView 首页/弹框减法: - 删侧边栏「连接新主机」、无 tmux 横幅与安装引导、页头「新建连接」 - 搜索移到「全部主机」行;添加主机虚线卡与主机卡等高(骨架复刻) - 会话卡加 ✕ + 复用终端页同款二次确认 - 会话选择器 sheet → 项目风格居中弹框(去手柄/Header/最近) 终端页修复: - 侧边栏会话列表改创建顺序,切换不再重排抖动 - 焦点高亮上移到 pane 容器边框(四边完整,去负 padding) - 软键盘双重让位修复:画布忽略键盘 + TXKeyboardObserver 显式让位一次 - 自动聚焦:focusTick 脉冲强制翻转 FocusState + surface 就绪补聚焦; vendor 补 iPadOS 触摸板 indirectPointer 的 becomeFirstResponder Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
555 lines
29 KiB
Swift
555 lines
29 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
import GhosttyTerminal
|
||
import TXCore
|
||
|
||
struct ContentView: View {
|
||
@StateObject private var manager = SessionManager()
|
||
@StateObject private var router = AppRouter()
|
||
@StateObject private var hostStore = HostStore()
|
||
@StateObject private var credStore = FileCredentialStore()
|
||
@StateObject private var tailscaleStore = TailscaleStore()
|
||
@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 {
|
||
RootView(manager: manager, router: router, hostStore: hostStore,
|
||
credStore: credStore, tailscaleStore: tailscaleStore)
|
||
}
|
||
}
|
||
.environmentObject(theme)
|
||
.preferredColorScheme(theme.palette.isDark ? .dark : .light)
|
||
.onChange(of: theme.palette.id) { _, _ in
|
||
// 主题切换:app 视图经 palette 响应式更新;这里把终端配色同步推给所有会话的 surface。
|
||
manager.applyTerminalTheme(theme.palette.terminalTheme)
|
||
}
|
||
.onAppear {
|
||
manager.bind(credentials: credStore, tailscale: tailscaleStore)
|
||
#if DEBUG
|
||
UIPreviewFixture.forceLandscapeIfRequested()
|
||
// 无头 UI 验证:造假会话+假主机供截图对拍(不建连接、不写盘),与 -txHost 真实连接互斥。
|
||
if UIPreviewFixture.isEnabled {
|
||
hostStore.injectFixtureHosts(UIPreviewFixture.hosts)
|
||
tailscaleStore.injectFixtureConnections(UIPreviewFixture.tailnets)
|
||
if let s = manager.spawnFixtureSessions() { router.enter(s.id) }
|
||
return
|
||
}
|
||
#endif
|
||
#if DEBUG
|
||
// 无头验证**真实的「首页点主机」路径**(`-txOpenAsHost 1`):把启动参数造成一台主机塞进
|
||
// 内存主机表,再走 manager.open(host:) —— 即探测 tmux → 弹选择器 → 选完才进终端。
|
||
// 与 `-txHost` 的老路径(autoConnectIfConfigured:直连 + 立刻进终端)互斥。
|
||
if UserDefaults.standard.bool(forKey: "txOpenAsHost"),
|
||
let h = UIPreviewFixture.hostFromLaunchArgs() {
|
||
hostStore.injectFixtureHosts([h])
|
||
manager.open(host: h)
|
||
return
|
||
}
|
||
#endif
|
||
// 无头验证:启动参数带 -txHost 时建会话并直接进终端(老路径,保留给回归脚本)。
|
||
if let s = manager.autoConnectIfConfigured() { router.enter(s.id) }
|
||
}
|
||
.onChange(of: scenePhase) { _, phase in
|
||
switch phase {
|
||
case .background: manager.enterBackground()
|
||
case .active: manager.enterForeground()
|
||
default: break
|
||
}
|
||
}
|
||
#if DEBUG
|
||
// 无头验证:terminalx://ui/<动作> 驱动导航(见 UIPreviewFixture.handle)。
|
||
// 非 ui/ 前缀的 URL(如 terminalx://resume)只用于把 app 拉回前台,无需处理。
|
||
.onOpenURL { url in
|
||
guard url.host == "ui" else { return }
|
||
let parts = url.pathComponents.filter { $0 != "/" }
|
||
_ = UIPreviewFixture.handle(uiCommand: parts, manager: manager, router: router)
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
|
||
/// 顶层壳:会话画布常驻在底,首页盖在其上。
|
||
///
|
||
/// **为什么不用 fullScreenCover**:会话必须在离开终端后继续跑并保留内容,且「最小化缩回首页卡片」
|
||
/// 需要 matchedGeometry —— 那要求两端在同一视图层级,独立 presentation 层做不到。
|
||
struct RootView: View {
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var hostStore: HostStore
|
||
@ObservedObject var credStore: FileCredentialStore
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
SessionCanvas(manager: manager, router: router,
|
||
hostStore: hostStore, credStore: credStore, tailscaleStore: tailscaleStore)
|
||
if router.route == .home {
|
||
HomeView(manager: manager, router: router, hostStore: hostStore,
|
||
credStore: credStore, tailscaleStore: tailscaleStore)
|
||
// 首页略微推远后淡出,与终端从 97% 放大到位对偶 —— 读作"推进到会话里"。
|
||
.transition(TX.Motion.pushBack)
|
||
}
|
||
}
|
||
.background(p.bgDeep.ignoresSafeArea())
|
||
// 沉浸只在终端页:设计稿 1b/3a 顶部就是胶囊/logo(无状态栏),1e 首页与 1g 设置有状态栏。
|
||
.statusBarHidden(router.route != .home)
|
||
// 连接流程的一部分:探测出 tmux 会话后在**首页上**弹选择器,选完才进终端。
|
||
// 挂在 RootView 而不是终端页 —— 用户点主机时还没进终端。
|
||
// 项目风格的居中**弹框**(曾是系统 sheet:手柄+半屏形态读作"页面",用户裁决改弹框)。
|
||
// 点遮罩 = 取消本次连接(弹框语义:点外面就是不想连了;原 sheet 下滑 = 原生终端太隐晦)。
|
||
.overlay {
|
||
if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice {
|
||
SessionPickerDialog(
|
||
host: s.displayName,
|
||
choice: choice,
|
||
onAttach: { s.attachTmuxSession($0) },
|
||
onCreate: { s.createTmuxSession($0) },
|
||
onNative: { s.useNativeTerminal() },
|
||
onCancel: { manager.close(s.id) })
|
||
.transition(TX.Motion.pop)
|
||
}
|
||
}
|
||
.animation(TX.Motion.standard, value: manager.sessionAwaitingChoice?.id)
|
||
// 会话就绪(选择完成 / 无 tmux / 失败)→ 进终端页。
|
||
.onChange(of: manager.enterRequest) { _, id in
|
||
guard let id else { return }
|
||
manager.enterRequest = nil
|
||
router.enter(id)
|
||
}
|
||
// 兜底:路由指向的会话已不存在(被关闭/清理)→ 回首页,而不是停在空画布上黑屏。
|
||
.onChange(of: manager.sessions.count) { _, _ in
|
||
if let id = router.route.sessionID, manager.session(id) == nil { router.goHome() }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 所有会话的终端视图**常驻挂载**,只有当前路由指向的那条可见。
|
||
///
|
||
/// 这是"回首页/切会话内容不丢"的根:GhosttyKit 的 surface 一旦从视图树摘除就释放 grid,
|
||
/// 故沿用 tmux 切 tab 已验证的手法——ZStack 全挂载 + opacity 切换 + 关掉非活动者的命中测试。
|
||
/// 画布尺寸恒为全屏,故切换路由**不会触发 PTY resize**。
|
||
struct SessionCanvas: View {
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var hostStore: HostStore
|
||
@ObservedObject var credStore: FileCredentialStore
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
ForEach(manager.sessions) { session in
|
||
let visible = router.route.sessionID == session.id
|
||
TerminalScreen(session: session, manager: manager, router: router,
|
||
tailscaleStore: tailscaleStore)
|
||
.opacity(visible ? 1 : 0)
|
||
// 离开终端时轻微缩小淡出,观感上"缩回首页卡片"。
|
||
// `scaleEffect` 只是渲染变换、不改 frame → **不触发 PTY resize**,
|
||
// 故不能用 frame 动画替代(那会让 tmux 反复重排)。
|
||
.scaleEffect(visible || reduceMotion ? 1 : 0.97)
|
||
.animation(TX.Motion.standard, value: visible)
|
||
.allowsHitTesting(visible)
|
||
}
|
||
}
|
||
// 关掉系统的隐式键盘让位:终端页的让位由 TerminalScreen 按 TXKeyboardObserver
|
||
// **显式做一次**(隐式 + footBand 双重让位曾把终端压到 6 行,见 TXParts 注释)。
|
||
.ignoresSafeArea(.keyboard)
|
||
}
|
||
}
|
||
|
||
/// 终端主界面 · 沉浸轨道(设计稿 `1b` + `3a`,用户裁决后侧边栏只剩收起/固定两态)。
|
||
///
|
||
/// 布局:左侧 `TerminalSidebar`(56 ⇄ 288,直接参与布局)+ 右舞台。舞台顶部留 86pt
|
||
/// 预留带给浮起胶囊,底部一条提示条。开合就是侧边栏的宽度动画:收起时明细淡出、
|
||
/// 终端从右侧盖上来;展开反向。开合各触发一次终端 resize。
|
||
///
|
||
/// **安全区**:整屏 chrome 铺到**物理**边缘(`ignoresSafeArea()`),安全区由两处底栏
|
||
/// (提示条 / 侧边栏脚)自己让出 —— 见 `TX.Layout.footBand`,两侧分割线落在同一条 y 上。
|
||
struct TerminalScreen: View {
|
||
@ObservedObject var session: TerminalSession
|
||
@ObservedObject var manager: SessionManager
|
||
@ObservedObject var router: AppRouter
|
||
@ObservedObject var tailscaleStore: TailscaleStore
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.horizontalSizeClass) private var hSize
|
||
@State private var sidebarSearch = ""
|
||
/// 软键盘高度(显式让位,见 TXKeyboardObserver 注释)。
|
||
@StateObject private var keyboard = TXKeyboardObserver()
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
// 先量安全区,再铺满物理屏(顺序反了就量不到,见 TXScreenMetrics)。
|
||
TXScreenMetrics { geo in
|
||
screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom)
|
||
.ignoresSafeArea()
|
||
}
|
||
// 选中本会话(进终端 / 侧边栏切换)→ 终端自动拿键盘焦点,不必再点一下屏幕
|
||
// (用户裁决)。SessionCanvas 常驻挂载,onAppear 只在建会话时来一次,切换靠这里。
|
||
.onChange(of: router.route.sessionID) { _, id in
|
||
if id == session.id { session.focusTick &+= 1 }
|
||
}
|
||
}
|
||
|
||
private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||
HStack(spacing: 0) {
|
||
// 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的
|
||
// 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起),
|
||
// 反之展开。开合各触发一次终端 resize。
|
||
TerminalSidebar(manager: manager, router: router,
|
||
tailscaleStore: tailscaleStore,
|
||
railWidth: railWidth, safeBottom: safeBottom,
|
||
onSettings: goHomeToSettings,
|
||
search: $sidebarSearch)
|
||
stage(safeTop: safeTop, safeBottom: safeBottom)
|
||
}
|
||
.background(p.bgDeep)
|
||
// 关闭确认(3c):自绘浮层,不用系统 alert/sheet —— 设计给的是精确尺寸的卡片。
|
||
.overlay {
|
||
switch router.modal {
|
||
case .closeConfirm:
|
||
CloseConfirmDialog(
|
||
session: session,
|
||
onDetach: { router.dismissModal(); closeSession(kill: false) },
|
||
onKill: { router.dismissModal(); closeSession(kill: true) },
|
||
onCancel: { router.dismissModal() })
|
||
case nil:
|
||
EmptyView()
|
||
}
|
||
}
|
||
// host key 告警挂在会话上(多会话各自一份):安全告警即使会话不在前台也应弹出。
|
||
.alert("⚠️ Host Key 已变化", isPresented: Binding(
|
||
get: { session.hostKeyMismatch != nil },
|
||
set: { if !$0 { session.dismissHostKeyMismatch() } }
|
||
), presenting: session.hostKeyMismatch) { _ in
|
||
Button("信任新密钥", role: .destructive) { session.trustNewHostKey() }
|
||
Button("取消", role: .cancel) { session.dismissHostKeyMismatch() }
|
||
} message: { info in
|
||
Text("\(info.egress)/\(info.host):\(info.port) 的 host key 与已记住的不同,可能是中间人攻击。\n\n已记住:\(info.storedFp)\n本次:\(info.presentedFp)")
|
||
}
|
||
}
|
||
|
||
/// 折叠轨道宽(竖屏/窄窗口 48)。占位列宽度必须与侧边栏收起宽一致,否则多出一条底色缝。
|
||
private var railWidth: CGFloat {
|
||
hSize == .compact ? TX.Layout.railWidthCompact : TX.Layout.railWidth
|
||
}
|
||
|
||
/// 侧边栏设置齿轮:首版回首页(那里有设置入口)。
|
||
private func goHomeToSettings() {
|
||
router.goHome()
|
||
}
|
||
|
||
// MARK: - 舞台
|
||
|
||
private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View {
|
||
VStack(spacing: 0) {
|
||
// 顶带(真实布局行,用户定稿):✕/⌄ 按钮组 + 弱化标题 + tmux window tab 组,
|
||
// 全在终端**外**的 chrome 带上 —— 终端 pane 从带下方开始,不再被悬浮胶囊遮内容。
|
||
TerminalTopBar(session: session, onClose: requestClose, onMinimize: minimizeSession)
|
||
.padding(.horizontal, TX.Space.m)
|
||
// iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。
|
||
.padding(.top, TX.Layout.capsuleTop(safeTop))
|
||
.padding(.bottom, TX.Space.s)
|
||
// 「未检测到 tmux」横幅已删(用户裁决减法):无 tmux 静默走原生窗口,
|
||
// 状态只在底部状态行一句话("无 tmux · 客户端窗口"),不打扰。
|
||
paneArea
|
||
// 内容离 pane 顶边一点呼吸位(预留带已随悬浮胶囊废除)。
|
||
.padding(.top, TX.Space.s)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(p.terminalBackground)
|
||
.clipShape(paneShape)
|
||
.overlay(
|
||
// 终端的**焦点高亮就画在容器边框上**(在线转 accent):单 pane 曾自画
|
||
// accent 描边,但它与容器边框同位 —— 左/底被容器的灰边盖住、顶边又因
|
||
// 内容内衬悬空出一条 10pt 间隔(2026-07-26 真机截图)。容器是唯一边框
|
||
// 后四边干净、无间隔。
|
||
// 右边不再推出屏外(旧灰边框时代的处理):现在这一圈是焦点高亮,
|
||
// 缺右边读作"没画完"(用户裁决)。右缘在物理圆角的平直段上,无切角问题。
|
||
paneShape.strokeBorder(session.isLive ? TXAccent.border : p.border,
|
||
lineWidth: 1)
|
||
.animation(TX.Motion.quick, value: session.isLive)
|
||
)
|
||
// 只在**朝轨道那一侧**留 2pt 缝;右边直接铺到屏幕物理边缘(沉浸)。
|
||
// 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。
|
||
.padding(.leading, TX.Layout.paneGap)
|
||
// 键盘可见时底栏不再额外让 home indicator(那块已被键盘盖住)。
|
||
TerminalActionBar(session: session,
|
||
safeBottom: keyboard.height > 0 ? 0 : safeBottom)
|
||
}
|
||
// 软键盘的**唯一**让位:整个舞台(含底栏)抬到键盘/输入附件条之上。
|
||
.padding(.bottom, keyboard.height)
|
||
.background(p.bgDeep)
|
||
.animation(TX.Motion.standard, value: keyboard.height)
|
||
}
|
||
|
||
/// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。
|
||
///
|
||
/// 终端右侧贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角,
|
||
/// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。
|
||
/// 故右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、顶边(挨顶带)、
|
||
/// 底边(挨提示条)才是真正需要圆角把 pane 和 chrome 分开的地方。
|
||
private var paneShape: UnevenRoundedRectangle {
|
||
UnevenRoundedRectangle(
|
||
topLeadingRadius: TX.Radius.pane,
|
||
bottomLeadingRadius: TX.Radius.pane,
|
||
bottomTrailingRadius: TX.Radius.pane,
|
||
topTrailingRadius: 0)
|
||
}
|
||
|
||
@ViewBuilder private var paneArea: some View {
|
||
ZStack {
|
||
if let tmux = session.tmuxController {
|
||
TmuxStage(controller: tmux, focusTick: session.focusTick,
|
||
focusEligible: router.route.sessionID == session.id)
|
||
} else {
|
||
RawTerminalView(model: session,
|
||
focusEligible: router.route.sessionID == session.id)
|
||
}
|
||
// 连接中 / 失败 / 已关闭时给出解释与出路(已连接时此层不渲染,不遮挡终端)。
|
||
TerminalStateOverlay(session: session,
|
||
onRetry: { session.retry() },
|
||
onBack: { router.goHome() },
|
||
onCancel: {
|
||
let id = session.id
|
||
router.goHome()
|
||
manager.close(id)
|
||
})
|
||
// 连接中 → 已连接(或失败)时这层淡出,不硬切。
|
||
.animation(TX.Motion.standard, value: session.phaseText)
|
||
}
|
||
}
|
||
|
||
/// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `closeSession(kill:)`。
|
||
private func requestClose() {
|
||
router.present(.closeConfirm)
|
||
}
|
||
|
||
/// 执行关闭。tmux 会话默认只 `detach-client`(服务端继续跑);`kill=true` 才 `kill-session`。
|
||
/// 原生窗口没有服务端兜底,直接 teardown。
|
||
private func closeSession(kill: Bool) {
|
||
let id = session.id
|
||
if let tmux = session.tmuxController {
|
||
if kill { tmux.killSession() } else { tmux.detach() }
|
||
}
|
||
router.goHome() // 动画在 router 里(TX.Motion.standard)
|
||
// 给 detach/kill 命令一点时间落地再拆传输层(tmux 会回 %exit 走既有 onExit 收尾)。
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||
manager.close(id)
|
||
}
|
||
}
|
||
|
||
private func minimizeSession() {
|
||
session.captureSnapshot() // 缩回首页前抓一次镜像,卡片立刻有内容
|
||
manager.minimize(session.id)
|
||
router.goHome()
|
||
}
|
||
|
||
}
|
||
|
||
/// 普通(非 tmux)单终端视图。
|
||
struct RawTerminalView: View {
|
||
@ObservedObject var model: TerminalSession
|
||
/// 本会话是否正被路由展示(焦点只给看得见的终端,别从前台会话手里抢键盘)。
|
||
let focusEligible: Bool
|
||
@FocusState private var focused: Bool
|
||
|
||
var body: some View {
|
||
TerminalSurfaceView(context: model.state)
|
||
.terminalFocusOnAppear($focused)
|
||
// 选中本会话(进终端/侧边栏切换)→ 自动拿键盘焦点。**必须先翻 false 再置 true**:
|
||
// onAppear 早把 FocusState 挂在 true 上(当时视图未进 window、聚焦失败),
|
||
// true→true 不触发 updateUIView,becomeFirstResponder 永远不会重试。
|
||
.onChange(of: model.focusTick) { _, _ in
|
||
focused = false
|
||
Task { @MainActor in focused = true }
|
||
}
|
||
.task {
|
||
for _ in 0 ..< 300 where model.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
model.markSurfaceReady()
|
||
// surface 就绪补一次聚焦(此刻必已进 window):连接建立早于进终端时,
|
||
// route 变化的 focusTick 脉冲可能先于本视图挂载而扑空。
|
||
if focusEligible {
|
||
focused = false
|
||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||
focused = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// tmux control-mode 舞台:所有 window 常驻挂载、只显活动 window。
|
||
/// window 切换已上移到轨道数字 / 侧边栏 window 行(设计稿去掉了 tab 条)。
|
||
struct TmuxStage: View {
|
||
@ObservedObject var controller: TmuxController
|
||
/// 键盘焦点脉冲(TerminalSession.focusTick 直通):变化时活动 pane 自动拿焦点。
|
||
let focusTick: Int
|
||
/// 本会话是否正被路由展示(焦点只给看得见的终端)。
|
||
let focusEligible: Bool
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
@Environment(\.displayScale) private var displayScale
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
Group {
|
||
if controller.windows.isEmpty {
|
||
VStack {
|
||
Spacer()
|
||
Text("正在枚举 tmux window…")
|
||
.font(TX.Font.mono(12))
|
||
.foregroundStyle(p.textWeak)
|
||
Spacer()
|
||
}
|
||
} else {
|
||
// 所有窗口常驻挂载、仅激活窗口可见:切 window 不销毁其它窗口的 pane 视图,
|
||
// 故 ghostty surface 不被释放、grid 内容保留(否则切回旧窗口会空白),
|
||
// 且后台窗口持续接收 %output 保持实时。非激活窗口关掉命中测试,避免吞触摸。
|
||
ZStack {
|
||
ForEach(controller.windows) { win in
|
||
TmuxWindowView(window: win, controller: controller,
|
||
focusTick: focusTick, focusEligible: focusEligible)
|
||
.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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 一个 window:按 tmux 可见布局树把多 pane 绝对定位为分屏(每 pane 一 surface)。
|
||
/// pane 尺寸调整**不做 app 侧 UI**(第一版减法,用户裁决):交给 tmux 自己(prefix+方向键等),
|
||
/// app 只被动跟随 `%layout-change`。
|
||
struct TmuxWindowView: View {
|
||
@ObservedObject var window: TmuxWindow
|
||
let controller: TmuxController
|
||
let focusTick: Int
|
||
let focusEligible: Bool
|
||
|
||
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))
|
||
let items = leaves(layout.root)
|
||
ZStack(alignment: .topLeading) {
|
||
ForEach(items, id: \.pane.raw) { item in
|
||
if let surface = window.panes[item.pane] {
|
||
TmuxPaneView(
|
||
surface: surface,
|
||
isActive: window.activePane == item.pane,
|
||
// 单 pane:surface 帧必须与容器完全一致(其实测格子是
|
||
// 客户端尺寸的权威,见 paneViewportChanged),不留缝。
|
||
solo: items.count == 1,
|
||
focusTick: focusTick,
|
||
focusEligible: focusEligible,
|
||
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
|
||
}
|
||
}
|
||
// 容器尺寸测量已上移到 TmuxStage(单一来源),此处不再驱动 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 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。
|
||
struct TmuxPaneView: View {
|
||
@ObservedObject var surface: TmuxPaneSurface
|
||
let isActive: Bool
|
||
/// window 里只有这一个 pane:不留 pane 缝,surface 像素帧 == 容器帧(尺寸权威所需)。
|
||
let solo: Bool
|
||
/// 键盘焦点脉冲:变化且本 pane 活动时自动 becomeFirstResponder。
|
||
let focusTick: Int
|
||
/// 本会话是否正被路由展示(焦点只给看得见的终端)。
|
||
let focusEligible: Bool
|
||
let onTap: () -> Void
|
||
let onReady: () -> Void
|
||
@FocusState private var focused: Bool
|
||
@EnvironmentObject var theme: TXThemeManager
|
||
private var p: TXPalette { theme.palette }
|
||
|
||
var body: some View {
|
||
TerminalSurfaceView(context: surface.state)
|
||
.terminalFocusOnAppear($focused)
|
||
// 单 pane 不自画圆角与描边:容器(paneShape)是唯一边框/高亮(同位双描边
|
||
// 会互相盖,见 TerminalScreen.paneArea 注释)。多 pane 保留 pane 间描边。
|
||
.clipShape(RoundedRectangle(cornerRadius: solo ? 0 : 6))
|
||
.overlay {
|
||
if !solo {
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.strokeBorder(isActive ? TXAccent.border : p.divider,
|
||
lineWidth: isActive ? 1 : 0.5)
|
||
}
|
||
}
|
||
// 非活动 pane 叠透明 tap-catcher(TerminalSurfaceView 会吞触摸);活动 pane 直通终端。
|
||
.overlay {
|
||
if !isActive {
|
||
Color.black.opacity(0.04).contentShape(Rectangle()).onTapGesture { onTap() }
|
||
}
|
||
}
|
||
.padding(solo ? 0 : TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt;单 pane 不缩
|
||
.onChange(of: isActive) { _, active in focused = active }
|
||
// 选中本会话(进终端/侧边栏切换)→ 活动 pane 自动拿键盘焦点。
|
||
// **先翻 false 再置 true**:FocusState 可能停留在挂载时的 true(当时聚焦失败),
|
||
// true→true 不触发 updateUIView(见 RawTerminalView 同款注释)。
|
||
.onChange(of: focusTick) { _, _ in
|
||
guard isActive else { return }
|
||
focused = false
|
||
Task { @MainActor in focused = true }
|
||
}
|
||
.task {
|
||
for _ in 0 ..< 300 where surface.state.surfaceSize == nil {
|
||
try? await Task.sleep(nanoseconds: 30_000_000)
|
||
}
|
||
onReady()
|
||
// surface 就绪补一次聚焦(进终端早于 pane 挂载时,focusTick 脉冲会扑空)。
|
||
if focusEligible, isActive {
|
||
focused = false
|
||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||
focused = true
|
||
}
|
||
}
|
||
}
|
||
}
|