Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ public import GhosttyKit
// MARK: - Surface sizing and scale

extension TerminalSurface {
/// Holds surface-size writes while a host is committing an interactive
/// geometry transaction. The next size reconciliation after this flag is
/// cleared supplies the authoritative frame and renderer drawable size.
@MainActor
public func setSurfaceSizeUpdatesDeferred(_ deferred: Bool) {
surfaceSizeUpdatesDeferred = deferred
}

/// Reports whether the host has explicitly deferred surface size writes.
/// The portal owns this phase so the final resize pass does not depend on
/// AppKit's `inLiveResize` value, which may clear before that pass runs.
@MainActor
private var shouldDeferSurfaceSizeUpdates: Bool {
surfaceSizeUpdatesDeferred
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived
/// from backing-space points and truncated (never rounded up).
func pixelDimension(from value: CGFloat) -> UInt32 {
Expand Down Expand Up @@ -165,7 +181,8 @@ extension TerminalSurface {
@MainActor
public func reapplyAssignedGrid() {
guard ioMode.usesManualIO, lastUncappedPixelWidth > 0, lastUncappedPixelHeight > 0,
lastXScale > 0, lastYScale > 0 else { return }
lastXScale > 0, lastYScale > 0,
!shouldDeferSurfaceSizeUpdates else { return }
_ = updateSize(
width: CGFloat(lastUncappedPixelWidth) / lastXScale,
height: CGFloat(lastUncappedPixelHeight) / lastYScale,
Expand Down Expand Up @@ -205,6 +222,7 @@ extension TerminalSurface {
suppressAssignedGridPin: Bool = false,
caller: StaticString = #function
) -> Bool {
guard !shouldDeferSurfaceSizeUpdates else { return false }
guard let surface = liveSurfaceForGhosttyAccess(reason: "updateSize") else { return false }
_ = layerScale

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ 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)?
/// Prevents renderer/PTY size writes while a host is committing an
/// interactive geometry transaction. The host clears this after its final
/// pane frame is installed, then performs one authoritative size update.
var surfaceSizeUpdatesDeferred = false
/// 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
Expand Down
171 changes: 165 additions & 6 deletions Sources/GhosttyTerminalView.swift

Large diffs are not rendered by default.

253 changes: 241 additions & 12 deletions Sources/TerminalWindowPortal.swift

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions cmuxTests/GhosttyDrawableSizeRetryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,56 @@ 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()
hostedView.setWindowLiveResizeActive(true)

// 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
Expand Down
130 changes: 130 additions & 0 deletions cmuxTests/TerminalWindowPortalLayoutPassRefreshTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,138 @@ 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 {

/// 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)
}
realizeWindowLayout(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)
drainMainQueue()
realizeWindowLayout(window)

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)
drainMainQueue()
drainMainQueue()
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)
drainMainQueue()
drainMainQueue()

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)
}
realizeWindowLayout(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)
drainMainQueue()
realizeWindowLayout(window)

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)
drainMainQueue()
drainMainQueue()
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
/// Metal drawFrame while the window's transaction is still open, and
Expand Down
Loading
Loading