diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneContentAreaView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneContentAreaView.swift index ac4a8b04bc42..3cdcf202d2db 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneContentAreaView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneContentAreaView.swift @@ -12,7 +12,7 @@ import QuartzCore /// /// A container in it (``PaneMotionHoldFollowing``: a terminal's host, a browser's chrome; FRAME OWNER S3) takes the /// area's bounds in every frame instead and hears the hold once, so its own ground covers the pane; only its producer -/// keeps its size. +/// keeps its size (a terminal's surface follows too with `motion.frameOwner.terminals` on, cmux-ad m6). public final class PaneContentAreaView: NSView { public override init(frame frameRect: NSRect) { super.init(frame: frameRect) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneMotionHold.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneMotionHold.swift index 25bf0ca0697e..2bbfa7a244ab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneMotionHold.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneMotionHold.swift @@ -40,8 +40,10 @@ public protocol PaneMotionHolding: NSView { /// A container in a pane's content area that follows the area in every frame of a hold (FRAME OWNER REFACTOR S3: a /// terminal's host, a browser's chrome). The area gives it its own bounds in each frame, so its background, padding, -/// toolbar and scroller cover the pane; only its producer (the terminal surface, the page: pixels drawn on another -/// thread or in another process) keeps its size, on the hold's edges, and lays out once when the hold ends. +/// toolbar and scroller cover the pane. Its producer (the terminal surface, the page: pixels drawn on another thread or +/// in another process) keeps its size on the hold's edges and lays out once when the hold ends; with +/// `motion.frameOwner.terminals` on, a terminal's surface follows the area too (cmux-ad m6, S4: each size shows with +/// its frame, or the last frame stays, top-left). @MainActor public protocol PaneMotionHoldFollowing: NSView { /// Whether it follows now (a page kind that cannot hold its pixels yet is held whole, as in S1). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/TerminalDragTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/TerminalDragTunables.swift index d392948a6325..a05691813379 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/TerminalDragTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/TerminalDragTunables.swift @@ -17,12 +17,16 @@ public nonisolated struct TerminalDragTunables { default: true, code: "TerminalDragTunables.holdsSurfaceDuringDrag") /// Off (the default): a resized terminal's view takes its new bounds at once and Ghostty's frame of that size - /// lands 1-4 refreshes later (FRAME OWNER REFACTOR P2). On: ready-or-keep (stage S4): in every commit a - /// terminal's bounds and its pixels change together, or both keep their last size. Read on every layout, so one - /// build films both. + /// lands 1-4 refreshes later (FRAME OWNER REFACTOR P2), and a key resize holds the surface at one size per press. + /// On: ready-or-keep (stage S4): in every commit a terminal's bounds and its pixels change together, or both keep + /// their last size, and in a key resize the terminal shows its pane's size in every frame while its PTY gets one + /// grid per press (cmux-ad m6). It turns on by default when a held key resize with many terminals drops no more + /// refreshes with it on than off (each moving terminal costs one new IOSurface and one main wake per frame until + /// the Ghostty fork's coalesced frame-ready wake and render-target slack land). Read on every layout, so one build + /// films both. public static let framesOwnedByCommit = Tunable.toggle( "motion.frameOwner.terminals", section, "Terminal frame and size in one commit", - help: "A resized terminal shows its new size only in the commit that has its frame drawn at that size; until then it keeps its last size and frame. Off: the size changes at once and the frame comes 1-4 refreshes later.", + help: "A resized terminal shows its new size only in the commit that has its frame drawn at that size; until then it keeps its last size and frame. In a key resize the terminal follows its pane in every frame, and its program gets one size per press. Off: the size changes at once and the frame comes 1-4 refreshes later, and a key resize keeps one terminal size per press.", default: false, code: "TerminalDragTunables.framesOwnedByCommit") public static var all: [TunableDescriptor] { [holdsSurfaceDuringDrag.descriptor, framesOwnedByCommit.descriptor] } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalFramePresenter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalFramePresenter.swift index 7a1985db1dcb..22c135a8279e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalFramePresenter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalFramePresenter.swift @@ -117,6 +117,18 @@ final class TerminalFramePresenter { /// Whether `view` waits for a present here. func isWaiting(_ view: TerminalSurfaceView) -> Bool { requests[ObjectIdentifier(view)] != nil } + #if DEBUG || CMUX_BENCH_HOOKS + /// What this presenter holds for `view` (`debug.motion` trace): "request" (a size waits for its frame), "watch" + /// (its shown size changed outside a present), "commit" (its commit is reported at the next refresh), or "-". + func debugState(of view: TerminalSurfaceView) -> String { + let id = ObjectIdentifier(view) + if requests[id] != nil { return "request" } + if watched[id] != nil { return "watch" } + if commits[id] != nil { return "commit" } + return "-" + } + #endif + // MARK: Frames /// One refresh: the commits of earlier passes are done (``commits``), and watched terminals are read. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView+PaneMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView+PaneMotion.swift index 1d0f3111600c..4ad94e49c69a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView+PaneMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView+PaneMotion.swift @@ -3,10 +3,11 @@ public import CmuxNextDesign /// FRAME OWNER REFACTOR S3: in a keyboard resize the terminal host is a container /// that follows its pane in every frame: the pane's content area gives it its bounds each frame, so the terminal's own -/// ground, padding, scroller and overlays cover the whole pane and the window's shell never shows inside it. Only the -/// surface (the producer: Ghostty draws it on its renderer thread) keeps its size: the final size the press asked for -/// (``holdSurface(size:anchor:)``, one grid per press), else the size it had when the hold began, fixed at the pane edge -/// that does not move. No grid and no reflow per frame: only the surface's origin moves. +/// ground, padding, scroller and overlays cover the whole pane and the window's shell never shows inside it. With +/// `motion.frameOwner.terminals` on (cmux-ad m6) the surface follows too: it takes the area it shows in, in every frame +/// (``followedSurfaceFrame(_:in:)``), and the PTY gets one grid per press. With the switch off only the surface's origin +/// moves: it keeps the final size the press asked for (``holdSurface(size:anchor:)``), else the size it had when the +/// hold began, fixed at the pane edge that does not move. extension TerminalHostView: PaneMotionHoldFollowing { public func followPaneMotion(_ hold: PaneMotionHold?, heldSize: CGSize?) { followedHold = hold.map { ($0, heldSize ?? bounds.size) } @@ -28,9 +29,16 @@ extension TerminalHostView: PaneMotionHoldFollowing { /// (less a legacy scroller's strip). One exception to the still edge: a pane that grows down with its prompt in the /// lower half (`motionAnchor` answered `.bottom`) keeps the surface's bottom on its moving bottom edge, so the prompt on /// the final grid's last row stays in view; the surface is taller than the pane then, so it still covers it. + /// + /// With `motion.frameOwner.terminals` on (cmux-ad m6: pixels per frame, the program's size per press) the surface takes + /// the area it shows in, in every frame, anchored top-left: the frame owner shows each size's frame with its bounds, + /// or keeps the last frame (clipped, top-left) until it is drawn; the PTY still gets one grid per press (the final + /// grid, ``TerminalSurfaceView`` `restAt`). Except the low prompt above (`restAt` held the surface on the bottom edge): + /// it keeps that hold. func followedSurfaceFrame(_ followed: (hold: PaneMotionHold, heldSize: CGSize), in area: CGRect) -> CGRect { // perf-hot: 4ms motion-frame - let size = surfaceHold?.size ?? surfaceSize(forHostSize: followed.heldSize) let shown = CGRect(x: area.minX, y: area.minY, width: max(0, area.width - scrollerWidth), height: area.height) + if TerminalDragTunables.framesOwnedByCommit.value, surfaceHold?.anchor != .bottom { return shown } + let size = surfaceHold?.size ?? surfaceSize(forHostSize: followed.heldSize) var hold = followed.hold if case .bottom? = surfaceHold?.anchor { hold.bottom = true } return hold.producerFrame(size, in: shown) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView.swift index a0b6fa8beb92..5a3db4399847 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalHostView.swift @@ -404,7 +404,8 @@ public final class TerminalHostView: NSView { current?.place(surfaceFrame, anchor: surfaceAnchor) } - /// The spring landed: the surface takes the host's area again (the same size, unless the landing differs). + /// The hold ends: the spring landed, or (with `motion.frameOwner.terminals` on) a press whose terminal follows its + /// pane (`restAt`). The surface takes the host's area again (the same size, unless the landing differs). func releaseSurfaceHold() { guard surfaceHold != nil else { return } surfaceHold = nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift index 108f8c1b86da..7ded77dad11c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+FrameOwner.swift @@ -27,7 +27,7 @@ extension TerminalSurfaceView { /// refreshes later. On: a new size goes to Ghostty and the view keeps its size, its old frame on the `anchor` edge /// of `frame` (the host clips it and paints the terminal background beside it), until its window's commit shows /// the frame drawn at the new size with the new bounds. One size waits at a time; the newest goes next. - func place(_ frame: CGRect, anchor: PaneMotionAnchor) { + func place(_ frame: CGRect, anchor: PaneMotionAnchor) { // perf-hot: 1ms motion-frame (each frame of a key resize) let owned = TerminalDragTunables.framesOwnedByCommit.value if owned != frameOwnership.embedderPresents { setFramesOwnedByCommit(owned) } guard owned, isShown, window != nil, surface != nil else { @@ -58,7 +58,7 @@ extension TerminalSurfaceView { } /// Asks Ghostty for a frame at the wanted size, keeps the view's size and waits for its window's commit. - func requestWantedFrame() { + func requestWantedFrame() { // perf-hot: 1ms motion-frame (each frame of a key resize) guard frameOwnership.embedderPresents, let wanted = frameOwnership.wanted else { return } guard isShown, let presenter = framePresenter else { // Not shown (or no window): nothing to keep; the view takes the size and the scale it wants now. @@ -80,7 +80,7 @@ extension TerminalSurfaceView { /// Inside the commit: the view takes the size Ghostty's shown frame has, on the anchor edge of the wanted frame, /// and its layer the same geometry (a held display scale included) in this commit. Returns whether it wants /// another size since. - func landRequestedFrame() -> Bool { + func landRequestedFrame() -> Bool { // perf-hot: 1ms motion-frame (each frame of a key resize, under the CA lock) applyHeldScale() guard let size = frameOwnership.inFlight, let wanted = frameOwnership.wanted else { return false } frameOwnership.inFlight = nil diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+MotionSample.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+MotionSample.swift new file mode 100644 index 000000000000..f5528de9a64c --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+MotionSample.swift @@ -0,0 +1,56 @@ +#if DEBUG || CMUX_BENCH_HOOKS +import AppKit +import CmuxNextDesign +import GhosttyNextKit +import IOSurface + +extension TerminalSurfaceView { + /// Adds `motionSample` to `debug.motion`'s trace samplers, once: a static `let` runs its initializer one time (the + /// first surface view reads it). + static let motionSamplerRegistration: Void = MotionDebug.registeredSamplers.append(motionSample) + + /// `debug.motion` `trace_*` sampler (FRAME OWNER S4 defect (a): which present path drew each terminal frame in a + /// drag): per display frame, each shown terminal surface as `terminal`: + /// `[x, y, width, height, shown width px, shown height px, frames, off-main presents, fresh presents, + /// display passes, main draws, late presents, owned, presenter, host frame]`. + /// The rect is the surface layer in window points with a top-left origin (the committed model); `shown` is the + /// IOSurface the layer shows (0 before the first frame); the counters are Ghostty's render counters, cumulative + /// (a frame that went through main is one of `frames` that is neither an off-main nor a fresh present); + /// `owned` is 1 while its window's commit owns its frame (`motion.frameOwner.terminals`); `presenter` is + /// ``TerminalFramePresenter/debugState(of:)`` (request, watch, commit or -); `host frame` is what + /// `ghostty_surface_host_frame` answers now without presenting (owned only, else "-"). It reads only while the trace + /// records; nothing runs when it does not. + static func motionSample(_ view: NSView) -> [String: [Any]]? { + guard let surfaceView = view as? TerminalSurfaceView else { return nil } + guard let window = surfaceView.window, let surface = surfaceView.surface, !surfaceView.isHiddenOrHasHiddenAncestor, + let layer = surfaceView.layer, let root = window.contentView?.layer else { return [:] } + let rect = layer.convert(layer.bounds, to: root) + let height = root.bounds.height + let top = root.isGeometryFlipped ? rect.minY : height - rect.maxY + func rounded(_ value: CGFloat) -> Double { (Double(value) * 100).rounded() / 100 } + let shown = surfaceView.presentedFrame + let counters = ghostty_surface_render_counters(surface) + let owned = surfaceView.frameOwnership.embedderPresents + let presenter = TerminalFramePresenter.forWindow(window).debugState(of: surfaceView) + let row: [Any] = [rounded(rect.minX), rounded(top), rounded(rect.width), rounded(rect.height), + shown?.width ?? 0, shown?.height ?? 0, + counters.frames, counters.host_off_main_presents, counters.host_fresh_presents, + counters.host_display_passes, counters.host_main_draws, counters.host_late_presents, + owned ? 1 : 0, presenter, owned ? Self.name(surfaceView.hostFrame(present: false)) : "-"] + return ["terminal": [row]] + } + + private static func name(_ state: ghostty_host_frame_e) -> String { + switch state { + case GHOSTTY_HOST_FRAME_NONE: "none" + case GHOSTTY_HOST_FRAME_HIDDEN: "hidden" + case GHOSTTY_HOST_FRAME_SHOWN: "shown" + case GHOSTTY_HOST_FRAME_READY: "ready" + case GHOSTTY_HOST_FRAME_PRESENTED: "presented" + case GHOSTTY_HOST_FRAME_PENDING: "pending" + case GHOSTTY_HOST_FRAME_HELD: "held" + default: "other" + } + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift index 6354d688c4f2..5d3f8633de21 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+PaneMotion.swift @@ -46,7 +46,13 @@ extension TerminalSurfaceView: PaneMotionTargeting { return host.surfaceSize(forHostSize: hostSize) } - /// Reports the grid a view of `size` points fits and holds the surface at that size in its host. + /// Reports the grid a view of `size` points fits and holds the surface at that size in its host. In a key resize + /// whose pane its host follows (FRAME OWNER S3) with `motion.frameOwner.terminals` on, only the grid goes (cmux-ad m6: + /// the program's size per press); the surface takes its pane's size in every frame + /// (``TerminalHostView/followedSurfaceFrame(_:in:)``), never held at the final size. One exception, as for a split + /// or a close: a pane that grows down with the prompt in its lower half (`motionAnchor` answers `.bottom`) keeps the + /// surface at its final size on the moving bottom edge, so the prompt on the final grid's last row stays in view (a + /// surface at the pane's size shows the final grid's top rows; review of #35470, F2). private func restAt(_ size: NSSize, growing: Bool) -> PaneMotionAnchor { guard let surface else { return .top } let pixels = convertToBacking(size) @@ -57,6 +63,15 @@ extension TerminalSurfaceView: PaneMotionTargeting { else { return .top } reportFinalGrid(TerminalGridSize(columns: Int(columns), rows: Int(rows))) let anchor = motionAnchor(growing: growing) + if anchor == .top, let host = superview as? TerminalHostView, host.followedHold != nil, + TerminalDragTunables.framesOwnedByCommit.value { + // The newest press's anchor wins: a hold an earlier press or a join set (a low prompt's bottom hold, or the + // final size a terminal took before its area handed it the follow) goes now, not at the landing (review of + // #35470 at 447f784c3cf, G1: a stale bottom hold through a reverse press, and grid1, grid2, grid1, grid2 for + // two presses after a join). + host.releaseSurfaceHold() + return .top + } (superview as? TerminalHostView)?.holdSurface(size: size, anchor: anchor) return anchor } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift index 063bee3334a9..d3adac4bd983 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView.swift @@ -146,6 +146,9 @@ public final class TerminalSurfaceView: NSView { super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) bridge.view = self createSurface(mode: mode) + #if DEBUG || CMUX_BENCH_HOOKS + _ = Self.motionSamplerRegistration + #endif } @available(*, unavailable) @@ -466,7 +469,13 @@ public final class TerminalSurfaceView: NSView { // 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. - let report = geometry.viewSized(desired, force: forceReport, holding: !forceReport && (isInPaneMotion || isFollowingResizeGesture)) + // 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. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyResizeFirstTickPinTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyResizeFirstTickPinTests.swift index 635dbd1f21c2..b3f5f97ae25c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyResizeFirstTickPinTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyResizeFirstTickPinTests.swift @@ -35,11 +35,15 @@ struct KeyResizeFirstTickPinTests { } /// (a) Ctrl-Shift-H on p2 (right of p1): p2 grows to the left and its right edge stays. The first tick is late (0.1 s: - /// most of the travel in one step). In every frame p2's surface keeps its place against the right edge. - @Test(arguments: [0.1, 1.0 / 120]) - func theHeldSurfaceRidesTheEdgeThatStaysWhenTheFirstTickIsLate(firstStep: Double) throws { + /// most of the travel in one step). In every frame p2's surface keeps its place against the right edge. With + /// `motion.frameOwner.terminals` on (cmux-ad m6) the surface fills its area, so it keeps that place because it follows + /// the pane; with the switch off it holds the final size against that edge (the hold's edge picked once). + @Test(arguments: [0.1, 1.0 / 120], [false, true]) + func theHeldSurfaceRidesTheEdgeThatStaysWhenTheFirstTickIsLate(firstStep: Double, switchOn: Bool) throws { let rig = Ground.Rig(Ground.column(Ground.pair), focused: "p2") defer { rig.close() } + defer { Ground.releaseFrames() } + try #require(Ground.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") let host = try #require(rig.root.context.hosts["p2"]) func rightEdge() -> CGFloat { host.convert(host.roundedRect, to: nil).maxX } func surfaceRight() throws -> CGFloat { let s = try Self.surface("p2", in: rig); return s.convert(s.bounds, to: nil).maxX } @@ -59,12 +63,16 @@ struct KeyResizeFirstTickPinTests { } /// (b) Ctrl-Shift-H twice before one tick: each terminal sends one grid, the final one. Then H and L before one tick: - /// no terminal sends a grid or takes a new surface size. - @Test func pressesBeforeOneTickCoalesce() async throws { + /// no terminal sends a grid or takes a new surface size. In both states of `motion.frameOwner.terminals` (review of + /// #35470, T5). + @Test(arguments: [false, true]) + func pressesBeforeOneTickCoalesce(switchOn: Bool) async throws { let rig = Ground.Rig(Ground.column(Ground.pair), focused: "p2") defer { rig.close() } + defer { Ground.releaseFrames() } let panes: [PaneID] = ["p1", "p2"] for pane in panes { _ = try await Self.grids(pane, rig) } + try #require(Ground.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) var frames = 0 @@ -79,6 +87,8 @@ struct KeyResizeFirstTickPinTests { var sizes: [PaneID: CGSize] = [:] for pane in panes { sizes[pane] = try Self.surface(pane, in: rig).frame.size } + // The store is process-wide: the switch again after the awaits, before the presses. + try #require(Ground.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) #expect(PaneResize.press("p2", .right, model: rig.model, view: rig.root)) frames = 0 diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeContentHoldTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeContentHoldTests.swift index b2ca58e107c9..cb68dc6ff5fe 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeContentHoldTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeContentHoldTests.swift @@ -68,12 +68,65 @@ struct KeyboardResizeContentHoldTests { } } - /// FRAME OWNER S3, the app's pane content with a terminal in each pane: the containers (each terminal host) take their - /// pane's content area in every frame of the spring, and the producer (the terminal surface) takes a new size once, its - /// final size in the key's turn, and never again in the press. - @Test func theProducerResizesOnceAndTheContainersFollowEveryFrame() throws { + /// The app's pane content with a terminal in each pane (cmux-ad m6, the lead's contract: pixels per frame, program size + /// per press): in every frame of the spring each terminal host (the container) takes its pane's content area, and its + /// surface takes the size the host shows it at, so the text follows the pane; the press asks each PTY for one grid. + /// FRAME OWNER S3 held the surface at its final size through the press (one size per press): the text froze, then + /// jumped (Lawrence on dogfood 363e39f2). + @Test func theTerminalFollowsItsPaneInEveryFrameAndSendsOneGridPerTerminal() throws { let rig = PaneGroundCoversPaneTests.Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + try #require(PaneGroundCoversPaneTests.ownFrames(), "the switch motion.frameOwner.terminals did not turn on") + let panes: [PaneID] = ["p1", "p2"] + var hosts: [PaneID: [CGSize]] = [:], surfaces: [PaneID: [CGSize]] = [:], off: [String] = [] + var last: [PaneID: (host: CGSize, surface: CGSize)] = [:] + func note(_ label: String) throws { + for pane in panes { + let session = try #require(rig.provider.sessions[pane]?.session) + let area = try #require(session.view.superview, "\(pane)'s terminal host is not in its pane") + let now = (host: session.view.frame.size, surface: session.surfaceView.frame.size) + if let was = last[pane] { + if now.host != was.host { hosts[pane, default: []].append(now.host) } + if now.surface != was.surface { surfaces[pane, default: []].append(now.surface) } + } + last[pane] = now + if now.host != area.bounds.size { off.append("\(label): \(pane)'s host \(now.host) in its area \(area.bounds.size)") } + let shown = session.view.surfaceSize(forHostSize: session.view.bounds.size) + if now.surface != shown { off.append("\(label): \(pane)'s surface \(now.surface) in a \(shown) area") } + } + } + let reports = TerminalSurfaceView.gridReports + try note("at rest") + #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + rig.root.layoutSubtreeIfNeeded() + try note("the key's turn") + var frames = 0 + while frames < 240, rig.frame() { + frames += 1 + try note("frame \(frames)") + } + rig.commit() + try note("after the landing") + #expect(frames > 3, "the press did not spring (\(frames) frames)") + #expect(off.isEmpty, "\(off.count) frames showed a terminal host or surface off its pane: \(off.prefix(3))") + for pane in panes { + #expect((surfaces[pane] ?? []).count > 3, "\(pane)'s surface took \(surfaces[pane]?.count ?? 0) sizes in a \(frames)-frame spring: its text did not follow its pane") + #expect((hosts[pane] ?? []).count > 3, "\(pane)'s terminal host took \(hosts[pane]?.count ?? 0) sizes in a \(frames)-frame spring") + } + #expect(TerminalSurfaceView.gridReports - reports == UInt64(panes.count), "the press sent \(TerminalSurfaceView.gridReports - reports) grids for \(panes.count) terminals") + } + + /// The default (switch off; review of #35470 at c4d6c936c92, T5: the base checked this path). FRAME OWNER S3, the + /// app's pane content with a terminal in each pane: the containers (each terminal host) take their pane's content + /// area in every frame of the spring, and the producer (the terminal surface) takes a new size once, its final size + /// in the key's turn, and never again in the press. + @Test func theProducerResizesOnceWithTheSwitchOff() throws { + let rig = PaneGroundCoversPaneTests.Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + try #require(PaneGroundCoversPaneTests.ownFrames(false), "the switch motion.frameOwner.terminals did not turn off") let panes: [PaneID] = ["p1", "p2"] var hosts: [PaneID: [CGSize]] = [:], surfaces: [PaneID: [CGSize]] = [:], off: [String] = [] var last: [PaneID: (host: CGSize, surface: CGSize)] = [:] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeReflowAfterLandingTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeReflowAfterLandingTests.swift index 1ca37487b213..d42e8be99f35 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeReflowAfterLandingTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/KeyboardResizeReflowAfterLandingTests.swift @@ -55,15 +55,72 @@ struct KeyboardResizeReflowAfterLandingTests { } } - /// FRAME OWNER S3, the app's pane content with a terminal in each pane: the - /// producer (the terminal surface) lays out once, at its final size no later than the press's first frame tick (one - /// grid per terminal; FRAME OWNER S1B moved it there from the key's turn), and nothing in it lays out in any later - /// frame of the spring or after the landing; the containers (the terminal hosts) lay out in every frame, and at the - /// landing's release only the hold ends. + /// The app's pane content with a terminal in each pane (cmux-ad m6, the lead's contract: pixels per frame, program + /// size per press): the press sends each terminal's final grid no later than its first frame tick (one grid per + /// terminal; FRAME OWNER S1B moved it there from the key's turn) and none later; in every frame the containers (the + /// terminal hosts) take their pane's area and each surface the size its host shows it at, so its text follows the + /// pane (S3 laid the surface out once per press: the text froze, then jumped); at the landing's release each surface + /// rests at its host's size. @Test(arguments: [LayoutDirection.left, .right]) - func theProducerLaysOutOnceAndTheContainersEveryFrame(direction: LayoutDirection) throws { + func theTerminalFollowsItsPaneInEveryFrameAndItsGridGoesAtThePress(direction: LayoutDirection) throws { let rig = PaneGroundCoversPaneTests.Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + try #require(PaneGroundCoversPaneTests.ownFrames(), "the switch motion.frameOwner.terminals did not turn on") + let panes: [PaneID] = ["p1", "p2"] + func sizes() throws -> [PaneID: (host: CGSize, surface: CGSize)] { + try Dictionary(uniqueKeysWithValues: panes.map { pane in + let session = try #require(rig.provider.sessions[pane]?.session) + return (pane, (session.view.frame.size, session.surfaceView.frame.size)) + }) + } + let reports = TerminalSurfaceView.gridReports + let start = try sizes() + #expect(PaneResize.press("p2", direction, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + try #require(rig.frame(), "the press did not spring") + let atPress = try sizes() + #expect(TerminalSurfaceView.gridReports - reports == UInt64(panes.count), "up to its first frame the press sent \(TerminalSurfaceView.gridReports - reports) grids for \(panes.count) terminals") + var frames = 1, hostsOff: [String] = [], surfacesOff: [String] = [] + func check(_ label: String) throws { + for pane in panes { + let session = try #require(rig.provider.sessions[pane]?.session) + let area = try #require(session.view.superview) + let host = session.view, surface = session.surfaceView.frame.size + if host.frame.size != area.bounds.size { hostsOff.append("\(label): \(pane)'s host \(host.frame.size) in \(area.bounds.size)") } + let shown = host.surfaceSize(forHostSize: host.bounds.size) + if surface != shown { surfacesOff.append("\(label): \(pane)'s surface \(surface) in a \(shown) area") } + } + } + try check("frame 1") + while frames < 240, rig.frame() { + frames += 1 + try check("frame \(frames)") + } + rig.commit() + try check("after the landing") + let landed = try sizes() + #expect(frames > 3, "the press did not spring (\(frames) frames)") + for pane in panes { + #expect(atPress[pane]?.surface != start[pane]?.surface, "\(pane)'s surface did not follow its pane in the press's first frame") + #expect(landed[pane]?.surface != atPress[pane]?.surface, "\(pane)'s surface stayed at its first frame's size through the spring") + } + #expect(surfacesOff.isEmpty, "\(surfacesOff.count) frames showed a surface off its pane's size: \(surfacesOff.prefix(3))") + #expect(hostsOff.isEmpty, "a terminal host did not lay out at its pane's area in a frame: \(hostsOff.prefix(3))") + #expect(TerminalSurfaceView.gridReports - reports == UInt64(panes.count), "the press sent \(TerminalSurfaceView.gridReports - reports) grids for \(panes.count) terminals") + } + + /// The default (switch off; review of #35470 at c4d6c936c92, T5: the base checked this path). FRAME OWNER S3, the + /// app's pane content with a terminal in each pane: the producer (the terminal surface) lays out once, at its final + /// size no later than the press's first frame tick (one grid per terminal; FRAME OWNER S1B moved it there from the + /// key's turn), and nothing in it lays out in any later frame of the spring or after the landing; the containers + /// (the terminal hosts) lay out in every frame, and at the landing's release only the hold ends. + @Test(arguments: [LayoutDirection.left, .right]) + func theProducerLaysOutOnceWithTheSwitchOff(direction: LayoutDirection) throws { + let rig = PaneGroundCoversPaneTests.Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + try #require(PaneGroundCoversPaneTests.ownFrames(false), "the switch motion.frameOwner.terminals did not turn off") let panes: [PaneID] = ["p1", "p2"] func sizes() throws -> [PaneID: (host: CGSize, surface: CGSize)] { try Dictionary(uniqueKeysWithValues: panes.map { pane in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneContentPolicyTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneContentPolicyTests.swift index f64278d5b591..2c512cfec53c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneContentPolicyTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneContentPolicyTests.swift @@ -11,11 +11,17 @@ import Testing /// FRAME OWNER REFACTOR S3: the rules the content policy keeps beside PaneGroundCoversPaneTests (the shell never shows /// inside a pane rect). -/// - In a key resize the terminal surface (the producer) stays fixed at the pane edge that does not move, for each side -/// a pane can grow or shrink on, while its host (the container) follows the pane in every frame. -/// - One grid per press: each terminal asks its PTY for its final grid once, by the press's first frame tick (FRAME -/// OWNER S1B; it was the key's turn), and its surface takes a -/// new size at most once (no surface `setFrameSize` per frame, no reflow per frame). +/// - In a key resize with `motion.frameOwner.terminals` off (the default until the flip), the terminal surface stays +/// on the pane edge that does not move, for each side a pane can grow or shrink on, while its host (the container) +/// follows the pane in every frame; the surface takes one size per press, at the press, and none in a spring frame +/// or at the landing (the tests named `...WithTheSwitchOff`). +/// - Pixels per frame, program size per press (cmux-ad m6, the lead's contract; the switch on, which these tests set): +/// in every frame each terminal's surface has the size its pane shows it at, so its text follows the pane (S3 held it +/// at one size per press: the text froze, then jumped; Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl +/// shift hjkl but browsers/terminals do not anymore"). In both states each terminal asks its PTY for its final grid +/// once, by the press's first frame tick (FRAME OWNER S1B). With the window on screen, a size whose frame Ghostty +/// has not drawn yet keeps the last frame, top-left (TerminalKeyResizeFrameTests); these windows draw nothing, so +/// each size shows at once. /// - S3c: a closing pane shows its ground as it was at the close, the same host comes back when a close is taken back, /// and nothing of it is left in the view tree after the landing. @MainActor @@ -42,10 +48,12 @@ struct PaneContentPolicyTests { // MARK: Note 2: the surface stays fixed at the pane edge that does not move - /// One press on each side of each axis. p2 grows to the left (H: its leading edge moves, the surface rides the - /// trailing edge), p1 grows to the right (L on p1: the trailing edge moves, it rides the leading edge), p2 grows up - /// (K: its top edge moves, it rides the bottom), p1 grows down (J on p1: its bottom edge moves, it rides the top). - /// The shrinking pane beside each rides its own edge that stays. The host follows the pane in every frame. + /// One press on each side of each axis. p2 grows to the left (H: its leading edge moves), p1 grows to the right (L + /// on p1: the trailing edge moves), p2 grows up (K: its top edge moves), p1 grows down (J on p1: its bottom edge + /// moves). In every frame each surface keeps its place against the pane edge that does not move, and the host + /// follows the pane. With `motion.frameOwner.terminals` on (cmux-ad m6) the surface fills its area, so it is on that + /// edge because it is on every edge; aKeyPressShowsEachSurfaceAtItsPanesSizeAndSendsOneGrid checks the size. This case + /// guards the switch-off path and the low-prompt bottom hold (aPromptOnTheLastRowRidesTheBottomOfAPaneAKeyGrowsDown). nonisolated struct Side: Sendable, CustomTestStringConvertible { let vertical: Bool let pressed: PaneID @@ -98,15 +106,357 @@ struct PaneContentPolicyTests { #expect(off.isEmpty, "\(off.count) frames off: \(off.prefix(3))") } - // MARK: Note 3: one grid per press + // MARK: Note 3: pixels per frame, one grid per press (cmux-ad m6) - /// One press with the switch on: each terminal asks its PTY for one grid, the final one, by the press's first frame; its - /// surface takes a new size at most once in the whole press (none per frame), and none at the landing. + /// One press: in every frame each terminal's surface has the size its pane shows it at, and it takes a new size in + /// most of them (its text follows the pane); each terminal asks its PTY for one grid, the final one, by the press's + /// first frame. @Test(arguments: [LayoutDirection.left, .right, .up, .down]) - func aKeyPressSizesEachSurfaceOnceAndSendsOneGrid(direction: LayoutDirection) async throws { + func aKeyPressShowsEachSurfaceAtItsPanesSizeAndSendsOneGrid(direction: LayoutDirection) async throws { let vertical = direction == .up || direction == .down let rig = Rig(PaneGroundCoversPaneTests.column(vertical ? PaneGroundCoversPaneTests.stack : PaneGroundCoversPaneTests.pair), focused: "p2") defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let panes: [PaneID] = ["p1", "p2"] + for pane in panes { _ = try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames() + var off: [String] = [], sizes: [PaneID: [CGSize]] = [:] + var last: [PaneID: CGSize] = [:] + for pane in panes { last[pane] = try Self.surface(pane, in: rig).frame.size } + func note(_ label: String) throws { + off += try Self.offTheirPanes(panes, in: rig, label) + for pane in panes { + let size = try Self.surface(pane, in: rig).frame.size + if size != last[pane] { sizes[pane, default: []].append(size) } + last[pane] = size + } + } + let reportsBefore = TerminalSurfaceView.gridReports + PaneGroundCoversPaneTests.ownFrames() + #expect(PaneResize.press("p2", direction, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + rig.root.layoutSubtreeIfNeeded() + try note("the key's turn") + let frames = try Self.run(rig) { frame in try note("frame \(frame)") } + try note("after the landing") + #expect(frames > 3, "the press did not spring (\(frames) frames)") + #expect(off.isEmpty, "\(off.count) frames showed a surface off its pane's size: \(off.prefix(3))") + #expect(TerminalSurfaceView.gridReports - reportsBefore <= UInt64(panes.count), "more grid reports than terminals for one press") + for pane in panes { + let grids = try await Self.grids(pane, rig) + PaneGroundCoversPaneTests.ownFrames() + let final = try #require(try Self.surface(pane, in: rig).fittedGrid) + #expect(grids == [final], "\(pane) asked its PTY for \(grids), not its final grid \(final) once") + #expect((sizes[pane] ?? []).count > 3, "\(pane)'s surface took \(sizes[pane]?.count ?? 0) sizes in a \(frames)-frame spring: its text did not follow its pane") + } + } + + /// A held key (three Ctrl-Shift-H presses 4 frames apart retarget one spring): in every frame each terminal's + /// surface has the size its pane shows it at; each terminal asks its PTY for the final grid of each press, once, by + /// that press's first frame (at most one grid per press, the last one the final). + @Test func aHeldKeyShowsEachSurfaceAtItsPanesSizeAndSendsAtMostOneGridPerPress() async throws { + let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let panes: [PaneID] = ["p1", "p2"] + for pane in panes { _ = try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames() + var off: [String] = [], grids: [PaneID: [TerminalGridSize]] = [:] + var frames = 0 + for press in 1...3 { + PaneGroundCoversPaneTests.ownFrames() + #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root), "press \(press) moved nothing") + rig.root.syncWithModel() + // The press's first frame is its own (S1B): its grid goes there. + if rig.frame() { frames += 1 } + off += try Self.offTheirPanes(panes, in: rig, "press \(press)") + for pane in panes { grids[pane, default: []] += try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames() + for _ in 1...3 where rig.frame() { + frames += 1 + off += try Self.offTheirPanes(panes, in: rig, "frame \(frames)") + } + } + try Self.run(rig) { _ in + frames += 1 + off += try Self.offTheirPanes(panes, in: rig, "frame \(frames)") + } + off += try Self.offTheirPanes(panes, in: rig, "after the landing") + #expect(frames > 12, "the presses did not spring (\(frames) frames)") + #expect(off.isEmpty, "\(off.count) frames showed a surface off its pane's size: \(off.prefix(3))") + for pane in panes { + let final = try #require(try Self.surface(pane, in: rig).fittedGrid) + let sent = (grids[pane] ?? []) + (try await Self.grids(pane, rig)) + PaneGroundCoversPaneTests.ownFrames() + #expect(sent.count <= 3 && sent.last == final, "\(pane) asked its PTY for \(sent) over 3 presses (final \(final))") + } + } + + /// A press and its reverse in one key hold (Ctrl-Shift-H, then L 4 frames later, before the first lands): the panes + /// rest where they began. Each terminal hears each press's final grid by that press's first frame (the reverse one too, though + /// it rests at the size the hold began at), its surface has its pane's size in every frame, and it rests at its start + /// size. + @Test func aPressAndItsReverseInOneHoldSendEachGridInItsTurn() async throws { + let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let panes: [PaneID] = ["p1", "p2"] + for pane in panes { _ = try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames() + var start: [PaneID: (size: CGSize, grid: TerminalGridSize?)] = [:] + for pane in panes { + let surface = try Self.surface(pane, in: rig) + start[pane] = (surface.frame.size, surface.fittedGrid) + } + var off: [String] = [] + PaneGroundCoversPaneTests.ownFrames() + #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + #expect(rig.frame(), "the first press did not spring") + off += try Self.offTheirPanes(panes, in: rig, "frame 1") + var atPress: [PaneID: [TerminalGridSize]] = [:] + for pane in panes { atPress[pane] = try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames() + for frame in 2...4 { + #expect(rig.frame(), "the first press landed after \(frame) frames") + off += try Self.offTheirPanes(panes, in: rig, "frame \(frame)") + } + PaneGroundCoversPaneTests.ownFrames() + #expect(PaneResize.press("p2", .right, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + #expect(rig.frame(), "the reverse press did not spring") + off += try Self.offTheirPanes(panes, in: rig, "the reverse press's first frame") + var atReverse: [PaneID: [TerminalGridSize]] = [:] + for pane in panes { atReverse[pane] = try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames() + let frames = try Self.run(rig) { frame in off += try Self.offTheirPanes(panes, in: rig, "reverse frame \(frame)") } + #expect(frames > 3, "the reverse press did not spring (\(frames) frames)") + #expect(off.isEmpty, "\(off.count) frames showed a surface off its pane's size: \(off.prefix(3))") + for pane in panes { + let was = try #require(start[pane]), surface = try Self.surface(pane, in: rig) + let startGrid = try #require(was.grid, "SETUP: \(pane) fits no grid at rest") + #expect(atPress[pane]?.count == 1 && atPress[pane]?.first != startGrid, "\(pane): the first press sent \(atPress[pane] ?? [])") + #expect(atReverse[pane] == [startGrid], "\(pane): the reverse press sent \(atReverse[pane] ?? []), not the start grid \(startGrid)") + #expect(try await Self.grids(pane, rig).isEmpty, "\(pane) sent a grid at the landing") + PaneGroundCoversPaneTests.ownFrames() + #expect(surface.frame.size == was.size, "\(pane)'s surface rests at \(surface.frame.size), not its start size \(was.size)") + } + } + + /// The surfaces of `panes` whose size is not the one their pane shows them at now (their host's area, + /// ``TerminalHostView/surfaceSize(forHostSize:)``), labeled with `label`. + static func offTheirPanes(_ panes: [PaneID], in rig: Rig, _ label: String) throws -> [String] { + try panes.compactMap { pane in + let surface = try Self.surface(pane, in: rig) + let host = try #require(rig.provider.sessions[pane]?.session.view) + let shown = host.surfaceSize(forHostSize: host.bounds.size) + return surface.frame.size == shown ? nil : "\(label): \(pane)'s surface \(surface.frame.size) in a \(shown) area" + } + } + + /// Review of #35470 (F3): a terminal tab parked when a key press began (another tab showed) and shown again during the + /// spring follows its pane from the frame it shows: it is unhidden before its area hands it the hold (in the next + /// layout pass), and `restAt` held its surface at the final size then, a size its pane did not have yet. With the + /// switch off (the default; T5) the shown terminal takes at most one new size from the frame it shows (the final one, + /// held), and both states land at its area's size. + @Test(arguments: [false, true]) + func aParkedTerminalShownDuringAKeyHoldFollowsFromItsFirstFrame(switchOn: Bool) async throws { + let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let pane = try #require(rig.root.context.hosts["p2"]?.content as? PaneContentView) + let shown = try #require(rig.provider.sessions["p2"]?.session) + let io = GridRecordingTerminalIO() + let parked = TerminalSession(io: io) + defer { + parked.close() + io.finish() + } + // The second tab shows once, then the first one again: the second stays parked (hidden) in the pane. + _ = pane.show(parked.view) + rig.root.layoutSubtreeIfNeeded() + _ = pane.show(shown.view) + rig.root.layoutSubtreeIfNeeded() + try #require(parked.view.isHidden && parked.view.superview != nil, "the second tab was not parked in the pane") + try #require(PaneGroundCoversPaneTests.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") + #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + rig.root.layoutSubtreeIfNeeded() + for _ in 1...6 { _ = rig.frame() } + // The tab switch back to the parked terminal, mid-spring. + _ = pane.show(parked.view) + rig.root.layoutSubtreeIfNeeded() + var off: [String] = [], sizes: [CGSize] = [] + func check(_ label: String) { + let host = parked.view, size = parked.surfaceView.frame.size + let area = host.surfaceSize(forHostSize: host.bounds.size) + if switchOn { + if size != area { off.append("\(label): its surface \(size) in a \(area) area") } + } else if size != sizes.last { + sizes.append(size) + } + } + check("the switch's frame") + let frames = try Self.run(rig) { frame in check("frame \(frame)") } + #expect(frames > 3, "the spring landed before the switch (\(frames) frames left)") + #expect(off.isEmpty, "the shown terminal did not follow its pane: \(off.prefix(3))") + #expect(sizes.count <= 2, "with the switch off the shown terminal took \(sizes.count - 1) new sizes after the frame it showed: \(sizes)") + let host = parked.view + #expect(parked.surfaceView.frame.size == host.surfaceSize(forHostSize: host.bounds.size), "the shown terminal did not land at its area's size") + } + + /// Review of #35470 (F2): a pane that a key press grows down keeps a prompt on its last row in view, as S3 did (and as + /// a split or a close does, TerminalPaneMotionFinalGridTests): the PTY gets the final, taller grid at the press, and a + /// surface at the pane's size in each frame showed that grid's top rows, cut off at the bottom, so the prompt was out + /// of view for the whole spring. The surface keeps its final size on the moving bottom edge instead: the prompt rides + /// the bottom of the pane, and the rows above it come into view as the pane grows. + /// Both states of `motion.frameOwner.terminals` (review of #35470, T5): with it off, S3's hold keeps the same rule. + @Test(arguments: [false, true]) + func aPromptOnTheLastRowRidesTheBottomOfAPaneAKeyGrowsDown(switchOn: Bool) async throws { + let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.stack), focused: "p1") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let terminal = try #require(rig.provider.sessions["p1"]) + // A device attributes query after the prompt: its reply says the lines before it were parsed. + terminal.io.send(.output(Data((String(repeating: "line\r\n", count: 200) + "$ \u{1b}[c").utf8))) + await terminal.io.nextWrite() + PaneGroundCoversPaneTests.ownFrames(switchOn) + #expect(PaneResize.press("p1", .down, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + rig.root.layoutSubtreeIfNeeded() + var taller = 0, cut: [String] = [] + let frames = try Self.run(rig) { frame in + let clip: NSView = terminal.session.view + let surface = terminal.session.surfaceView.convert(terminal.session.surfaceView.bounds, to: clip) + let visible = clip.bounds + guard surface.height > visible.height + 1 else { return } + taller += 1 + let off = clip.isFlipped ? surface.maxY - visible.maxY : visible.minY - surface.minY + if abs(off) >= 1 { cut.append("frame \(frame): \(off) pt of the terminal's bottom is cut off") } + } + #expect(frames > 3, "the press did not spring (\(frames) frames)") + #expect(taller > 3, "the terminal kept its final size on \(taller) of \(frames) frames: its prompt went out of view") + #expect(cut.isEmpty, "\(cut.prefix(3))") + } + + /// Review of #35470 at 447f784c3cf (G1 a): Ctrl-Shift-J grows p1 down with its prompt in the lower half, so its + /// surface keeps the final size on the bottom edge (F2); Ctrl-Shift-K before the landing reverses it. From the reverse + /// press's first frame each surface has its pane's size again: the newest press's anchor (`.top`) wins. The bottom + /// hold stayed until the landing, so the shrinking pane showed the text with its top step cut off over an empty band. + /// With the switch off (the default; T5), the reverse press ends the bottom hold too: from its first frame each surface + /// keeps one size, the size the reverse press rests at (for p1 its start size, shorter than the bottom hold's). + @Test(arguments: [false, true]) + func aReversePressAfterALowPromptGrowsDownFollowsItsPane(switchOn: Bool) async throws { + let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.stack), focused: "p1") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let terminal = try #require(rig.provider.sessions["p1"]) + // A device attributes query after the prompt: its reply says the lines before it were parsed. + terminal.io.send(.output(Data((String(repeating: "line\r\n", count: 200) + "$ \u{1b}[c").utf8))) + await terminal.io.nextWrite() + PaneGroundCoversPaneTests.ownFrames(switchOn) + let startSize = terminal.session.surfaceView.frame.size + #expect(PaneResize.press("p1", .down, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + var held = 0 + for _ in 1...4 where rig.frame() { + let surface = terminal.session.surfaceView.frame.size, host = terminal.session.view + if surface.height > host.surfaceSize(forHostSize: host.bounds.size).height + 1 { held += 1 } + } + try #require(held > 0, "the grow-down press kept no bottom hold: the case under test did not happen") + #expect(PaneResize.press("p1", .up, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + var off: [String] = [], firstFrame: [PaneID: CGSize] = [:] + func check(_ label: String, frame: Int?) throws { + if switchOn { + off += try Self.offTheirPanes(["p1", "p2"], in: rig, label) + return + } + for pane: PaneID in ["p1", "p2"] { + let surface = try Self.surface(pane, in: rig) + if frame == 1 { firstFrame[pane] = surface.frame.size } + if let first = firstFrame[pane], surface.frame.size != first { + off.append("\(label): \(pane)'s surface took \(surface.frame.size) after \(first) at the reverse press's first frame") + } + } + } + let frames = try Self.run(rig) { frame in try check("reverse frame \(frame)", frame: frame) } + try check("after the landing", frame: nil) + #expect(frames > 3, "the reverse press did not spring (\(frames) frames)") + #expect(off.isEmpty, "\(off.count) frames showed a surface off its pane's size after the reverse press: \(off.prefix(3))") + #expect(terminal.session.surfaceView.frame.size == startSize, "p1's surface rests at \(terminal.session.surfaceView.frame.size), not its start size \(startSize)") + if !switchOn { + #expect(firstFrame["p1"] == startSize, "from the reverse press p1's surface kept \(String(describing: firstFrame["p1"])), not its start size \(startSize): the bottom hold stayed") + } + } + + /// Review of #35470 at 447f784c3cf (G1 b): a parked terminal tab shown again during a key hold is unhidden before its + /// area hands it the follow (in the area's next layout pass), so its surface joined the spring with the OFF path's hold + /// at the press's final size; a second press before the landing left that hold, and the landing's release sized the + /// surface from it: grid1, grid2, grid1, grid2 for two presses. Each press sends one grid, and none goes after the + /// landing. (A tab added new mid-hold hears the follow in `didAddSubview`, before its surface joins: no hold.) + /// Both states of `motion.frameOwner.terminals` (review of #35470, T5): one grid per press with it off too. + @Test(arguments: [false, true]) + func aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress(switchOn: Bool) async throws { + let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let pane = try #require(rig.root.context.hosts["p2"]?.content as? PaneContentView) + let shown = try #require(rig.provider.sessions["p2"]?.session) + let io = GridRecordingTerminalIO() + let parked = TerminalSession(io: io) + defer { + parked.close() + io.finish() + } + func marker() async -> [TerminalGridSize] { + parked.surfaceView.bridge.takeUnretainedValue().input.focusGained() + return await io.gridsUntilMarker() + } + // The second tab shows once, then the first one again: the second stays parked (hidden) in the pane. + _ = pane.show(parked.view) + rig.root.layoutSubtreeIfNeeded() + _ = pane.show(shown.view) + rig.root.layoutSubtreeIfNeeded() + try #require(parked.view.isHidden && parked.view.superview != nil, "the second tab was not parked in the pane") + _ = await marker() + PaneGroundCoversPaneTests.ownFrames(switchOn) + #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + rig.root.layoutSubtreeIfNeeded() + for _ in 1...4 { _ = rig.frame() } + // The tab switch back to the parked terminal, mid-spring. + _ = pane.show(parked.view) + rig.root.layoutSubtreeIfNeeded() + for _ in 1...2 { _ = rig.frame() } + let atJoin = await marker() + PaneGroundCoversPaneTests.ownFrames(switchOn) + // A second press before the landing. + #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + _ = rig.frame() + let atSecond = await marker() + PaneGroundCoversPaneTests.ownFrames(switchOn) + let frames = try Self.run(rig) + let afterLanding = await marker() + let final = try #require(parked.surfaceView.fittedGrid) + #expect(frames > 3, "the second press did not spring (\(frames) frames)") + #expect(atJoin.count == 1, "the join asked the PTY for \(atJoin), not the first press's final grid once") + #expect(atSecond == [final], "the second press asked the PTY for \(atSecond), not its final grid \(final) once") + #expect(afterLanding.isEmpty, "the landing asked the PTY for \(afterLanding) after the presses' grids") + } + + // MARK: The default path (switch off): one surface size per press (T5) + + /// The default (switch off; review of #35470 at c4d6c936c92, T5: the base checked this path, and the m6 tests now + /// turn the switch on). One press: each terminal asks its PTY for one grid, the final one, by the press's first + /// frame; its surface takes a new size at most once in the whole press (none per frame), and none at the landing. + @Test(arguments: [LayoutDirection.left, .right, .up, .down]) + func aKeyPressSizesEachSurfaceOnceWithTheSwitchOff(direction: LayoutDirection) async throws { + let vertical = direction == .up || direction == .down + let rig = Rig(PaneGroundCoversPaneTests.column(vertical ? PaneGroundCoversPaneTests.stack : PaneGroundCoversPaneTests.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } let panes: [PaneID] = ["p1", "p2"] for pane in panes { _ = try await Self.grids(pane, rig) } var sizes: [PaneID: [CGSize]] = [:] @@ -120,6 +470,7 @@ struct PaneContentPolicyTests { } } let reportsBefore = TerminalSurfaceView.gridReports + PaneGroundCoversPaneTests.ownFrames(false) #expect(PaneResize.press("p2", direction, model: rig.model, view: rig.root)) rig.root.syncWithModel() rig.root.layoutSubtreeIfNeeded() @@ -136,12 +487,14 @@ struct PaneContentPolicyTests { } } - /// A held key (three Ctrl-Shift-H presses 4 frames apart retarget one spring): each terminal asks its PTY for the - /// final grid of each press, once, by that press's first frame (at most one grid per press, the last one the final), - /// and its surface takes a new size only in a press's turn or first frame, never in another frame or at the landing. - @Test func aHeldKeySizesEachSurfaceOnlyInAPressTurn() async throws { + /// The default (switch off; T5, base check kept). A held key (three Ctrl-Shift-H presses 4 frames apart retarget + /// one spring): each terminal asks its PTY for the final grid of each press, once, by that press's first frame (at + /// most one grid per press, the last one the final), and its surface takes a new size only in a press's turn or + /// first frame, never in another frame or at the landing. + @Test func aHeldKeySizesEachSurfaceOnlyInAPressTurnWithTheSwitchOff() async throws { let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } let panes: [PaneID] = ["p1", "p2"] for pane in panes { _ = try await Self.grids(pane, rig) } var last: [PaneID: CGSize] = [:] @@ -156,12 +509,14 @@ struct PaneContentPolicyTests { } var frames = 0 for press in 1...3 { + PaneGroundCoversPaneTests.ownFrames(false) #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root), "press \(press) moved nothing") rig.root.syncWithModel() // The press's first frame is its own (S1B): its rest size and its grid go there. if rig.frame() { frames += 1 } try note("press \(press)", frame: false) for pane in panes { grids[pane, default: []] += try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames(false) for _ in 1...3 where rig.frame() { frames += 1 try note("frame \(frames)", frame: true) @@ -181,12 +536,14 @@ struct PaneContentPolicyTests { } } - /// A press and its reverse in one key hold (Ctrl-Shift-H, then L 4 frames later, before the first lands): the panes - /// rest where they began. Each terminal hears each press's final grid by that press's first frame (the reverse one too, though - /// it rests at the size the hold began at), and its surface takes no new size at the landing. - @Test func aPressAndItsReverseInOneHoldSendEachGridInItsTurn() async throws { + /// The default (switch off; T5, base check kept). A press and its reverse in one key hold (Ctrl-Shift-H, then L 4 + /// frames later, before the first lands): the panes rest where they began. Each terminal hears each press's final + /// grid by that press's first frame (the reverse one too, though it rests at the size the hold began at), and its + /// surface takes no new size at the landing. + @Test func aPressAndItsReverseRestsAtTheStartSizeWithTheSwitchOff() async throws { let rig = Rig(PaneGroundCoversPaneTests.column(PaneGroundCoversPaneTests.pair), focused: "p2") defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } let panes: [PaneID] = ["p1", "p2"] for pane in panes { _ = try await Self.grids(pane, rig) } var start: [PaneID: (size: CGSize, grid: TerminalGridSize?)] = [:] @@ -194,12 +551,15 @@ struct PaneContentPolicyTests { let surface = try Self.surface(pane, in: rig) start[pane] = (surface.frame.size, surface.fittedGrid) } + PaneGroundCoversPaneTests.ownFrames(false) #expect(PaneResize.press("p2", .left, model: rig.model, view: rig.root)) rig.root.syncWithModel() #expect(rig.frame(), "the first press did not spring") var atPress: [PaneID: [TerminalGridSize]] = [:] for pane in panes { atPress[pane] = try await Self.grids(pane, rig) } + PaneGroundCoversPaneTests.ownFrames(false) for frame in 2...4 { #expect(rig.frame(), "the first press landed after \(frame) frames") } + PaneGroundCoversPaneTests.ownFrames(false) #expect(PaneResize.press("p2", .right, model: rig.model, view: rig.root)) rig.root.syncWithModel() #expect(rig.frame(), "the reverse press did not spring") @@ -208,6 +568,7 @@ struct PaneContentPolicyTests { atReverse[pane] = try await Self.grids(pane, rig) sizeAtReverse[pane] = try Self.surface(pane, in: rig).frame.size } + PaneGroundCoversPaneTests.ownFrames(false) let frames = try Self.run(rig) #expect(frames > 3, "the reverse press did not spring (\(frames) frames)") for pane in panes { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneGroundCoversPaneTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneGroundCoversPaneTests.swift index 7bf774af75b2..c55fbc0aa266 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneGroundCoversPaneTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneGroundCoversPaneTests.swift @@ -151,16 +151,36 @@ struct PaneGroundCoversPaneTests { + "\(type(of: culprit)) at x \(Int(r.minX))..\(Int(r.maxX)) y \(Int(r.minY))..\(Int(r.maxY))" } + /// Turns `motion.frameOwner.terminals` on (or, with `on` false, off) for the frames that follow (off by default + /// until its held-key cost is fixed): the key-resize contract tests (cmux-ad m6) run with it on, and the guards of + /// the default path (one surface size per press) with it off. The store is process-wide and another test sets or + /// resets it, so an async test calls this again after each `await`, before its next press or frame (main-actor code + /// between two awaits never interleaves with another test). Its value: whether the switch has the value asked for. + @discardableResult + static func ownFrames(_ on: Bool = true) -> Bool { + TunableStore.shared.activate(file: nil) + TunableStore.shared.register(TerminalDragTunables.all) + return TunableStore.shared.set(TerminalDragTunables.framesOwnedByCommit.key, .bool(on)) == .bool(on) + } + + /// The switch back to its default (a test that called ``ownFrames()`` defers this). + static func releaseFrames() { + TunableStore.shared.set(TerminalDragTunables.framesOwnedByCommit.key, nil) + } + nonisolated static let pair: SplitNode = .split("x", axis: .horizontal, ratio: 0.5, a: .leaf("p1"), b: .leaf("p2")) nonisolated static let stack: SplitNode = .split("x", axis: .vertical, ratio: 0.5, a: .leaf("p1"), b: .leaf("p2")) static func column(_ root: SplitNode) -> ScreenLayout { .columns([LayoutColumn(id: "c0", width: 1, root: root)]) } - /// (a) One Ctrl-Shift-H/L/K/J press on p2: at every tick, each pane's terminal ground covers its content area. - @Test(arguments: [LayoutDirection.left, .right, .up, .down]) - func aKeyPressShowsEachPanesOwnGroundInEveryFrame(direction: LayoutDirection) throws { + /// (a) One Ctrl-Shift-H/L/K/J press on p2: at every tick, each pane's terminal ground covers its content area. In both + /// states of `motion.frameOwner.terminals` (review of #35470, T5). + @Test(arguments: [LayoutDirection.left, .right, .up, .down], [false, true]) + func aKeyPressShowsEachPanesOwnGroundInEveryFrame(direction: LayoutDirection, switchOn: Bool) throws { let rig = Rig(Self.column(direction == .left || direction == .right ? Self.pair : Self.stack), focused: "p2") defer { rig.close() } + defer { Self.releaseFrames() } + try #require(Self.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") #expect(rig.misses().isEmpty, "SETUP: at rest a pane's ground does not cover it: \(rig.misses())") #expect(PaneResize.press("p2", direction, model: rig.model, view: rig.root)) rig.root.syncWithModel() @@ -178,10 +198,14 @@ struct PaneGroundCoversPaneTests { #expect(found.isEmpty, "\(found.count) pane frames show no ground of their own (the shell shows there); first: \(found.prefix(3)), last: \(found.suffix(1))") } - /// (b) A held key: three Ctrl-Shift-L presses 4 frames apart retarget one spring. - @Test func aHeldKeyShowsEachPanesOwnGroundInEveryFrame() throws { + /// (b) A held key: three Ctrl-Shift-L presses 4 frames apart retarget one spring. In both states of + /// `motion.frameOwner.terminals` (review of #35470, T5). + @Test(arguments: [false, true]) + func aHeldKeyShowsEachPanesOwnGroundInEveryFrame(switchOn: Bool) throws { let rig = Rig(Self.column(Self.pair), focused: "p1") defer { rig.close() } + defer { Self.releaseFrames() } + try #require(Self.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") #expect(rig.misses().isEmpty, "SETUP: at rest a pane's ground does not cover it: \(rig.misses())") var found: [String] = [] var frames = 0 diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripFollowsPaneTickTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripFollowsPaneTickTests.swift index 5a688da99f1c..be11bd7ff361 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripFollowsPaneTickTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripFollowsPaneTickTests.swift @@ -5,6 +5,7 @@ import CmuxNextDesign import CmuxNextDesignTokens @testable import CmuxNextLayout @testable import CmuxNextTabs +@testable import CmuxNextTerminal import CmuxNextTestSupport import Testing @@ -30,7 +31,10 @@ import Testing @Suite(.serialized, .closesItsWindows) struct TabStripFollowsPaneTickTests { /// A pane's content view: every size it takes. It answers a pane spring's start with `anchor`, the edge its held - /// content rides, as a terminal does (`.bottom` while its prompt is on its last row, the common shell case). + /// content rides: content that still keeps one size through a key resize (a page until S5, an agent chat). With + /// `motion.frameOwner.terminals` on, a terminal does not hold: it follows its pane in every frame (cmux-ad m6, + /// ``aKeyResizeKeepsEachStripAndTerminalOnItsPaneInEveryFrame(direction:)``); with it off (the default until the + /// flip), it keeps one size per press. final class Content: NSView, PaneMotionTargeting { var sizes: [CGSize] = [] var anchor = PaneMotionAnchor.top @@ -129,14 +133,18 @@ struct TabStripFollowsPaneTickTests { /// content view fills) and sits at its top; its tabs were laid out at the width it shows; the content below it /// never shows under it. static func stray(_ pane: PaneID, in rig: Rig) -> String? { - guard let host = rig.root.context.hosts[pane], host.window != nil, !host.isHiddenOrHasHiddenAncestor, - let view = rig.provider.views[pane] else { return nil } + guard let host = rig.root.context.hosts[pane], let view = rig.provider.views[pane] else { return nil } + return stray(pane, host: host, view: view, content: rig.provider.contents[pane], scale: rig.window.backingScaleFactor) + } + + /// ``stray(_:in:)`` for `view`, the pane content `host` shows, with `content` below its strip, in a window of `scale`. + static func stray(_ pane: PaneID, host: PaneHostView, view: PaneContentView, content: NSView?, scale: CGFloat) -> String? { + guard host.window != nil, !host.isHiddenOrHasHiddenAncestor else { return nil } let area = host.convert(host.contentRect, to: nil) // A split's new pane before its first frame at a real size. guard area.width >= 1, area.height >= 1 else { return nil } let strip = view.stripView let shown = strip.convert(strip.bounds, to: nil) - let scale = rig.window.backingScaleFactor func px(_ value: CGFloat) -> Int { Int((value * scale).rounded()) } var off: [String] = [] if px(shown.minX) != px(area.minX) { off.append("leading edge \(shown.minX) vs pane \(area.minX)") } @@ -148,7 +156,7 @@ struct TabStripFollowsPaneTickTests { if tabs.width > 0, px(tabs.minX) < px(shown.minX) || px(tabs.maxX) > px(shown.maxX) { off.append("tabs laid out at \(tabs.minX)...\(tabs.maxX), past the strip's \(shown.minX)...\(shown.maxX)") } - if let content = rig.provider.contents[pane] { + if let content { let visible = content.convert(content.visibleRect, to: nil) if !visible.isEmpty, px(visible.maxY) > px(shown.minY) { off.append("content shows under the strip (up to \(visible.maxY), strip from \(shown.minY))") } } @@ -242,6 +250,87 @@ struct TabStripFollowsPaneTickTests { } } + // MARK: (b) with terminals (cmux-ad m6): the strip and the terminal under it follow the pane in every frame + + /// Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but browsers/terminals do not anymore": + /// the strip followed its pane (above), the terminal under it kept one size per press (RESIZE KEYS K3's hold). With a + /// terminal in each pane (the app's pane content, a real Ghostty surface) and one press of each arrow on p2: in every + /// frame each strip is on its pane; after each frame's layout pass, each terminal host takes its pane's content area + /// and its surface the size the host shows it at (the text follows the pane); each terminal asks its PTY for one + /// grid, the final one (the program's size per press). These windows draw nothing, so each size shows at once; with + /// the window on screen, a size not drawn yet keeps the last frame, top-left (TerminalKeyResizeFrameTests). + /// Both states of `motion.frameOwner.terminals` (review of #35470, T5): the surface follows its pane with it on, and + /// with it off (the default) it takes at most one new size in the press; the strips, the hosts and the grids are the + /// same in both. + @Test(arguments: [LayoutDirection.left, .right, .up, .down], [false, true]) + func aKeyResizeKeepsEachStripAndTerminalOnItsPaneInEveryFrame(direction: LayoutDirection, switchOn: Bool) async throws { + typealias Ground = PaneGroundCoversPaneTests + let vertical = direction == .up || direction == .down + let rig = Ground.Rig(Ground.column(vertical ? Ground.stack : Ground.pair), focused: "p2") + defer { rig.close() } + defer { PaneGroundCoversPaneTests.releaseFrames() } + let panes: [PaneID] = ["p1", "p2"] + for pane in panes { _ = try await PaneContentPolicyTests.grids(pane, rig) } + var sizes: [PaneID: [CGSize]] = [:] + /// `atRest`: the surface has its host's size (at rest and after the landing in both states, and in each frame + /// with the switch on); else (the switch off, in the press) its new sizes are counted. + func off(_ label: String, terminals: Bool, atRest: Bool = false) throws -> [String] { + var found: [String] = [] + for pane in panes { + let host = try #require(rig.root.context.hosts[pane]) + let view = try #require(host.content as? PaneContentView, "\(pane) shows no pane content") + let session = try #require(rig.provider.sessions[pane]?.session) + let terminal = session.view + if let stray = Self.stray(pane, host: host, view: view, content: terminal, scale: rig.window.backingScaleFactor) { + found.append("\(label): \(stray)") + } + guard terminals else { continue } + let area = try #require(terminal.superview, "\(pane)'s terminal host is not in its pane") + if terminal.frame.size != area.bounds.size { + found.append("\(label): \(pane)'s terminal host \(terminal.frame.size) in \(area.bounds.size)") + } + let shown = terminal.surfaceSize(forHostSize: terminal.bounds.size), size = session.surfaceView.frame.size + if switchOn || atRest { + if size != shown { found.append("\(label): \(pane)'s surface \(size) in a \(shown) area") } + } else if size != sizes[pane]?.last { + sizes[pane, default: []].append(size) + } + } + return found + } + let atRest = try off("at rest", terminals: true, atRest: true) + try #require(atRest.isEmpty, "\(atRest)") + for pane in panes { sizes[pane] = try [PaneContentPolicyTests.surface(pane, in: rig).frame.size] } + let before = try panes.map { try #require(rig.root.context.hosts[$0]).frame } + try #require(PaneGroundCoversPaneTests.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") + #expect(PaneResize.press("p2", direction, model: rig.model, view: rig.root)) + rig.root.syncWithModel() + var found: [String] = [], frames = 0, moving = true + while moving, frames < 240 { + moving = rig.root.advanceFrame(1.0 / 120) + frames += 1 + found += try off("frame \(frames) tick", terminals: false) + rig.root.layoutSubtreeIfNeeded() + found += try off("frame \(frames) commit", terminals: true) + } + rig.commit() + found += try off("after the landing", terminals: true, atRest: true) + try #require(frames > 3, "the press did not spring (\(frames) frames)") + #expect(try panes.map { try #require(rig.root.context.hosts[$0]).frame } != before, "the press moved nothing") + #expect(found.isEmpty, "\(found.count) frames showed a strip or a terminal off its pane: \(found.prefix(6))") + if !switchOn { + for pane in panes { + let taken = (sizes[pane] ?? []).dropFirst() + #expect(taken.count <= 1, "\(pane)'s surface took \(taken.count) sizes in one press with the switch off: \(Array(taken))") + } + } + for pane in panes { + let grids = try await PaneContentPolicyTests.grids(pane, rig) + let final = try #require(try PaneContentPolicyTests.surface(pane, in: rig).fittedGrid) + #expect(grids == [final], "\(pane) asked its PTY for \(grids), not its final grid \(final) once") + } + } + // MARK: (a) split, close, ratio, divider release and column springs: the guard (trace fact 2: the strip follows // these springs already) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalKeyResizeFrameTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalKeyResizeFrameTests.swift new file mode 100644 index 000000000000..7ba867019d72 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalKeyResizeFrameTests.swift @@ -0,0 +1,303 @@ +import AppKit +@testable import CmuxNextApp +import CmuxNextBridge +import CmuxNextCompat +@testable import CmuxNextDesign +@testable import CmuxNextLayout +import CmuxNextSignposts +@testable import CmuxNextTabs +@testable import CmuxNextTerminal +import CmuxNextTestSupport +import Foundation +import Testing + +/// cmux-ad m6 (Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but browsers/terminals do not +/// anymore"), part (a) for terminals, with `motion.frameOwner.terminals` on. The contract of a key resize: in EVERY frame +/// each terminal shows pixels at its pane's size in that frame, or the previous frame's (clipped and anchored top-left, +/// never stretched, never a background gap); the PROGRAM's grid (the PTY's size) changes at most once per press. Pixels +/// per frame; program size per press. +/// +/// Before: the key resize's hold (RESIZE KEYS K3) kept each surface at one size per press (S3's host follows its pane, +/// but its surface keeps the press's final size, or its start size, on the hold's still edges), so the terminal froze, +/// then jumped. Film on dogfood 437f5a22 (px-keys-single-3k-s1r): the terminal right of the divider had its left ring on +/// its pane's left edge in 14 of 50 motion frames, off by -8 to +21 px in 20, cut off in 16. +/// +/// The app's own pane content (`PaneContentView`: its tab strip over its content area, which a key hold follows, FRAME +/// OWNER S1/S3) with a terminal in each of p1 | p2, in a window on screen; one Ctrl-Shift-L press on p2 +/// (`PaneResize.press`): the divider moves, so p2's LEFT edge moves. The test steps the spring itself, one frame per step +/// (a display-link spring may not animate on a CI host). After each frame's commit it reads, per terminal, in the +/// committed layer tree: +/// - the surface's top-left within 1 device px of its host area's top-left; +/// - a size its pane had in this press, this frame or earlier (the newest frame drawn, or an older one kept until then: +/// never a size the pane has not had yet), and never stretched (the layer's pixels are the frame's pixels); +/// - after the landing, each terminal at its final size; each PTY at most one grid for the press. +/// It prints each frame's main time (step and layout: p50, p95, max). +/// +/// It needs a window on a display (a display worker: `cmux-ci run --display --label gui-session-v1`). +@MainActor @Suite(.serialized, .closesItsWindows, .timeLimit(.minutes(1)), + .enabled(if: ["CMUX_TEST_REQUIRE_GUI", "CMUX_CI_DISPLAY_ROUTED"].contains { ProcessInfo.processInfo.environment[$0] == "1" })) +struct TerminalKeyResizeFrameTests { + @Test func aKeyResizeShowsEachTerminalAtItsPanesSizeInEveryFrameAndResizesItsProgramOnce() async throws { + let screen = try await PaneScreen() + defer { screen.close() } + // Warm-up press: one-time setup is not the press under test. + _ = PaneResize.press("p2", .left, model: screen.model, view: screen.root) + _ = await Self.step(screen, record: nil) + await Self.settle(screen) + let resizesBefore = screen.ios.map { $0.resizes.count } + + let before = screen.sessions.map(Sample.init) + _ = PaneResize.press("p2", .right, model: screen.model, view: screen.root) + var frames: [[Sample]] = [before] + let (steps, intervals) = await Self.step(screen) { frames.append($0) } + try #require(steps >= 4, "the press's spring ran \(steps) frames: no spring ran") + try #require(frames.contains { $0.map(\.area) != before.map(\.area) }, "the press changed no pane's area") + + var misplaced: [String] = [] + var unsized: [String] = [] + var stretched: [String] = [] + // Review of #35470 (T1): the sizes allowed include the start size, so count the frames in which each terminal + // took a new size: a terminal that keeps one size through the spring (the defect) fails here. + var changes = Array(repeating: 0, count: before.count) + for index in 1.. Bool { abs(a - b) * scale <= 1 } + if !near(sample.topLeft.x, sample.areaTopLeft.x) || !near(sample.topLeft.y, sample.areaTopLeft.y) { + misplaced.append("frame \(index) p\(terminal + 1): top-left \(sample.topLeft), its area's \(sample.areaTopLeft)") + } + let had = frames[0...index].map { $0[terminal].area } + if !had.contains(where: { near(sample.layer.width, $0.width) && near(sample.layer.height, $0.height) }) { + unsized.append("frame \(index) p\(terminal + 1): layer \(Self.text(sample.layer)), area \(Self.text(sample.area))") + } + if let shown = sample.shownPixels, abs(sample.layer.width * scale - shown.width) > 1 || abs(sample.layer.height * scale - shown.height) > 1 { + stretched.append("frame \(index) p\(terminal + 1): layer \(Self.text(sample.layer)) at \(scale)x, frame \(Self.text(shown)) px") + } + } + } + // The press's grid reaches the IO through the ordered input stream: read it after the panes rest. + await Self.settle(screen) + let rested = screen.sessions.map(Sample.init) + let landed = rested.enumerated().filter { _, sample in + abs(sample.layer.width - sample.area.width) * sample.scale > 1 || abs(sample.layer.height - sample.area.height) * sample.scale > 1 + }.map { "p\($0.offset + 1): layer \(Self.text($0.element.layer)), area \(Self.text($0.element.area))" } + let resizes = zip(screen.ios.map { $0.resizes.count }, resizesBefore).map { $0 - $1 } + let sorted = intervals.sorted() + let p = { (q: Double) in sorted.isEmpty ? 0 : sorted[min(sorted.count - 1, Int(Double(sorted.count) * q))] } + print("S4 key resize: \(steps) spring frames; main ms per frame (step + layout) p50 \(p(0.5)) p95 \(p(0.95)) max \(sorted.last ?? 0); " + + "PTY grids per terminal \(resizes); misplaced \(misplaced.count), unsized \(unsized.count), stretched \(stretched.count)") + #expect(misplaced.isEmpty, "\(misplaced.count) frames showed a terminal off its pane's top-left: \(misplaced.prefix(8))") + #expect(unsized.isEmpty, "\(unsized.count) frames showed a terminal at a size its pane had not had yet: \(unsized.prefix(8))") + #expect(stretched.isEmpty, "\(stretched.count) frames showed a terminal's frame stretched: \(stretched.prefix(8))") + #expect(landed.isEmpty, "after the landing a terminal does not show its pane's size: \(landed)") + #expect(resizes.allSatisfy { $0 <= 1 }, "a press resized a terminal's program more than once: \(resizes)") + #expect(changes.allSatisfy { $0 > 3 }, "a terminal took a new size in only \(changes) of \(steps) spring frames: it did not follow its pane") + } + + /// Review of #35470 (F1): a terminal whose frames wait at the landing (a busy program, or one that holds its frames + /// with synchronized output, mode 2026, as agent TUIs do) still gets one grid for the press. The size in flight at the + /// landing is an earlier spring size; the landing's release sized the surface from it and reported its grid (the + /// spring was over), and the newest size reported the final grid again when it went to Ghostty: final, stale, final. + /// The grids are locked as the daemon locks them (an announced grid: a pixel resize keeps mode 2026 set). + @Test func aTerminalWhoseFrameWaitsAtTheLandingGetsOneGridForThePress() async throws { + let screen = try await PaneScreen() + defer { screen.close() } + _ = PaneResize.press("p2", .left, model: screen.model, view: screen.root) + _ = await Self.step(screen, record: nil) + await Self.settle(screen) + for (session, io) in zip(screen.sessions, screen.ios) { + let grid = try #require(session.surfaceView.fittedGrid, "a terminal fits no grid at rest") + io.send(.resize(cols: grid.columns, rows: grid.rows)) + } + // p1 holds its frames from here. Ghostty ends a hold 1 s after the program's last `ESC[?2026h`, so the spring + // below sends it again in each frame. + let hold = Data("\u{1b}[?2026h".utf8) + screen.ios[0].send(.output(hold)) + await Self.settle(screen) + let presenter = TerminalFramePresenter.forWindow(screen.window) + let resizesBefore = screen.ios.map { $0.resizes.count } + + _ = PaneResize.press("p2", .right, model: screen.model, view: screen.root) + var steps = 0, moving = true, waitedInSpring = 0, waitingAtLanding = false + while moving, steps < 240 { + screen.ios[0].send(.output(hold)) + moving = screen.root.advanceFrame(1.0 / 120) + screen.root.layoutSubtreeIfNeeded() // sync-layout: the stepped frame's own layout pass, as the frame tick runs it + if presenter.isWaiting(screen.sessions[0].surfaceView) { waitedInSpring += 1 } + if !moving { waitingAtLanding = presenter.isWaiting(screen.sessions[0].surfaceView) } + await Self.commit() + steps += 1 + } + try #require(steps >= 4, "the press's spring ran \(steps) frames: no spring ran") + try #require(waitedInSpring > 1, "p1's sizes waited in \(waitedInSpring) of \(steps) frames: p1 did not hold its frames") + try #require(waitingAtLanding, "p1 had no size in flight at the landing: the case under test did not happen") + screen.ios[0].send(.output(Data("\u{1b}[?2026l".utf8))) + await Self.settle(screen) + let resizes = zip(screen.ios.map { $0.resizes.count }, resizesBefore).map { $0 - $1 } + let grids = screen.ios[0].resizes.suffix(resizes[0]).map { "\($0.columns)x\($0.rows)" } + #expect(resizes.allSatisfy { $0 <= 1 }, "a press resized a terminal's program more than once: \(resizes) (p1: \(grids))") + } + + /// One terminal in one committed frame: its surface layer's size and top-left, its host's area size and top-left + /// (points; top-lefts in the window's root layer, the committed model), and the size of the frame it shows (px). + struct Sample { + var layer: CGSize + var area: CGSize + var topLeft: CGPoint + var areaTopLeft: CGPoint + var shownPixels: CGSize? + var scale: CGFloat + + @MainActor init(_ session: TerminalSession) { + let surface = session.surfaceView + let host = session.view + layer = surface.layer?.bounds.size ?? .zero + area = host.surfaceSize(forHostSize: host.bounds.size) + scale = surface.layer?.contentsScale ?? 2 + shownPixels = surface.presentedFrame.map { CGSize(width: $0.width, height: $0.height) } + // The host is not flipped: its area's top is its bounds' maxY (an accessory strip sits at its bottom). + func top(_ layer: CALayer?) -> CGPoint { + guard let layer, let root = host.window?.contentView?.layer else { return .zero } + let shown = layer.convert(layer.bounds, to: root) + return CGPoint(x: shown.minX, y: root.isGeometryFlipped ? shown.minY : shown.maxY) + } + topLeft = top(surface.layer) + areaTopLeft = top(host.layer) + } + } + + /// p1 | p2 in a window on screen, each pane the app's `PaneContentView` with a terminal in its content area; the + /// switch on, Reduce Motion off, the spring stepped by the test. + final class PaneScreen { + final class Panes: LayoutPaneContentProvider { + let reveal: LaunchReveal = { + let reveal = LaunchReveal() + reveal.markAllReady() + return reveal + }() + var views: [PaneID: PaneContentView] = [:] + + func makeContentView(for pane: PaneID) -> NSView { + let tab = TabItem(id: TabID("tab-\(pane.rawValue)"), title: "zsh") + let view = PaneContentView(stripModel: TabStripModel(tabs: [tab], selectedID: tab.id), reveal: reveal) + views[pane] = view + return view + } + } + + let provider = Panes() + let model: LayoutModel + let root: LayoutRootView + let window: NSWindow + var sessions: [TerminalSession] = [] + var ios: [ScriptedTerminalIO] = [] + private let drew: Bool + + init() async throws { + _ = NSApplication.shared + TunableStore.shared.activate(file: nil) + TunableStore.shared.register(TerminalDragTunables.all) + drew = WindowDrawPolicy.drawsWhenOccluded + WindowDrawPolicy.drawsWhenOccluded = true + let columns = [LayoutColumn(id: "c0", width: 1, root: .split("x", axis: .horizontal, ratio: 0.5, a: .leaf("p1"), b: .leaf("p2")))] + model = LayoutModel(screens: [LayoutScreen(id: "s", name: "s", layout: .columns(columns))], activeScreenID: "s", focusedPane: "p2") + root = LayoutRootView(model: model, contentProvider: provider) + root.context.reduceMotionOverride = false + window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 1000, height: 600), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = root + window.orderFrontRegardless() + root.layoutSubtreeIfNeeded() // sync-layout: the panes exist before their terminals attach + root.layoutSubtreeIfNeeded() // sync-layout: what joined the window in that pass lays out in this one + root.driver.isManual = true + let on = TunableStore.shared.set(TerminalDragTunables.framesOwnedByCommit.key, .bool(true)) + if on != .bool(true) { close() } + try #require(on == .bool(true), "the switch motion.frameOwner.terminals is not registered") + let line = String(repeating: "y", count: 40) + let text = Array(repeating: line, count: 400).joined(separator: "\r\n") + for pane: PaneID in ["p1", "p2"] { + let io = ScriptedTerminalIO() + let session = TerminalSession(io: io) + ios.append(io) + guard session.surfaceView.surface != nil, let content = provider.views[pane] else { + close() + try #require(Bool(false), "libghostty created no surface, or pane \(pane) has no content") + return + } + _ = content.show(session.view) + content.layoutSubtreeIfNeeded() // sync-layout: the attach's own layout pass + io.send(.output(Data(text.utf8))) + sessions.append(session) + } + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while ContinuousClock.now < deadline, sessions.contains(where: { $0.surfaceView.presentedFrame == nil }) { + await TerminalKeyResizeFrameTests.commit() + } + let drawn = !sessions.contains { $0.surfaceView.presentedFrame == nil } + if !drawn { close() } + try #require(drawn, "a terminal never showed its first frame") + } + + func close() { + root.driver.isManual = false + for io in ios { io.finish() } + window.close() + WindowDrawPolicy.drawsWhenOccluded = drew + TunableStore.shared.set(TerminalDragTunables.framesOwnedByCommit.key, nil) + } + } + + static func text(_ size: CGSize) -> String { "\(size.width)x\(size.height)" } + + static func ms(_ duration: Duration) -> Double { + let ms = Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15 + return (ms * 100).rounded() / 100 + } + + /// Steps the spring to its landing, one frame per step: the step, its layout pass, its commit (`record` gets each + /// committed frame), then 6 commits for the landing's release. Returns the frames stepped and each frame's main + /// time (step and layout, ms). + static func step(_ screen: PaneScreen, record: (([Sample]) -> Void)?) async -> (Int, [Double]) { + var intervals: [Double] = [] + var moving = true + var steps = 0 + while moving, steps < 240 { + let started = ContinuousClock.now + moving = screen.root.advanceFrame(1.0 / 120) + screen.root.layoutSubtreeIfNeeded() // sync-layout: the stepped frame's own layout pass, as the frame tick runs it + intervals.append(ms(started.duration(to: .now))) + await commit() + record?(screen.sessions.map(Sample.init)) + steps += 1 + } + for _ in 0..<6 { + await commit() + record?(screen.sessions.map(Sample.init)) + } + return (steps, intervals) + } + + /// Commits until the panes and their terminals have not changed for 300 ms of wall-clock time (at most 3 s). + static func settle(_ screen: PaneScreen) async { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + var last: [CGSize] = [] + var changed = ContinuousClock.now + while ContinuousClock.now < deadline, changed.duration(to: .now) < .milliseconds(300) { + await commit() + let now = screen.sessions.flatMap { [$0.view.bounds.size, $0.surfaceView.layer?.bounds.size ?? .zero] } + if now != last { (last, changed) = (now, .now) } + } + } + + static func commit() async { + await withCheckedContinuation { (done: CheckedContinuation) in + CommitBoundary().after { _ in done.resume() } + } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalLaunchFrameKeeperTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalLaunchFrameKeeperTests.swift index 6fe370af5804..f9b99b97a33f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalLaunchFrameKeeperTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalLaunchFrameKeeperTests.swift @@ -197,7 +197,12 @@ import Testing /// REOPEN VIDEO on build d77b7cfc (6 of 30 kill cycles): a split resized a terminal after its frame was saved, the /// 5 s interval blocked a new save, and the kill 3-4 s later left a frame of the old size (refused at launch) or /// none. A resize saves again as soon as the terminal is quiet, with no interval. - @Test func aResizedTerminalIsSavedAgainWithoutWaitingForTheInterval() async throws { + @Test(arguments: [false, true]) + func aResizedTerminalIsSavedAgainWithoutWaitingForTheInterval(switchOn: Bool) async throws { + // Review of #35470 (T5): in both states of `motion.frameOwner.terminals` (off, the default: the surface takes + // its new size in the layout; on: in the commit that shows its frame, a refresh or two later). + defer { PaneGroundCoversPaneTests.releaseFrames() } + try #require(PaneGroundCoversPaneTests.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") let shown = try await Shown() defer { shown.close() } let directory = FileManager.default.temporaryDirectory.appending(path: "keeper-resize-\(UUID().uuidString)") @@ -206,6 +211,8 @@ import Testing frames.keep("tab_resized", session: shown.session, work: Self.work) try #require(try await Self.eventually { !Self.files("tab_resized", in: directory).isEmpty }, "the first frame was never kept") let saves = frames.keepSaves + // The store is process-wide: the switch again after the awaits, before the resize. + try #require(PaneGroundCoversPaneTests.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") // The pane halves (Cmd-D beside it): the surface view gets a new size, as the app's layout gives it. No IO // event comes with it (the app sends its grid on the main actor, not through the IO's resize stream). let before = shown.session.frameChanges.generation @@ -213,14 +220,23 @@ import Testing frame.size.width /= 2 shown.window.setFrame(frame, display: true) shown.session.view.layoutSubtreeIfNeeded() - #expect(shown.session.frameChanges.generation > before, "the surface's new size counted as no change") + // With `motion.frameOwner.terminals` on (FRAME OWNER S4), the surface takes its new size in the commit that + // shows its frame drawn at that size, a refresh or two after the layout, and the change counts then; with it off + // (the default), in the layout. The wait covers both. + let counted = try await Self.eventually(2) { shown.session.frameChanges.generation > before } + #expect(counted, "the surface's new size counted as no change") let resaved = try await Self.eventually(3) { frames.keepSaves > saves } #expect(resaved, "a resized terminal was not saved again within 3 s: a kill now leaves a frame the launch refuses") } /// Review of b710d40: a resized terminal that keeps changing (a spinner, a log) must still be saved at its new size /// (a frame of the old size is refused at launch), once the size holds, without waiting for its output to stop. - @Test func aBusyResizedTerminalIsSavedAtItsNewSize() async throws { + @Test(arguments: [false, true]) + func aBusyResizedTerminalIsSavedAtItsNewSize(switchOn: Bool) async throws { + // Review of #35470 (T5): in both states of `motion.frameOwner.terminals` (off, the default: the surface takes + // its new size in the layout; on: in the commit that shows its frame, a refresh or two later). + defer { PaneGroundCoversPaneTests.releaseFrames() } + try #require(PaneGroundCoversPaneTests.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") let shown = try await Shown() defer { shown.close() } let directory = FileManager.default.temporaryDirectory.appending(path: "keeper-busy-resize-\(UUID().uuidString)") @@ -229,10 +245,17 @@ import Testing frames.keep("tab_busy_resized", session: shown.session, work: Self.work) try #require(try await Self.eventually { !Self.files("tab_busy_resized", in: directory).isEmpty }, "the first frame was never kept") let saves = frames.keepSaves + // The store is process-wide: the switch again after the awaits, before the resize. + try #require(PaneGroundCoversPaneTests.ownFrames(switchOn), "the switch motion.frameOwner.terminals did not take \(switchOn)") var frame = shown.window.frame frame.size.width /= 2 shown.window.setFrame(frame, display: true) shown.session.view.layoutSubtreeIfNeeded() + // The surface shows its new size in the layout (the default), or with its frame drawn at it a refresh or two + // later with `motion.frameOwner.terminals` on (FRAME OWNER S4). The wait covers both. + let host = shown.session.view, target = host.surfaceSize(forHostSize: host.bounds.size) + let showsNewSize = try await Self.eventually(2) { shown.session.surfaceView.bounds.size == target } + try #require(showsNewSize, "the surface never showed its new size \(target)") let resized = shown.session.surfaceView.bounds.size // It never goes quiet: output every 30 ms for 4 s. let busyUntil = ContinuousClock.now.advanced(by: .seconds(4)) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalSurfaceCommitSizeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalSurfaceCommitSizeTests.swift index a27e455834f1..0639963967c7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalSurfaceCommitSizeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TerminalSurfaceCommitSizeTests.swift @@ -80,6 +80,61 @@ struct TerminalSurfaceCommitSizeTests { "no frame went to the screen off the main thread after the new scale landed (host_off_main_presents stays \(offMain))") } + /// FRAME OWNER S3 + S4 in a key resize (cmux-ad m6; first found as S4 defect (b), red step d509169a15477622073341e5): + /// the host follows its pane (S3, ``PaneMotionHoldFollowing``) on a hold that keeps the pane's trailing or bottom + /// edge still, and the pane's size changes while the terminal's new size waits for its frame. The kept frame is + /// anchored top-left (the lead's m6 contract: the previous size's frame, clipped and anchored top-left, never + /// stretched): its top-left stays on the area's top-left, and the new size lands with its frame. Before, the kept + /// frame rode another edge than the pane's content did for the refreshes until the new size showed. + @Test func aKeptFrameStaysOnTheEdgesItsHoldNames() async throws { + let fixture = try await Fixture() + defer { fixture.close() } + let host = fixture.session.view + let surface = fixture.session.surfaceView + let presenter = TerminalFramePresenter.forWindow(fixture.window) + let rest = surface.frame + try #require(rest.width > 200 && rest.height > 100, "the terminal's rest frame is \(rest)") + + // A trailing hold: the pane's trailing edge stands still and the pane narrows (its left edge moves). + host.followPaneMotion(PaneMotionHold(trailing: true, bottom: false), heldSize: host.bounds.size) + fixture.layoutNow() + fixture.container.terminalWidth -= 80 + fixture.layoutNow() + try #require(presenter.isWaiting(surface), "the narrower size did not wait for its frame (frame \(surface.frame))") + #expect(surface.frame.size == rest.size, "while its new size waits the terminal is \(surface.frame.size), it keeps \(rest.size)") + #expect(surface.frame.minX == host.bounds.minX && surface.frame.maxY == host.bounds.maxY, + "a trailing hold: the kept frame \(surface.frame) is not anchored on its area's top-left \(host.bounds)") + let narrowShown = await fixture.waitShown(width: rest.width - 80) + #expect(narrowShown, "the narrower size never showed its own frame") + #expect(surface.frame == CGRect(x: rest.minX, y: rest.minY, width: rest.width - 80, height: rest.height), + "the landed frame is \(surface.frame)") + host.followPaneMotion(nil, heldSize: nil) + fixture.container.terminalWidth += 80 + fixture.layoutNow() + let restShown = await fixture.waitShown(width: rest.width) + #expect(restShown, "the rest size never showed again") + + // A bottom hold: the pane's bottom edge stands still and the pane gets shorter (its top edge moves). + host.followPaneMotion(PaneMotionHold(trailing: false, bottom: true), heldSize: host.bounds.size) + fixture.layoutNow() + fixture.container.terminalHeight -= 40 + fixture.layoutNow() + try #require(presenter.isWaiting(surface), "the shorter size did not wait for its frame (frame \(surface.frame))") + #expect(surface.frame.size == rest.size, "while its new size waits the terminal is \(surface.frame.size), it keeps \(rest.size)") + #expect(surface.frame.minX == host.bounds.minX && surface.frame.maxY == host.bounds.maxY, + "a bottom hold: the kept frame \(surface.frame) is not anchored on its area's top-left \(host.bounds)") + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while ContinuousClock.now < deadline, presenter.isWaiting(surface) { await fixture.commits(1) } + #expect(!presenter.isWaiting(surface), "the shorter size still waits for its frame after 2 s") + #expect(surface.frame == CGRect(x: rest.minX, y: rest.minY, width: rest.width, height: rest.height - 40), + "the landed frame is \(surface.frame)") + host.followPaneMotion(nil, heldSize: nil) + fixture.container.terminalHeight += 40 + fixture.layoutNow() + #expect(fixture.mismatches.isEmpty, + "\(fixture.mismatches.count) turns ended with the layer at one size and its frame at another: \(fixture.mismatches.prefix(8))") + } + /// A terminal hidden while its new size waits for its frame (a tab switched away mid-drag) takes the size its /// host gives it then, and keeps it: the pass that sees it hidden never puts back the older size it had asked /// for (review of 114c5ef217d). @@ -311,11 +366,12 @@ struct TerminalSurfaceCommitSizeTests { /// The window's content: its `layout()` gives the terminal the width asked for, as a pane's layout does. final class ResizingContainer: NSView { var terminalWidth: CGFloat = 600 + var terminalHeight: CGFloat = 300 weak var terminal: NSView? override func layout() { super.layout() - terminal?.frame = NSRect(x: 0, y: 0, width: terminalWidth, height: 300) + terminal?.frame = NSRect(x: 0, y: 0, width: terminalWidth, height: terminalHeight) } }