diff --git a/Sources/GhosttyNSView+CellMetrics.swift b/Sources/GhosttyNSView+CellMetrics.swift new file mode 100644 index 000000000000..fc9412373990 --- /dev/null +++ b/Sources/GhosttyNSView+CellMetrics.swift @@ -0,0 +1,21 @@ +import AppKit +import CmuxTerminal + +extension GhosttyNSView { + /// Invalidates a queued metric snapshot by reading the current runtime. + /// Ghostty reports backing pixels; AppKit scrolling and hit testing use points. + @discardableResult + func synchronizeCellMetrics() -> Bool { + guard let size = terminalSurface?.cellSizePoints(), + size.width.isFinite, size.height.isFinite, + size.width > 0, size.height > 0, + cellSize != size else { return false } + cellSize = size + NotificationCenter.default.post( + name: .ghosttyDidUpdateCellSize, + object: self, + userInfo: [GhosttyNotificationKey.cellSize: size] + ) + return true + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fc52ef3fc381..03d5044d1a78 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5454,7 +5454,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { layer?.masksToBounds = true let didChangeDrawable = applyDrawableGeometry(geometry) let surfaceSizeChanged = terminalSurface.commitPaneGeometry(geometry) - return didChangeDrawable || surfaceSizeChanged + return synchronizeCellMetrics() || didChangeDrawable || surfaceSizeChanged } /// Re-applies the current pane size: the committed geometry for a @@ -5468,7 +5468,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let geometry = terminalSurface.committedPaneGeometry else { return false } let didChangeDrawable = applyDrawableGeometry(geometry) let surfaceSizeChanged = terminalSurface.reapplyCommittedPaneGeometry() - return didChangeDrawable || surfaceSizeChanged + return synchronizeCellMetrics() || didChangeDrawable || surfaceSizeChanged } return commitOwnBounds() } @@ -13687,7 +13687,7 @@ final class GhosttySurfaceScrollView: NSView { // publishes its inner frame here. if surfaceView.paneGeometryIsPortalOwned { paneGeometryPortal?.requestPaneGeometryCommit(for: self) - return false + return surfaceView.synchronizeCellMetrics() } return surfaceView.commitPaneGeometry( size: CGSize(width: width, height: height), diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2917ba17a5d7..4eb6a4ee2b44 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2120,6 +2120,7 @@ A11EAF000000000000000000 /* GhosttyNotificationDispatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAF000000000000000001 /* GhosttyNotificationDispatcherTests.swift */; }; D78971000000000000000001 /* GhosttyNotificationScrollRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78971000000000000000002 /* GhosttyNotificationScrollRuntimeBridge.swift */; }; 09F6D65337EEB5988F365778 /* GhosttyNSView+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C738D190FCB85202992C756 /* GhosttyNSView+Accessibility.swift */; }; + 123810000000000000000006 /* GhosttyNSView+CellMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123810000000000000000005 /* GhosttyNSView+CellMetrics.swift */; }; B88357000000000000000001 /* GhosttyNSView+ClipboardActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000002 /* GhosttyNSView+ClipboardActions.swift */; }; B88357000000000000000003 /* GhosttyNSView+ClipboardInputSequencing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000004 /* GhosttyNSView+ClipboardInputSequencing.swift */; }; 3436831626068CC556129EB3 /* GhosttyNSView+CodexActionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FA91EF89C03A74979E6EF7 /* GhosttyNSView+CodexActionCommand.swift */; }; @@ -3810,6 +3811,9 @@ C0DE6B420000000000000001 /* TerminalPanelTextBoxState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE6B420000000000000002 /* TerminalPanelTextBoxState.swift */; }; 6819AF60AEA6455DB57CC7FE /* TerminalPanelUnavailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 811B03E4004A4D1FB6650158 /* TerminalPanelUnavailableView.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; + 123810000000000000000002 /* TerminalPaneMetricInvalidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123810000000000000000001 /* TerminalPaneMetricInvalidationTests.swift */; }; + 123810000000000000000004 /* TerminalPaneMetricsFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123810000000000000000003 /* TerminalPaneMetricsFixture.swift */; }; + 123810000000000000000008 /* TerminalPaneMetricsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 123810000000000000000007 /* TerminalPaneMetricsWindow.swift */; }; B88351000000000000000001 /* TerminalPasteboardReadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88351000000000000000002 /* TerminalPasteboardReadRequest.swift */; }; B8835B000000000000000003 /* TerminalPasteFailureNotice.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835B000000000000000004 /* TerminalPasteFailureNotice.swift */; }; B8835B000000000000000005 /* TerminalPasteFailureNoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835B000000000000000006 /* TerminalPasteFailureNoticePresenter.swift */; }; @@ -6434,6 +6438,7 @@ A11EAF000000000000000001 /* GhosttyNotificationDispatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNotificationDispatcherTests.swift; sourceTree = ""; }; D78971000000000000000002 /* GhosttyNotificationScrollRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyNotificationScrollRuntimeBridge.swift; sourceTree = ""; }; 2C738D190FCB85202992C756 /* GhosttyNSView+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+Accessibility.swift"; sourceTree = ""; }; + 123810000000000000000005 /* GhosttyNSView+CellMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+CellMetrics.swift"; sourceTree = ""; }; B88357000000000000000002 /* GhosttyNSView+ClipboardActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+ClipboardActions.swift"; sourceTree = ""; }; B88357000000000000000004 /* GhosttyNSView+ClipboardInputSequencing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+ClipboardInputSequencing.swift"; sourceTree = ""; }; A9FA91EF89C03A74979E6EF7 /* GhosttyNSView+CodexActionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyNSView+CodexActionCommand.swift"; sourceTree = ""; }; @@ -8100,6 +8105,9 @@ C0DE6B420000000000000002 /* TerminalPanelTextBoxState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelTextBoxState.swift; sourceTree = ""; }; 811B03E4004A4D1FB6650158 /* TerminalPanelUnavailableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelUnavailableView.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; + 123810000000000000000001 /* TerminalPaneMetricInvalidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneMetricInvalidationTests.swift; sourceTree = ""; }; + 123810000000000000000003 /* TerminalPaneMetricsFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneMetricsFixture.swift; sourceTree = ""; }; + 123810000000000000000007 /* TerminalPaneMetricsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneMetricsWindow.swift; sourceTree = ""; }; B88351000000000000000002 /* TerminalPasteboardReadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteboardReadRequest.swift; sourceTree = ""; }; B8835B000000000000000004 /* TerminalPasteFailureNotice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteFailureNotice.swift; sourceTree = ""; }; B8835B000000000000000006 /* TerminalPasteFailureNoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteFailureNoticePresenter.swift; sourceTree = ""; }; @@ -10301,6 +10309,7 @@ A9E88F76F64B4E908DD1CC4F /* GhosttyHostActionHandler.swift */, 5A23CB32F1DB4F76A94E7328 /* GhosttyApp+KeybindOverrides.swift */, A5001015 /* GhosttyTerminalView.swift */, + 123810000000000000000005 /* GhosttyNSView+CellMetrics.swift */, 126570000000000000000007 /* GhosttyTerminalView+Representable.swift */, 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */, DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */, @@ -12312,6 +12321,9 @@ C12758000000000000000009 /* PlainPastePTYFixture.swift */, C12758000000000000000007 /* TerminalPlainTextPastePTYTests.swift */, D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */, + 123810000000000000000003 /* TerminalPaneMetricsFixture.swift */, + 123810000000000000000007 /* TerminalPaneMetricsWindow.swift */, + 123810000000000000000001 /* TerminalPaneMetricInvalidationTests.swift */, 5B6E489A561B4E319CE3331B /* TerminalPortalTransitionAttributionTests.swift */, 776C2DFD963A4F6E87A10FDA /* TerminalWindowPortalExternalGeometrySyncTests.swift */, 60D3266B6D154D5C9F14286E /* TerminalPortalReconciliationReentrancyTests.swift */, @@ -14962,6 +14974,7 @@ F0A110070000000000000001 /* GhosttyMouseSessionLedgerSurfaceIdentity.swift in Sources */, D78971000000000000000001 /* GhosttyNotificationScrollRuntimeBridge.swift in Sources */, 09F6D65337EEB5988F365778 /* GhosttyNSView+Accessibility.swift in Sources */, + 123810000000000000000006 /* GhosttyNSView+CellMetrics.swift in Sources */, B88357000000000000000001 /* GhosttyNSView+ClipboardActions.swift in Sources */, B88357000000000000000003 /* GhosttyNSView+ClipboardInputSequencing.swift in Sources */, 3436831626068CC556129EB3 /* GhosttyNSView+CodexActionCommand.swift in Sources */, @@ -17942,6 +17955,9 @@ A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */, 11189010A1B2C3D4E5F60718 /* TerminalNotificationSocketAttributionTests.swift in Sources */, 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */, + 123810000000000000000002 /* TerminalPaneMetricInvalidationTests.swift in Sources */, + 123810000000000000000004 /* TerminalPaneMetricsFixture.swift in Sources */, + 123810000000000000000008 /* TerminalPaneMetricsWindow.swift in Sources */, 4E96608F6F558E792BE96C3B /* TerminalPasteFailureNoticeTests.swift in Sources */, B12715000000000000000001 /* TerminalPastePreparationReapingTests.swift in Sources */, C12758000000000000000010 /* TerminalPasteWorkerStartupBenchmark.swift in Sources */, diff --git a/cmuxTests/TerminalPaneMetricInvalidationTests.swift b/cmuxTests/TerminalPaneMetricInvalidationTests.swift new file mode 100644 index 000000000000..42953f1af10e --- /dev/null +++ b/cmuxTests/TerminalPaneMetricInvalidationTests.swift @@ -0,0 +1,170 @@ +import AppKit +import CmuxTerminal +import GhosttyKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct TerminalPaneMetricInvalidationTests { + @Test(arguments: [CGFloat(1), CGFloat(2)]) + func fontCallbacksUseCurrentLogicalCellSize(backingScale: CGFloat) async throws { + let fixture = try await TerminalPaneMetricsFixture(backingScale: backingScale) + defer { fixture.tearDown() } + try await fixture.bind() + #expect(fixture.surface.performInternalBindingAction("set_font_size:24")) + #expect(fixture.surface.performInternalBindingAction("set_font_size:13")) + try await fixture.waitUntil("logical cell metrics") { + fixture.hosted.surfaceView.cellSize == fixture.surface.cellSizePoints() + } + let sample = try #require(fixture.surface.rawSizingSample()) + #expect(fixture.hosted.surfaceView.cellSize.width == CGFloat(sample.cellWidthPx) / backingScale) + #expect(fixture.hosted.surfaceView.cellSize.height == CGFloat(sample.cellHeightPx) / backingScale) + } + + @Test(arguments: [CGFloat(1), CGFloat(2)]) + func hiddenOutputReflowsThroughDividerDragAndSplitClose(backingScale: CGFloat) async throws { + let fixture = try await TerminalPaneMetricsFixture(backingScale: backingScale) + defer { fixture.tearDown() } + // The original #12381 path: output arrives before the split is shown. + try fixture.writeRows() + try await fixture.bind() + let originalCell = try #require(fixture.surface.cellSizePoints()) + let originalFont = ghostty_surface_font_size(try #require(fixture.surface.surface)) + for width: CGFloat in [280, 620, 360] { + try await fixture.moveDivider(to: width) + try assertGridAndText(fixture, cell: originalCell, font: originalFont) + } + try await fixture.closeSibling() + try assertGridAndText(fixture, cell: originalCell, font: originalFont) + } + + @Test func paneGeometryInvalidatesStaleLargerCellMetrics() async throws { + let fixture = try await TerminalPaneMetricsFixture() + defer { fixture.tearDown() } + try await fixture.bind() + let expected = try #require(fixture.surface.cellSizePoints()) + // Simulate a delayed metric notification from the previous layout. + // Repair must come from each pane geometry boundary, with no window + // resize, config reload, or font-size mutation. + for closeSibling in [false, true] { + fixture.hosted.surfaceView.cellSize = CGSize(width: expected.width * 2, height: expected.height * 2) + if closeSibling { try await fixture.closeSibling() } + else { try await fixture.moveDivider(to: 340) } + #expect(fixture.hosted.surfaceView.cellSize == expected) + } + } + + @Test func sameFrameReconciliationRepairsMetricCache() async throws { + let fixture = try await TerminalPaneMetricsFixture() + defer { fixture.tearDown() } + try await fixture.bind() + let expected = try #require(fixture.surface.cellSizePoints()) + let frame = fixture.hosted.frame + fixture.hosted.surfaceView.cellSize = CGSize(width: expected.width * 2, height: expected.height * 2) + _ = fixture.hosted.reconcileGeometryNow() + #expect(fixture.hosted.frame == frame) + #expect(fixture.hosted.surfaceView.cellSize == expected) + } + + @Test func splitCloseRecoversAfterNativeResizeEndCallbackWasLost() async throws { + let fixture = try await TerminalPaneMetricsFixture() + defer { fixture.tearDown() } + try await fixture.bind() + let portal = try #require(TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(fixture.window)]) + portal.isWindowLiveResizeActiveOverrideForTesting = true + defer { portal.isWindowLiveResizeActiveOverrideForTesting = false } + fixture.split.setPosition(320, ofDividerAt: 0) + TerminalWindowPortalRegistry.synchronizeForAnchor(fixture.anchor, syncLayout: false) + #expect(fixture.surface.committedPaneGeometry?.phase == .interactive) + portal.isWindowLiveResizeActiveOverrideForTesting = false + // No window notification: only the surviving pane's layout changes. + try await fixture.closeSibling() + #expect(fixture.surface.committedPaneGeometry?.phase == .settled) + } + + @Test func activeDividerDoesNotFinishWhenNativeWindowResizeIsInactive() async throws { + let fixture = try await TerminalPaneMetricsFixture() + defer { fixture.tearDown() } + try await fixture.bind() + TerminalWindowPortalRegistry.beginInteractiveGeometryResize(in: fixture.window) + var dividerActive = true + defer { + if dividerActive { TerminalWindowPortalRegistry.endInteractiveGeometryResize(in: fixture.window) } + } + fixture.split.setPosition(320, ofDividerAt: 0) + TerminalWindowPortalRegistry.synchronizeExternalGeometryNow(for: fixture.window) + #expect(fixture.surface.committedPaneGeometry?.phase == .interactive) + TerminalWindowPortalRegistry.endInteractiveGeometryResize(in: fixture.window) + dividerActive = false + try await fixture.settle() + #expect(fixture.surface.committedPaneGeometry?.phase == .settled) + } + + private func assertGridAndText(_ fixture: TerminalPaneMetricsFixture, cell: CGSize, font: Float) throws { + let sample = try #require(fixture.surface.rawSizingSample()) + let runtime = try #require(fixture.surface.surface) + var grid = ghostty_surface_grid_metrics_s() + #expect(ghostty_surface_grid_metrics(runtime, &grid)) + #expect(Int(grid.columns) == sample.columns) + #expect(Int(grid.rows) == sample.rows) + #expect(fixture.surface.cellSizePoints() == cell) + #expect(fixture.hosted.surfaceView.cellSize == cell) + #expect(ghostty_surface_font_size(runtime) == font) + // Text selection unwraps soft-wrapped lines even for rectangles. + // Inspect the native grid export, including each span's cell extent. + let rendered = try #require(fixture.surface.mobileRenderGridFrame(stateSeq: 0, includeTheme: false)) + #expect(rendered.frame.columns == sample.columns) + #expect(rendered.frame.rows == sample.rows) + for span in rendered.frame.rowSpans { + let cellWidth = try #require(span.cellWidth) + #expect(span.row < sample.rows) + #expect(span.column + cellWidth <= sample.columns) + } + #expect(rendered.rows.allSatisfy { $0.count <= sample.columns }) + // The native render-grid export is the physical reflow oracle above, + // but it can omit soft-wrapped text while a hidden pane is settling. + // Ghostty's direct reader preserves those logical lines and their + // markers, which is the behavior this regression path needs to prove. + let text = try readSurfaceText(fixture) + let compact = text.filter { !$0.isWhitespace } + for index in 1...4 { + let expected = "R\(index)" + String(repeating: "=", count: 74) + "END\(index)" + #expect(compact.components(separatedBy: expected).count == 2) + #expect(compact.components(separatedBy: "R\(index)").count == 2) + #expect(compact.components(separatedBy: "END\(index)").count == 2) + } + } + + private func readSurfaceText(_ fixture: TerminalPaneMetricsFixture) throws -> String { + let runtime = try #require(fixture.surface.surface) + let selection = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ), + rectangle: true + ) + var text = ghostty_text_s() + guard ghostty_surface_read_text(runtime, selection, &text) else { return "" } + defer { ghostty_surface_free_text(runtime, &text) } + guard let pointer = text.text, text.text_len > 0 else { return "" } + return String( + decoding: Data(bytes: pointer, count: Int(text.text_len)), + as: UTF8.self + ) + } +} diff --git a/cmuxTests/TerminalPaneMetricsFixture.swift b/cmuxTests/TerminalPaneMetricsFixture.swift new file mode 100644 index 000000000000..9fcfeb49163d --- /dev/null +++ b/cmuxTests/TerminalPaneMetricsFixture.swift @@ -0,0 +1,124 @@ +import AppKit +import CmuxTerminal +import GhosttyKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A process-backed terminal in an actual vertical AppKit split. +@MainActor +final class TerminalPaneMetricsFixture { + let workspace = TerminalPortalTestWorkspace() + let window: NSWindow + let split = NSSplitView() + let anchor = NSView() + let sibling = NSView() + let surface: TerminalSurface + var hosted: GhosttySurfaceScrollView { surface.hostedView } + + deinit {} + + init(backingScale: CGFloat = 1) async throws { + _ = NSApplication.shared + let metricsWindow = TerminalPaneMetricsWindow( + contentRect: NSRect(x: 0, y: 0, width: 960, height: 640), + styleMask: [.titled, .closable], backing: .buffered, defer: false + ) + metricsWindow.testBackingScale = backingScale + window = metricsWindow + window.isReleasedWhenClosed = false + window.animationBehavior = .none + surface = TerminalSurface( + tabId: workspace.id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, initialCommand: "/bin/cat" + ) + do { + try await waitUntil("runtime creation") { self.surface.surface != nil } + let content = try #require(window.contentView) + split.frame = content.bounds + split.isVertical = true + split.dividerStyle = .thin + content.addSubview(split) + split.addArrangedSubview(anchor) + split.addArrangedSubview(sibling) + split.adjustSubviews() + window.orderFront(nil) + window.displayIfNeeded() + } catch { + tearDown() + throw error + } + } + + func bind() async throws { + TerminalWindowPortalRegistry.bind( + hostedView: hosted, to: anchor, visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + hosted.setVisibleInUI(true) + try await settle() + } + + func settle() async throws { + try await waitUntil("native grid convergence") { + self.window.contentView?.layoutSubtreeIfNeeded() + guard let sample = self.surface.rawSizingSample(), + let geometry = self.surface.committedPaneGeometry, + geometry.phase == .settled else { return false } + let view = self.hosted.surfaceView + guard let scroll = self.hosted.subviews.compactMap({ $0 as? NSScrollView }).first else { return false } + var grid = ghostty_surface_grid_metrics_s() + guard let runtime = self.surface.surface, + ghostty_surface_grid_metrics(runtime, &grid) else { return false } + return geometry.size == view.frame.size && + abs(self.hosted.frame.width - self.anchor.frame.width) < 1 && + abs(view.frame.width - scroll.contentView.bounds.width) < 1 && + abs(view.frame.height - scroll.contentView.bounds.height) < 1 && + abs(CGFloat(sample.surfaceWidthPx) - view.bounds.width * self.window.backingScaleFactor) < 2 && + sample.columns == Int(grid.columns) && sample.rows == Int(grid.rows) + } + } + + func moveDivider(to x: CGFloat) async throws { + split.setPosition(x, ofDividerAt: 0) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor, syncLayout: false) + try await settle() + } + + func closeSibling() async throws { + sibling.removeFromSuperview() + split.adjustSubviews() + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor, syncLayout: false) + try await settle() + } + + func writeRows() throws { + let runtime = try #require(surface.surface) + let fill = String(repeating: "=", count: 74) + let output = "\u{1B}[2J\u{1B}[H" + (1...4).map { "R\($0)\(fill)END\($0)\r\n" }.joined() + output.withCString { ghostty_surface_process_output(runtime, $0, UInt(output.utf8.count)) } + } + + func waitUntil(_ stage: String = "condition", _ predicate: () -> Bool) async throws { + let deadline = ProcessInfo.processInfo.systemUptime + 5 + while !predicate(), ProcessInfo.processInfo.systemUptime < deadline { + await withCheckedContinuation { continuation in + RunLoop.main.perform(inModes: [.common]) { continuation.resume() } + } + } + try #require(predicate(), "Timed out during \(stage): runtime=\(surface.surface != nil), anchor=\(anchor.frame), hosted=\(hosted.frame), native=\(String(describing: surface.rawSizingSample()))") + } + + func tearDown() { + TerminalWindowPortalRegistry.detach(hostedView: hosted) + surface.releaseSurfaceForTesting() + surface.teardownSurface() + window.close() + workspace.tearDown() + } +} diff --git a/cmuxTests/TerminalPaneMetricsWindow.swift b/cmuxTests/TerminalPaneMetricsWindow.swift new file mode 100644 index 000000000000..7f973c72beab --- /dev/null +++ b/cmuxTests/TerminalPaneMetricsWindow.swift @@ -0,0 +1,11 @@ +import AppKit + +/// Exercises Retina conversion even when the fleet console has a 1x display. +@MainActor +final class TerminalPaneMetricsWindow: NSWindow { + var testBackingScale: CGFloat = 1 + + deinit {} + + override var backingScaleFactor: CGFloat { testBackingScale } +}