Skip to content

macOS: defer buffer reflow during live-resize - #555

Open
gilbert-barajas wants to merge 3 commits into
migueldeicaza:mainfrom
gilbert-barajas:defer-reflow-during-live-resize
Open

macOS: defer buffer reflow during live-resize#555
gilbert-barajas wants to merge 3 commits into
migueldeicaza:mainfrom
gilbert-barajas:defer-reflow-during-live-resize

Conversation

@gilbert-barajas

@gilbert-barajas gilbert-barajas commented May 22, 2026

Copy link
Copy Markdown

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; processSizeChange only triggers a terminal.resize when the column/row count actually changes (guarded by newCols != 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:

setFrameSize
  → processSizeChange            (only fires when cols/rows change)
    → terminal.resize(cols:rows:)
      → resizeBuffers
        → buffer.reflow          ← scans the entire scrollback (reflowWider / reflowNarrower)

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; direct Terminal.resize calls, so it's independent of the view layer) — a 120→80-column drag, release build, on an M-series Mac:

scrollback reflows/drag drag total single reflow wasted
1k 40 10.7 ms 0.6 ms 17×
5k 40 51 ms 4.1 ms 13×
10k 40 109 ms 7.1 ms 15×

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 metalLiveResizeThrottleEnabled pattern that the same setFrameSize body already uses for the Metal renderer:

  • While inLiveResize, skip processSizeChange and set a pendingLiveResizeProcessSizeChange flag.
  • In viewDidEndLiveResize, run a single processSizeChange at 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:

SWIFTTERM_LIVE_RESIZE_REFLOW=1

This matches the env-var style of the existing SWIFTTERM_METAL_LIVE_RESIZE_THROTTLE toggle. 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_REFLOW opt-out and the LiveResizeReflowBenchmark test.

Verification

  • swift build clean (only pre-existing withUnsafeBytes warnings in MetalTerminalRenderer.swift, unrelated).
  • swift test --filter reflowCostByScrollbackDepth runs the benchmark above (release + debug).
  • Manually verified the deferred path: resizing a window during an active session no longer skips frames; the cell grid snaps to the new size on mouse-up.

Notes

super.setFrameSize, updateScrollerFrame, updateProgressBarFrame, needsDisplay / Metal display request, and updateCursorPosition all still run on every tick — only the reflow itself defers. The visible surrounding chrome (scroller, progress bar, frame) tracks the drag normally.

gilbert-barajas and others added 2 commits May 22, 2026 12:34
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>
@migueldeicaza

Copy link
Copy Markdown
Owner

I am a bit perplexed by the fact that this is a problem, as processSizeChange should only trigger if the number of columns and rows changed, so 60 fps is fine to process, as long as we do not actually trigger the resize - so I am wondering how is it that you ended up in that situation.

@gilbert-barajas

Copy link
Copy Markdown
Author

Good catch — thanks for reading the path closely. You're right: processSizeChange only calls terminal.resize when the column/row count actually changes (the newCols != terminal.cols || newRows != terminal.rows guard), so it's per-step, not per-frame. My PR description and the code comment both overstated it as "every tick" — I've fixed the comment to match.

The cost is per cell-boundary crossing. I added a reproducible benchmark (LiveResizeReflowBenchmark — direct Terminal.resize calls, so it's independent of the view layer); a 120→80-column drag, release build, on an M-series Mac:

scrollback reflows/drag drag total single reflow wasted
1k 40 10.7 ms 0.6 ms 17×
5k 40 51 ms 4.1 ms 13×
10k 40 109 ms 7.1 ms 15×

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 (SWIFTTERM_LIVE_RESIZE_REFLOW=1 to opt out); I default it on to match that norm, but I'm happy to flip it to opt-in/default-off if you'd rather not change defaults. Benchmark's in the test suite if you want to poke at the numbers.

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.
@gilbert-barajas

Copy link
Copy Markdown
Author

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 SyncDebug no-op that unblocks the build (it's referenced on main but never committed; happy to pull that out into its own PR too if you'd prefer). Force-pushed the branch.

@migueldeicaza

Copy link
Copy Markdown
Owner

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants