diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift new file mode 100644 index 000000000000..4094aa06880b --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift @@ -0,0 +1,67 @@ +import CmuxNextTerminalGeometry +import GhosttyNextKit + +/// A terminal view's grid reports to its PTY owner (the cmux-tui daemon resizes the PTY and sends SIGWINCH for each). +/// Every report goes through one ``TerminalGridReportCoalescer``, so its last sent grid is the PTY's: a person's divider +/// drag sends its grids while it moves, coalesced as Ghostty.app does (cmux-ad m29: the newest grid at most once per +/// 25 ms, so a full-screen program, which the reflow preview does not re-wrap, follows its pane); every other report +/// (at rest, forced, a spring's final grid) goes now. A drag's grid is the grid of a size shown on screen: a size asked of +/// Ghostty and not shown yet waits for the commit that shows it with its frame (``shown(_:live:)``), so the program never +/// redraws at a size the person does not see (the RESIZE OWNER's rule, cmux-ad m29). +@MainActor +final class TerminalGridReporter { + private weak var view: TerminalSurfaceView? + /// The report going out now is a layout spring's final grid (the PTY owner sends it in the caller's turn). + private var sendingFinal = false + private(set) lazy var coalescer = TerminalGridReportCoalescer { [weak self] in self?.deliver($0) } + + init(view: TerminalSurfaceView) { + self.view = view + } + + /// Whether a drag's grid waits in the coalescer's window. + var isWaiting: Bool { coalescer.isWaiting } + /// A drag's grid waits for its size to show (asked of Ghostty, not shown yet). + private(set) var awaitsShown = false + + /// A drag's grid: coalesced when its size shows now (the bounds took it), else when the presenter shows it. + func drag(_ grid: TerminalGridSize, shownNow: Bool) { + if shownNow { coalesced(grid) } else { awaitsShown = true } + } + + /// The presenter's commit showed a size with its frame (``TerminalSurfaceView/frameDidLand()``): the grid that size + /// fits goes, coalesced while the drag `live`s, else now (a size that landed after the drag ended). + func shown(_ grid: TerminalGridSize?, live: Bool) { + guard awaitsShown, let grid else { return } + awaitsShown = false + if live { coalesced(grid) } else { now(grid) } + } + + /// A drag's grid: it waits for the window, or opens one. + func coalesced(_ grid: TerminalGridSize) { + coalescer.viewSized(grid) + } + + /// `grid` goes now; a grid that waits is older and is dropped. `final`: a layout spring's final grid. + func now(_ grid: TerminalGridSize, final: Bool = false) { + awaitsShown = false // a size that waits to show is older than this grid; its landing sends nothing after it + sendingFinal = final + defer { sendingFinal = false } + coalescer.reportNow(grid) + } + + /// The drag ended (or will not end for this view): the grid that waits goes now. + func flush() { + coalescer.flush() + } + + private func deliver(_ grid: TerminalGridSize) { + guard let view, let surface = view.surface else { return } + TerminalSurfaceView.gridReports &+= 1 + let size = ghostty_surface_size(surface) + let metrics = TerminalGridMetrics(cellWidth: Int(size.cell_width_px), cellHeight: Int(size.cell_height_px), + paddingWidth: 0, paddingHeight: 0) + let cells = metrics.cellPixels(of: grid) + view.session?.surfaceDidReport(grid: grid, pixelWidth: cells.width, pixelHeight: cells.height, final: sendingFinal) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift index 7ded77dad11c..4909f7cda90a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift @@ -151,9 +151,11 @@ extension TerminalSurfaceView { } /// After the presenter landed this terminal's frame, outside the Core Animation lock: the copy-mode cursor box - /// follows the new size (it reads Ghostty under the terminal's lock, so never inside the lock span). + /// follows the new size (it reads Ghostty under the terminal's lock, so never inside the lock span), and a drag's grid + /// that waited for this size to show goes to the PTY owner (``TerminalGridReporter/shown(_:live:)``). func frameDidLand() { copyMode.syncCursor() + gridReports.shown(fittedGrid, live: isFollowingResizeGesture) } /// Shown again while Ghostty leaves new sizes to cmux: its commit is reported once its frame shows. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift index 4468ffd82d8b..a68d28244ee6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift @@ -100,8 +100,8 @@ extension TerminalSurfaceView: PaneMotionTargeting { /// (Lawrence's P0): in every frame of the drag and of its release each terminal shows its text at that frame's pane size, /// never frozen or anchored. The surface takes its pane's size in every frame through the placement /// any resize uses (`TerminalHostView` `placeSurface`, the frame owner's ``place(_:anchor:)``), and the reflow preview -/// re-wraps its text at that size. The grid goes to the PTY once, when the release landed: a program does not redraw at -/// every size while the owner's announcements come a round trip behind (torn rows mid-drag). Before, the drag hold +/// re-wraps its text at that size. The program gets its size while it moves, coalesced as Ghostty.app does (cmux-ad m29: +/// ``TerminalGridReporter``), and the newest at the release (a full-screen program froze with one grid). Before, the drag hold /// (`terminalDrag.holdSurface`, RESIZE VIDEOS A3) kept the drag-start size and text through the drag, and the release's /// spring held its final size (pixel-perfect's trace, STATUS 13764: 157-159 held ticks in col-drag-2, the surface up to /// 150 pt off its pane). @@ -110,7 +110,7 @@ extension TerminalSurfaceView { guard isFollowingResizeGesture != active else { return } isFollowingResizeGesture = active // The start ends a spring's hold the drag caught (a split or a press still landing): the surface follows the drag - // from this frame. The end sends the grid the view fits, once (none when the PTY has it already). - if active { endPaneMotion() } else if isHeldGridPending { updateSurfaceSize() } + // from this frame. The end sends the grid that waits in the coalescer's window now, then any grid still held. + if active { endPaneMotion() } else { gridReports.flush(); if isHeldGridPending { updateSurfaceSize() } } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift index c21c866d9e73..1057fe456cdc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift @@ -43,20 +43,17 @@ public final class TerminalSurfaceView: NSView { var frameOwnership = TerminalFrameOwnership() /// A person drags a divider or edge of this terminal's pane (its host's ``TerminalHostView/followsResizeGesture``): the - /// surface follows its pane in every frame (the reflow preview), and its grid is held and goes once, when it lands. + /// surface follows its pane in every frame, and its grids go to the PTY coalesced (``gridReports``, cmux-ad m29). var isFollowingResizeGesture = false + /// Every grid report to the PTY owner: a drag's coalesced, every other one now. + lazy var gridReports = TerminalGridReporter(view: self) /// Reports the grid the pane's layout spring will land at, now (``TerminalGridPolicy/finalSized(_:)``), /// unless the owner already has it. func reportFinalGrid(_ grid: TerminalGridSize) { defer { updateReflowPreview() } - guard let surface, let report = geometry.finalSized(grid) else { return } - Self.gridReports &+= 1 - let size = ghostty_surface_size(surface) - let metrics = TerminalGridMetrics(cellWidth: Int(size.cell_width_px), cellHeight: Int(size.cell_height_px), - paddingWidth: 0, paddingHeight: 0) - let cells = metrics.cellPixels(of: report) - session?.surfaceDidReport(grid: report, pixelWidth: cells.width, pixelHeight: cells.height, final: true) + guard surface != nil, let report = geometry.finalSized(grid) else { return } + gridReports.now(report, final: true) } /// The pane's layout spring landed: the final grid no longer stands in for the view's, and the surface @@ -231,9 +228,10 @@ public final class TerminalSurfaceView: NSView { super.viewWillMove(toSuperview: newSuperview) } - /// Stops following a resize drag that will not end for this view: its next shown size reports as any other. + /// Stops following a resize drag that will not end for this view: its waiting grid goes now, its next size as any other. func stopFollowingResizeGesture() { isFollowingResizeGesture = false + gridReports.flush() } public override func viewWillMove(toWindow newWindow: NSWindow?) { @@ -465,26 +463,23 @@ public final class TerminalSurfaceView: NSView { publish(size: size) guard size.columns > 0, size.rows > 0 else { return } let desired = TerminalGridSize(columns: Int(size.columns), rows: Int(size.rows)) - let metrics = TerminalGridMetrics(cellWidth: Int(size.cell_width_px), cellHeight: Int(size.cell_height_px), - paddingWidth: 0, paddingHeight: 0) // A pane on a layout spring (Cmd-Shift-Enter zoom, split, close) resizes this view on every // display frame: the surface draws each size (the reflow preview), and the grid goes to the // owner once the spring lands (`paneMotionDidSettle`), one PTY resize instead of one per frame. + // A drag (its release's spring too) sends its grids while it moves, coalesced (cmux-ad m29). // A size whose frame is not drawn yet and is no longer the one wanted (FRAME OWNER S4: the pane // moved on, or its spring landed, while the frame waited) reports nothing: the wanted size // reports its grid when it goes to Ghostty (`requestWantedFrame`). The landing's release sized // from such a size and sent its stale grid, then the final grid again (review of #35470, F1). let stale = frameOwnership.inFlight.map { $0 != frameOwnership.wanted?.size } ?? false let report = geometry.viewSized(desired, force: forceReport, - holding: stale || (!forceReport && (isInPaneMotion || isFollowingResizeGesture))) - // In the same main-thread turn as set_size: the frame of the new size - // draws at the next display pass, already reflowed. - // A held grid is reported when the spring lands: the preview draws it meanwhile. - updateReflowPreview(reporting: report != nil || geometry.held) + holding: stale || (!forceReport && isInPaneMotion && !isFollowingResizeGesture)) + // In the same main-thread turn as set_size: the frame of the new size draws at the next display pass, already + // reflowed. A held or coalesced grid is reported later: the preview draws it meanwhile. + updateReflowPreview(reporting: report != nil || geometry.held || gridReports.isWaiting) guard let report else { return } - Self.gridReports &+= 1 - let cells = metrics.cellPixels(of: report) - session?.surfaceDidReport(grid: report, pixelWidth: cells.width, pixelHeight: cells.height) + // A drag's grid goes once its size shows: now with the bounds, or when the presenter shows it (`frameDidLand`). + if !isFollowingResizeGesture || forceReport { gridReports.now(report) } else { gridReports.drag(report, shownNow: frameOwnership.inFlight == nil) } } private func publish(size: ghostty_surface_size_s) { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DividerDragTerminalResizeCountTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DividerDragTerminalResizeCountTests.swift index 18dfb44bac1b..e944e32ac46b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DividerDragTerminalResizeCountTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DividerDragTerminalResizeCountTests.swift @@ -8,20 +8,21 @@ import CmuxNextDesign import Foundation import Testing -/// A person's divider drag resizes each terminal's PTY once, when the drag lands, not once per cell the -/// divider crosses (RESIZE VIDEOS, Lawrence 2026-11-02: "terminals reflow once at the end, one SIGWINCH, -/// never garbage mid-animation"). The frames follow the pointer with no spring, so the terminals reported a -/// grid on every drag event: the program in each one (htop, vim, a TUI that redraws on SIGWINCH) redrew at -/// every size while the daemon's announcements came a round trip behind the view, torn rows mid-drag. -/// Through the drag a terminal now draws its grid reflowed to the pane locally (the reflow preview, as in a -/// split's spring) and sends one grid when the drag's release lands, as a Chromium page lays out once. -/// Real terminal sessions and the app's ResizeCoordinator (`SplitTerminalResizeCountTests`); the test is the clock. +/// cmux-ad m18/m29: during a person's divider or column-edge drag, the program in each terminal gets its new size while +/// the drag moves, coalesced as Ghostty.app does (`TerminalGridReportCoalescer`: the first new grid opens a 25 ms window, +/// later grids replace it, the newest goes when it closes), and the release's grid goes when the release lands. Before +/// (feat at 058a07e4ae1): one grid per drag, at the landing. A full-screen program (the alternate screen, which the +/// reflow preview does not re-wrap) stayed at its start size for the whole drag, the terminal background beside it, then +/// jumped at the mouse-up (the RESIZE OWNER's film of px-col-drag-2-sw-terminals at 96ad37a03a3, STATUS 14461). Still +/// never one grid per frame: the program redraws at most once per window. +/// Real terminal sessions and the app's ResizeCoordinator (`SplitTerminalResizeCountTests`); the test steps the frames, +/// and real time passes between them (12 ms per frame) so the coalescer's windows close as in a drag. @MainActor -@Suite(.closesItsWindows) +@Suite(.closesItsWindows, .timeLimit(.minutes(1))) struct DividerDragTerminalResizeCountTests { typealias Harness = SplitTerminalResizeCountTests - @Test func aDividerDragResizesEachPTYOnceWhenItLands() async throws { + @Test func aDividerDragResizesEachPTYWhileItMovesCoalescedAndAtTheLanding() async throws { _ = NSApplication.shared let screen = Harness.Screen(Harness.pair) defer { screen.close() } @@ -35,25 +36,30 @@ struct DividerDragTerminalResizeCountTests { let handle = try #require(view.dividerViews[.split("r")]) let start = handle.convert(CGPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) view.handleDrag(kind: .split("r"), event: .began(start, [])) - // 30 drag events, 8 pt each: the divider crosses many cells of both terminals. - for step in 1...30 { - view.handleDrag(kind: .split("r"), event: .moved(CGPoint(x: start.x - CGFloat(step) * 8, y: start.y), [])) + // 40 frames of 6 pt, 12 ms apart (about half a second): the divider crosses many cells of both terminals. + let moves = 40 + for step in 1...moves { + view.handleDrag(kind: .split("r"), event: .moved(CGPoint(x: start.x - CGFloat(step) * 6, y: start.y), [])) screen.root.syncWithModel() await screen.step() + try await Task.sleep(for: .milliseconds(12)) // the drag's own pace: the coalescer's 25 ms windows close } - let midDrag = screen.terminals.mapValues(\.pty.applied.count) - view.handleDrag(kind: .split("r"), event: .ended(CGPoint(x: start.x - 240, y: start.y), [])) + await screen.drain() + let midDrag = screen.terminals.mapValues(\.pty.applied) + view.handleDrag(kind: .split("r"), event: .ended(CGPoint(x: start.x - CGFloat(moves) * 6, y: start.y), [])) screen.root.syncWithModel() var moving = true for _ in 1...240 where moving { moving = await screen.step() } #expect(!moving, "the release never landed") await screen.drain() - #expect(midDrag.values.allSatisfy { $0 == 0 }, "PTY resizes during the drag: \(midDrag)") for pane: PaneID in ["p1", "p2"] { let terminal = try #require(screen.terminals[pane]) + let during = midDrag[pane] ?? [] + #expect(during.count >= 2, "\(pane): its PTY got \(during.count) grids while the drag moved: it waits for the landing (\(during))") + #expect(during.count <= moves / 2, "\(pane): \(during.count) PTY grids in \(moves) drag frames: one per frame, not coalesced") + #expect(zip(during, during.dropFirst()).allSatisfy { $0 != $1 }, "\(pane): the same grid twice in a row: \(during)") let applied = terminal.pty.applied - #expect(applied.count == 1, "\(pane): \(applied.count) PTY resizes for one divider drag: \(applied)") if let grid = terminal.session.surfaceView.fittedGrid, let last = applied.last { #expect(last == CellSize(cols: grid.columns, rows: grid.rows), "\(pane): the PTY rests at \(last), the view fits \(grid)") } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift new file mode 100644 index 000000000000..6a7095e9558d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift @@ -0,0 +1,86 @@ +import AppKit +@testable import CmuxNextDesign +@testable import CmuxNextLayout +@testable import CmuxNextTerminal +import CmuxNextTestSupport +import Testing + +/// cmux-ad m29, rule from the RESIZE OWNER (frame-owner-s4's point 4): every grid a terminal's program gets during a +/// divider drag is the grid of a size a commit showed on screen. In a drag the size asked of Ghostty can be up to two +/// refreshes ahead of the size shown with its frame, and with the 25 ms coalescing the newest asked size can be one that +/// never shows, so the program would redraw at a size the person never sees. While the divider moves the program also +/// gets a new grid at least every few windows, and the last grid goes at the release. +/// +/// S4's rig on a display (`TerminalKeyResizeFrameTests.PaneScreen`, its daemon announcing each grid); a drag of the split's +/// divider 6 pt left per frame for 40 frames, paced 12 ms apart in real time (the coalescer's 25 ms windows close as in a +/// person's drag), then the release and its landing. After each commit the grid each shown frame fits is recorded. +@MainActor @Suite(.serialized, .closesItsWindows, .timeLimit(.minutes(1)), + .enabled(if: ["CMUX_TEST_REQUIRE_GUI", "CMUX_CI_DISPLAY_ROUTED"].contains { ProcessInfo.processInfo.environment[$0] == "1" })) +struct TerminalDragShownGridTests { + typealias Rig = TerminalKeyResizeFrameTests + + @Test func everyGridADragSendsIsTheGridOfAShownFrame() async throws { + let screen = try await Rig.PaneScreen(announcesGrids: true) + defer { screen.close() } + await Rig.settle(screen) + let view = try #require(screen.root.screenViews["s"]) + let handle = try #require(view.dividerViews[.split("x")]) + let start = handle.convert(CGPoint(x: handle.bounds.midX, y: handle.bounds.midY), to: nil) + let before = screen.ios.map { $0.resizes.count } + var shown = Array(repeating: Set(), count: screen.sessions.count) + var seen = before, unseen: [String] = [] + // Per commit: the grids each shown frame fits so far, then each PTY grid that arrived since the last commit must be + // one of them (frame-owner-s4: a drag step that asks a new size in the turn of a landing must send the landed grid, + // not the new ask, which no commit has shown yet). + func record() { + for (index, session) in screen.sessions.enumerated() { + // A size shows when the committed layer and the frame it presents have one pixel size; the next size may + // already be asked (a drag asks one per frame), so a size in flight does not hide the one shown. + let sample = Rig.GridSample(session), view = session.surfaceView + if let layer = view.layer, let frame = view.presentedFrame, let fits = sample.fits, + Int((layer.bounds.width * layer.contentsScale).rounded()) == frame.width, + Int((layer.bounds.height * layer.contentsScale).rounded()) == frame.height { + shown[index].insert(Rig.text(fits)) + } + let resizes = screen.ios[index].resizes + for grid in resizes.dropFirst(seen[index]).map({ "\($0.columns)x\($0.rows)" }) where !shown[index].contains(grid) { + unseen.append("p\(index + 1): \(grid) before any commit showed it (shown: \(shown[index].sorted().prefix(6)))") + } + seen[index] = resizes.count + } + } + record() + + view.handleDrag(kind: .split("x"), event: .began(start, [])) + let moves = 40 + for move in 1...moves { + view.handleDrag(kind: .split("x"), event: .moved(CGPoint(x: start.x - CGFloat(move) * 6, y: start.y), [])) + _ = screen.root.advanceFrame(1.0 / 120) + screen.root.layoutSubtreeIfNeeded() // sync-layout: the stepped frame's own layout pass, as the frame tick runs it + await Rig.commit() + record() + try await Task.sleep(for: .milliseconds(12)) // the drag's own pace + } + let duringDrag = zip(screen.ios.map { $0.resizes.count }, before).map { $0 - $1 } + view.handleDrag(kind: .split("x"), event: .ended(CGPoint(x: start.x - CGFloat(moves) * 6, y: start.y), [])) + screen.root.syncWithModel() + var moving = true, steps = 0 + while moving || steps < 6, steps < 240 { + moving = screen.root.advanceFrame(1.0 / 120) + screen.root.layoutSubtreeIfNeeded() // sync-layout: the release's frames + await Rig.commit() + record() + steps += 1 + } + await Rig.settle(screen) + record() + + let rested = zip(screen.sessions, screen.ios).map { session, io in + (fits: session.surfaceView.fittedGrid.map { "\($0.columns)x\($0.rows)" }, newest: io.resizes.last.map { "\($0.columns)x\($0.rows)" }) + } + print("drag shown grids: PTY grids during the drag \(duringDrag); grids never shown \(unseen.count); rested \(rested)") + #expect(duringDrag.allSatisfy { $0 >= 2 }, "a program got \(duringDrag) grids while the divider moved: it waits for the landing") + #expect(unseen.isEmpty, "\(unseen.count) grids went to a program at a size no commit showed: \(unseen.prefix(6))") + #expect(rested.allSatisfy { $0.fits == $0.newest }, "after the landing a program's newest grid is not its view's: \(rested)") + } +}