Skip to content

feat: add row-level diff rendering #179

Merged
ccbrown merged 9 commits into
ccbrown:mainfrom
cachix:row-level-diff
May 5, 2026
Merged

feat: add row-level diff rendering #179
ccbrown merged 9 commits into
ccbrown:mainfrom
cachix:row-level-diff

Conversation

@domenkozar
Copy link
Copy Markdown
Contributor

@domenkozar domenkozar commented Mar 19, 2026

What It Does

Extract per-row rendering from Canvas so individual rows can be written and compared independently. The terminal now diffs against the previous canvas and only re-renders rows that changed, reducing flicker and redundant output in both fullscreen and inline modes.

In fullscreen mode, absolute cursor positioning targets only changed rows. In inline mode, relative cursor movement achieves the same. The clear_canvas path in fullscreen mode now preserves output above the canvas area.

I'd be happy to add more tests for regressions since it's a substantial change in the core logic of rendering.

Related Issues

#117

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 19, 2026

Codecov Report

❌ Patch coverage is 94.08060% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.03%. Comparing base (4d3380f) to head (5b23023).

Files with missing lines Patch % Lines
packages/iocraft/src/terminal.rs 95.83% 5 Missing and 21 partials ⚠️
packages/iocraft/src/canvas.rs 87.87% 4 Missing and 16 partials ⚠️
packages/iocraft/src/render.rs 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #179      +/-   ##
==========================================
+ Coverage   88.00%   89.03%   +1.03%     
==========================================
  Files          35       35              
  Lines        5662     6340     +678     
  Branches     5662     6340     +678     
==========================================
+ Hits         4983     5645     +662     
+ Misses        569      566       -3     
- Partials      110      129      +19     
Files with missing lines Coverage Δ
packages/iocraft/src/components/text.rs 99.64% <100.00%> (ø)
packages/iocraft/src/render.rs 93.08% <75.00%> (+0.23%) ⬆️
packages/iocraft/src/canvas.rs 95.27% <87.87%> (+0.07%) ⬆️
packages/iocraft/src/terminal.rs 88.11% <95.83%> (+12.42%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@domenkozar domenkozar force-pushed the row-level-diff branch 2 times, most recently from 85f56e3 to 322e793 Compare March 19, 2026 12:57
domenkozar added a commit to cachix/devenv that referenced this pull request Mar 19, 2026
This reduces TUI flicker by only re-rendering rows that changed.
Also bumps crossterm to 0.29.0 to match iocraft's dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@domenkozar
Copy link
Copy Markdown
Contributor Author

We're releasing this part of devenv 2.0.6 and will let you know what folks say.

domenkozar added a commit to cachix/devenv that referenced this pull request Mar 20, 2026
…#179)"

This reverts commit 037d76f.

I'm seeing some weird output with duplicated Configuring shell, revert
for now.
@ccbrown
Copy link
Copy Markdown
Owner

ccbrown commented Mar 20, 2026

Thanks for the PR! It looks great at a glance, but I'll do my best to review this fully in the next few days.

@owtaylor
Copy link
Copy Markdown
Contributor

Neat stuff! - it was pretty much exactly what I needed for a project that I'm working on. I gave it some testing and found some issues with inline mode:

  • Adding lines at the bottom edge of the screen wasn't right - it tried to move the cursor down to add another line, but couldn't. Newlines needed to be added instead to force scrolling.
  • In the case of an inline buffer larger than the screen (the render_loop duplicate rendering issue #118 case), we don't want to use the scrollback-buffer-clearing fallback if we can avoid it - clearing the scrollback buffer, and rewriting everything is very likely to cause flashing, so there's a huge win in being precise.

I've put fixes for these at https://github.com/owtaylor/iocraft/tree/row-level-diff-fixes - feel free to add them to your PR if they look useful.

@domenkozar
Copy link
Copy Markdown
Contributor Author

Thanks @owtaylor I've pulled your fixes in!

@ccbrown ccbrown self-requested a review March 25, 2026 20:42
@ccbrown ccbrown added the enhancement New feature or request label Mar 25, 2026
@domenkozar
Copy link
Copy Markdown
Contributor Author

Note that I've reverted this change in devenv for now as it doesn't fully work yet. I'll test the bug fixes in following days.

@pamelia
Copy link
Copy Markdown

pamelia commented Mar 31, 2026

looking forward to this!

AodhanHayter pushed a commit to AodhanHayter/devenv that referenced this pull request Apr 3, 2026
…#179)"

This reverts commit 037d76f.

I'm seeing some weird output with duplicated Configuring shell, revert
for now.
@domenkozar
Copy link
Copy Markdown
Contributor Author

I've rebased on main

@djordjeglbvc
Copy link
Copy Markdown
Contributor

djordjeglbvc commented Apr 16, 2026

Hi!
I think I may have found a bug in this pull request.
I have created a gist which demonstrates it here (it basically uses a component to follow mouse cursor, and flips fg/bg):
https://gist.github.com/djordjeglbvc/120d95f2fd639a910d5b1c6d73c4b3dc

Effect of the bug on this example is that moving mouse across the window corrupts the data of the footer view (instead of footer data, the content of the row below the mouse is displayed there)

On main, everything is shown correctly, but there is a very visible flicker while moving a mouse (at least on my terminal: konsole 25.04.2).

edit:
Here is the fix and few regression tests, works for me, I didn't find any bad side-effects:
https://github.com/djordjeglbvc/iocraft/tree/row-level-diff-write_canvas_bugfix

@owtaylor
Copy link
Copy Markdown
Contributor

owtaylor commented Apr 16, 2026

+                // In fullscreen (alternate screen) the cursor is guaranteed to
+                // be at (0, 0) after EnterAlternateScreen.  Calling
+                // cursor::position() inside BeginSynchronizedUpdate can return
+                // a stale value from the main screen on some terminals,

Good work at figuring this out! I didn't try out fullscreen operation at all. (*) I'm wondering if the actual explanation is just slightly different - that calling EnterAlternateScreen does not move the cursor is at 0,0 and just leaves the cursor where it was on the main screen. I checked the implementation for vte, and also avt which is used in tests.

Beyond the comment, I think the flushes are now unnecessary and should be removed (not because they harm anything, but because they don't make any sense any more.)

(*) I was first wondering "if this is the explanation, then why isn't fullscreen completely broken" - but trying out examples/fullscreen it is exactly as broken as I would expect with the original patch.

One note, if someone is trying to find docs for the alternate screen behavior, the exact sequence used by crossterm for EnterAlternateScreen is https://terminalguide.namepad.de/mode/p1049/ - "Alternate Screen Buffer, With Cursor Save and Clear on Enter".

@djordjeglbvc
Copy link
Copy Markdown
Contributor

Thanks, you are right, explanation in comment is wrong. If I understand EnterAlternateScreen correctly, it does move the cursor to home, but it is called only once (when entering alternate screen mode), and this code is called every time canvas is cleared (may be multiple times). Not resetting here explicitly causes artifacts when changing height in fullscreen mode. Removed in my branch.

I also removed flush calls, and also did some manual tests to see if it would break something for me - it didn't.

I'm wondering if the actual explanation is just slightly different - that calling EnterAlternateScreen does not move the cursor is at 0,0 and just leaves the cursor where it was on the main screen. I checked the implementation for vte, and also avt which is used in tests.

Beyond the comment, I think the flushes are now unnecessary and should be removed (not because they harm anything, but because they don't make any sense any more.)

(*) I was first wondering "if this is the explanation, then why isn't fullscreen completely broken" - but trying out examples/fullscreen it is exactly as broken as I would expect with the original patch.

@domenkozar
Copy link
Copy Markdown
Contributor Author

@ccbrown let me know what you'd like me to do here :)

@ccbrown
Copy link
Copy Markdown
Owner

ccbrown commented Apr 30, 2026

@ccbrown let me know what you'd like me to do here :)

No need to do anything. :) I was just waiting a bit to let everyone here identify any other bugs. I'll try to find time this weekend to do a final round of review and testing, then merge it if it looks good.

Thanks!

Copy link
Copy Markdown
Owner

@ccbrown ccbrown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! This is great.

A few minor comments. Let me know what you think, but I don't think it would be a premature optimization to go ahead and eliminate where possible the new resets that this adds.

Comment thread examples/fullscreen.rs Outdated
}

pub(crate) fn row_eq(&self, other: &Self, y: usize) -> bool {
self.width == other.width && self.row(y) == other.row(y)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the width check necessary here? I would think if the width differed, but the rows had the same number of non-empty cells, this should return true. Is there a case I'm not thinking of?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC I added this because I was seeing garbage when resizing the terminal.

Comment thread packages/iocraft/src/canvas.rs
samchouse and others added 6 commits May 5, 2026 08:31
Extract per-row rendering from Canvas so individual rows can be
written and compared independently. The terminal now diffs against
the previous canvas and only re-renders rows that changed, reducing
flicker and redundant output in both fullscreen and inline modes.

In fullscreen mode, absolute cursor positioning targets only changed
rows. In inline mode, relative cursor movement achieves the same.
The clear_canvas path in fullscreen mode now preserves output above
the canvas area.

Co-Authored-By: Samuel Corsi-House <sam@chouse.dev>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the canvas grows beyond its previous height, MoveToNextLine (CSI E)
was used to reach the new rows. CSI E only repositions within existing
terminal content — it won't create new lines when the cursor is at the
bottom of the screen. Use \r\n instead for rows beyond the previous
canvas height to actually extend the scrollback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of unconditionally clearing when the canvas height >= terminal
height, check each changed row during the diff. Only fall back to a
full rewrite if a changed row is above the visible area (off-screen).
When only visible rows changed, the normal row-level diff handles it
without any flicker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
In fullscreen (alternate screen) mode, EnterAlternateScreen guarantees the
cursor starts at (0, 0).  The previous code called cursor::position() to
determine prev_canvas_top_row, but inside BeginSynchronizedUpdate some
terminals return a stale cursor position from the main screen.

The wrong prev_canvas_top_row causes all subsequent row-level diffs to use
incorrect absolute positions — every MoveTo(0, top_row + y) is offset by
the stale value, so changed rows get written to wrong terminal positions,
corrupting the visible display.

Fix: set prev_canvas_top_row = 0 unconditionally for fullscreen mode and
queue an explicit MoveTo(0, 0) as a safety measure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add three tests for the fullscreen prev_canvas_top_row behavior:

- test_fullscreen_initial_write_sets_zero_top_row:
  Exercises the full initial-write → diff pipeline.  Verifies that
  write_canvas(None, …) anchors prev_canvas_top_row at 0.  Without
  the fix, cursor::position() is called and fails in non-TTY test
  environments (timeout), so this test reliably catches the regression.

- test_fullscreen_diff_zero_top_row_renders_correctly:
  Verifies that with prev_canvas_top_row = 0, a single-cell diff
  (simulating mouse overlay) writes each changed row to its correct
  terminal position.

- test_fullscreen_diff_nonzero_top_row_offsets_changed_rows:
  Demonstrates the root cause: with a non-zero prev_canvas_top_row,
  every changed row Y is written to terminal line (top_row + Y) instead
  of line Y.  Unchanged rows are skipped by row_eq, so the corruption
  is never self-correcting.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
djordjeglbvc and others added 3 commits May 5, 2026 08:31
Remove unnecessary flushes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
write_row_impl previously emitted csi!("0m") at both the start and end
of every row.  Each row's trailing reset already leaves the writer in
a clean SGR state, so the leading reset of the next row is redundant.

Document the contract on write_ansi_row_without_newline (callers must
ensure clean SGR state; function leaves clean SGR state) and seed the
state once in write_impl for the public canvas writers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Owner

@ccbrown ccbrown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@ccbrown ccbrown merged commit dd287dc into ccbrown:main May 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants