Skip to content

Rebuild effects when the terminal is resized - #4

Merged
dhh merged 4 commits into
omacom:masterfrom
yashranaway:feat/restart-on-resize
Aug 10, 2026
Merged

Rebuild effects when the terminal is resized#4
dhh merged 4 commits into
omacom:masterfrom
yashranaway:feat/restart-on-resize

Conversation

@yashranaway

@yashranaway yashranaway commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Makes ttfx respond to terminal resizes by rebuilding the current effect at the new canvas size. This removes the 80x24 startup problem currently worked around in Omarchy and also handles later window resizes.

<<< AI wording below >>>>

Problem

Newly created terminal windows can briefly expose the pty default of 80x24 before the compositor assigns the real size. ttfx measures once while constructing the engine, so a fullscreen animation that starts in that window keeps an 80x24 canvas and paints into one corner.

Omarchy currently works around this by polling stty size for up to two seconds before starting ttfx: omacom/omarchy@354c2f0

Fix

Record SIGWINCH and verify that the terminal dimensions differ from those used to build the current canvas. When they do, finish that render pass without advancing the cursor, then rebuild the same selected effect against the new dimensions while carrying the RNG state forward and reusing the existing output area. This handles both the initial compositor resize and later window resizes.

The existing run_effect API remains unchanged for library callers, and no effect logic or CLI surface changes.

Verification

  • Real pty resized from 80x24 to 100x40 while ColorShift was running: output changed from ESC[24A to ESC[40A in the same process, then exited cleanly on SIGINT.
  • Effect parity: 354 passed, 0 failed.
  • TTY byte-stream parity: 41 passed, 0 failed.
  • CLI corpus: 19 passed, 0 failed.
  • Library unit tests plus engine, geometry, and graphics goldens pass.

The full test command on this Linux/aarch64 machine still has the existing exact-easing failure at OutExpo p=0.03 (4 ULP); it is unchanged from master and unrelated to this patch.

@dhh
dhh force-pushed the feat/restart-on-resize branch from 4e66c58 to 67c0c0f Compare August 10, 2026 14:32
yashranaway and others added 3 commits August 10, 2026 12:41
Newly created terminals can briefly expose the pty default of 80x24 before the compositor assigns the real window size. Since effect state is built from the measured canvas, that leaves fullscreen animations painting into one corner.

Record SIGWINCH, verify that the dimensions actually changed, and end the current render pass without advancing the cursor. The CLI then rebuilds the same selected effect against the new dimensions, carrying the RNG state forward and reusing the existing output area. Normal runs and the existing library runner remain unchanged.
Six fixes from the review of omacom#4, plus a pty-driven regression test that
fails on all of them without these changes.

Only react to SIGWINCH when stdout is a terminal. The signal reaches every
process in the terminal's foreground group whatever its stdout points at,
and terminal_size() falls back to stderr, so `ttfx pour | less` in a window
being resized restarted mid-stream: the consumer saw a truncated first run
followed by a complete second one. A file sink drains too fast to show it,
which is why the test drives a deliberately slow reader.

Compare the canvas geometry, not the raw terminal size. With an input-sized
canvas and no anchor offsets, most resizes cannot move a single rendered
cell, and restarting for those is pure loss. compute_layout is now factored
out of Terminal::new so a resize can re-derive the geometry from the stored
line lengths and compare.

Wait for the size to settle before rebuilding. Dragging a window edge emits
a SIGWINCH per step; rebuilding for each one pinned the animation at its
opening frames for the whole drag and started it over on release. A 6-step
drag now costs at most 3 rebuilds instead of 7.

Wipe the old area instead of reusing the canvas. Reusing it moved up by the
new visible_top from an anchor that no longer had that much room above it,
so the blank-line loop ran past the anchor and scrolled a line of scrollback
away per rebuild. The resize path now returns to the top of the area it
allocated, erases to the end of the screen, and lays the new canvas out from
there.

Leave the cursor hidden across a rebuild. Restoring it between runs strobed
the cursor 20-40 times a second through a drag.

Drop the libc dependency. Only signal(2) and a handful of constants were
needed, and signal was already hand-declared; the pacing sleep now slices
rather than reaching for nanosleep, which also makes it portable.

Rebasing onto master also had to reconcile the run loop with the arena
teardown skip that landed in omacom#5: forgetting the engine now happens on the
exit paths inside the loop, never on a resize rebuild, which still drops
its engine so a long session of resizes cannot accumulate them.
@dhh
dhh force-pushed the feat/restart-on-resize branch from 67c0c0f to 6db0138 Compare August 10, 2026 19:48
@dhh

dhh commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for this — the flag protocol is right (the take-then-re-read ordering means no resize can be lost or double-counted), and the restart loop itself is sound. I reviewed it, pushed fixes for six things onto your branch, and rebased it onto current master. I force-pushed, so your local branch will need a git fetch && git reset --hard origin/feat/restart-on-resize. Your original tip was 67c0c0f if you want to diff against it.

What changed

Only react to SIGWINCH when stdout is a terminal. The signal goes to every process in the terminal's foreground group regardless of where stdout points, and terminal_size() falls back to stderr, so ttfx pour | less in a window being resized restarted mid-stream — the consumer got a truncated first run followed by a complete second one. Worth noting this does not reproduce with > file.txt: a file sink drains instantly and the animation finishes before the signal lands. It needs a consumer slower than the renderer.

Compare canvas geometry, not raw terminal size. With the default input-sized canvas and sw anchors, going 24x80 → 30x100 cannot move a single rendered cell, but it replayed the effect from frame 0. compute_layout is now factored out of Terminal::new so a resize re-derives the geometry and compares that instead.

Coalesce the winch storm. Dragging a window edge emits a SIGWINCH per step; rebuilding for each one pinned the animation at its opening frames for the whole drag and only started over on release. A 6-step drag went from 7 rebuilds to 3.

Wipe the old area instead of reuse_canvas. Reusing it moved up by the new visible_top from an anchor that no longer had that much room above it, so the blank-line loop ran past the anchor and scrolled — eating a line of scrollback per rebuild, which during a drag is dozens. The resize path now returns to the top of the area it allocated, erases to end of screen, and lays out from there.

Keep the cursor hidden across a rebuild. restore_cursor on the resize path emitted show-cursor, and the next prep_canvas immediately re-hid it — 20-40 round-trips a second through a drag.

Dropped the libc dependency. Only signal(2) and a few constants were needed, and signal was already hand-declared here to keep the dependency list at three. The pacing sleep now slices instead of reaching for nanosleep, which also drops the timespec/EINTR portability question.

Test

Added tools/tests/resize_behavior.py, wired into the Linux CI job. It spawns the binary on a real pty, drives TIOCSWINSZ, and asserts on the emitted byte stream. It reports all checks passed on the current head and 5 failed against the branch as originally submitted, so it guards these specifically rather than just going green.

One rebase note

The rebase applied with zero conflicts and produced code that didn't compile: this PR moves engine construction into a restart loop, and the arena-teardown skip from #5 sits after that region and referenced what are now loop-local bindings. Reconciled by forgetting the engine on the exit paths inside the loop — a resize rebuild deliberately still drops its engine, so a long session of resizes can't accumulate them. That fix landed in my commit, so the two earlier commits don't build in isolation on the new base; squash-merging would give a clean bisect.

354 effect-parity checks, 41 tty byte-stream checks, the CLI corpus and cargo test are all green, and throughput is unchanged against master.

— 🤖 Claude, posting on behalf of @dhh

The restart machinery had grown three layers that each cost more than they
returned.

Fold the debounce into the run loop. wait_for_resize_to_settle polled from
main with a 40ms sleep, and it ran after the screen had already been wiped,
so every rebuild showed a blank hole for at least that long. Terminal now
restarts a 50ms quiet window on each SIGWINCH and checks it where the frame
loop already checks for interrupts: the old canvas keeps animating until the
window expires, so the wait costs nothing on screen. A 6-step drag now
rebuilds twice instead of three times.

Drop sleep_until_signal. Slicing the frame delay into 4ms pieces bought at
most one frame of latency — the default frame rate is 60, so the delay it was
slicing is 16.7ms — on a path that then deliberately waits 50ms for the size
to settle. thread::sleep is back.

Collapse run_effect, run_effect_resize_aware and run_effect_inner into one
run_effect(effect, ctx, stop_on_resize). The split existed to keep the old
signature "unchanged for library callers" that do not exist, and main was
branching on resize_aware to choose between two wrappers that branch on the
same bool again. dump_effect now shares the single match, which also puts
forget_engine on one exit path instead of three.

Co-Authored-By: Claude <noreply@anthropic.com>
@dhh
dhh merged commit 1b101d8 into omacom:master Aug 10, 2026
3 checks passed
@yashranaway
yashranaway deleted the feat/restart-on-resize branch August 11, 2026 05:10
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