Skip to content

feat: rewrite only the lines that changed (#125) - #131

Merged
meszmate merged 1 commit into
mainfrom
feat/line-diff-renderer
Aug 14, 2026
Merged

meszmate merged 1 commit into
mainfrom
feat/line-diff-renderer

Conversation

@meszmate

Copy link
Copy Markdown
Owner

Fixes #125.

Answering @erxonxi's question directly: the unwired renderDiff in src/terminal/screen.zig is 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.render homed 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.zig keeps the previous frame, compares line by line, and rewrites only the rows that differ.

48-row log view with a spinner, 600 frames:

mode total per frame
.full 1,649,400 bytes 2749
.diff 23,115 bytes 38

When it steps aside

Diffing addresses rows absolutely, so it repaints in full whenever frame row n is not terminal row n:

  • a line wider than the terminal — it would wrap and shift every row below it
  • a frame taller than the terminal — it would scroll
  • a line that leaves a colour or attribute switched on — the lines under it inherit that styling and cannot be redrawn on their own
  • the frame after any of the above

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.render produces — diff; views that bleed colour across lines render exactly as they do today. 38;2;r;g;b and 4:3 sub-parameters are parsed properly, so a truecolor sequence's literal 0 argument 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 = .full opts 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.zig replays 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.zig carries unit tests for the SGR/hyperlink scanner.

API

Additive. zz.FrameRenderer / zz.RenderMode are exported so the renderer can be used standalone over any std.Io.Writer.

Program.last_view_hash and Program.last_line_count moved into the renderer; both were internal render bookkeeping.

zig build test and zig build clean on 0.16.0.

`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
meszmate force-pushed the feat/line-diff-renderer branch from 548b7bd to b6dbc4d Compare August 14, 2026 04:30
@meszmate
meszmate merged commit f9dd507 into main Aug 14, 2026
12 checks passed
@meszmate
meszmate deleted the feat/line-diff-renderer branch August 28, 2026 12:09
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.

Is the full-frame redraw intentional, or is a diff path planned?

1 participant