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
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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() } }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -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)")
}
Expand Down
Loading
Loading