diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift index 2b92775cadda..21357d6fd5f8 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift @@ -1,18 +1,10 @@ public import AppKit public import Foundation public import GhosttyKit -public import CmuxTerminalCore // MARK: - Surface sizing and scale extension TerminalSurface { - /// Assigns the host that authorizes view-driven renderer and PTY sizing. - /// - Parameter authority: The current portal, or nil when the surface is detached. - @MainActor - public func setSurfaceResizeAuthority(_ authority: (any TerminalSurfaceResizeAuthority)?) { - surfaceResizeAuthority = authority - } - /// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived /// from backing-space points and truncated (never rounded up). func pixelDimension(from value: CGFloat) -> UInt32 { @@ -23,6 +15,7 @@ extension TerminalSurface { } return UInt32(floored) } + @MainActor func scaleFactors(for view: any TerminalSurfaceNativeViewing) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let scale = max( @@ -34,6 +27,7 @@ extension TerminalSurface { ) return (scale, scale, scale) } + func scaleApproximatelyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs - rhs) <= epsilon } @@ -171,8 +165,7 @@ extension TerminalSurface { @MainActor public func reapplyAssignedGrid() { guard ioMode.usesManualIO, lastUncappedPixelWidth > 0, lastUncappedPixelHeight > 0, - lastXScale > 0, lastYScale > 0, - surfaceResizeAuthority?.isRendererResizeDeferred != true else { return } + lastXScale > 0, lastYScale > 0 else { return } _ = updateSize( width: CGFloat(lastUncappedPixelWidth) / lastXScale, height: CGFloat(lastUncappedPixelHeight) / lastYScale, @@ -212,7 +205,6 @@ extension TerminalSurface { suppressAssignedGridPin: Bool = false, caller: StaticString = #function ) -> Bool { - guard surfaceResizeAuthority?.isRendererResizeDeferred != true else { return false } guard let surface = liveSurfaceForGhosttyAccess(reason: "updateSize") else { return false } _ = layerScale @@ -378,9 +370,11 @@ extension TerminalSurface { } } } + if fittedSize.fontChanged && !sizeChanged { ghostty_surface_refresh(surface) } + // Deferred from above on a DPI increase: now that set_size grew the grid, // applying the larger cell only shrinks it back to the final width. if deferScaleUntilResized { @@ -388,6 +382,7 @@ extension TerminalSurface { lastXScale = xScale lastYScale = yScale } + // Remote tmux display surfaces: report every APPLIED resize — // including same-grid re-applies, since a resize that lands on new // pixels without changing cols×rows still refines the measured @@ -425,9 +420,11 @@ extension TerminalSurface { manualSizeReportPendingWindowAttach = true } } + // Let Ghostty continue rendering on its own wakeups for steady-state frames. return true } + /// The current monospace cell size in points, or nil if the runtime /// surface is not ready. Used by remote tmux mirror sizing. @MainActor @@ -441,6 +438,7 @@ extension TerminalSurface { height: Double(size.cell_height_px) / scale ) } + /// Raw sizing sample for calibration diagnostics: `ghostty_surface_size`'s /// device-pixel fields UNCONVERTED, plus the attached view's bounds in /// points and its window's backing scale. Callers separate view layout, @@ -462,6 +460,7 @@ extension TerminalSurface { backingScale: attachedView?.window?.backingScaleFactor ) } + /// Delivers the manual-size report that was skipped because the view was /// outside any window when the size applied (see /// ``manualSizeReportPendingWindowAttach``). Called from the attach path; @@ -478,6 +477,7 @@ extension TerminalSurface { manualSizeReportPendingWindowAttach = false report(sample) } + /// Which of ``renderedGridCells()``'s nil conditions currently hold — /// lets sizing diagnostics name the mechanism (view detached from its /// window vs surface not live vs no real grid) instead of a bare nil. @@ -488,6 +488,7 @@ extension TerminalSurface { surfaceLive: liveSurfaceForGhosttyAccess(reason: "renderedGridDiagnostics") != nil ) } + /// The on-screen rendered grid, or nil while the runtime surface is not /// live, is not in a window, or has no real grid yet. @MainActor @@ -500,4 +501,5 @@ extension TerminalSurface { guard cols > 1, rows > 1 else { return nil } return (cols, rows) } + } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 26b58526ebb9..43ea7d128300 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -306,7 +306,6 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// the pinned grid and clips or letterboxes the difference — the same /// answer tmux gives a client whose size disagrees with the window. var assignedGrid: (columns: Int, rows: Int)? - @MainActor weak var surfaceResizeAuthority: (any TerminalSurfaceResizeAuthority)? /// Temporary runtime font-size ownership while a mobile viewport is fitted. var mobileViewportFontFitState: MobileViewportFontFitState? // Debug metadata is read from debug/CLI paths off the main thread; the @@ -839,6 +838,7 @@ extension TerminalSurface: TerminalSurfacing {} /// exclusively owned by the request from creation until `close()` runs. private struct TerminalSurfaceHeadlessWindowCloseRequest: @unchecked Sendable { let window: NSWindow + @MainActor func close() { window.contentView = nil diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalPortalResizePhase.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalPortalResizePhase.swift deleted file mode 100644 index 63645f7fcff3..000000000000 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalPortalResizePhase.swift +++ /dev/null @@ -1,72 +0,0 @@ -/// Owns the boundary between live pane geometry and committed renderer sizes. -/// -/// A host keeps publication deferred until it has installed final pane geometry: -/// ```swift -/// var phase = TerminalPortalResizePhase() -/// phase.begin() -/// phase.requestEnd() -/// // Install final pane frames before releasing renderer publication. -/// phase.commitEnd(nativeResizeActive: false) -/// ``` -public struct TerminalPortalResizePhase: Sendable { - private enum Phase: Equatable, Sendable { - case idle - case resizing - case ending - case completedAwaitingNativeEnd - } - - private var phase = Phase.idle - - /// Creates an idle resize phase. - public init() {} - - /// Whether renderer and PTY size publication must wait for final geometry. - public var defersRenderer: Bool { - phase == .resizing || phase == .ending - } - - /// Whether a resize-end event is waiting for the final geometry pass. - public var isEnding: Bool { phase == .ending } - - /// Starts a new resize, superseding callbacks from an earlier transaction. - public mutating func begin() { - phase = .resizing - } - - /// Keeps publication deferred until the final geometry pass commits. - public mutating func requestEnd() { - phase = .ending - } - - /// Ignores callbacks from a completed resize until AppKit clears its signal - /// or an explicit start establishes a new resize transaction. - /// - /// - Parameter active: The current native live-resize signal. - /// - Returns: Whether the caller should reconcile geometry for this event. - public mutating func observeNativeResize(active: Bool) -> Bool { - switch (phase, active) { - case (.completedAwaitingNativeEnd, true): - return false - case (.completedAwaitingNativeEnd, false): - phase = .idle - case (.idle, true): - phase = .resizing - default: - break - } - return true - } - - /// Releases publication only after the portal installs final pane geometry. - /// - /// - Parameter nativeResizeActive: Whether native callbacks still belong to the ended resize. - public mutating func commitEnd(nativeResizeActive: Bool) { - phase = nativeResizeActive ? .completedAwaitingNativeEnd : .idle - } - - /// Returns the phase to idle when its portal is retired. - public mutating func reset() { - phase = .idle - } -} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceResizeAuthority.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceResizeAuthority.swift deleted file mode 100644 index c5fa4d0652b5..000000000000 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceResizeAuthority.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// The host that decides when view geometry may update drawable and PTY sizes. -/// -/// Surfaces hold this authority weakly and read it synchronously on the main -/// actor. The host owns the phase; adapters do not maintain copied resize flags. -@MainActor -public protocol TerminalSurfaceResizeAuthority: AnyObject { - /// Whether view-driven sizing must wait for the host's final geometry commit. - var isRendererResizeDeferred: Bool { get } -} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPortalResizePhaseTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPortalResizePhaseTests.swift deleted file mode 100644 index 553ef3fb7f8c..000000000000 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalPortalResizePhaseTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import CmuxTerminalCore -import Testing - -struct TerminalPortalResizePhaseTests { - @Test func nativeEndDoesNotReleaseBeforeGeometryCommit() { - var phase = TerminalPortalResizePhase() - phase.begin() - phase.requestEnd() - - let acceptsNativeEnd = phase.observeNativeResize(active: false) - #expect(acceptsNativeEnd) - #expect(phase.isEnding) - #expect(phase.defersRenderer) - - phase.commitEnd(nativeResizeActive: false) - #expect(!phase.isEnding) - #expect(!phase.defersRenderer) - } - - @Test func lateNativeCallbacksCannotReopenACompletedResize() { - var phase = TerminalPortalResizePhase() - phase.begin() - phase.requestEnd() - phase.commitEnd(nativeResizeActive: true) - - let acceptsLateTick = phase.observeNativeResize(active: true) - #expect(!acceptsLateTick) - #expect(!phase.defersRenderer) - let acceptsNativeEnd = phase.observeNativeResize(active: false) - #expect(acceptsNativeEnd) - #expect(!phase.defersRenderer) - - let acceptsNewResize = phase.observeNativeResize(active: true) - #expect(acceptsNewResize) - #expect(phase.defersRenderer) - } - - @Test func explicitStartSupersedesAnOldNativeEndSignal() { - var phase = TerminalPortalResizePhase() - phase.begin() - phase.requestEnd() - phase.commitEnd(nativeResizeActive: true) - phase.begin() - - let acceptsNewResize = phase.observeNativeResize(active: true) - #expect(acceptsNewResize) - #expect(phase.defersRenderer) - #expect(!phase.isEnding) - } - - @Test func retirementReleasesAnUnfinishedResize() { - var phase = TerminalPortalResizePhase() - phase.begin() - phase.requestEnd() - phase.reset() - - #expect(!phase.defersRenderer) - #expect(!phase.isEnding) - let acceptsOrdinaryLayout = phase.observeNativeResize(active: false) - #expect(acceptsOrdinaryLayout) - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6bc3e767255a..106762ab3cfe 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4046,7 +4046,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private let scrollSpeedAccumulator = TerminalScrollSpeedAccumulator() private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? - private weak var portalResizeAuthority: (any TerminalSurfaceResizeAuthority)? private var deferSurfaceSizeForPortalGeometrySettlement = false private var deferredSurfaceSizeRetryQueued = false, needsSurfaceSizeRetryAfterMetalLayerRealizes = false private var deferredSurfaceSizeNonMetalRetryCount = 0 @@ -4112,18 +4111,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if !visible { terminalPointerGesture.cancel() } } - fileprivate var isRendererResizeDeferred: Bool { - portalResizeAuthority?.isRendererResizeDeferred == true - } - - /// Reads resize permission from the portal that currently owns this view. - fileprivate func setPortalResizeAuthority(_ authority: (any TerminalSurfaceResizeAuthority)?) { - portalResizeAuthority = authority - terminalSurface?.setSurfaceResizeAuthority(authority) - clipsToBounds = true - layer?.masksToBounds = true - } - override init(frame frameRect: NSRect) { imageTransferPreparation = nil super.init(frame: frameRect) @@ -4168,7 +4155,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // GhosttyMetalLayer provides render stats and opt-in frame notifications for // input sequencing that needs to wait for terminal redraws. wantsLayer = true - clipsToBounds = true layer?.masksToBounds = true setupKeyboardCopyModeCursorOverlay() installEventMonitor() @@ -4554,7 +4540,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } terminalSurface = surface tabId = surface.tabId - surface.setSurfaceResizeAuthority(portalResizeAuthority) if !isAlreadyAttached { surface.attachToView(self) } else { @@ -5133,8 +5118,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } private var isWindowLiveResizeActive: Bool { - portalResizeAuthority?.isRendererResizeDeferred - ?? (inLiveResize || window?.inLiveResize == true) + inLiveResize || window?.inLiveResize == true } @discardableResult private func scheduleDeferredSurfaceSizeRetryIfNeeded() -> Bool { @@ -5146,7 +5130,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { @MainActor fileprivate func reconcileSurfaceSizeAfterMetalLayerAttachIfNeeded() { guard needsSurfaceSizeRetryAfterMetalLayerRealizes else { return }; deferredSurfaceSizeNonMetalRetryCount = 0; _ = updateSurfaceSize() } - /// Publishes view geometry only after its portal permits renderer resizing. @discardableResult private func updateSurfaceSize( size: CGSize? = nil, bypassLiveResizeCoalescing: Bool = false, caller: StaticString = #function @@ -5169,21 +5152,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } if pendingSurfaceSize != size { deferredSurfaceSizeNonMetalRetryCount = 0 } pendingSurfaceSize = size - clipsToBounds = true - layer?.masksToBounds = true - if isRendererResizeDeferred { -#if DEBUG - let signature = "windowLiveResize-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))" - if lastSizeSkipSignature != signature { - cmuxDebugLog( - "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=windowLiveResize size=\(String(format: "%.1fx%.1f", size.width, size.height))" - ) - lastSizeSkipSignature = signature - } -#endif - return false - } if let deferralReason = activeSurfaceResizeDeferralReason() { scheduleDeferredSurfaceSizeRetryIfNeeded() #if DEBUG @@ -9713,8 +9681,6 @@ final class GhosttySurfaceScrollView: NSView { private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var sessionContentWidthPresentation = SessionContentWidthPresentation.disabled - /// Keeps asynchronous presents matched to the renderer throughout a live drag. - private var committedRendererSize: CGSize? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 private var pendingAutomaticFirstResponderApply = false private var pendingAutomaticFirstResponderFocusTransactionId: UUID? @@ -9911,15 +9877,6 @@ final class GhosttySurfaceScrollView: NSView { ) } - /// Binds the renderer to its portal's read-only publication authority. - func setPortalResizeAuthority(_ authority: (any TerminalSurfaceResizeAuthority)?) { - surfaceView.setPortalResizeAuthority(authority) - clipsToBounds = true - layer?.masksToBounds = true - surfaceView.clipsToBounds = true - surfaceView.layer?.masksToBounds = true - } - init(surfaceView: GhosttyNSView) { #if DEBUG dispatchPrecondition(condition: .onQueue(.main)) @@ -9948,7 +9905,6 @@ final class GhosttySurfaceScrollView: NSView { scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true scrollView.drawsBackground = false - scrollView.clipsToBounds = true scrollView.backgroundColor = .clear scrollView.contentView.clipsToBounds = true scrollView.contentView.drawsBackground = false @@ -9956,17 +9912,12 @@ final class GhosttySurfaceScrollView: NSView { scrollView.surfaceView = surfaceView documentView = NSView(frame: .zero) - surfaceView.autoresizingMask = [] - surfaceView.translatesAutoresizingMaskIntoConstraints = true scrollView.documentView = documentView documentView.addSubview(surfaceView) super.init(frame: .zero) wantsLayer = true - clipsToBounds = true layer?.masksToBounds = true - scrollView.clipsToBounds = true - documentView.clipsToBounds = true backgroundView.wantsLayer = true backgroundView.layer?.backgroundColor = NSColor.clear.cgColor @@ -10419,31 +10370,11 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.terminalSurface?.forceRefresh(reason: reason) } - /// Uses the last valid renderer size while the portal holds publication. - private func resolvedDeferredRendererSize(deferred: Bool, fallback: CGSize) -> CGSize { - guard deferred, - let committedRendererSize, - committedRendererSize.width > 0, - committedRendererSize.height > 0 else { - return fallback - } - return committedRendererSize - } - - /// Moves pane chrome immediately while committing renderer geometry through its authority. @discardableResult private func synchronizeGeometryAndContent( forceViewportSync: Bool? = nil, preservedReviewOriginY: CGFloat? = nil ) -> Bool { - clipsToBounds = true - layer?.masksToBounds = true - scrollView.clipsToBounds = true - scrollView.contentView.clipsToBounds = true - surfaceView.clipsToBounds = true - surfaceView.layer?.masksToBounds = true - surfaceView.autoresizingMask = [] - let deferRendererResize = surfaceView.isRendererResizeDeferred let preservedReviewOriginY = preservedReviewOriginY ?? { guard scrollbackViewportIntent.preservesViewportDuringPendingSync else { return nil } return max(scrollView.contentView.bounds.origin.y, 0) @@ -10460,20 +10391,15 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(backgroundView, to: bounds) let contentFrame = sessionContentFrame _ = setFrameIfNeeded(scrollView, to: contentFrame) - if didScrollbarAppearanceChange { scrollView.tile() } - let targetSize = scrollView.contentView.bounds.size + let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) #endif - let rendererSize = resolvedDeferredRendererSize( - deferred: deferRendererResize, - fallback: targetSize - ) - let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: rendererSize) + let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) let targetDocumentFrame = CGRect( origin: documentView.frame.origin, - size: CGSize(width: targetSize.width, height: documentView.frame.height) + size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height) ) _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) _ = setFrameIfNeeded(mobileViewportBorderOverlayView, to: contentFrame) @@ -10507,14 +10433,12 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(overlay, to: contentFrame) } bringPaneDropTargetToFrontIfNeeded() + // NSScrollView can defer clip-view/content-size updates until its own layout pass, + // which makes interactive width changes arrive a queue turn late on Sequoia. + if didScrollbarAppearanceChange { + scrollView.tile() + } scrollView.layoutSubtreeIfNeeded() - let settledTargetSize = scrollView.contentView.bounds.size - let settledRendererSize = resolvedDeferredRendererSize( - deferred: deferRendererResize, - fallback: settledTargetSize - ) - let committedRendererFrame = CGRect(origin: surfaceView.frame.origin, size: settledRendererSize) - _ = setFrameIfNeeded(surfaceView, to: committedRendererFrame) updateNotificationRingPath() updateFlashPath(style: lastFlashStyle) updateFlashAppearance(style: lastFlashStyle) @@ -10523,11 +10447,8 @@ final class GhosttySurfaceScrollView: NSView { preservedReviewOriginY: preservedReviewOriginY ) synchronizeSurfaceView() - let didCoreSurfaceChange = deferRendererResize ? false : synchronizeCoreSurface() - if !deferRendererResize { - committedRendererSize = surfaceView.frame.size - } - return !sizeApproximatelyEqual(previousSurfaceSize, surfaceView.frame.size) || didCoreSurfaceChange + let didCoreSurfaceChange = synchronizeCoreSurface() + return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } /// Updates terminal content geometry without shrinking pane-level overlays. @@ -13246,12 +13167,7 @@ final class GhosttySurfaceScrollView: NSView { private func synchronizeTerminalGeometryAfterScrollerStyleChange() { scrollView.layoutSubtreeIfNeeded() let targetSize = scrollView.contentView.bounds.size - let deferRendererResize = surfaceView.isRendererResizeDeferred - let rendererSize = resolvedDeferredRendererSize( - deferred: deferRendererResize, - fallback: targetSize - ) - let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: rendererSize) + let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize) _ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame) let targetDocumentFrame = CGRect( origin: documentView.frame.origin, @@ -13259,10 +13175,7 @@ final class GhosttySurfaceScrollView: NSView { ) _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) synchronizeSurfaceView() - if !deferRendererResize { - _ = synchronizeCoreSurface() - committedRendererSize = surfaceView.frame.size - } + _ = synchronizeCoreSurface() } private func handleTerminalScrollBarPreferenceChange() { @@ -13869,7 +13782,7 @@ extension GhosttyNSView: NSTextInputClient { // MARK: - SwiftUI Wrapper struct GhosttyTerminalView: NSViewRepresentable { - @Environment(\.workspaceAttentionColor) var workspaceAttentionColor + @Environment(\.workspaceAttentionColor) private var workspaceAttentionColor @Environment(\.paneDropZone) var paneDropZone let terminalSurface: TerminalSurface @@ -13989,5 +13902,4 @@ struct GhosttyTerminalView: NSViewRepresentable { /// already be gone by then. weak var vacancyParkedSurface: TerminalSurface? } - } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 76424c7b4f11..91610e672678 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -3,7 +3,6 @@ import ObjectiveC import CmuxAppKitSupportUI import CmuxFoundation import CmuxTerminal -import CmuxTerminalCore #if DEBUG import Bonsplit #endif @@ -630,7 +629,7 @@ private final class SplitDividerOverlayView: NSView { } @MainActor -final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { +final class WindowTerminalPortal: NSObject { #if DEBUG static var isPointerDragActiveForTesting = false /// Instance-scoped so a test drives only its own portal's live-resize @@ -663,10 +662,8 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { static var usesCoalescedAnchorFailsafe = false /// Deferred redraws keyed by hosted view; drain after the current layout turn. private var pendingDeferredSurfaceRefreshes: [ObjectIdentifier: String] = [:] - private var pendingLiveResizeSurfaceRefreshes: [ObjectIdentifier: String] = [:] private var lastDeferredSurfaceRefreshFrames: [ObjectIdentifier: NSRect] = [:] private var hasDeferredSurfaceRefreshScheduled = false - private var resizePhase = TerminalPortalResizePhase() private var hasExternalGeometrySyncScheduled = false private var pendingExternalGeometrySyncRequiresImmediate = false /// a non-immediate request folded into an immediately-scheduled pass @@ -744,11 +741,7 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { init(window: NSWindow, syncLayout: Bool = true) { self.window = window super.init() - if window.inLiveResize || TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive(in: window) { - resizePhase.begin() - } hostView.wantsLayer = true - hostView.clipsToBounds = true hostView.layer?.masksToBounds = true hostView.postsFrameChangedNotifications = true hostView.postsBoundsChangedNotifications = true @@ -781,16 +774,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { guard geometryObservers.isEmpty else { return } let center = NotificationCenter.default - geometryObservers.append(center.addObserver( - forName: NSWindow.willStartLiveResizeNotification, - object: window, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - guard let self, self.selfFrameWriteDepth == 0 else { return } - self.beginWindowLiveResizePhase() - } - }) geometryObservers.append(center.addObserver( forName: NSWindow.didResizeNotification, object: window, @@ -807,7 +790,12 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { guard Thread.isMainThread else { return } MainActor.assumeIsolated { #if DEBUG - // Only diagnose programmatic growth; never symbolize a stack per live-resize tick. + // Standing tripwire for PROGRAMMATIC window growth — the + // ever-growing-terminal's signature. didResize posts + // synchronously inside setFrame, so the stack names the + // resizer. User-driven live resizes are skipped entirely: + // symbolicating a stack per tick is exactly the kind of + // observer-chain work that made live resizes sluggish. if let self, let resized = notification.object as? NSWindow, !resized.inLiveResize { let old = self.lastObservedWindowSize let new = resized.frame.size @@ -824,7 +812,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { #endif guard let self, self.selfFrameWriteDepth == 0 else { return } if self.isWindowLiveResizeActive { - guard self.resizePhase.observeNativeResize(active: true) else { return } // Live resize: run the pass INSIDE this tick so hosted // frames commit together with the window's new size. The // pass forces subtree layout first (fresh anchor frames) @@ -848,7 +835,7 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { ) { [weak self] _ in MainActor.assumeIsolated { guard let self, self.selfFrameWriteDepth == 0 else { return } - self.endWindowLiveResizePhase() + self.scheduleExternalGeometrySynchronize() } }) geometryObservers.append(center.addObserver( @@ -915,6 +902,7 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } }) } + private func removeReferenceGeometryObservers() { for observer in referenceGeometryObservers { NotificationCenter.default.removeObserver(observer) @@ -926,7 +914,9 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { scheduleExternalGeometrySynchronize(forceImmediate: true) } - /// True while AppKit reports a native window-edge resize. + /// True while the hosting window is in an interactive live resize + /// (title-bar/edge drag). Split-divider drags are deliberately NOT + /// window live resizes — they keep the immediate per-callback sync path. private var isWindowLiveResizeActive: Bool { #if DEBUG if isWindowLiveResizeActiveOverrideForTesting { return true } @@ -934,22 +924,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { return hostView.inLiveResize || window?.inLiveResize == true } - /// The publication permission shared by this portal's terminal views. - var isRendererResizeDeferred: Bool { - resizePhase.defersRenderer - } - - /// Holds renderer publication before a window or divider resize begins. - func beginWindowLiveResizePhase() { - resizePhase.begin() - } - - /// Releases the resize phase through the queued final geometry pass. - func endWindowLiveResizePhase() { - resizePhase.requestEnd() - scheduleExternalGeometrySynchronize() - } - /// The portal whose sync pass is currently on the stack, if any. A /// request arriving for THAT portal during its own pass is not dropped /// — a pass's layout can produce genuinely new geometry (an imposed @@ -1109,8 +1083,16 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { fileprivate func synchronizeAllEntriesFromExternalGeometryChange() { if let activePortalId = Self.currentlySynchronizingPortalId { if activePortalId == ObjectIdentifier(self) { + // Our own pass is on the stack (a re-entrant main-queue drain + // during its layout fired the queued block). Mark the follow-up + // the pass schedules on exit. resyncRequestedDuringPass = true } else { + // A DIFFERENT portal's pass is on the stack. The scheduling + // flag is already down by the time performSync calls here, so + // returning without rescheduling would drop the request + // forever and leave this portal parked at stale geometry. + // Re-queue it to run after the current pass unwinds. scheduleExternalGeometrySynchronize(forceImmediate: false) } return @@ -1121,9 +1103,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { #endif defer { Self.currentlySynchronizingPortalId = nil - if !isRendererResizeDeferred { - flushPendingLiveResizeSurfaceRefreshesIfReady() - } if resyncRequestedDuringPass { resyncRequestedDuringPass = false DispatchQueue.main.async { [weak self] in @@ -1131,24 +1110,21 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } } } - let nativeResizeActive = isWindowLiveResizeActive - guard resizePhase.observeNativeResize(active: nativeResizeActive) else { return } - let endingLiveResize = resizePhase.isEnding - guard ensureInstalled(syncLayout: false) else { - if endingLiveResize { - resizePhase.commitEnd(nativeResizeActive: nativeResizeActive) - } - return - } + // Content-based echo cut. A sync pass lays out hosted split views + // and writes hostView.frame, and the notifications those emit can + // be DELIVERED AFTER the pass ends (block observers on .main), so + // no in-pass flag can catch them all — the sync then re-runs + // forever on identical geometry, pinning the main thread. An echo + // carries the exact geometry the last pass left behind, so it dies + // here in one cheap comparison; any real change differs somewhere + // and syncs fully. + // Installation must not consume this pass's layout change before the + // settlement check. Otherwise its second hierarchy sync immediately + // sees the signature the first one just wrote and publishes a transient + // terminal size during workspace reveal. + guard ensureInstalled(syncLayout: false) else { return } let hierarchyWasAlreadySettled = synchronizeLayoutHierarchy() - if endingLiveResize { - resizePhase.commitEnd(nativeResizeActive: nativeResizeActive) - } - synchronizeAllHostedViews( - excluding: nil, - syncLayout: false, - portalIsPrepared: true - ) + synchronizeAllHostedViews(excluding: nil) reconcileVisibleHostedViewsAfterGeometrySync(reason: "portal.externalGeometrySync") if hierarchyWasAlreadySettled { finishVisibleEntryGeometrySettlements() @@ -1264,8 +1240,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { @discardableResult private func ensureInstalled(syncLayout: Bool = true) -> Bool { guard let window else { return false } - hostView.clipsToBounds = true - hostView.layer?.masksToBounds = true guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window) else { return false } let browserHost = preferredBrowserHost(in: container) @@ -1495,12 +1469,10 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { lastDeferredSurfaceRefreshFrames.removeValue(forKey: hostedId) - pendingLiveResizeSurfaceRefreshes.removeValue(forKey: hostedId) clearPresentationNotificationState(for: hostedId) return } lastDeferredSurfaceRefreshFrames.removeValue(forKey: hostedId) - pendingLiveResizeSurfaceRefreshes.removeValue(forKey: hostedId) clearPresentationNotificationState(for: hostedId) #if DEBUG lastPortalTargetByHostedId.removeValue(forKey: hostedId) @@ -1517,7 +1489,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { #endif if let hostedView = entry.hostedView { hostedView.finishPortalGeometrySettlement() - hostedView.setPortalResizeAuthority(nil) if let restoredMask = preAdoptionAutoresizingMaskByHostedId.removeValue(forKey: hostedId) { hostedView.autoresizingMask = restoredMask } @@ -1537,7 +1508,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } entry.visibleInUI = false entry.hostedView?.finishPortalGeometrySettlement() - entry.hostedView?.setPortalResizeAuthority(nil) entry.awaitingGeometrySettlement = false entry.transientRecoveryRetriesRemaining = 0 entriesByHostedId[hostedId] = entry @@ -1632,7 +1602,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { syncLayout: Bool ) { guard ensureInstalled(syncLayout: syncLayout) else { return } - hostedView.setPortalResizeAuthority(self) let hostedId = ObjectIdentifier(hostedView) let anchorId = ObjectIdentifier(anchorView) @@ -1769,15 +1738,31 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } func synchronizeHostedViewForAnchor(_ anchorView: NSView, syncLayout: Bool = true) { + // Anchor geometry callbacks fire for every layout pass — including + // the passes our own syncs run — and treating each one as a + // synchronous full-portal sync (hierarchy layout + every hosted + // view + a deferred follow-up) kept the display cycle busy + // indefinitely under churn. Outside a split-divider drag they + // coalesce into the scheduled pass like every other trigger; during + // a divider drag the immediate path below keeps the dragged split + // visually glued. + // + // A live WINDOW resize takes the coalesced path too, on purpose. + // Unlike a divider drag (one or two anchors move), a window resize + // fires this callback for EVERY visible pane in the same layout + // pass, so the full-portal fan-out below did panes × callbacks + // work per display frame. Syncing just this anchor's hosted view + // keeps the pane glued to the geometry the layout pass produced; + // the per-tick scheduled pass (windowDidResize) catches panes whose + // window-relative position changed without their own frame + // changing, and the end-of-resize sync (windowDidEndLiveResize → + // scheduleExternalGeometrySynchronize) stays unconditional. guard TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive(in: window) else { - let nativeResizeActive = isWindowLiveResizeActive - guard resizePhase.observeNativeResize(active: nativeResizeActive) else { return } - if !isRendererResizeDeferred { + if !isWindowLiveResizeActive { pruneDeadEntries() } let anchorId = ObjectIdentifier(anchorView) if let hostedId = hostedByAnchorId[anchorId] { - entriesByHostedId[hostedId]?.hostedView?.setPortalResizeAuthority(self) synchronizeHostedView(withId: hostedId, syncLayout: false) } scheduleExternalGeometrySynchronize(forceImmediate: false) @@ -1796,6 +1781,9 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { synchronizeHostedView(withId: primaryHostedId, syncLayout: syncLayout) } + // Failsafe: during aggressive divider drags/structural churn, one anchor can miss a + // geometry callback while another fires. Reconcile all mapped hosted views so no stale + // frame remains "stuck" onscreen until the next interaction. // // With the AppKit sidebar experiment on (value pushed from // ContentView's dispatcher, the flag's single evaluation site), the @@ -1818,7 +1806,14 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } private func reconcileVisibleHostedViewsAfterGeometrySync(reason: String, syncLayout: Bool = true) { - guard !isRendererResizeDeferred else { return } + // During a live window resize this pass would re-reconcile every + // visible surface once per resize tick, right after + // synchronizeHostedView already reconciled the ones whose geometry + // changed — and then force a redraw per surface per frame. Skip it + // mid-resize; the end-of-resize sync (windowDidEndLiveResize → + // scheduleExternalGeometrySynchronize) runs it unconditionally once + // live resize is over. + guard !isWindowLiveResizeActive else { return } for (hostedId, entry) in entriesByHostedId { guard entry.visibleInUI, let hostedView = entry.hostedView, !hostedView.isHidden else { continue } if hostedView.reconcileGeometryNow() { @@ -1883,34 +1878,6 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } } - /// Delivers held redraws after final geometry opens the publication gate. - private func flushPendingLiveResizeSurfaceRefreshesIfReady() { - guard !isRendererResizeDeferred, !pendingLiveResizeSurfaceRefreshes.isEmpty else { return } - let pending = pendingLiveResizeSurfaceRefreshes - pendingLiveResizeSurfaceRefreshes = [:] - for (pendingId, pendingReason) in pending { - lastDeferredSurfaceRefreshFrames.removeValue(forKey: pendingId) - guard let entry = entriesByHostedId[pendingId], - entry.visibleInUI, - let hostedView = entry.hostedView, - !hostedView.isHidden else { continue } - hostedView.refreshSurfaceNow(reason: pendingReason) - } - } - - /// Rechecks the phase when a redraw queued before resize reaches delivery. - @discardableResult - private func holdDeferredSurfaceRefreshesDuringLiveResizeIfNeeded() -> Bool { - guard isRendererResizeDeferred else { return false } - for (hostedId, reason) in pendingDeferredSurfaceRefreshes { - if pendingLiveResizeSurfaceRefreshes[hostedId] == nil { - pendingLiveResizeSurfaceRefreshes[hostedId] = reason - } - } - pendingDeferredSurfaceRefreshes.removeAll() - return true - } - private func deferSurfaceRefresh(forHostedId hostedId: ObjectIdentifier, reason: String) { guard let entry = entriesByHostedId[hostedId], let hostedView = entry.hostedView else { return } let frame = hostedView.frame @@ -1918,19 +1885,12 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { return } lastDeferredSurfaceRefreshFrames[hostedId] = frame - if isRendererResizeDeferred { - pendingLiveResizeSurfaceRefreshes[hostedId] = reason - return - } pendingDeferredSurfaceRefreshes[hostedId] = reason guard !hasDeferredSurfaceRefreshScheduled else { return } hasDeferredSurfaceRefreshScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.hasDeferredSurfaceRefreshScheduled = false - if self.holdDeferredSurfaceRefreshesDuringLiveResizeIfNeeded() { - return - } let pending = self.pendingDeferredSurfaceRefreshes self.pendingDeferredSurfaceRefreshes = [:] for (pendingId, pendingReason) in pending { @@ -1944,13 +1904,8 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } } - /// Reconciles a batch against one prepared portal hierarchy. - private func synchronizeAllHostedViews( - excluding hostedIdToSkip: ObjectIdentifier?, - syncLayout: Bool = true, - portalIsPrepared: Bool = false - ) { - guard portalIsPrepared || ensureInstalled(syncLayout: false) else { return } + private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?, syncLayout: Bool = true) { + guard ensureInstalled(syncLayout: syncLayout) else { return } if syncLayout { synchronizeLayoutHierarchy() } else { @@ -1960,19 +1915,19 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { let hostedIds = Array(entriesByHostedId.keys) for hostedId in hostedIds { if hostedId == hostedIdToSkip { continue } - // Hidden entries retain their last frame until they become visible. + // An already-hidden entry for a hidden tab is a no-op here by + // design: its frame is deliberately left alone while hidden, and + // becoming visible schedules its own sync (updateEntryVisibility). + // Skipping it matters — a session of mirrored tmux windows keeps + // dozens of hidden surfaces, and computing every one's + // ancestor-clipped frame on every geometry tick made live window + // resizes visibly sluggish. if let entry = entriesByHostedId[hostedId], !entry.visibleInUI, entry.hostedView?.isHidden == true { continue } - synchronizeHostedView( - withId: hostedId, - syncLayout: syncLayout, - portalIsPrepared: true, - deferDividerOverlay: true - ) + synchronizeHostedView(withId: hostedId, syncLayout: syncLayout) } - ensureDividerOverlayOnTop() } private func resetTransientRecoveryRetryIfNeeded(forHostedId hostedId: ObjectIdentifier, entry: inout Entry) { @@ -2017,13 +1972,8 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { return true } - private func synchronizeHostedView( - withId hostedId: ObjectIdentifier, - syncLayout: Bool = true, - portalIsPrepared: Bool = false, - deferDividerOverlay: Bool = false - ) { - guard portalIsPrepared || ensureInstalled(syncLayout: syncLayout) else { return } + private func synchronizeHostedView(withId hostedId: ObjectIdentifier, syncLayout: Bool = true) { + guard ensureInstalled(syncLayout: syncLayout) else { return } guard var entry = entriesByHostedId[hostedId] else { return } guard let hostedView = entry.hostedView else { entriesByHostedId.removeValue(forKey: hostedId) @@ -2311,7 +2261,16 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } if geometryChanged { _ = hostedView.reconcileGeometryNow() - if entry.visibleInUI, !shouldHide, !hostedView.isHidden, !isRendererResizeDeferred { + // Hidden surfaces keep geometry bookkeeping and redraw on reveal. + // Mid window live-resize, skip the synchronous redraw for visible + // ones too: reconcileGeometryNow already pushed the new size into + // the runtime (a ghostty size change schedules its own repaint), + // and forcing displayIfNeeded plus an extra surface refresh for + // every visible pane on every resize tick — sometimes before the + // pane's Metal layer was even realized — is what made resizing a + // window full of mirrored panes drag. The end-of-resize sync runs + // after live resize is over and takes this branch normally. + if entry.visibleInUI, !shouldHide, !hostedView.isHidden, !isWindowLiveResizeActive { if syncLayout { hostedView.refreshSurfaceNow(reason: "portal.frameChange") } else { @@ -2343,8 +2302,18 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { ) #endif hostedView.isHidden = false + // A reveal can happen without any frame delta (same targetFrame), which means the + // normal frame-change refresh path won't run. Nudge geometry + redraw so newly + // revealed terminals don't sit on a stale/blank IOSurface until later focus churn. hostedView.reconcileGeometryNow() - if syncLayout, !isRendererResizeDeferred { + // Mid window live-resize the pass runs synchronously inside the + // resize tick's still-open transaction (see the didResize + // observer), where refreshSurfaceNow's displayIfNeeded reaches + // ghostty's Metal drawFrame and wedges on a present only that + // transaction can commit. Unlike the frame-change branch above, + // a reveal cannot skip its redraw outright — the surface would + // sit blank until later churn — so defer it one main-queue turn. + if syncLayout, !isWindowLiveResizeActive { hostedView.refreshSurfaceNow(reason: "portal.reveal") } else { deferSurfaceRefresh(forHostedId: hostedId, reason: "portal.reveal.deferred") @@ -2373,9 +2342,7 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } #endif - if !deferDividerOverlay { - ensureDividerOverlayOnTop() - } + ensureDividerOverlayOnTop() } private func updatePresentationState( @@ -2466,13 +2433,10 @@ final class WindowTerminalPortal: NSObject, TerminalSurfaceResizeAuthority { } func tearDown() { - pendingLiveResizeSurfaceRefreshes.removeAll() - pendingDeferredSurfaceRefreshes.removeAll() removeGeometryObservers() for hostedId in Array(entriesByHostedId.keys) { detachHostedView(withId: hostedId) } - resizePhase.reset() for scheduler in presentationNotificationSchedulers.values { scheduler.cancel() } @@ -2560,7 +2524,9 @@ enum TerminalWindowPortalRegistry { clearActiveSplitDividerDrag() return false } + guard let event = NSApp.currentEvent else { return false } + switch event.type { case .leftMouseUp: clearActiveSplitDividerDrag() @@ -2570,6 +2536,7 @@ enum TerminalWindowPortalRegistry { default: return false } + if let activeSplitDividerDragWindowId, let activeSplitDividerDragEventNumber { let hasActiveWindow = NSApp.windows.contains { ObjectIdentifier($0) == activeSplitDividerDragWindowId } if hasActiveWindow, event.eventNumber == activeSplitDividerDragEventNumber { @@ -2577,7 +2544,9 @@ enum TerminalWindowPortalRegistry { } clearActiveSplitDividerDrag() } + guard event.type == .leftMouseDown else { return false } + let candidateWindows = currentSplitDividerDragCandidateWindows(for: event) let mouseLocation = NSEvent.mouseLocation for window in candidateWindows { @@ -2587,20 +2556,25 @@ enum TerminalWindowPortalRegistry { return true } } + return false } + fileprivate static func isSplitDividerDragActive(in window: NSWindow?) -> Bool { guard let window, isCurrentEventSplitDividerDrag() else { return false } return activeSplitDividerDragWindowId == ObjectIdentifier(window) } + private static func clearActiveSplitDividerDrag() { activeSplitDividerDragWindowId = nil activeSplitDividerDragEventNumber = nil } + // Only the event's own window may latch drag ownership: a foreign drag routed through an occluded host must not self-authorize its cursor. fileprivate static func noteSplitDividerInteraction(in window: NSWindow?, event: NSEvent?) { guard let window, let event, event.window === window, (NSEvent.pressedMouseButtons & 1) != 0 else { return } + switch event.type { case .leftMouseDown, .leftMouseDragged: activeSplitDividerDragWindowId = ObjectIdentifier(window) @@ -2609,6 +2583,7 @@ enum TerminalWindowPortalRegistry { break } } + private static func currentSplitDividerDragCandidateWindows(for event: NSEvent) -> [NSWindow] { var candidateWindows: [NSWindow] = [] if let eventWindow = event.window { @@ -2622,6 +2597,7 @@ enum TerminalWindowPortalRegistry { } return candidateWindows } + private static func bindBlockReason( expectedSurfaceId: UUID?, expectedGeneration: UInt64?, @@ -2641,6 +2617,7 @@ enum TerminalWindowPortalRegistry { } return "guardRejected" } + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return } let windowId = ObjectIdentifier(window) @@ -2664,9 +2641,11 @@ enum TerminalWindowPortalRegistry { .OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } + private static func removePortal(for window: NSWindow) { removePortal(windowId: ObjectIdentifier(window), window: window) } + private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) { if let portal = portalsByWindowId.removeValue(forKey: windowId) { portal.tearDown() @@ -2674,6 +2653,7 @@ enum TerminalWindowPortalRegistry { hostedToWindowId = hostedToWindowId.filter { $0.value != windowId } interactiveGeometryResizeCountsByWindowId.removeValue(forKey: windowId) interactiveGeometryResizeOwnerWindowIds = interactiveGeometryResizeOwnerWindowIds.filter { $0.value != windowId } + guard let window else { return } if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) { NotificationCenter.default.removeObserver(observer) @@ -2681,23 +2661,27 @@ enum TerminalWindowPortalRegistry { objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN) } + private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set) { hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in mappedWindowId != windowId || validHostedIds.contains(hostedId) } } + private static func portal(for window: NSWindow, syncLayout: Bool = true) -> WindowTerminalPortal { if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { portalsByWindowId[ObjectIdentifier(window)] = existing installWindowCloseObserverIfNeeded(for: window) return existing } + let portal = WindowTerminalPortal(window: window, syncLayout: syncLayout) objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) portalsByWindowId[ObjectIdentifier(window)] = portal installWindowCloseObserverIfNeeded(for: window) return portal } + private static func existingPortal(for window: NSWindow) -> WindowTerminalPortal? { if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { portalsByWindowId[ObjectIdentifier(window)] = existing @@ -2706,6 +2690,7 @@ enum TerminalWindowPortalRegistry { } return portalsByWindowId[ObjectIdentifier(window)] } + static func bind( hostedView: GhosttySurfaceScrollView, to anchorView: NSView, @@ -2715,6 +2700,7 @@ enum TerminalWindowPortalRegistry { expectedGeneration: UInt64? = nil ) { guard let window = anchorView.window else { return } + let windowId = ObjectIdentifier(window) let hostedId = ObjectIdentifier(hostedView) let guardState = hostedView.portalBindingGuardState() @@ -2744,15 +2730,18 @@ enum TerminalWindowPortalRegistry { #endif return } + // Representable coordinators stage registry binds after their framework // callbacks return. Each pane consumes the committed anchor geometry; // WindowTerminalPortal coalesces their deferred full convergence into // one window-owned pass instead of forcing window layout per pane. let nextPortal = portal(for: window, syncLayout: false) + if let oldWindowId = hostedToWindowId[hostedId], oldWindowId != windowId { portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) } + nextPortal.bindUsingCommittedGeometry( hostedView: hostedView, to: anchorView, @@ -2762,25 +2751,31 @@ enum TerminalWindowPortalRegistry { hostedToWindowId[hostedId] = windowId pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds()) } + static func synchronizeForAnchor(_ anchorView: NSView, syncLayout: Bool = true) { guard let window = anchorView.window else { return } let portal = portal(for: window, syncLayout: syncLayout) portal.synchronizeHostedViewForAnchor(anchorView, syncLayout: syncLayout) } + static func scheduleExternalGeometrySynchronize(for window: NSWindow, forceImmediate: Bool = true) { existingPortal(for: window)?.scheduleExternalGeometrySynchronize(forceImmediate: forceImmediate) } + #if DEBUG static func synchronizeExternalGeometryNow(for window: NSWindow) { existingPortal(for: window)?.synchronizeAllEntriesFromExternalGeometryChange() } #endif + static func beginInteractiveGeometryResize(in window: NSWindow?) { beginInteractiveGeometryResize(windowId: window.map(ObjectIdentifier.init)) } + static func endInteractiveGeometryResize(in window: NSWindow?) { endInteractiveGeometryResize(windowId: window.map(ObjectIdentifier.init)) } + static func beginInteractiveGeometryResize(owner: AnyObject, in window: NSWindow?) { let ownerId = ObjectIdentifier(owner) guard interactiveGeometryResizeOwnerWindowIds[ownerId] == nil, @@ -2793,6 +2788,7 @@ enum TerminalWindowPortalRegistry { beginInteractiveGeometryResize(windowId: nil) } } + static func endInteractiveGeometryResize(owner: AnyObject) { let ownerId = ObjectIdentifier(owner) if let windowId = interactiveGeometryResizeOwnerWindowIds.removeValue(forKey: ownerId) { @@ -2801,26 +2797,20 @@ enum TerminalWindowPortalRegistry { endInteractiveGeometryResize(windowId: nil) } } + private static func beginInteractiveGeometryResize(windowId: ObjectIdentifier?) { guard let windowId else { unscopedInteractiveGeometryResizeCount += 1 - if unscopedInteractiveGeometryResizeCount == 1 { - for portal in portalsByWindowId.values { - portal.beginWindowLiveResizePhase() - } - } return } interactiveGeometryResizeCountsByWindowId[windowId, default: 0] += 1 - if interactiveGeometryResizeCountsByWindowId[windowId] == 1 { - portalsByWindowId[windowId]?.beginWindowLiveResizePhase() - } #if DEBUG if interactiveGeometryResizeCountsByWindowId[windowId] == 1 { cmuxDebugLog("portal.geometryResize.begin") } #endif } + private static func endInteractiveGeometryResize(windowId: ObjectIdentifier?) { guard let windowId else { guard unscopedInteractiveGeometryResizeCount > 0 else { return } @@ -2828,18 +2818,19 @@ enum TerminalWindowPortalRegistry { if unscopedInteractiveGeometryResizeCount == 0 { for (portalWindowId, portal) in portalsByWindowId where interactiveGeometryResizeCountsByWindowId[portalWindowId, default: 0] == 0 { - portal.endWindowLiveResizePhase() + portal.scheduleExternalGeometrySynchronize(forceImmediate: false) } } return } + guard let count = interactiveGeometryResizeCountsByWindowId[windowId], count > 0 else { return } if count == 1 { interactiveGeometryResizeCountsByWindowId.removeValue(forKey: windowId) // Apply the final exact renderer and PTY dimensions only in the - // window whose geometry phase just cleared. + // window whose pixel-only coalescing gate just cleared. if unscopedInteractiveGeometryResizeCount == 0 { - portalsByWindowId[windowId]?.endWindowLiveResizePhase() + portalsByWindowId[windowId]?.scheduleExternalGeometrySynchronize(forceImmediate: false) } // Single choke point every drag-end path funnels through (tracker // onEnded, legacy gesture onEnded, cursor failsafe): observers @@ -2856,6 +2847,7 @@ enum TerminalWindowPortalRegistry { interactiveGeometryResizeCountsByWindowId[windowId] = count - 1 } } + #if DEBUG /// Test support: clears interactive geometry state after a failed test /// whose balancing end call may not have run. @@ -2868,6 +2860,7 @@ enum TerminalWindowPortalRegistry { isPointerDragActiveForTesting = false } #endif + static func scheduleExternalGeometrySynchronizeForAllWindows(forceImmediate: Bool = true) { // Same latest-request-wins coalescing for callers that don't have a // concrete window handle yet. @@ -2903,11 +2896,13 @@ enum TerminalWindowPortalRegistry { } } } + static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) { let hostedId = ObjectIdentifier(hostedView) guard let windowId = hostedToWindowId[hostedId], let portal = portalsByWindowId[windowId] else { return } portal.hideEntry(forHostedId: hostedId) } + /// Hides every registered terminal portal owned by one inactive workspace. static func hideHostedViews(forWorkspaceID workspaceID: UUID) { for portal in portalsByWindowId.values { @@ -2927,6 +2922,7 @@ enum TerminalWindowPortalRegistry { guard let windowId = hostedToWindowId[hostedId], let portal = portalsByWindowId[windowId] else { return visibleInUI } return portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) } + /// Whether the registry entry still names this anchor, including while the /// anchor is temporarily detached and therefore has no live window binding. static func hasEntry(for hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool { @@ -2934,6 +2930,7 @@ enum TerminalWindowPortalRegistry { guard let windowId = hostedToWindowId[hostedId], let portal = portalsByWindowId[windowId] else { return false } return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView) } + static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool { let hostedId = ObjectIdentifier(hostedView) guard let window = anchorView.window else { return false } @@ -2941,6 +2938,7 @@ enum TerminalWindowPortalRegistry { guard hostedToWindowId[hostedId] == windowId, let portal = portalsByWindowId[windowId] else { return false } return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView) } + static func isPresented(_ hostedView: GhosttySurfaceScrollView) -> Bool { let hostedId = ObjectIdentifier(hostedView) guard let windowId = hostedToWindowId[hostedId], @@ -2949,14 +2947,17 @@ enum TerminalWindowPortalRegistry { } return portal.isPresented(hostedView, hostedId: hostedId) } + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { let portal = portal(for: window) return portal.viewAtWindowPoint(windowPoint) } + static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? { let portal = portal(for: window) return portal.terminalViewAtWindowPoint(windowPoint) } + static func terminalPaneDropTargetAtWindowPoint( _ windowPoint: NSPoint, in window: NSWindow @@ -2964,7 +2965,9 @@ enum TerminalWindowPortalRegistry { let portal = portal(for: window) return portal.terminalPaneDropTargetAtWindowPoint(windowPoint) } + } + extension Notification.Name { /// Posted when the last interactive geometry resize session in a window /// ends (sidebar/split divider drags). Fired from the registry's single diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3112d834a00b..0ebc4e1a71e8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3359,7 +3359,6 @@ C13519000000000000000013 /* TerminalPathEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000014 /* TerminalPathEnvironmentTests.swift */; }; F87910110000000000000001 /* TerminalPendingConfigurationReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87910110000000000000002 /* TerminalPendingConfigurationReload.swift */; }; 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */; }; - 126570000000000000000004 /* TerminalPortalTestWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */; }; D0F101000000000000000003 /* TerminalRawMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F101000000000000000013 /* TerminalRawMode.swift */; }; D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */; }; D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */; }; @@ -3392,7 +3391,6 @@ B5EE1F85425A4223B017226C /* TerminalWindowPortalEngineDivergenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54F4341871F2416AB0E1F5DE /* TerminalWindowPortalEngineDivergenceTests.swift */; }; AA11BB22CC33DD44EE55F002 /* TerminalWindowPortalLayoutPassRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55F001 /* TerminalWindowPortalLayoutPassRefreshTests.swift */; }; C95BAC3D37A6111B487582DC /* TerminalWindowPortalLifecycleHiddenRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DB195131CD53CCCCEEEB79 /* TerminalWindowPortalLifecycleHiddenRefreshTests.swift */; }; - 126570000000000000000006 /* TerminalWindowPortalLifecycleTests+Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126570000000000000000005 /* TerminalWindowPortalLifecycleTests+Workspace.swift */; }; D86862000000000000000001 /* TerminalWorkingDirectoryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86862000000000000000002 /* TerminalWorkingDirectoryResolver.swift */; }; B7758A010000000000000001 /* TerminationWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7758A010000000000000002 /* TerminationWatchdog.swift */; }; B7758A030000000000000001 /* TerminationWatchdogAtomic.c in Sources */ = {isa = PBXBuildFile; fileRef = B7758A030000000000000002 /* TerminationWatchdogAtomic.c */; }; @@ -7119,7 +7117,6 @@ C13519000000000000000014 /* TerminalPathEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPathEnvironmentTests.swift; sourceTree = ""; }; F87910110000000000000002 /* TerminalPendingConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPendingConfigurationReload.swift; sourceTree = ""; }; 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPortalReconciliation.swift; sourceTree = ""; }; - 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalPortalTestWorkspace.swift"; sourceTree = ""; }; D0F101000000000000000013 /* TerminalRawMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRawMode.swift; sourceTree = ""; }; D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalScrollSpeedSettings.swift; sourceTree = ""; }; D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollSpeedSettingsFileStoreTests.swift; sourceTree = ""; }; @@ -7151,7 +7148,6 @@ 54F4341871F2416AB0E1F5DE /* TerminalWindowPortalEngineDivergenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortalEngineDivergenceTests.swift; sourceTree = ""; }; AA11BB22CC33DD44EE55F001 /* TerminalWindowPortalLayoutPassRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortalLayoutPassRefreshTests.swift; sourceTree = ""; }; 43DB195131CD53CCCCEEEB79 /* TerminalWindowPortalLifecycleHiddenRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortalLifecycleHiddenRefreshTests.swift; sourceTree = ""; }; - 126570000000000000000005 /* TerminalWindowPortalLifecycleTests+Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalWindowPortalLifecycleTests+Workspace.swift"; sourceTree = ""; }; D86862000000000000000002 /* TerminalWorkingDirectoryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWorkingDirectoryResolver.swift; sourceTree = ""; }; B7758A010000000000000002 /* TerminationWatchdog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminationWatchdog.swift; sourceTree = ""; }; B7758A030000000000000002 /* TerminationWatchdogAtomic.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = TerminationWatchdogAtomic.c; sourceTree = ""; }; @@ -10808,8 +10804,6 @@ 570ED364A7E41085FB2D31FE /* MainWindowSelfSizingTests.swift */, 43DB195131CD53CCCCEEEB79 /* TerminalWindowPortalLifecycleHiddenRefreshTests.swift */, AA11BB22CC33DD44EE55F001 /* TerminalWindowPortalLayoutPassRefreshTests.swift */, - 126570000000000000000005 /* TerminalWindowPortalLifecycleTests+Workspace.swift */, - 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */, 54F4341871F2416AB0E1F5DE /* TerminalWindowPortalEngineDivergenceTests.swift */, 6190C0116190C0116190C011 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift */, 606600010000000000000002 /* WindowTerminalHostViewTitlebarHitTests.swift */, @@ -15679,7 +15673,6 @@ 11189010A1B2C3D4E5F60718 /* TerminalNotificationSocketAttributionTests.swift in Sources */, 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */, C13519000000000000000013 /* TerminalPathEnvironmentTests.swift in Sources */, - 126570000000000000000004 /* TerminalPortalTestWorkspace.swift in Sources */, D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */, 222425EC9FE2E318D8F6F46A /* TerminalSearchNavigationTests.swift in Sources */, C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */, @@ -15692,7 +15685,6 @@ B5EE1F85425A4223B017226C /* TerminalWindowPortalEngineDivergenceTests.swift in Sources */, AA11BB22CC33DD44EE55F002 /* TerminalWindowPortalLayoutPassRefreshTests.swift in Sources */, C95BAC3D37A6111B487582DC /* TerminalWindowPortalLifecycleHiddenRefreshTests.swift in Sources */, - 126570000000000000000006 /* TerminalWindowPortalLifecycleTests+Workspace.swift in Sources */, B7758A020000000000000001 /* TerminationWatchdogTests.swift in Sources */, 970400000000000000000001 /* TextBoxEscapePassthroughTests.swift in Sources */, C0DE86250000000000000001 /* TextBoxIMECompositionLayoutTests.swift in Sources */, diff --git a/cmuxTests/GhosttyDrawableSizeRetryTests.swift b/cmuxTests/GhosttyDrawableSizeRetryTests.swift index b5a5bf6b09df..a83d1c84253e 100644 --- a/cmuxTests/GhosttyDrawableSizeRetryTests.swift +++ b/cmuxTests/GhosttyDrawableSizeRetryTests.swift @@ -91,59 +91,6 @@ struct GhosttyDrawableSizeRetryTests { #expect(realizedLayer.drawableSize == expectedDrawableSize) } - /// The view-level end callback must not bypass the portal's publication - /// gate and advance the drawable before final pane geometry is installed. - @Test func liveResizeEndBypassDoesNotPublishBeforePortalFinalPass() throws { - _ = NSApplication.shared - - let initialSize = CGSize(width: 800, height: 600) - let targetSize = CGSize(width: 1296, height: 893) - let window = NSWindow( - contentRect: NSRect(origin: .zero, size: initialSize), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - ) - window.isReleasedWhenClosed = false - defer { - window.orderOut(nil) - window.close() - } - - let terminalSurface = TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - let hostedView = terminalSurface.hostedView - hostedView.frame = NSRect(origin: .zero, size: initialSize) - window.contentView?.addSubview(hostedView) - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - hostedView.layoutSubtreeIfNeeded() - - let surfaceView = try #require(findGhosttyNSView(in: hostedView)) - _ = surfaceView.forceRefreshSurface() - let initialDrawableSize = surfaceView.debugLastDrawableSizeForTesting() - let portal = WindowTerminalPortal(window: window) - defer { portal.tearDown() } - hostedView.setPortalResizeAuthority(portal) - portal.beginWindowLiveResizePhase() - - // GhosttyNSView.viewDidEndLiveResize uses bypass=true. The portal has - // not committed its final pane frame yet, so this callback must keep - // the last drawable epoch instead of publishing targetSize. - hostedView.frame.size = targetSize - surfaceView.frame.size = targetSize - surfaceView.viewDidEndLiveResize() - - #expect( - surfaceView.debugLastDrawableSizeForTesting() == initialDrawableSize, - "The end callback must not publish a stale size before the portal final pass" - ) - } - private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? { if let view = view as? GhosttyNSView { return view diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 39b883446f86..1cd68156449e 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -5491,7 +5491,6 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { var trackedWindows: [NSWindow] = [] var trackedPortals: [WindowTerminalPortal] = [] var trackedSurfaces: [TerminalSurface] = [] - var testWorkspace: TerminalPortalTestWorkspace? override func tearDown() { // Global flags first: a failed assertion can skip a test's own reset, @@ -5521,8 +5520,6 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { window.close() } trackedWindows.removeAll() - testWorkspace?.tearDown() - testWorkspace = nil // Let queued coalesced portal passes fire as no-ops now rather than // inside a later test's layout pass. @@ -5570,6 +5567,17 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { return portal } + func makeTrackedTerminalSurface() -> TerminalSurface { + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + trackedSurfaces.append(surface) + return surface + } + func realizeWindowLayout(_ window: NSWindow) { window.makeKeyAndOrderFront(nil) window.displayIfNeeded() @@ -6424,10 +6432,12 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { TerminalWindowPortalRegistry.endInteractiveGeometryResize(in: window) interactionIsActive = false - XCTAssertTrue( - waitUntil(timeout: 2) { - surface.debugCurrentPixelSize().width < initialPixelSize.width - }, + drainMainQueue() + drainMainQueue() + + XCTAssertLessThan( + surface.debugCurrentPixelSize().width, + initialPixelSize.width, "Ending the resize interaction should flush the final exact terminal width" ) } diff --git a/cmuxTests/TerminalPortalTestWorkspace.swift b/cmuxTests/TerminalPortalTestWorkspace.swift deleted file mode 100644 index d7324933d464..000000000000 --- a/cmuxTests/TerminalPortalTestWorkspace.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -/// Gives portal tests a real selected workspace that can authorize presentation. -@MainActor -final class TerminalPortalTestWorkspace { - let id: UUID - private let manager: TabManager - private let appDelegate: AppDelegate - private let previousAppDelegate: AppDelegate? - private let windowID: UUID - - init() { - previousAppDelegate = AppDelegate.shared - appDelegate = previousAppDelegate ?? AppDelegate() - manager = TabManager(autoWelcomeIfNeeded: false, createInitialWorkspace: true) - id = manager.tabs[0].id - windowID = appDelegate.registerMainWindowContextForTesting(tabManager: manager) - AppDelegate.shared = appDelegate - } - - func tearDown() { - manager.tabs.forEach { $0.teardownAllPanels() } - appDelegate.unregisterMainWindowContextForTesting(windowId: windowID) - appDelegate.forgetRecoverableMainWindowRoute(windowId: windowID) - AppDelegate.shared = previousAppDelegate - } -} diff --git a/cmuxTests/TerminalWindowPortalLayoutPassRefreshTests.swift b/cmuxTests/TerminalWindowPortalLayoutPassRefreshTests.swift index 6a96bed55a85..11e93090cb8c 100644 --- a/cmuxTests/TerminalWindowPortalLayoutPassRefreshTests.swift +++ b/cmuxTests/TerminalWindowPortalLayoutPassRefreshTests.swift @@ -19,173 +19,7 @@ private final class LayoutSyncingAnchorView: NSView { } } -/// Test window whose native resize phase can be held across notifications. -private final class LiveResizeProbeWindow: NSWindow { - /// Simulates AppKit's native `inLiveResize` value for ordering tests. - var liveResizeActive = false - - /// Returns the simulated native live-resize state. - override var inLiveResize: Bool { liveResizeActive } -} - extension TerminalWindowPortalLifecycleTests { - func testDetachDuringResizeDoesNotPublishUncommittedRestoreGeometry() { - assertRetirementKeepsCommittedSize(detach: true) - } - - func testHideDuringResizeDoesNotPublishUncommittedRestoreGeometry() { - assertRetirementKeepsCommittedSize(detach: false) - } - - private func assertRetirementKeepsCommittedSize(detach: Bool) { - let window = makeTestWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 340)) - layoutResizeTestWindow(window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) - window.contentView?.addSubview(anchor) - let portal = makeTrackedPortal(window: window) - let surface = makeTrackedTerminalSurface() - let hosted = surface.hostedView - portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - let committed = surface.debugCurrentPixelSize() - - portal.beginWindowLiveResizePhase() - hosted.beginPortalGeometrySettlement() - // A native view callback has observed a new size, but the portal has - // not committed it. Retirement must discard that pending publication. - hosted.surfaceView.setFrameSize(NSSize(width: 180, height: 120)) - let hostedID = ObjectIdentifier(hosted) - if detach { - portal.detachHostedView(withId: hostedID) - } else { - portal.hideEntry(forHostedId: hostedID) - } - - XCTAssertEqual(surface.debugCurrentPixelSize().width, committed.width) - XCTAssertEqual(surface.debugCurrentPixelSize().height, committed.height) - } - - - /// A late native didResize notification can arrive after the portal has - /// committed the end pass while AppKit still reports inLiveResize. That - /// callback must not reopen the renderer gate without a new resize start. - @MainActor - func testLateDidResizeAfterEndDoesNotRelatchRendererPhase() throws { - let window = trackTestWindow(LiveResizeProbeWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false - )) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - layoutResizeTestWindow(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let portal = makeTrackedPortal(window: window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) - contentView.addSubview(anchor) - let surface = makeTrackedTerminalSurface() - portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - - let committedRendererSize = surface.hostedView.surfaceView.frame.size - window.liveResizeActive = true - portal.isWindowLiveResizeActiveOverrideForTesting = true - anchor.setFrameSize(NSSize(width: 200, height: 140)) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertEqual(surface.hostedView.surfaceView.frame.size, committedRendererSize) - - // Keep AppKit's native signal true while the queued end pass commits; - // this models the ordering where didEnd clears later than the portal. - portal.isWindowLiveResizeActiveOverrideForTesting = false - let endTarget = NSSize(width: 220, height: 150) - anchor.setFrameSize(endTarget) - NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: window) - XCTAssertTrue(waitUntil(timeout: 2) { - surface.hostedView.surfaceView.frame.size != committedRendererSize - }, "The resize-end pass must publish the final renderer geometry") - let rendererSizeAfterEnd = surface.hostedView.surfaceView.frame.size - XCTAssertNotEqual(rendererSizeAfterEnd, committedRendererSize) - - // A stale didResize must not reopen the phase after the end pass. - NotificationCenter.default.post(name: NSWindow.didResizeNotification, object: window) - window.liveResizeActive = false - let settledTarget = NSSize(width: 196, height: 132) - anchor.setFrameSize(settledTarget) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitUntil(timeout: 2) { - surface.hostedView.frame.size == settledTarget && - surface.hostedView.surfaceView.frame.size != rendererSizeAfterEnd - }) - - XCTAssertEqual(surface.hostedView.frame.size, settledTarget) - XCTAssertNotEqual( - surface.hostedView.surfaceView.frame.size, - rendererSizeAfterEnd, - "A late didResize callback must not strand the renderer in the deferred phase" - ) - withExtendedLifetime(surface) {} - } - - /// A refresh queued by a pre-resize layout callback may execute after the - /// window enters live resize. Delivery must re-check the current phase and - /// wait for the final geometry pass instead of presenting in that gap. - @MainActor - func testQueuedRefreshCrossingLiveResizeBoundaryWaitsForFinalPass() throws { - let window = makeTestWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), - styleMask: [.titled, .closable, .resizable] - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - layoutResizeTestWindow(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let portal = makeTrackedPortal(window: window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) - contentView.addSubview(anchor) - let surface = makeTrackedTerminalSurface() - portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - - surface.resetDebugForceRefreshCount() - anchor.setFrameSize(NSSize(width: 220, height: 150)) - portal.synchronizeHostedViewForAnchor(anchor, syncLayout: false) - - // The refresh was queued while the portal was at rest. Enter the live - // phase before that queue block runs to exercise the boundary race. - portal.isWindowLiveResizeActiveOverrideForTesting = true - portal.synchronizeHostedViewForAnchor(anchor, syncLayout: false) - drainMainQueue() - XCTAssertEqual( - surface.debugForceRefreshCount(), - 0, - "A refresh queued before live resize must not present until final geometry commits" - ) - - portal.isWindowLiveResizeActiveOverrideForTesting = false - NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: window) - XCTAssertTrue(waitUntil(timeout: 2) { surface.debugForceRefreshCount() > 0 }) - XCTAssertGreaterThan( - surface.debugForceRefreshCount(), - 0, - "The held refresh must be delivered after the final live-resize pass" - ) - withExtendedLifetime(surface) {} - } /// A geometry sync that runs inside an AppKit layout pass must not force a /// synchronous surface redraw. displayIfNeeded there reaches ghostty's diff --git a/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift b/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift index 23896ac73e96..392a6aa0173c 100644 --- a/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift +++ b/cmuxTests/TerminalWindowPortalLifecycleHiddenRefreshTests.swift @@ -10,51 +10,6 @@ import CmuxTerminal extension TerminalWindowPortalLifecycleTests { - /// Every AppKit boundary around a portal-hosted Ghostty surface must clip - /// its descendants. The renderer replaces the terminal view's backing - /// layer with an IOSurface layer, so the view-level clip chain is the - /// invariant that survives stale drawables and live-resize frame churn. - @MainActor - func testPortalHostedTerminalUsesViewLevelClippingAtEveryBoundary() throws { - let window = makeTestWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340) - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - layoutResizeTestWindow(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let portal = makeTrackedPortal(window: window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) - contentView.addSubview(anchor) - let surface = makeTrackedTerminalSurface() - portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - - XCTAssertTrue( - portal.hostView.clipsToBounds, - "The window-level portal host must clip stale terminal contents to the content region" - ) - XCTAssertTrue( - surface.hostedView.clipsToBounds, - "Each hosted pane must clip its renderer and overlays to the pane bounds" - ) - XCTAssertTrue( - surface.hostedView.surfaceView.clipsToBounds, - "The terminal view must keep a view-level clip after Ghostty installs its IOSurface layer" - ) - XCTAssertTrue(portal.hostView.layer?.masksToBounds == true) - XCTAssertTrue(surface.hostedView.layer?.masksToBounds == true) - XCTAssertTrue(surface.hostedView.surfaceView.layer?.masksToBounds == true) - withExtendedLifetime((portal, surface)) {} - } - @MainActor func testPortalSkipsSynchronousRefreshForHiddenSurfaces() throws { let window = makeTestWindow( @@ -203,56 +158,6 @@ extension TerminalWindowPortalLifecycleTests { withExtendedLifetime((leftSurface, rightSurface)) {} } - /// Regression for Claude/Codex TUIs: a live resize must not publish an - /// intermediate renderer/PTY size while the portal is still committing - /// pane geometry. Publishing a grid-changing size in that window lets an - /// asynchronous Ghostty frame race the host frame and leaves the TUI - /// composed from multiple widths. - @MainActor - func testWindowLiveResizeKeepsCommittedTerminalSizeUntilEnd() throws { - let window = makeTestWindow( - contentRect: NSRect(x: 0, y: 0, width: 760, height: 420), - styleMask: [.titled, .closable, .resizable] - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - layoutResizeTestWindow(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let portal = makeTrackedPortal(window: window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 520, height: 280)) - contentView.addSubview(anchor) - let surface = makeTrackedTerminalSurface() - portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - - let committedSize = surface.debugCurrentPixelSize() - XCTAssertGreaterThan(committedSize.width, 0) - XCTAssertGreaterThan(committedSize.height, 0) - - portal.isWindowLiveResizeActiveOverrideForTesting = true - anchor.setFrameSize(NSSize(width: 180, height: 120)) - portal.synchronizeHostedViewForAnchor(anchor) - - XCTAssertEqual( - surface.debugCurrentPixelSize().width, - committedSize.width, - "A live resize must keep the committed renderer width until the final geometry pass" - ) - XCTAssertEqual( - surface.debugCurrentPixelSize().height, - committedSize.height, - "A live resize must keep the committed renderer height until the final geometry pass" - ) - withExtendedLifetime(surface) {} - } - /// Regression: during a live window resize, each `didResize` tick must /// synchronize hosted terminal frames INSIDE the tick — in the same /// transaction that commits the window's new size. The portal's queued @@ -312,126 +217,6 @@ extension TerminalWindowPortalLifecycleTests { withExtendedLifetime(surface) {} } - /// The outer pane must follow a live window-resize tick, but the inner - /// Ghostty layer stays on its last committed drawable until resize end. - /// This is the ordering invariant that makes an asynchronous old present - /// harmless: the pane's view-level clip contains it for the whole drag. - @MainActor - func testLiveResizeKeepsRendererFrameAtCommittedSizeUntilEnd() throws { - let window = makeTestWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), - styleMask: [.titled, .closable, .resizable] - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - layoutResizeTestWindow(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let portal = makeTrackedPortal(window: window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) - contentView.addSubview(anchor) - let surface = makeTrackedTerminalSurface() - portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - - let committedRendererSize = surface.hostedView.surfaceView.frame.size - XCTAssertGreaterThan(committedRendererSize.width, 1) - XCTAssertGreaterThan(committedRendererSize.height, 1) - - portal.isWindowLiveResizeActiveOverrideForTesting = true - let liveTarget = NSSize( - width: max(32, committedRendererSize.width - 80), - height: max(24, committedRendererSize.height - 50) - ) - anchor.setFrameSize(liveTarget) - portal.synchronizeHostedViewForAnchor(anchor) - - XCTAssertEqual( - surface.hostedView.frame.size, - liveTarget, - "The pane boundary must track the live resize immediately" - ) - XCTAssertEqual( - surface.hostedView.surfaceView.frame.size, - committedRendererSize, - "The renderer frame must not advance to an uncommitted live-resize drawable" - ) - - portal.isWindowLiveResizeActiveOverrideForTesting = false - let finalTarget = NSSize( - width: max(24, liveTarget.width - 24), - height: max(20, liveTarget.height - 20) - ) - anchor.setFrameSize(finalTarget) - NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: window) - XCTAssertTrue(waitUntil(timeout: 2) { - surface.hostedView.frame.size == finalTarget && - surface.hostedView.surfaceView.frame.size != committedRendererSize - }, "Resize end must commit the final pane and renderer geometry") - - XCTAssertEqual(surface.hostedView.frame.size, finalTarget) - XCTAssertNotEqual( - surface.hostedView.surfaceView.frame.size, - committedRendererSize, - "Resize end must commit the final renderer frame" - ) - withExtendedLifetime(surface) {} - } - - /// A resize-end notification can race portal teardown. Even when the - /// target hierarchy is unavailable, the renderer phase must close so a - /// later reattachment can resize the surface again. - @MainActor - func testLiveResizeEndClearsDeferredPhaseWhenPortalInstallationFails() throws { - let window = makeTestWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), - styleMask: [.titled, .closable, .resizable] - ) - defer { - NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) - window.orderOut(nil) - } - layoutResizeTestWindow(window) - guard let contentView = window.contentView else { - XCTFail("Expected content view") - return - } - - let portal = makeTrackedPortal(window: window) - let anchor = NSView(frame: NSRect(x: 8, y: 8, width: 240, height: 160)) - contentView.addSubview(anchor) - let surface = makeTrackedTerminalSurface() - portal.bind(hostedView: surface.hostedView, to: anchor, visibleInUI: true) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertTrue(waitForResizeTestGeometry(surface, anchor: anchor)) - - portal.isWindowLiveResizeActiveOverrideForTesting = true - anchor.setFrameSize(NSSize(width: 200, height: 140)) - portal.synchronizeHostedViewForAnchor(anchor) - portal.isWindowLiveResizeActiveOverrideForTesting = false - - // Force the end pass through ensureInstalled's unavailable-target path. - portal.window = nil - NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: window) - drainMainQueue() - drainMainQueue() - - // Reattach the same window. A stale deferred phase would keep this - // geometry write from reaching the terminal surface. - portal.window = window - anchor.setFrameSize(NSSize(width: 220, height: 150)) - portal.synchronizeHostedViewForAnchor(anchor) - XCTAssertEqual(surface.hostedView.frame.size, NSSize(width: 220, height: 150)) - XCTAssertEqual(surface.hostedView.surfaceView.frame.size, NSSize(width: 220, height: 150)) - withExtendedLifetime(surface) {} - } - /// Regression: switching a pane's tab from a terminal to a browser hides /// the terminal only through its registry entry — the SwiftUI update that /// carries visible=false is dropped by the portal-host ownership gate diff --git a/cmuxTests/TerminalWindowPortalLifecycleTests+Workspace.swift b/cmuxTests/TerminalWindowPortalLifecycleTests+Workspace.swift deleted file mode 100644 index 9032a88d527d..000000000000 --- a/cmuxTests/TerminalWindowPortalLifecycleTests+Workspace.swift +++ /dev/null @@ -1,45 +0,0 @@ -import AppKit -import CmuxTerminal -import GhosttyKit - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -extension TerminalWindowPortalLifecycleTests { - func layoutResizeTestWindow(_ window: NSWindow) { - window.makeKeyAndOrderFront(nil) - window.displayIfNeeded() - window.contentView?.layoutSubtreeIfNeeded() - } - - func waitForResizeTestGeometry(_ surface: TerminalSurface, anchor: NSView) -> Bool { - waitUntil(timeout: 2) { - let hosted = surface.hostedView - let view = hosted.surfaceView - let pixels = surface.debugCurrentPixelSize() - let expected = view.expectedPixelSize(for: view.bounds.size) - return hosted.isVisibleInUI && !hosted.isHidden && - hosted.frame.size == anchor.bounds.size && - view.bounds.width > 1 && view.bounds.height > 1 && - view.bounds.width <= hosted.bounds.width && - pixels.width == UInt32(expected.width.rounded(.down)) && - pixels.height == UInt32(expected.height.rounded(.down)) - } - } - - func makeTrackedTerminalSurface() -> TerminalSurface { - let workspace = testWorkspace ?? TerminalPortalTestWorkspace() - testWorkspace = workspace - let surface = TerminalSurface( - tabId: workspace.id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - workingDirectory: nil - ) - trackedSurfaces.append(surface) - return surface - } -}