From 68f0e4d62232970a0e6c11ddb3c284bc76033f97 Mon Sep 17 00:00:00 2001
From: Sergey Fedorov <barracuda@macos-powerpc.org>
Date: Tue, 21 Jul 2026 21:07:35 +0000
Subject: [PATCH 07/13] feat(vendor): skip recompositing/reblitting unchanged
 rows in Renderer

Consumes upstream's per-line revision stamping (vtbackend::Line::revision(),
batch-advanced by Grid::finalizeRevisions()). Terminal::captureLineGenerations()
snapshots each visible row's revision into RenderBuffer::lineGenerations the same
frame cells/lines are built. (The old vendor stack fed this from a bespoke
LineSoA generation counter; upstream master since grew its own revision system,
so that primitive was dropped on rebase and this consumer rewired to the upstream
one -- same contract, no behavior change.)

Renderer::render() gains an opt-in RenderDirtyMode::SkipUnchangedRows (default
stays FullFrame, so every existing call site is unaffected): the single-pass
render path diffs each row's RenderBuffer::lineGenerations entry against the
previous frame's snapshot and filters cells/lines down to only the rows that
changed, plus whichever row the cursor was on or has moved to (the cursor
overlay leaves no trace in lineGenerations). lastDirtyRows() exposes which
rows actually painted so a frontend can scope setNeedsDisplayInRect:/blit
to just those pixel rows.

RenderBuffer::lineGenerationsBase (paired with Terminal::
captureLineGenerations()) records the absolute LineOffset lineGenerations[0]
corresponds to, so isRowDirty() can map a RenderCell/RenderLine's absolute
position back to an index without re-deriving Grid::render()'s own
scrollOffset/extraLines math.

A caller opting into SkipUnchangedRows takes on two obligations documented
on the new members: its RenderTarget must retain unchanged pixels across
frames (no per-frame full clear), and it must discard its own last-frame
bookkeeping whenever pageSize()/scrollOffset() change (a resize/scroll
changes what row N even means). The actual frontend consumer lands
separately on the `frontend` branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzTmVgP2ruMz987VJ78k77
---
 src/vtbackend/RenderBuffer.hpp | 23 ++++++++++
 src/vtbackend/Terminal.cpp     | 34 ++++++++++++++
 src/vtbackend/Terminal.hpp     | 12 +++++
 src/vtrasterizer/Renderer.cpp  | 81 +++++++++++++++++++++++++++++++---
 src/vtrasterizer/Renderer.hpp  | 71 ++++++++++++++++++++++++++++-
 5 files changed, 214 insertions(+), 7 deletions(-)

diff --git a/src/vtbackend/RenderBuffer.hpp b/src/vtbackend/RenderBuffer.hpp
index ebcb296a..e521f732 100644
--- a/src/vtbackend/RenderBuffer.hpp
+++ b/src/vtbackend/RenderBuffer.hpp
@@ -109,11 +109,34 @@ struct RenderBuffer
     std::optional<RenderCursor> cursor {};
     uint64_t frameID {};
 
+    /// Per-screen-line snapshot of vtbackend::Line::revision(), indexed by screen row (0 = top of
+    /// the visible page), captured the same frame as cells/lines above. A frontend that wants to
+    /// avoid recompositing/reblitting unchanged rows compares this against the value it stored for
+    /// the SAME row last frame; a row whose revision is unchanged had no content mutation between
+    /// the two frames (Terminal::captureLineGenerations() calls Grid::finalizeRevisions() first, so
+    /// every line dirtied since the previous capture carries a strictly higher batch seqno). Always
+    /// populated -- the cost is one uint64_t copy per screen row (not per cell), negligible next to
+    /// building cells/lines above; a frontend that has no use for it (e.g. one that always
+    /// recomposites the whole frame anyway) simply never reads it. A resize/scroll/reflow
+    /// invalidates row INDICES (row 5 last frame is not necessarily the same content as row 5 this
+    /// frame), so a frontend must discard its own last-frame snapshot whenever pageSize() or
+    /// scrollOffset() changed, not just diff blindly.
+    std::vector<uint64_t> lineGenerations {};
+
+    /// The LineOffset that lineGenerations[0] corresponds to (matches the starting offset
+    /// Grid::render() itself walks from: -scrollOffset - extraLines). RenderCell::position.line and
+    /// RenderLine::lineOffset are absolute grid LineOffsets, so a consumer maps either to a
+    /// lineGenerations index via `*cellOrLine.position.line - *lineGenerationsBase` rather than
+    /// re-deriving the scrollOffset/extraLines math itself (which only Terminal has access to).
+    LineOffset lineGenerationsBase {};
+
     void clear()
     {
         cells.clear();
         lines.clear();
         cursor.reset();
+        lineGenerations.clear();
+        lineGenerationsBase = LineOffset {};
     }
 };
 
diff --git a/src/vtbackend/Terminal.cpp b/src/vtbackend/Terminal.cpp
index 52091fe5..c15453ae 100644
--- a/src/vtbackend/Terminal.cpp
+++ b/src/vtbackend/Terminal.cpp
@@ -637,6 +637,7 @@ void Terminal::fillRenderBufferInternal(RenderBuffer& output, bool includeSelect
     auto& displayedScreen = pageAt(_displayedPage);
 
     if (_displayedPage == PageIndex(0))
+    {
         _lastRenderPassHints = displayedScreen.render(RenderBufferBuilder { *this,
                                                                             displayedScreen,
                                                                             output,
@@ -650,7 +651,10 @@ void Terminal::fillRenderBufferInternal(RenderBuffer& output, bool includeSelect
                                                       _viewport.scrollOffset(),
                                                       renderLinesPerCell,
                                                       smoothScrollExtra);
+        captureLineGenerations(output, displayedScreen, _viewport.scrollOffset(), smoothScrollExtra);
+    }
     else
+    {
         _lastRenderPassHints = displayedScreen.render(RenderBufferBuilder { *this,
                                                                             displayedScreen,
                                                                             output,
@@ -663,6 +667,8 @@ void Terminal::fillRenderBufferInternal(RenderBuffer& output, bool includeSelect
                                                                             includeSelection },
                                                       _viewport.scrollOffset(),
                                                       renderLinesPerCell);
+        captureLineGenerations(output, displayedScreen, _viewport.scrollOffset(), LineCount(0));
+    }
 
     // Save the baseLine used for the main screen before the bottom status line shifts it.
     auto const mainScreenLine = baseLine;
@@ -678,6 +684,34 @@ void Terminal::fillRenderBufferInternal(RenderBuffer& output, bool includeSelect
     applyScreenTransitionBlending(output);
 }
 
+void Terminal::captureLineGenerations(RenderBuffer& output,
+                                      Screen& displayedScreen,
+                                      ScrollOffset scrollOffset,
+                                      LineCount extraLines)
+{
+    // Batch-stamp every line dirtied since the last capture, so the revisions read below are
+    // current. In this port captureLineGenerations() is the sole finalizer (no delta consumer /
+    // daemon is running to advance the seqno underneath us), so a line changed this frame carries a
+    // strictly higher revision than the value the previous frame captured for it. finalizeRevisions()
+    // is O(page) and no-ops the seqno when nothing was dirty, so an idle frame stays free.
+    auto& grid = displayedScreen.grid();
+    grid.finalizeRevisions();
+
+    // Mirrors Grid::render()'s own row range exactly (Grid.hpp): the scrolled-into-history start
+    // offset, the same extraLines widening for smooth scrolling, and the same page-size-driven
+    // row count -- so row N here is the SAME grid line Grid::render() just built RenderCells/
+    // RenderLines for.
+    auto const availableAbove = *grid.historyLineCount() - *scrollOffset;
+    auto const extraOffset = std::min(*extraLines, std::max(0, availableAbove));
+    auto const rowCount = *pageSize().lines + extraOffset;
+
+    output.lineGenerations.resize(static_cast<size_t>(rowCount));
+    auto i = -*scrollOffset - extraOffset;
+    output.lineGenerationsBase = LineOffset(i);
+    for (auto row = size_t { 0 }; row < static_cast<size_t>(rowCount); ++row, ++i)
+        output.lineGenerations[row] = grid.lineAt(LineOffset(i)).revision();
+}
+
 void Terminal::updateCursorMotionAnimation(RenderBuffer& output)
 {
     if (!output.cursor.has_value())
diff --git a/src/vtbackend/Terminal.hpp b/src/vtbackend/Terminal.hpp
index ff5dbab2..ee06fdba 100644
--- a/src/vtbackend/Terminal.hpp
+++ b/src/vtbackend/Terminal.hpp
@@ -1963,6 +1963,18 @@ class Terminal
     void mainLoop();
     void fillRenderBufferInternal(RenderBuffer& output, bool includeSelection);
     LineCount fillRenderBufferStatusLine(RenderBuffer& output, bool includeSelection, LineOffset base);
+
+    /// Snapshots each visible screen row's Line::revision() into output.lineGenerations, indexed
+    /// the same way Grid::render() walks rows (accounting for scrollOffset and extraLines), and
+    /// records that indexing's starting LineOffset into output.lineGenerationsBase, so a frontend
+    /// can diff row N this frame against row N last frame to skip recompositing/reblitting unchanged
+    /// rows. Runs Grid::finalizeRevisions() first (batch-stamping every line dirtied since the last
+    /// capture), so it is not const: the revisions it reads are the very thing it stamps. Purely
+    /// additive bookkeeping otherwise -- does not affect cells/lines/cursor.
+    void captureLineGenerations(RenderBuffer& output,
+                                Screen& displayedScreen,
+                                ScrollOffset scrollOffset,
+                                LineCount extraLines);
     void updateIndicatorStatusLine();
     void updateCursorVisibilityState() const noexcept;
     void updateHoveringHyperlinkState();
diff --git a/src/vtrasterizer/Renderer.cpp b/src/vtrasterizer/Renderer.cpp
index 3187ff99..974325a1 100644
--- a/src/vtrasterizer/Renderer.cpp
+++ b/src/vtrasterizer/Renderer.cpp
@@ -652,11 +652,11 @@ void Renderer::publishFontMetricsAndDescriptions()
     }
 }
 
-bool Renderer::render(vtbackend::Terminal& terminal, bool pressure)
+bool Renderer::render(vtbackend::Terminal& terminal, bool pressure, RenderDirtyMode dirtyMode)
 {
     try
     {
-        return renderImpl(terminal, pressure);
+        return renderImpl(terminal, pressure, dirtyMode);
     }
     catch (std::exception const& e)
     {
@@ -669,7 +669,7 @@ bool Renderer::render(vtbackend::Terminal& terminal, bool pressure)
     return false;
 }
 
-bool Renderer::renderImpl(vtbackend::Terminal& terminal, bool pressure)
+bool Renderer::renderImpl(vtbackend::Terminal& terminal, bool pressure, RenderDirtyMode dirtyMode)
 {
     // Hold _applyMutex across the whole frame: this both applies any staged reconfiguration and then
     // renders from _gridMetrics / the texture atlas. Holding it for the full duration makes a GUI-thread
@@ -747,16 +747,63 @@ bool Renderer::renderImpl(vtbackend::Terminal& terminal, bool pressure)
 
     auto const primaryPressure = pressure && terminal.isPrimaryScreen();
 
+    _lastDirtyRows.clear();
+
     if (smoothPixelOffset == 0)
     {
         // --- Single-pass rendering: no smooth scroll offset, no scissor needed ---
         setSmoothScrollOffset(0);
+        // Populated only for RenderDirtyMode::SkipUnchangedRows: backing storage for the filtered
+        // spans passed to renderCells()/renderLines() below (declared here so it outlives the
+        // renderPass() lambda call that reads the spans).
+        auto filteredCells = std::vector<vtbackend::RenderCell> {};
+        auto filteredLines = std::vector<vtbackend::RenderLine> {};
         renderPass(primaryPressure, [&] {
             vtbackend::RenderBufferRef const renderBuffer = terminal.renderBuffer();
             cursorOpt = renderBuffer.get().cursor;
-            renderCells(std::span(renderBuffer.get().cells));
-            renderLines(std::span(renderBuffer.get().lines));
+            if (dirtyMode != RenderDirtyMode::SkipUnchangedRows)
+            {
+                renderCells(std::span(renderBuffer.get().cells));
+                renderLines(std::span(renderBuffer.get().lines));
+                return;
+            }
+
+            // The previous frame's cursor row must always redraw even if its generation is
+            // unchanged: the cursor overlay (cell-color inversion or a dedicated shape, drawn
+            // separately below) left no trace in lineGenerations, so skipping it would leave a
+            // stale cursor glyph on screen once the cursor moves away from that row.
+            auto const previousCursorLine = _previousCursorPosition
+                                                ? std::optional { _previousCursorPosition->line }
+                                                : std::nullopt;
+            auto const currentCursorLine =
+                cursorOpt ? std::optional { cursorOpt->position.line } : std::nullopt;
+            // Rows are visited in ascending LineOffset order (cells/lines are each sorted by
+            // position, per findCellPartitionPoint()'s contract), so recording a row only when it
+            // differs from the last one recorded de-duplicates without a set.
+            auto const rowIsDirty = [&](vtbackend::LineOffset line) {
+                if (!(line == previousCursorLine || line == currentCursorLine || isRowDirty(renderBuffer.get(), line)))
+                    return false;
+                if (_lastDirtyRows.empty() || _lastDirtyRows.back() != line)
+                    _lastDirtyRows.push_back(line);
+                return true;
+            };
+
+            filteredCells.clear();
+            for (auto const& cell: renderBuffer.get().cells)
+                if (rowIsDirty(cell.position.line))
+                    filteredCells.push_back(cell);
+
+            filteredLines.clear();
+            for (auto const& line: renderBuffer.get().lines)
+                if (rowIsDirty(line.lineOffset))
+                    filteredLines.push_back(line);
+
+            renderCells(std::span(filteredCells));
+            renderLines(std::span(filteredLines));
+
+            updateDirtyTrackingSnapshot(renderBuffer.get());
         });
+        _previousCursorPosition = cursorOpt.transform([](auto const& c) { return c.position; });
     }
     else
     {
@@ -911,6 +958,30 @@ void Renderer::renderLines(std::span<vtbackend::RenderLine const> lines)
     }
 }
 
+bool Renderer::isRowDirty(vtbackend::RenderBuffer const& buffer, vtbackend::LineOffset lineOffset) const
+{
+    if (!_havePreviousLineGenerations)
+        return true;
+
+    auto const currentIndex = *lineOffset - *buffer.lineGenerationsBase;
+    if (currentIndex < 0 || static_cast<size_t>(currentIndex) >= buffer.lineGenerations.size())
+        return true; // Out of the current snapshot's bounds; cannot claim knowledge, so redraw.
+
+    auto const previousIndex = *lineOffset - *_previousLineGenerationsBase;
+    if (previousIndex < 0 || static_cast<size_t>(previousIndex) >= _previousLineGenerations.size())
+        return true; // This grid line was not part of the previous frame's visible page at all.
+
+    return buffer.lineGenerations[static_cast<size_t>(currentIndex)]
+           != _previousLineGenerations[static_cast<size_t>(previousIndex)];
+}
+
+void Renderer::updateDirtyTrackingSnapshot(vtbackend::RenderBuffer const& buffer)
+{
+    _previousLineGenerations = buffer.lineGenerations;
+    _previousLineGenerationsBase = buffer.lineGenerationsBase;
+    _havePreviousLineGenerations = true;
+}
+
 size_t Renderer::findCellPartitionPoint(std::vector<vtbackend::RenderCell> const& cells,
                                         vtbackend::LineCount statusLineBoundary)
 {
diff --git a/src/vtrasterizer/Renderer.hpp b/src/vtrasterizer/Renderer.hpp
index 9e6d6fd6..de901bfe 100644
--- a/src/vtrasterizer/Renderer.hpp
+++ b/src/vtrasterizer/Renderer.hpp
@@ -43,6 +43,21 @@ struct RenderCursor
     }
 };
 
+/// Controls whether Renderer::render() may skip re-emitting draw commands for screen rows whose
+/// content provably did not change since the previous call (see vtbackend::RenderBuffer::
+/// lineGenerations). Opt-in: a caller whose RenderTarget does not preserve unchanged pixels across
+/// frames (e.g. one that clears its surface every frame) must keep FullFrame, or unchanged rows
+/// will render as blank.
+enum class RenderDirtyMode
+{
+    /// Emit commands for every visible cell/line every frame, as if no dirty-tracking existed.
+    FullFrame,
+    /// Skip cells/lines whose row generation is unchanged from the previous render() call. The
+    /// caller's RenderTarget must retain previously-drawn pixels for skipped rows (no per-frame
+    /// full-surface clear), or their content will not appear.
+    SkipUnchangedRows,
+};
+
 /**
  * Renders a terminal's screen to the current OpenGL context.
  */
@@ -244,13 +259,32 @@ class Renderer
      *                       CPU intensive but allow to render fast.
      *                       The user shall not notice that, because this frame
      *                       is known already to be updated right after again.
+     * @param dirtyMode      FullFrame (default) renders every visible cell/line, matching prior
+     *                       behavior exactly. SkipUnchangedRows additionally skips rows whose
+     *                       content did not change since the previous render() call; only use this
+     *                       when the RenderTarget in use retains unchanged pixels across frames.
      *
      * @return true if a font reconfiguration was applied during this frame and the display must
      *         re-derive its geometry against the new cell size (see applyPendingReconfig()). The flag is
      *         consumed here, under _applyMutex, so it cannot also be consumed concurrently by a GUI-thread
      *         applyStagedReconfigDuringSetup().
      */
-    [[nodiscard]] bool render(vtbackend::Terminal& terminal, bool pressureHint);
+    [[nodiscard]] bool render(vtbackend::Terminal& terminal,
+                              bool pressureHint,
+                              RenderDirtyMode dirtyMode = RenderDirtyMode::FullFrame);
+
+    /// The screen rows (grid-absolute LineOffset, main display only) the most recent render() call
+    /// actually (re)painted, when it was called with RenderDirtyMode::SkipUnchangedRows. A caller
+    /// whose RenderTarget does not clear its surface on its own must erase exactly these rows to the
+    /// default background before render()'s painted commands are composited (renderCell() does not
+    /// emit a background rect for a default-background cell, since the old full-frame-clear model
+    /// made that redundant), and may restrict its presentation (e.g. a CGContextDrawImage blit or
+    /// setNeedsDisplayInRect:) to the pixel rows these cover. Empty and meaningless after a
+    /// RenderDirtyMode::FullFrame call (everything was repainted; treat as "all rows").
+    [[nodiscard]] std::vector<vtbackend::LineOffset> const& lastDirtyRows() const noexcept
+    {
+        return _lastDirtyRows;
+    }
 
     /// Synchronously applies any staged font/geometry reconfiguration.
     ///
@@ -349,7 +383,7 @@ class Renderer
 
   private:
     /// Internal implementation of render(), wrapped in a try/catch for graceful degradation.
-    [[nodiscard]] bool renderImpl(vtbackend::Terminal& terminal, bool pressure);
+    [[nodiscard]] bool renderImpl(vtbackend::Terminal& terminal, bool pressure, RenderDirtyMode dirtyMode);
 
     void configureTextureAtlas();
 
@@ -387,6 +421,18 @@ class Renderer
     /// @param lines  Contiguous sub-range of RenderLine entries to render.
     void renderLines(std::span<vtbackend::RenderLine const> lines);
 
+    /// Returns whether the row @p lineOffset belongs to, per @p buffer's lineGenerations/
+    /// lineGenerationsBase, changed since the previous render() call's snapshot (_previousLine
+    /// Generations/_previousLineGenerationsBase). A row outside either snapshot's bounds, or a
+    /// mismatched previous snapshot (see updateDirtyTrackingSnapshot()), is conservatively dirty.
+    [[nodiscard]] bool isRowDirty(vtbackend::RenderBuffer const& buffer,
+                                  vtbackend::LineOffset lineOffset) const;
+
+    /// Replaces the stored previous-frame generation snapshot with @p buffer's, for the next
+    /// render() call's isRowDirty() comparisons. Called once per frame, after rendering, only when
+    /// dirty-tracking is in use.
+    void updateDirtyTrackingSnapshot(vtbackend::RenderBuffer const& buffer);
+
     void executeImageDiscards();
 
     /// The atlas sizing the *configuration* asked for, kept apart from the effective sizing below.
@@ -506,6 +552,27 @@ class Renderer
     /// UI resize. nullopt until the first frame seeds it.
     std::optional<vtbackend::PageSize> _lastObservedTotalPageSize;
 
+    /// Dirty-row tracking (RenderDirtyMode::SkipUnchangedRows only), render-thread-only.
+    ///
+    /// The previous render() call's vtbackend::RenderBuffer::lineGenerations/lineGenerationsBase,
+    /// used by isRowDirty() to decide which rows the current frame may skip. Empty/false until the
+    /// first SkipUnchangedRows frame seeds it, and deliberately NOT reset when FullFrame frames
+    /// happen in between: a caller may alternate modes (e.g. always-FullFrame on resize), and a
+    /// stale-but-still-row-comparable snapshot is exactly as valid as a fresh one — isRowDirty()
+    /// only trusts it when the row is actually in bounds.
+    std::vector<uint64_t> _previousLineGenerations;
+    vtbackend::LineOffset _previousLineGenerationsBase {};
+    bool _havePreviousLineGenerations = false;
+
+    /// The previous frame's cursor position (single-pass path only), so its row can always be
+    /// forced dirty even when its line generation alone would say otherwise (the cursor overlay
+    /// itself is not part of a Line's tracked content).
+    std::optional<vtbackend::CellLocation> _previousCursorPosition;
+
+    /// Rows the most recent SkipUnchangedRows render() call painted; see lastDirtyRows(). Cleared at
+    /// the start of every render() call, populated only when dirtyMode == SkipUnchangedRows.
+    std::vector<vtbackend::LineOffset> _lastDirtyRows;
+
     /// Ensures a PendingReconfig exists and returns it. Caller must hold _reconfigMutex.
     PendingReconfig& ensurePendingLocked()
     {
