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(isPresented: Binding( get: { manager.sessionAwaitingChoice != nil }, set: { if !$0 { manager.sessionAwaitingChoice?.useNativeTerminal() } } )) { if let s = manager.sessionAwaitingChoice, let choice = s.pendingSessionChoice { SessionPickerSheet( host: s.displayName, choice: choice, onAttach: { s.attachTmuxSession($0) }, onCreate: { s.createTmuxSession($0) }, onNative: { s.useNativeTerminal() }) .environmentObject(theme) .presentationDetents([.medium, .large]) .presentationDragIndicator(.hidden) } } // 会话就绪(选择完成 / 无 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) } } } } /// 终端主界面 · 沉浸轨道(设计稿 `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 = "" /// 「安装并启用 tmux」引导弹层。 @State private var showTmuxInstall = false private var p: TXPalette { theme.palette } var body: some View { // 先量安全区,再铺满物理屏(顺序反了就量不到,见 TXScreenMetrics)。 TXScreenMetrics { geo in screen(safeTop: geo.safeAreaInsets.top, safeBottom: geo.safeAreaInsets.bottom) .ignoresSafeArea() } } private func screen(safeTop: CGFloat, safeBottom: CGFloat) -> some View { HStack(spacing: 0) { // 两态共用**同一个**视图实例(见 TerminalSidebar 的动画模型):开合就是它自己的 // 宽度动画(56 ⇄ 288),直接参与布局 —— 内容淡出的同时终端从右侧盖上来(收起), // 反之展开。开合各触发一次终端 resize。 TerminalSidebar(manager: manager, router: router, session: session, tailscaleStore: tailscaleStore, railWidth: railWidth, safeBottom: safeBottom, onNewHost: goHomeToAddHost, onSettings: goHomeToAddHost, search: $sidebarSearch) stage(safeTop: safeTop, safeBottom: safeBottom) } .background(p.bgDeep) // 关闭确认(3c)与分屏菜单(3b):自绘浮层,不用系统 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 .splitMenu(let vertical): SplitMenuView(session: session, vertical: vertical, onPick: { target in router.dismissModal() performSplit(target, vertical: vertical) }, onCancel: { router.dismissModal() }) case nil: EmptyView() } if showTmuxInstall { TmuxInstallSheet( onRun: { cmd in withAnimation(TX.Motion.standard) { showTmuxInstall = false } session.runInTerminal(cmd) }, onCancel: { withAnimation(TX.Motion.standard) { showTmuxInstall = false } }) } } // 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 } /// 轨道 + 号 / 设置齿轮:首版回首页(那里有「新建连接」与设置入口)。 /// 设计的「就地弹主机选择菜单」属 UI-4 的分屏/新建菜单一族,一起做。 private func goHomeToAddHost() { router.goHome() } // MARK: - 舞台 private func stage(safeTop: CGFloat, safeBottom: CGFloat) -> some View { ZStack(alignment: .top) { VStack(spacing: 0) { // 无 tmux 时的行内引导横幅(设计 2c):占一行,把 pane 往下推,不遮内容。 if session.tmuxUnavailable { NoTmuxBanner(onInstall: { withAnimation(TX.Motion.standard) { showTmuxInstall = true } }, onDismiss: { withAnimation(TX.Motion.standard) { session.dismissTmuxBanner() } }) .padding(.top, TX.Layout.capsuleInset) .transition(.opacity) } // 沉浸:pane 容器**从舞台顶部就开始**(底色 + 边框铺满),胶囊悬浮其上; // 只把 surface 内容下移 86pt 让出预留带 —— 预留带铺的是终端底色,故视觉连续。 paneArea // 横幅已占掉预留带时不再重复留白(设计 2c:多一条横幅时预留 122pt)。 .padding(.top, session.tmuxUnavailable ? TX.Space.hairline : TX.Layout.capsuleInset) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(p.terminalBackground) .clipShape(paneShape) .overlay( paneShape.strokeBorder(p.border, lineWidth: 1) // 顶边/右边与屏幕边缘齐平,那两条描边推出屏外:留着就是贴在屏幕 // 边上的一圈多余框线,还会在屏幕圆角处被切出斜口。 .padding(EdgeInsets(top: -1, leading: 0, bottom: 0, trailing: -1)) ) // 只在**朝轨道那一侧**留 2pt 缝;顶边/右边直接铺到屏幕物理边缘(沉浸)。 // 底边不留缝:pane 底边即与侧边栏脚同高的那条分割线。 .padding(.leading, TX.Layout.paneGap) TerminalActionBar(context: barContext, actions: barActions, safeBottom: safeBottom) } capsules(safeTop: safeTop) } .background(p.bgDeep) } /// pane 容器的形状:**只在朝 app chrome 的那几个角收圆角**。 /// /// 终端右上角贴着屏幕物理边缘,而 iPad/iPhone 的屏幕本身是大圆角 —— 在那儿再画一个 6pt 小圆角, /// 两种曲率必然打架:小圆角整段落在屏幕圆角之外,被"吃掉",只在切口处露出一块底色三角。 /// 故顶边/右边**不收角、不描边**,由屏幕自己的圆角收口;左边(挨轨道)、底边(挨提示条) /// 才是真正需要圆角把 pane 和 chrome 分开的地方。Stage Manager 的小窗同理(窗圆角接管)。 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) } else { RawTerminalView(model: session) } // 连接中 / 失败 / 已关闭时给出解释与出路(已连接时此层不渲染,不遮挡终端)。 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) } } private func capsules(safeTop: CGFloat) -> some View { HStack(alignment: .top) { TerminalTitleCapsule(session: session, onClose: requestClose, onMinimize: minimizeSession) Spacer(minLength: TX.Space.m) TerminalStatusCapsule(session: session) } .padding(.horizontal, TX.Space.m) // iPad 隐藏状态栏后顶边无遮挡 → 设计值 14;灵动岛/刘海机型按硬件安全区下移。 .padding(.top, TX.Layout.capsuleTop(safeTop)) } /// 红点:**必须**先确认(设计要求)。确认后的动作分两套,见 `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() } /// 分屏菜单选择后立即分屏(设计:选完不再问方向)。 private func performSplit(_ target: SplitMenuView.SplitTarget, vertical: Bool) { guard let tmux = session.tmuxController else { return } switch target { case .currentSession: tmux.splitWindow(vertical: vertical) case .otherSession(let name): tmux.splitWindow(vertical: vertical, joinSession: name) case .newSession: // 名字用会话数递增,避免与既有会话撞名(tmux 撞名会报错)。 tmux.splitIntoNewSession(vertical: vertical, name: "tx\(tmux.sessions.count + 1)") case .nativeWindow: break // 首版不可用(菜单里已灰显),见 HANDOFF §12.4 } } /// ⌘D / ⌘⇧D 的入口:先拉一次 list-sessions 再弹菜单(菜单要列 detached 会话)。 private func requestSplit(vertical: Bool) { session.tmuxController?.listSessions() router.present(.splitMenu(vertical: vertical)) } /// 动作条左侧那句话:说**当前会话**的状态(此前写死「已连接妙控键盘 · 附加行已收起」—— /// 没接键盘时那是假话;侧边栏三态的说明属于实现细节,用户不需要)。 private var barContext: String { if !session.isLive { return session.phaseText } if let t = session.tmuxSummary { let name = t.name.isEmpty ? "tmux" : "tmux \(t.name)" return "\(session.displayName) · \(name) · \(t.windows) window\(t.windows == 1 ? "" : "s")" } if session.tmuxSkipped { return "\(session.displayName) · 原生窗口" } return session.displayName } /// 底部动作(**触控可点**,不是快捷键提示)。tmux 相关的两个在原生窗口下灰显。 private var barActions: [TerminalActionBar.Action] { let tmux = session.tmuxController return [ .init(id: "split", icon: "square.split.2x1", title: "竖分屏", enabled: tmux != nil) { requestSplit(vertical: true) }, .init(id: "window", icon: "plus.rectangle.on.rectangle", title: "新建 window", enabled: tmux != nil) { tmux?.newWindow() }, .init(id: "home", icon: "house", title: "回首页") { router.goHome() }, ] } } /// 普通(非 tmux)单终端视图。 struct RawTerminalView: View { @ObservedObject var model: TerminalSession @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 常驻挂载、只显活动 window。 /// window 切换已上移到轨道数字 / 侧边栏 window 行(设计稿去掉了 tab 条)。 struct TmuxStage: View { @ObservedObject var controller: TmuxController @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) .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 间拖拽条。 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) } } // pane 之间的拖拽条(在 pane 之上,热区 12pt)。 ForEach(splitters(layout.root), id: \.id) { s in PaneSplitter(splitter: s, cellW: cw, cellH: ch, controller: controller) } } } 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) } } /// 收集所有相邻子节点之间的分隔位置。tmux 的 `resize-pane -x/-y` 作用于**包含该 pane 的那层分割**, /// 故 resize 目标取"前一个子节点里的任一 pane"即可。 private func splitters(_ node: TmuxLayout.Node) -> [SplitterSpec] { var out: [SplitterSpec] = [] func walk(_ n: TmuxLayout.Node) { switch n { case .leaf: break case .horizontal(let children, _): // 左右并排 → 竖直分隔条,拖动改前一个子节点的**宽度**。 for (i, child) in children.enumerated() where i < children.count - 1 { let r = child.rect if let pane = firstPane(child) { out.append(SplitterSpec( id: "h-\(pane.raw)-\(i)", isVertical: true, pane: pane, x: r.x + r.width, y: r.y, length: r.height, currentSize: r.width)) } } children.forEach(walk) case .vertical(let children, _): // 上下堆叠 → 水平分隔条,拖动改前一个子节点的**高度**。 for (i, child) in children.enumerated() where i < children.count - 1 { let r = child.rect if let pane = firstPane(child) { out.append(SplitterSpec( id: "v-\(pane.raw)-\(i)", isVertical: false, pane: pane, x: r.x, y: r.y + r.height, length: r.width, currentSize: r.height)) } } children.forEach(walk) } } walk(node) return out } private func firstPane(_ node: TmuxLayout.Node) -> TmuxPaneID? { switch node { case .leaf(let p, _): return p case .horizontal(let cs, _), .vertical(let cs, _): return cs.compactMap(firstPane).first } } } /// 一条分隔位置(格子坐标)。 struct SplitterSpec { let id: String /// true = 竖直条(左右分,拖动改宽);false = 水平条(上下分,拖动改高) let isVertical: Bool /// resize 目标 pane(前一个子节点里的任一 pane) let pane: TmuxPaneID let x: Int, y: Int /// 条的长度(格子) let length: Int /// 前一个子节点当前的 cols / rows let currentSize: Int } /// pane 拖拽条:热区 12pt、把手 44×3pt。 /// /// **拖动中只动视觉**(把手位移 + 高亮线),不改 pane frame、不发命令;**松手才发一次 `resize-pane`**, /// 然后等 tmux 回 `%layout-change` 更新真实 frame —— 与"路线 b"一致(客户端不自行重排)。 struct PaneSplitter: View { let splitter: SplitterSpec let cellW: CGFloat let cellH: CGFloat let controller: TmuxController @State private var drag: CGFloat = 0 @State private var dragging = false @EnvironmentObject var theme: TXThemeManager private var p: TXPalette { theme.palette } private var hit: CGFloat { TX.Layout.paneDragHit } var body: some View { Group { if splitter.isVertical { bar(width: hit, height: CGFloat(splitter.length) * cellH) .position(x: CGFloat(splitter.x) * cellW + drag, y: (CGFloat(splitter.y) + CGFloat(splitter.length) / 2) * cellH) } else { bar(width: CGFloat(splitter.length) * cellW, height: hit) .position(x: (CGFloat(splitter.x) + CGFloat(splitter.length) / 2) * cellW, y: CGFloat(splitter.y) * cellH + drag) } } .gesture( DragGesture(minimumDistance: 2) .onChanged { v in dragging = true drag = splitter.isVertical ? v.translation.width : v.translation.height } .onEnded { _ in // 位移 → 格子增量;松手发一次命令,视觉位移立刻归零(等 tmux 回布局)。 let cell = splitter.isVertical ? cellW : cellH let delta = cell > 0 ? Int((drag / cell).rounded()) : 0 let newSize = max(1, splitter.currentSize + delta) if delta != 0 { controller.resizePane(splitter.pane, cols: splitter.isVertical ? newSize : nil, rows: splitter.isVertical ? nil : newSize) } drag = 0 dragging = false } ) } /// 热区透明 + 居中 44×3pt 把手(拖动中把手转强调色)。 private func bar(width: CGFloat, height: CGFloat) -> some View { ZStack { Color.clear.frame(width: width, height: height) RoundedRectangle(cornerRadius: 2) .fill(dragging ? TXAccent.base : p.borderStrong) .frame(width: splitter.isVertical ? 3 : 44, height: splitter.isVertical ? 44 : 3) .opacity(dragging ? 1 : 0.55) } .contentShape(Rectangle()) } } /// 单个 pane 的终端面 + 焦点边框 + 点击选中。pane 之间留 2pt 缝(设计:舞台底色透出为缝)。 struct TmuxPaneView: View { @ObservedObject var surface: TmuxPaneSurface let isActive: 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) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( 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(TX.Layout.paneGap / 2) // 相邻 pane 各让 1pt → 缝 2pt .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() } } }