From 98cf07ce2a78e2740cf7bab37a70e10464520e8f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 14:20:14 -0800 Subject: [PATCH] Stabilize terminal render recovery after split topology churn --- Sources/GhosttyTerminalView.swift | 8 ++++ Sources/Workspace.swift | 66 +++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ecb5b7dc..5dd0385c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1914,6 +1914,14 @@ final class TerminalSurface: Identifiable, ObservableObject { return } + // Reassert display id on topology churn (split close/reparent) before forcing a refresh. + // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active + // but callbacks have not resumed for the current display. + if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, + displayID != 0 { + ghostty_surface_set_display_id(surface, displayID) + } + view.forceRefreshSurface() ghostty_surface_refresh(surface) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 582f1d33..272bd086 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -577,6 +577,7 @@ final class Workspace: Identifiable, ObservableObject { private var debugDidMoveTabEventCount: UInt64 = 0 #endif private var geometryReconcileScheduled = false + private var geometryReconcileNeedsRerun = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -2241,18 +2242,67 @@ final class Workspace: Identifiable, ObservableObject { /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. + private func reconcileTerminalGeometryPass() -> Bool { + var needsFollowUpPass = false + + // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + } + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let hostedView = terminalPanel.hostedView + let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 + let hasSurface = terminalPanel.surface.surface != nil + let isAttached = hostedView.window != nil && hostedView.superview != nil + + // Split close/reparent churn can transiently detach a surviving terminal view. + // Force one SwiftUI representable update so the portal binding reattaches it. + if !isAttached || !hasUsableBounds || !hasSurface { + terminalPanel.requestViewReattach() + needsFollowUpPass = true + } + + hostedView.reconcileGeometryNow() + terminalPanel.surface.forceRefresh() + } + + return needsFollowUpPass + } + + private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { + guard remainingPasses > 0 else { + geometryReconcileScheduled = false + geometryReconcileNeedsRerun = false + return + } + + let needsFollowUpPass = reconcileTerminalGeometryPass() + let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass + + if shouldRunAgain, remainingPasses > 1 { + geometryReconcileNeedsRerun = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) + } + return + } + + geometryReconcileScheduled = false + geometryReconcileNeedsRerun = false + } + private func scheduleTerminalGeometryReconcile() { - guard !geometryReconcileScheduled else { return } + guard !geometryReconcileScheduled else { + geometryReconcileNeedsRerun = true + return + } geometryReconcileScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } - self.geometryReconcileScheduled = false - - for panel in self.panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.surface.forceRefresh() - } + self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) } }