From 0550d683369454de35b1e67d07da4798edbc1a3e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:37:22 +0000 Subject: [PATCH 1/9] feat(next): TerminalGridReportCoalescer: one PTY grid report per 25 ms window, the newest (Ghostty's min_ms rule) T-KEYS (frame-owner-s4, STATUS 13889): a key resize or divider drag changes a terminal's fit grid on almost every frame, and each report resized the PTY and sent SIGWINCH. The coalescer follows Ghostty.app's src/termio/Thread.zig (min_ms 25): the first new grid opens a 25 ms DemandTimer window, later grids replace the waiting one, the fire sends the newest once; a grid equal to the last sent one sends nothing. flush() ends the window at once (gesture end), reportNow(_:) is the forced report, cancel() drops the waiting grid. Not wired yet: the T-KEYS key-path change calls it from the grid-report path. Tests: TerminalGridReportCoalescerTests (manual clock), run on the fleet. (cherry picked from commit 449475f4cadbc7152554c07c15b54b8a5198e3e3) --- .../TerminalGridReportCoalescer.swift | 115 +++++++++ .../TerminalGridReportCoalescerTests.swift | 238 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReportCoalescer.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReportCoalescer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReportCoalescer.swift new file mode 100644 index 000000000000..57cd9a072653 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReportCoalescer.swift @@ -0,0 +1,115 @@ +import CmuxNextTerminalGeometry +import CmuxNextWakeups + +/// Coalesces the grid reports a terminal view sends to its PTY owner (the cmux-tui daemon), which resizes +/// the PTY and sends SIGWINCH for each one. +/// +/// During a key resize or a divider drag the view's fit grid changes on almost every display frame. This +/// follows Ghostty.app's own rule (`src/termio/Thread.zig`, `min_ms = 25`): the shell sees at most one +/// resize per ``defaultInterval``, and always the newest grid. +/// +/// - A new grid while nothing waits starts one ``interval`` timer; it is not sent at once. +/// - Each later grid inside that window replaces the waiting one (no timer work: a compare and a store). +/// - When the timer fires, the newest waiting grid is sent once and the window ends. The next change +/// starts a new window. +/// - A grid equal to the last sent grid sends nothing: one that arrives while nothing waits starts no +/// window, and a waiting grid that changed back to the last sent grid makes the fire send nothing. +/// +/// ``flush()`` ends the window now. Call it when the gesture ends (the key resize or the drag lands, the +/// pane's layout spring settles) and for a report at rest, so the shell gets the final grid in the same +/// turn instead of up to 25 ms later. ``reportNow(_:)`` is the forced report (a new PTY owner, the +/// session's first report): it sends even a grid equal to the last sent one. ``cancel()`` drops the +/// waiting grid (the view is hidden, closed or detached from its session). +/// +/// Main thread only. It never blocks or sleeps: the window is a ``DemandTimer`` on the injected clock, so +/// tests drive it with a manual clock. A grid inside a window allocates nothing; a new window costs the +/// timer's one task. +@MainActor +final class TerminalGridReportCoalescer { + /// Ghostty.app's resize coalescing window (`Thread.zig`, `min_ms`). + nonisolated static let defaultInterval: Duration = .milliseconds(25) + + /// How long a window waits before it sends its newest grid. + let interval: Duration + + /// The grid the open window will send when it fires; nil while no window is open. + private(set) var pending: TerminalGridSize? + + /// The last grid handed to `send`; nil before the first send. + private(set) var lastSent: TerminalGridSize? + + /// Whether a window's timer is open (a grid waits). + var isWaiting: Bool { pending != nil } + + private let send: @MainActor (TerminalGridSize) -> Void + private let timer: DemandTimer + /// Counts windows, so a fire that was already on its way when its window ended (``flush()``, + /// ``cancel()``) sends nothing into the next window. + private var window: UInt64 = 0 + + /// - Parameters: + /// - interval: The coalescing window; Ghostty's 25 ms by default. + /// - clock: The window timer's clock; the terminal session passes its clock, tests a manual one. + /// - send: Reports one grid to the PTY owner. Called on the main thread, at most once per window. + init(interval: Duration = TerminalGridReportCoalescer.defaultInterval, + clock: any Clock = ContinuousClock(), + send: @escaping @MainActor (TerminalGridSize) -> Void) { + self.interval = interval + self.send = send + timer = DemandTimer(owner: "TerminalGridReportCoalescer.window", clock: clock) + } + + /// The view's fit grid is now `grid`: it waits for the open window, or opens one. + func viewSized(_ grid: TerminalGridSize) { + if pending != nil { + pending = grid + return + } + guard grid != lastSent else { return } + pending = grid + window &+= 1 + let window = window + timer.schedule(after: interval) { @MainActor [weak self] in + self?.windowEnded(window) + } + } + + /// Sends the waiting grid now (unless it is the last sent grid) and closes the window. Nothing waits: + /// nothing is sent. + func flush() { + guard let grid = pending else { return } + close() + deliver(grid) + } + + /// The forced report: sends `grid` now even when it equals the last sent grid, and drops the open + /// window (its grid is older than this one). + func reportNow(_ grid: TerminalGridSize) { + close() + lastSent = grid + send(grid) + } + + /// Drops the waiting grid and closes the window: nothing is sent. + func cancel() { + close() + } + + private func close() { + pending = nil + window &+= 1 + timer.cancel() + } + + private func windowEnded(_ ended: UInt64) { + guard ended == window, let grid = pending else { return } + pending = nil + deliver(grid) + } + + private func deliver(_ grid: TerminalGridSize) { + guard grid != lastSent else { return } + lastSent = grid + send(grid) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift new file mode 100644 index 000000000000..7a24f64869f7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift @@ -0,0 +1,238 @@ +@testable import CmuxNextTerminal +import CmuxNextTerminalGeometry +import CmuxNextWakeupsTestSupport +import Testing + +/// T-KEYS (frame-owner-s4): a key resize or a divider drag changes a terminal's fit grid on almost every display +/// frame, and each report resized the PTY and sent SIGWINCH. ``TerminalGridReportCoalescer`` applies Ghostty.app's +/// rule (`src/termio/Thread.zig`, `min_ms = 25`): one resize per 25 ms window, always the newest grid. Every test +/// runs on a manual clock: the window is a `DemandTimer` deadline, so nothing here waits on the wall clock. +@MainActor @Suite(.serialized, .timeLimit(.minutes(1))) +struct TerminalGridReportCoalescerTests { + /// One 120 Hz display frame, rounded down to the nanosecond. + static let frame: Duration = .nanoseconds(8_333_333) + + @MainActor final class Sent { + var grids: [TerminalGridSize] = [] + var times: [Duration] = [] + } + + struct Rig { + let clock: ManualClock + let sent: Sent + let coalescer: TerminalGridReportCoalescer + + @MainActor init() { + let clock = ManualClock() + let sent = Sent() + self.clock = clock + self.sent = sent + coalescer = TerminalGridReportCoalescer(clock: clock) { grid in + sent.grids.append(grid) + sent.times.append(clock.now.offset) + } + } + + /// Lets a released window fire: yields while a grid waits and no timer sleeps (the fire is on its way to + /// the main actor). A window still asleep on the clock stops the wait at once. + @MainActor func settle() async { + for _ in 0..<500 where coalescer.isWaiting && clock.sleeperCount == 0 { await Task.yield() } + } + + /// Yields enough turns for any fire that was released to land; for checks that nothing was sent. + @MainActor func drain() async { + for _ in 0..<50 { await Task.yield() } + } + } + + static func grid(_ columns: Int, _ rows: Int = 24) -> TerminalGridSize { + TerminalGridSize(columns: columns, rows: rows) + } + + /// (1) The first new grid is not sent at once: nothing before 25 ms, exactly one send at 25 ms, that grid. + @Test func theFirstChangeIsSentOnceWhenItsWindowEnds() async throws { + let rig = Rig() + let a = Self.grid(100) + rig.coalescer.viewSized(a) + #expect(rig.sent.grids.isEmpty, "the first grid went at once") + #expect(rig.coalescer.pending == a) + try await rig.clock.waitForSleeper() + #expect(rig.clock.sleeperDeadlines.map(\.offset) == [.milliseconds(25)]) + + rig.clock.advance(by: .milliseconds(24)) + await rig.drain() + #expect(rig.sent.grids.isEmpty, "the grid went before its window ended") + + rig.clock.advance(by: .milliseconds(1)) + await rig.settle() + #expect(rig.sent.grids == [a]) + #expect(rig.sent.times == [.milliseconds(25)]) + #expect(rig.coalescer.pending == nil && !rig.coalescer.isWaiting) + #expect(rig.coalescer.lastSent == a) + + rig.clock.advance(by: .milliseconds(100)) + await rig.drain() + #expect(rig.sent.grids == [a], "a closed window sent again") + } + + /// (2) Five changes inside one window send once: the newest. + @Test func fiveChangesInsideOneWindowSendOnlyTheNewest() async throws { + let rig = Rig() + let grids = (0..<5).map { Self.grid(100 + $0) } + rig.coalescer.viewSized(grids[0]) + try await rig.clock.waitForSleeper() + for grid in grids.dropFirst() { + rig.clock.advance(by: .milliseconds(4)) + await rig.drain() + rig.coalescer.viewSized(grid) + #expect(rig.clock.sleeperCount == 1, "a change inside the window opened another timer") + } + #expect(rig.sent.grids.isEmpty) + #expect(rig.coalescer.pending == grids[4]) + + rig.clock.advance(by: .milliseconds(9)) + await rig.settle() + #expect(rig.sent.grids == [grids[4]]) + #expect(rig.sent.times == [.milliseconds(25)]) + + rig.clock.advance(by: .milliseconds(100)) + await rig.drain() + #expect(rig.sent.grids == [grids[4]]) + } + + /// (3) A change on every 120 Hz frame for 100 ms: sends at most every 25 ms, each the newest grid at its fire, + /// and the final grid always goes. + @Test func aChangeEveryFrameSendsAtMostEveryWindowAndAlwaysTheFinalGrid() async throws { + let rig = Rig() + var newest = [Duration: TerminalGridSize]() + var latest: TerminalGridSize? + var elapsed = Duration.zero + var frames = 0 + while elapsed < .milliseconds(100) { + let grid = Self.grid(80 + frames, 24 + frames % 3) + rig.coalescer.viewSized(grid) + latest = grid + if rig.coalescer.isWaiting, rig.clock.sleeperCount == 0 { try await rig.clock.waitForSleeper() } + let before = rig.sent.grids.count + rig.clock.advance(by: Self.frame) + elapsed += Self.frame + await rig.settle() + if rig.sent.grids.count > before, let latest { newest[rig.sent.times.last ?? .zero] = latest } + frames += 1 + } + #expect(frames == 13) + rig.clock.advance(by: TerminalGridReportCoalescer.defaultInterval) + await rig.settle() + if let time = rig.sent.times.last, newest[time] == nil, let latest { newest[time] = latest } + + #expect(rig.sent.grids.count >= 3, "a 100 ms resize sent \(rig.sent.grids.count) grids") + #expect(rig.sent.grids.count <= 5, "more than one send per 25 ms: \(rig.sent.times)") + for (earlier, later) in zip(rig.sent.times, rig.sent.times.dropFirst()) { + #expect(later - earlier >= .milliseconds(25), "two sends \(later - earlier) apart: \(rig.sent.times)") + } + for (grid, time) in zip(rig.sent.grids, rig.sent.times) { + #expect(grid == newest[time], "at \(time) the send was \(grid), the newest grid was \(String(describing: newest[time]))") + } + #expect(rig.sent.grids.last == latest, "the final grid was not sent") + #expect(Set(rig.sent.grids).count == rig.sent.grids.count, "a grid was sent twice") + #expect(!rig.coalescer.isWaiting) + } + + /// (4) A grid that changes back to the last sent grid sends nothing, whether it arrives with nothing waiting + /// or replaces the waiting grid before the fire. + @Test func aChangeBackToTheLastSentGridSendsNothing() async throws { + let rig = Rig() + let a = Self.grid(100) + let b = Self.grid(90) + rig.coalescer.viewSized(a) + try await rig.clock.waitForSleeper() + rig.clock.advance(by: .milliseconds(25)) + await rig.settle() + #expect(rig.sent.grids == [a]) + + rig.coalescer.viewSized(a) + #expect(!rig.coalescer.isWaiting, "the last sent grid opened a window") + await rig.drain() + #expect(rig.clock.sleeperCount == 0) + + rig.coalescer.viewSized(b) + try await rig.clock.waitForSleeper() + rig.clock.advance(by: .milliseconds(10)) + await rig.drain() + rig.coalescer.viewSized(a) + rig.clock.advance(by: .milliseconds(15)) + await rig.settle() + await rig.drain() + #expect(rig.sent.grids == [a], "the grid went back to the last sent one and was sent again") + #expect(!rig.coalescer.isWaiting && rig.coalescer.lastSent == a) + } + + /// (5) `flush()` (the gesture ended) sends the waiting grid at once and closes the window: the timer sends + /// nothing more. With nothing waiting it sends nothing. + @Test func flushSendsTheWaitingGridAtOnceAndClosesTheWindow() async throws { + let rig = Rig() + rig.coalescer.flush() + #expect(rig.sent.grids.isEmpty, "a flush with nothing waiting sent") + + let a = Self.grid(100) + let b = Self.grid(110) + rig.coalescer.viewSized(a) + try await rig.clock.waitForSleeper() + rig.clock.advance(by: .milliseconds(8)) + rig.coalescer.viewSized(b) + rig.coalescer.flush() + #expect(rig.sent.grids == [b], "flush did not send the newest grid in its own turn") + #expect(rig.sent.times == [.milliseconds(8)]) + #expect(!rig.coalescer.isWaiting && rig.clock.sleeperCount == 0, "flush left the window's timer running") + + rig.clock.advance(by: .milliseconds(50)) + await rig.drain() + #expect(rig.sent.grids == [b], "the closed window's timer sent again") + + rig.coalescer.viewSized(b) + rig.coalescer.flush() + #expect(rig.sent.grids == [b], "a flush resent the last sent grid") + } + + /// (6) `cancel()` drops the waiting grid: nothing is sent, and the next change opens a new window. + @Test func cancelDropsTheWaitingGrid() async throws { + let rig = Rig() + let a = Self.grid(100) + rig.coalescer.viewSized(a) + try await rig.clock.waitForSleeper() + rig.coalescer.cancel() + #expect(rig.coalescer.pending == nil && rig.clock.sleeperCount == 0) + rig.clock.advance(by: .milliseconds(50)) + await rig.drain() + #expect(rig.sent.grids.isEmpty, "a cancelled grid was sent") + #expect(rig.coalescer.lastSent == nil) + + rig.coalescer.viewSized(a) + try await rig.clock.waitForSleeper() + rig.clock.advance(by: .milliseconds(25)) + await rig.settle() + #expect(rig.sent.grids == [a]) + #expect(rig.sent.times == [.milliseconds(75)]) + } + + /// The forced report (a new PTY owner) goes at once even when it equals the last sent grid, and drops the + /// older waiting grid. + @Test func reportNowSendsAtOnceEvenTheLastSentGrid() async throws { + let rig = Rig() + let a = Self.grid(100) + rig.coalescer.reportNow(a) + #expect(rig.sent.grids == [a]) + rig.coalescer.reportNow(a) + #expect(rig.sent.grids == [a, a], "the forced report was skipped as a duplicate") + + rig.coalescer.viewSized(Self.grid(120)) + try await rig.clock.waitForSleeper() + let c = Self.grid(130) + rig.coalescer.reportNow(c) + #expect(rig.sent.grids == [a, a, c]) + #expect(!rig.coalescer.isWaiting && rig.clock.sleeperCount == 0) + rig.clock.advance(by: .milliseconds(50)) + await rig.drain() + #expect(rig.sent.grids == [a, a, c], "the dropped window sent its older grid") + } +} From ab7dbbc5617b4a0cc82831cf4dc413ab245d35c9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:50:40 +0000 Subject: [PATCH 2/9] test(next): TerminalGridReportCoalescerTests: per-frame test compares sends to the newest grid in order (no Duration-keyed dictionary) (cherry picked from commit 47bbe04279733bf6914ad4b2c11c641af7d0118f) --- .../TerminalGridReportCoalescerTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift index 7a24f64869f7..0c17232f2396 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalGridReportCoalescerTests.swift @@ -104,7 +104,8 @@ struct TerminalGridReportCoalescerTests { /// and the final grid always goes. @Test func aChangeEveryFrameSendsAtMostEveryWindowAndAlwaysTheFinalGrid() async throws { let rig = Rig() - var newest = [Duration: TerminalGridSize]() + // The newest grid at each send, in send order. + var newestAtSend: [TerminalGridSize] = [] var latest: TerminalGridSize? var elapsed = Duration.zero var frames = 0 @@ -117,22 +118,21 @@ struct TerminalGridReportCoalescerTests { rig.clock.advance(by: Self.frame) elapsed += Self.frame await rig.settle() - if rig.sent.grids.count > before, let latest { newest[rig.sent.times.last ?? .zero] = latest } + if rig.sent.grids.count > before { newestAtSend.append(grid) } frames += 1 } #expect(frames == 13) + let beforeEnd = rig.sent.grids.count rig.clock.advance(by: TerminalGridReportCoalescer.defaultInterval) await rig.settle() - if let time = rig.sent.times.last, newest[time] == nil, let latest { newest[time] = latest } + if rig.sent.grids.count > beforeEnd, let latest { newestAtSend.append(latest) } #expect(rig.sent.grids.count >= 3, "a 100 ms resize sent \(rig.sent.grids.count) grids") #expect(rig.sent.grids.count <= 5, "more than one send per 25 ms: \(rig.sent.times)") for (earlier, later) in zip(rig.sent.times, rig.sent.times.dropFirst()) { #expect(later - earlier >= .milliseconds(25), "two sends \(later - earlier) apart: \(rig.sent.times)") } - for (grid, time) in zip(rig.sent.grids, rig.sent.times) { - #expect(grid == newest[time], "at \(time) the send was \(grid), the newest grid was \(String(describing: newest[time]))") - } + #expect(rig.sent.grids == newestAtSend, "a send was not the newest grid at its fire: \(rig.sent.times)") #expect(rig.sent.grids.last == latest, "the final grid was not sent") #expect(Set(rig.sent.grids).count == rig.sent.grids.count, "a grid was sent twice") #expect(!rig.coalescer.isWaiting) From 2d7728c6556f30ca20b100cbc77d45625ec68bcd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:35:22 +0000 Subject: [PATCH 3/9] test(next): m29 drags: the PTY gets coalesced grids while a divider drag moves, not one at the landing (red) cmux-ad m18/m29 (STATUS 14461): a full-screen program (alternate screen, not re-wrapped by the reflow preview) stayed at its start size for the whole drag, the terminal background beside it, and jumped at the mouse-up: the drag sends its one grid at the landing. DividerDragTerminalResizeCountTests now asks for Ghostty.app's rule: during a 40-frame drag (12 ms apart) each PTY gets at least 2 grids and at most one per two frames, never the same grid twice in a row, and rests at the grid its view fits. Sidebar-Look: no visual change --- .../DividerDragTerminalResizeCountTests.swift | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) 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)") } From 0a1efdf43f1788ca67f2f542cd806bb8ea1bb2ee Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:39:45 +0000 Subject: [PATCH 4/9] fix(next): m29 drags: a divider drag sends each terminal's grid while it moves, coalesced as Ghostty.app does cmux-ad m18/m29 (STATUS 14461): a full-screen program (alternate screen, which the reflow preview does not re-wrap) kept its start size for the whole drag, the terminal background beside it, and jumped at the mouse-up: a drag held its grid report until the landing (`holding: ... || isFollowingResizeGesture`). - TerminalGridReporter (new): every grid report of a terminal view goes through one TerminalGridReportCoalescer (#36553), so its last sent grid is the PTY's. A drag's grids are coalesced (the newest at most once per 25 ms, Ghostty.app's min_ms); every other report (at rest, forced, a spring's final grid) goes now. - updateSurfaceSize: a followed drag, and its release's spring, no longer hold the grid. - The drag's end, and a surface that stops following (it left its host or window), send the waiting grid now. - The path is the same for split dividers and column or row edges: every DividerHandleView kind goes through ScreenContentView.resizeGestureMoved to the same followers. Sidebar-Look: no visual change --- .../TerminalGridReporter.swift | 49 +++++++++++++++++++ .../TerminalSurfaceView+PaneMotion.swift | 8 +-- .../TerminalSurfaceView.swift | 32 +++++------- 3 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift new file mode 100644 index 000000000000..a538a42e02ed --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift @@ -0,0 +1,49 @@ +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. +@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: 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) { + 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+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 869a8bc414ad..3c1684dcd109 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?) { @@ -464,26 +462,22 @@ 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) + if isFollowingResizeGesture, !forceReport { gridReports.coalesced(report) } else { gridReports.now(report) } } private func publish(size: ghostty_surface_size_s) { From f04066a9cb858f0353b976f2f24e49f7b96675c9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:44:14 +0000 Subject: [PATCH 5/9] test(next): m29 drags: every grid a drag sends is the grid of a frame a commit showed (red) frame-owner-s4's point 4 (the RESIZE OWNER's rule): in a drag the size asked of Ghostty runs up to two refreshes ahead of the shown size, and the coalescer's newest can be a size that never shows. Display test with S4's rig: a 40-frame drag paced 12 ms apart; every PTY grid must be the fit grid of a frame shown with its bounds, at least 2 during the drag, and the newest at rest the view's. Sidebar-Look: no visual change --- .../TerminalDragShownGridTests.swift | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift new file mode 100644 index 000000000000..0ba713c66d64 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift @@ -0,0 +1,77 @@ +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) + func record() { + for (index, session) in screen.sessions.enumerated() { + let sample = Rig.GridSample(session) + if sample.showsItsOwnFrame, let fits = sample.fits { shown[index].insert(Rig.text(fits)) } + } + } + 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() + + var unseen: [String] = [] + for (index, io) in screen.ios.enumerated() { + for grid in io.resizes.dropFirst(before[index]).map({ "\($0.columns)x\($0.rows)" }) where !shown[index].contains(grid) { + unseen.append("p\(index + 1): \(grid) (shown: \(shown[index].sorted().prefix(6)))") + } + } + 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)") + } +} From e3452a6d0319451c33d620a45b0ac7daba50f80e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:46:06 +0000 Subject: [PATCH 6/9] fix(next): m29 drags: a drag's PTY grid is the grid of a size shown on screen frame-owner-s4's point 4 (the RESIZE OWNER's rule): a drag sent the grid of the size asked of Ghostty, up to two refreshes ahead of the shown size; coalesced, the newest could be a size that never showed. A drag's grid now goes when its size shows: at once when the bounds take it, else from the presenter's landing (frameDidLand, the one call site keys can reuse) through TerminalGridReporter.shown(_:live:). A size that lands after the drag ended goes now. Sidebar-Look: no visual change --- .../TerminalGridReporter.swift | 19 ++++++++++++++++++- .../TerminalSurfaceView+FrameOwner.swift | 4 +++- .../TerminalSurfaceView.swift | 3 ++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift index a538a42e02ed..06f79a365244 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift @@ -5,7 +5,9 @@ import GhosttyNextKit /// 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. +/// (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? @@ -19,6 +21,21 @@ final class TerminalGridReporter { /// 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) { 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.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift index 3c1684dcd109..6f4f6a937f45 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift @@ -477,7 +477,8 @@ public final class TerminalSurfaceView: NSView { // 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 } - if isFollowingResizeGesture, !forceReport { gridReports.coalesced(report) } else { gridReports.now(report) } + // 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) { From 049465f346d196364cd74b3f5c102670c51ddc7c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:48:55 +0000 Subject: [PATCH 7/9] test(next): m29 drags: a PTY grid must be shown before it arrives, checked per commit frame-owner-s4's check: a drag step that asks a new size in the turn of a landing must send the landed grid. The shown-grid test now checks, after each commit, that every PTY grid that arrived since is the fit grid of a frame some commit already showed (before: any frame of the whole run, which a later ask would also meet). Sidebar-Look: no visual change --- .../TerminalDragShownGridTests.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift index 0ba713c66d64..437481ed50e3 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift @@ -28,10 +28,19 @@ struct TerminalDragShownGridTests { 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() { let sample = Rig.GridSample(session) if sample.showsItsOwnFrame, let fits = sample.fits { 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() @@ -60,12 +69,6 @@ struct TerminalDragShownGridTests { await Rig.settle(screen) record() - var unseen: [String] = [] - for (index, io) in screen.ios.enumerated() { - for grid in io.resizes.dropFirst(before[index]).map({ "\($0.columns)x\($0.rows)" }) where !shown[index].contains(grid) { - unseen.append("p\(index + 1): \(grid) (shown: \(shown[index].sorted().prefix(6)))") - } - } 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)" }) } From 0b0ad0556652ff1ad982c1afb691641e1b1d0a54 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:37:13 +0000 Subject: [PATCH 8/9] fix(next): m29 drags: a grid sent now drops a drag grid still waiting for its size to show SPRINGS COALESCER's review: now() (a forced report, a spring's final grid, a report at rest) did not clear awaitsShown, so a later frameDidLand could send an older grid after it. The PTY's last grid stays the newest. Sidebar-Look: no visual change --- .../CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift index 06f79a365244..4094aa06880b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalGridReporter.swift @@ -44,6 +44,7 @@ final class TerminalGridReporter { /// `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) From 5b4fdce482dbd1f730e1297f8f2911e655d9af5f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:18:31 +0000 Subject: [PATCH 9/9] test(next): m29 drags: the shown-grid test records every committed frame shown at its own size Display runs 3c8a6bb8 (head) and 78cf9923 (red ref) both failed with 36-46 grids 'never shown': the test recorded a shown size only when no size was in flight (GridSample.showsItsOwnFrame), but a drag asks its next size in the same pass as the landing, so almost no drag frame was recorded. It now records a frame when the committed layer and the frame it presents have one pixel size, whatever is in flight. Sidebar-Look: no visual change --- .../CmuxNextAppTests/TerminalDragShownGridTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift index 437481ed50e3..6a7095e9558d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalDragShownGridTests.swift @@ -34,8 +34,14 @@ struct TerminalDragShownGridTests { // not the new ask, which no commit has shown yet). func record() { for (index, session) in screen.sessions.enumerated() { - let sample = Rig.GridSample(session) - if sample.showsItsOwnFrame, let fits = sample.fits { shown[index].insert(Rig.text(fits)) } + // 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)))")