diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6797329f..6dd560c4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8662,6 +8662,23 @@ struct GhosttyTerminalView: NSViewRepresentable { return !hostedViewHasSuperview } + private static func synchronizePortalGeometry( + for host: HostContainerView, + coordinator: Coordinator + ) { + let geometryRevision = host.geometryRevision + guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + if host.inLiveResize || host.window?.inLiveResize == true { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + return + } + // Avoid synchronizing the terminal portal while AppKit is still inside + // the current layout turn. Re-entrant syncs here can wedge window resize + // handling and leave the app spinning on the wait cursor. + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -8829,8 +8846,10 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + Self.synchronizePortalGeometry( + for: host, + coordinator: coordinator + ) } if host.window != nil, hostOwnsPortalNow { @@ -8866,8 +8885,10 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + Self.synchronizePortalGeometry( + for: host, + coordinator: coordinator + ) } } else if hostOwnsPortalNow, portalBindingStillLive() { // Bind is deferred until host moves into a window. Update the diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index b44fbffb..e4b78917 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -680,10 +680,18 @@ final class WindowTerminalPortal: NSObject { private func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true + let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true) DispatchQueue.main.async { [weak self] in guard let self else { return } - self.hasExternalGeometrySyncScheduled = false - self.synchronizeAllEntriesFromExternalGeometryChange() + let performSync = { + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + if requiresSettledLayout { + DispatchQueue.main.async(execute: performSync) + } else { + performSync() + } } } @@ -1785,9 +1793,11 @@ enum TerminalWindowPortalRegistry { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true DispatchQueue.main.async { - Self.hasPendingExternalGeometrySyncForAllWindows = false - for portal in Self.portalsByWindowId.values { - portal.synchronizeAllEntriesFromExternalGeometryChange() + DispatchQueue.main.async { + Self.hasPendingExternalGeometrySyncForAllWindows = false + for portal in Self.portalsByWindowId.values { + portal.synchronizeAllEntriesFromExternalGeometryChange() + } } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index b1c3445a..60018a44 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -13236,6 +13236,89 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" ) } + + func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + DispatchQueue.main.async { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.minX, + originalAnchorFrameInWindow.minX + 1, + "The queued layout shift should move the anchor to the right" + ) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.maxX, + originalAnchorFrameInWindow.maxX + 1, + "The shifted anchor should expose a new trailing region outside the stale portal frame" + ) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" + ) + } } @MainActor