macOS: defer buffer reflow during live-resize - #555
Conversation
setFrameSize chains into terminal.resize → resizeBuffers → buffer.reflow which walks the entire scrollback every tick (Buffer.swift's reflowWider / reflowNarrower). AppKit fires setFrameSize ~60×/sec while the user is dragging a window edge or split divider, so the intermediate reflows are wasted work (the target size is still moving) and block the main thread enough to make resizing visibly stuttery on terminals with non-trivial scrollback. This mirrors the existing metalLiveResizeThrottleEnabled pattern in the same setFrameSize body: when inLiveResize, skip processSizeChange and flag the deferral; viewDidEndLiveResize runs a single processSizeChange at the final size. The cell grid stays at pre-drag dimensions during the gesture — the surrounding frame still updates — which is the trade-off Terminal.app and iTerm2 already make. Hosts that prefer the per-tick behavior can restore it with SWIFTTERM_LIVE_RESIZE_REFLOW=1.
…ring commit but never committed, broke the build Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
I am a bit perplexed by the fact that this is a problem, as |
|
Good catch — thanks for reading the path closely. You're right: The cost is per cell-boundary crossing. I added a reproducible benchmark (
So a single reflow at 10k is ~7ms — under the frame budget; you're right that one on its own isn't catastrophic. The case for deferring is the accumulation: ~40 reflows of wasted work per drag (~109ms of main-thread time) during the one interaction where responsiveness matters most; a fast drag crosses 2–4 columns per frame, so 2–4 × 7ms exceeds the 16.7ms budget; and deep-scrollback users (20–50k) push a single reflow over budget on its own. It also lines up with what Terminal.app and iTerm2 already do — the grid holds during the drag and reflows once on release; SwiftTerm is currently the outlier. It's behind a flag with an env-var escape hatch ( |
processSizeChange only resizes/reflows on a col/row COUNT change (existing guard), not on every setFrameSize tick — correct the comment from per-tick to per-step. Add LiveResizeReflowBenchmark: a 120->80 drag runs ~40 full-scrollback reflows (release: ~109ms total at 10k scrollback) where the deferred path runs one (~7ms) — ~15x wasted work, scaling with scrollback depth.
5f97d8e to
e8204a4
Compare
|
Tidied the scope here — I split the unrelated viewport-scroll fix out to #579, so #555 is now just the live-resize change plus a one-line |
|
what remains of this patch contains some stuff that seems unrelated - like the keyboard changes (which incidentally might have undesired side effects and is worth reviewing independently). The benchmark itself belongs in the benchmark test suite, instead of the test suite. |
Problem
Dragging a window edge or split-view divider feels stuttery on terminals with non-trivial scrollback. AppKit calls
setFrameSize~60 times/second during the live-resize gesture;processSizeChangeonly triggers aterminal.resizewhen the column/row count actually changes (guarded bynewCols != terminal.cols || newRows != terminal.rows) — so it fires once per cell-width/height of mouse travel, not every tick — but each such step chains through:A single drag crosses many cell boundaries, so it runs many full-scrollback reflows whose intermediate results are never shown — the user is still dragging, only the final size matters. The deeper the scrollback, the more each reflow costs. I noticed it after raising scrollback above the default in a host app (5–10k lines), where the gesture goes from "smooth" to "release and re-grab to get another resize step."
Cost (measured)
LiveResizeReflowBenchmark(added here; directTerminal.resizecalls, so it's independent of the view layer) — a 120→80-column drag, release build, on an M-series Mac:A single reflow at 10k is ~7 ms — under the 16.7 ms frame budget on its own, so this isn't a per-frame catastrophe. The case for deferring is the accumulation: ~40 reflows of wasted main-thread work per drag, during the one interaction where responsiveness matters most; a fast drag crosses 2–4 columns per frame (2–4 × 7 ms exceeds the budget → dropped frames); and deep-scrollback users (20–50k) push a single reflow over budget on its own.
Fix
Mirror the existing
metalLiveResizeThrottleEnabledpattern that the samesetFrameSizebody already uses for the Metal renderer:inLiveResize, skipprocessSizeChangeand set apendingLiveResizeProcessSizeChangeflag.viewDidEndLiveResize, run a singleprocessSizeChangeat the final size and one matching display request.The cell grid then stays at its pre-drag dimensions for the duration of the gesture (the surrounding NSView frame still updates, so there's no layout glitch with siblings) and snaps to the new cell grid once the user releases the mouse. That's the same trade-off Terminal.app and iTerm2 make.
Opt-out
Hosts that prefer the old per-step behavior can keep it with:
This matches the env-var style of the existing
SWIFTTERM_METAL_LIVE_RESIZE_THROTTLEtoggle. Glad to flip the default to opt-out if you'd rather not change default behavior.Scope
macOS only (the throttle pattern this mirrors is macOS-only). No public API changes — adds the
SWIFTTERM_LIVE_RESIZE_REFLOWopt-out and theLiveResizeReflowBenchmarktest.Verification
swift buildclean (only pre-existingwithUnsafeByteswarnings inMetalTerminalRenderer.swift, unrelated).swift test --filter reflowCostByScrollbackDepthruns the benchmark above (release + debug).Notes
super.setFrameSize,updateScrollerFrame,updateProgressBarFrame,needsDisplay/ Metal display request, andupdateCursorPositionall still run on every tick — only the reflow itself defers. The visible surrounding chrome (scroller, progress bar, frame) tracks the drag normally.