feat: rewrite only the lines that changed (#125) - #131
Merged
Merged
Conversation
`Program.render` homed the cursor and rewrote every visible line on any view change, so a spinner ticking beside a screenful of streaming text cost a full frame of output ten times a second. The cell-level `Screen.renderDiff` in the codebase was never wired into the render loop, which works on view strings rather than cells. Adds a line-level renderer in `src/terminal/frame.zig`, matching the granularity the render loop actually has. It keeps the previous frame, compares line by line, and rewrites only the rows that differ. On a 48-row log view with a spinner, 600 frames cost 1,649,400 bytes before and 23,115 after -- 2749 bytes per frame down to 38. Diffing addresses rows absolutely, so it steps aside and repaints in full whenever frame row n is not terminal row n: - a line wider than the terminal, which would wrap and shift the rows below it - a frame taller than the terminal, which would scroll - a line that leaves a colour or attribute switched on, since the lines under it inherit that styling and cannot be redrawn alone - the frame after any of the above The runtime also invalidates after a resize, a suspend/resume, an inline image, `println`, and alt-screen switches. `Cmd.repaint` and `Program.invalidate()` cover anything else that writes to the terminal behind the framework's back. `Options.render_mode = .full` restores the previous behaviour. The renderer is a standalone value over a `std.Io.Writer`, so `tests/render_tests.zig` can replay its output through a small virtual screen and assert that a diffed frame leaves the terminal in exactly the state a full repaint would -- content and cursor position both.
meszmate
force-pushed
the
feat/line-diff-renderer
branch
from
August 14, 2026 04:30
548b7bd to
b6dbc4d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #125.
Answering @erxonxi's question directly: the unwired
renderDiffinsrc/terminal/screen.zigis cell-level, but the render loop works on view strings, not cells — which is why it was never called. This wires up a diff at the granularity the loop actually has: lines.What changed
Program.renderhomed the cursor and rewrote every visible line on any view change. A spinner ticking beside a screenful of streaming text therefore cost a full frame of output ten times a second — exactly the high CPU and input lag reported.src/terminal/frame.zigkeeps the previous frame, compares line by line, and rewrites only the rows that differ.48-row log view with a spinner, 600 frames:
.full.diffWhen it steps aside
Diffing addresses rows absolutely, so it repaints in full whenever frame row n is not terminal row n:
That third one is what makes this safe to have on by default. Rather than emitting a reset before each line (which would silently change how style-bleeding views look), a small SGR state machine tracks what each line leaves open and hands those frames to the full path. Views that close their styles per line — everything
Style.renderproduces — diff; views that bleed colour across lines render exactly as they do today.38;2;r;g;band4:3sub-parameters are parsed properly, so a truecolor sequence's literal0argument is not mistaken for SGR 0.The runtime also invalidates after a resize, a suspend/resume, an inline image,
println, and alt-screen switches. For anything else that writes to the terminal behind the framework's back,Cmd.repaint/Program.invalidate()restore the self-healing property.Options.render_mode = .fullopts out entirely.Cursor parity is preserved: a diffed frame leaves the cursor where a full repaint would, so a visible cursor and
.cursor-placed images behave identically either way.Tests
tests/render_tests.zigreplays the renderer's output through a small virtual screen, so the assertions are about what the terminal ends up showing, not which bytes came out. The central test runs a 12-frame script through both modes and asserts the resulting screens — and cursor positions — are identical.Plus: only the changed line is touched; a spinner frame stays under 64 bytes; shrinking/growing frames; wrapping, scrolling and style-bleed fallbacks; truecolor not misread as a reset; wide characters measured by display width;
invalidate; synchronized output emitted exactly once per frame.src/terminal/frame.zigcarries unit tests for the SGR/hyperlink scanner.API
Additive.
zz.FrameRenderer/zz.RenderModeare exported so the renderer can be used standalone over anystd.Io.Writer.Program.last_view_hashandProgram.last_line_countmoved into the renderer; both were internal render bookkeeping.zig build testandzig buildclean on 0.16.0.