This document explores options to reduce the CPU cost of rendering when only a small part of the visual tree changes.
It is internal documentation for framework contributors. It intentionally focuses on correctness and minimal-complexity approaches that fit the existing binding/dependency tracking and CellBuffer diff rendering model.
Today, TerminalApp.Render():
CellBuffer when a frame render is requested.buffer.Clear(theme.BaseTextStyle())).CellBufferDiffRenderer to minimize terminal writes (cell-diff).This keeps terminal output efficient, but it still runs a significant amount of UI traversal + rendering code on every frame, even when the visual change is localized (spinner tick, caret blink, hover change, etc.).
RenderOverride(CellBuffer) (controls remain unaware).Measure/Arrange can be skipped for subtrees because they do not depend on previously rendered pixels.
Rendering, however, is currently done by:
If we clear the whole buffer and then skip rendering parts of the tree, we would end up with missing output. So any “render dirty” optimization must either:
Therefore, a practical optimization is usually based on dirty regions (rectangles) and incremental buffer updates, not only “dirty flags”.
Summary: Always clear the full buffer and render the full tree.
Pros
Cons
Summary: Keep the render buffer as “current frame”, and on each render only repaint dirty rectangles by:
Pros
Cons
Summary: Render only the dirty visual and its children directly into the existing buffer.
Problem: This is only correct if every visual paints its entire bounds (including background). That is not true for
many controls (e.g., TextBlock typically paints glyphs only), so stale pixels remain.
This option is not recommended unless the framework enforces a “fully paints bounds” rule.
Summary: Each visual renders into its own buffer and is composited into the parent.
Pros
Cons
Not recommended for now.
Treat _renderBuffer as the authoritative “current frame” buffer:
CellBufferDiffRenderer continues to diff the buffer against its internal “last frame” state.
Start minimal:
Rectangle DirtyUnion.Then optionally evolve:
List<Rectangle> (bounded count), unioning when too many.Recommended heuristics:
N dirty rects, union them.Full repaint is the safe fallback when:
Pragmatic rule:
If measure or arrange runs during the frame, perform full repaint.
This keeps the incremental optimization targeted at render-only updates (caret, animation, hover, small content changes).
When a binding write affects render-only dependencies:
TerminalApp.ProcessBindingWrites() already identifies impacted visuals via the render dependency index.visual.Bounds to the dirty union.If the visual might have moved since last frame (rare without layout), this is still safe because:
If we want to avoid “full repaint on arrange” later, we must track old and new bounds:
Visual.LastArrangedBounds (internal) and update it after a successful arrange.oldBounds ∪ newBounds.This is optional and can be deferred until the render-only optimization is stable.
Algorithm for render-only repaint:
_renderBuffer to the base style.Root.RenderTree(_renderBuffer) with that clip active.Important: we render from root so that overlap/z-order is correct.
Update Visual.RenderTree to early-out:
Bounds does not intersect the current CellBuffer clip rectangle, skip:
RenderOverride,This preserves correctness and reduces CPU time significantly when repainting a small region.
When a visual is skipped because it is outside the dirty region:
This aligns with the existing dependency model used for measure/arrange/prepare children.
Even if rendering becomes incremental, the diff renderer can still diff the full buffer.
This preserves correctness and keeps the optimization localized to rendering CPU time.
If necessary later, the diff renderer can accept a dirty region and only scan those cells. This should be considered a separate optimization step because it complicates cursor/color state reconstruction.
This spec focuses on the core rendering optimization. Host-specific behavior should be addressed separately.
Low risk
Medium risk
High risk
Implement Option B in two stages:
RenderTree.Stage 1 has been implemented with the following concrete changes:
CellBuffer:
CurrentClipRect and provides ClipIntersects(Rectangle).ClearCurrentClip(Style) to clear only the current clip region without clearing hyperlink/text tables.
This is required for incremental repaint while keeping scalar tokens stable for cells outside the dirty region.Visual.RenderTree(CellBuffer):
Bounds does not intersect the current buffer clip.TerminalApp.Render():
Root.RenderTree(buffer) with the clip active.Notes: