SharpConsoleUI Rendering Pipeline
Want to write a control? This document explains how frames are produced. For the practical, step-by-step authoring path — deriving from
BaseControland overridingMeasureDOM/PaintDOM— start at Creating Your Own Control, which links the composite-control and from-scratch (BadgeControl) tutorials.
Overview#
SharpConsoleUI uses a sophisticated multi-stage rendering pipeline optimized for console applications. The architecture employs double buffering at two levels (window and screen), dirty tracking at three levels — a per-window PendingWork intent accumulator (Repaint/Relayout) plus per-cell and per-line dirty tracking — and occlusion culling to minimize unnecessary rendering work.
High-Level Architecture#
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
│ • Window creation and updates │
│ • Control invalidation (AddControl, SetTitle, etc.) │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ConsoleWindowSystem (Coordinator) │
│ • Event loop (Run) │
│ • Frame limiting and dirty checking │
│ • UpdateDisplay() orchestration │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Renderer (Multi-Pass) │
│ • Build render list (Z-order, occlusion) │
│ • Three passes: Normal → Active → AlwaysOnTop │
│ • Per-window visible regions calculation │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Window Content Rendering (DOM-based) │
│ • Measure → Arrange → Paint (layout stages) │
│ • CharacterBuffer (window-level buffer) │
│ • Pre/PostBufferPaint hooks (compositor effects) │
└──────────────────────┬──────────────────────────────────────┘
│
▼ (Direct Cell Path — no ANSI strings)
┌─────────────────────────────────────────────────────────────┐
│ Console Driver (Screen Buffer) │
│ • ConsoleBuffer (screen-level double buffer) │
│ • Direct cell copy from CharacterBuffer │
│ • Diff-based rendering (Cell/Line/Smart modes) │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Physical Console │
│ • Raw libc write (Unix) / Console.Write (Windows) │
│ • ANSI codes generated once during final output │
└─────────────────────────────────────────────────────────────┘
Key Components#
| Component | Responsibility | Location |
|---|---|---|
| ConsoleWindowSystem | Main event loop, rendering coordinator | ConsoleWindowSystem.cs |
| Renderer | Multi-pass rendering, occlusion culling | Renderer.cs |
| VisibleRegions | Rectangle subtraction, overlap detection | VisibleRegions.cs |
| Window | Content rendering, DOM rebuilding | Window.cs |
| CharacterBuffer | Window-level cell buffer, DOM paint target | Layout/CharacterBuffer.cs |
| ConsoleBuffer | Screen-level buffer, direct cell copy, diff-based rendering | Drivers/ConsoleBuffer.cs |
| NetConsoleDriver | Console abstraction, I/O handling | Drivers/NetConsoleDriver.cs |
1. Entry Points#
Main Event Loop: ConsoleWindowSystem.Run()#
File: ConsoleWindowSystem.cs:822-975
The primary entry point for application rendering. Runs continuously until the application exits.
public void Run()
{
while (!_shouldQuit)
{
ProcessOnce(); // Single frame processing
// Frame limiting (default: ~60 FPS)
int sleepTime = Math.Max(1, FrameDelayMilliseconds - (int)_stopwatch.ElapsedMilliseconds);
if (sleepTime > 0)
Thread.Sleep(sleepTime);
_stopwatch.Restart();
}
}
Key behaviors:
- Infinite loop until
_shouldQuitflag set - Calls
ProcessOnce()for each frame - Frame rate limiting via
FrameDelayMilliseconds(default: 16ms ≈ 60 FPS) - Precise timing using
Stopwatch
Single Frame Processing: ProcessOnce()#
File: ConsoleWindowSystem.cs:977-1002
Handles input, processes events, and conditionally renders.
public void ProcessOnce()
{
ProcessInput(); // Handle keyboard/mouse input
ProcessPendingEvents(); // Execute queued events
// Only render if something changed
if (_needsFullRedraw || _windows.Any(w => w.NeedsRedraw))
{
UpdateDisplay();
_needsFullRedraw = false;
}
}
Optimization: Dirty Checking
- Rendering only occurs when
_needsFullRedrawor any window hasNeedsRedrawflag set - Avoids wasting CPU cycles when UI is static
- Typical frame: ~99% of frames skip rendering in idle applications
Manual Rendering: UpdateDisplay()#
File: ConsoleWindowSystem.cs:2522-2766
The central rendering coordinator. Can also be called manually for immediate updates.
2. Core Rendering Coordinator: UpdateDisplay()#
File: ConsoleWindowSystem.cs:2522-2766
The heart of the rendering pipeline, orchestrating all rendering phases.
The Five Phases#
private void UpdateDisplay()
{
lock (_renderLock)
{
var startTime = DateTime.UtcNow;
// PHASE 1: Atomic desktop clearing
if (_needsDesktopClear)
{
_driver.ClearScreen();
_needsDesktopClear = false;
}
// PHASE 2: Build render list (occlusion culling)
var renderList = BuildRenderList();
// PHASE 3: Render passes (Normal → Active → AlwaysOnTop)
foreach (var pass in new[] { WindowPass.Normal,
WindowPass.Active,
WindowPass.AlwaysOnTop })
{
var windowsInPass = renderList.Where(w => GetWindowPass(w) == pass);
foreach (var window in windowsInPass)
{
_renderer.RenderWindow(window, renderList);
}
}
// PHASE 4: Flush to screen
_driver.Render();
// PHASE 5: Status bar rendering
if (_statusBar != null)
{
RenderStatusBar(DateTime.UtcNow - startTime);
}
}
}
Phase Breakdown#
Phase 1: Atomic Desktop Clearing
- Clears entire screen when requested (window resize, theme change)
- Sets
_needsDesktopClearflag on major layout changes - Atomic operation: No partial clearing
Phase 2: Build Render List
File: ConsoleWindowSystem.cs:2634-2670
Determines which windows to render and in what order:
private List<Window> BuildRenderList()
{
return _windows
.Where(w => w.Visible && !w.IsMinimized)
.OrderBy(w => w.ZOrder) // Back-to-front ordering
.ToList();
}
Z-Order management:
- Lower
ZOrder= rendered first (background) - Higher
ZOrder= rendered last (foreground) - Modal windows automatically get highest Z-order
Phase 3: Three-Pass Rendering
| Pass | Windows Rendered | Purpose |
|---|---|---|
| Normal | Standard windows | Base UI layer |
| Active | Focused/Active window | Ensure focus visible |
| AlwaysOnTop | Notifications, tooltips | UI chrome that must be visible |
This ensures correct visual stacking regardless of Z-order values.
Within each window, portal overlays (dropdowns, context menus, tooltips) render after all normal controls, appearing on top of the window's content.
Phase 4: Screen Flush
- Calls
_driver.Render()to commit all buffered changes - Single flush per frame: All windows rendered before screen update
- Diff-based rendering minimizes console I/O
Phase 5: Status Bar
- Rendered last, overlays all content
- Shows FPS, dirty characters, memory usage
- Optional, controlled by
ShowPerformanceMetricsproperty
3. Window Rendering Pipeline#
Entry: Renderer.RenderWindow()#
File: Renderer.cs:211-270
Renders a single window with occlusion culling.
public void RenderWindow(Window window, List<Window> allWindows)
{
// Calculate visible regions (occlusion culling)
var visibleRegions = VisibleRegions.CalculateVisibleRegions(
window.Bounds,
allWindows.Where(w => w.ZOrder > window.ZOrder).Select(w => w.Bounds)
);
if (visibleRegions.Count == 0)
return; // Completely occluded, skip rendering
// Render background fill
foreach (var region in visibleRegions)
{
FillRegion(region, window.BackgroundColor);
}
// Render border (if present)
if (window.HasBorder)
{
RenderBorder(window, visibleRegions);
}
// Render window content (direct cell path)
var buffer = window.EnsureContentReady(visibleRegions);
if (buffer != null)
{
RenderVisibleWindowContentFromBuffer(window, buffer, visibleRegions);
}
}
Note The direct cell path bypasses ANSI serialization entirely.
EnsureContentReady()runs the DOM layout pipeline (Measure → Arrange → Paint) to populate the window'sCharacterBuffer. ThenRenderVisibleWindowContentFromBuffer()copies cells directly fromCharacterBuffertoConsoleBufferusingWriteBufferRegion(), avoiding the previous serialize-to-ANSI → clip-with-regex → parse-ANSI round-trip.
Visible Regions Calculation#
File: VisibleRegions.cs:40-158
The most complex optimization: determining which parts of a window are actually visible.
Algorithm: Rectangle Subtraction
Original Window Bounds:
┌─────────────────┐
│ │
│ │
│ │
└─────────────────┘
Overlapping Window (higher Z-order):
┌─────────┐
│ Overlap │
└─────────┘
Result (visible regions):
┌───────┐ ┌──┐
│ R1 │ │R2│
├───────┴─────────┤ │
│ R3 │ │
└─────────────────┴──┘
Implementation highlights:
- Dual-buffer pooling: Reuses
List<LayoutRect>to avoid allocations - Early exit: Returns empty list if completely occluded
- Iterative subtraction: Each overlapping window subtracts from remaining regions
public static List<LayoutRect> CalculateVisibleRegions(
LayoutRect targetBounds,
IEnumerable<LayoutRect> overlappingBounds)
{
var visible = new List<LayoutRect> { targetBounds };
foreach (var overlap in overlappingBounds)
{
var temp = new List<LayoutRect>();
foreach (var region in visible)
{
if (!region.IntersectsWith(overlap))
{
temp.Add(region); // No overlap, keep as-is
}
else
{
// Subtract overlap, potentially creating 4 new rectangles
temp.AddRange(SubtractRectangle(region, overlap));
}
}
visible = temp;
if (visible.Count == 0)
break; // Completely occluded
}
return visible;
}
Performance impact:
- Worst case: Window completely visible = 1 region
- Best case: Window completely occluded = 0 regions (skip rendering)
- Typical case: 2-5 regions per partially occluded window
4. Window Content Rendering (DOM-Based)#
Entry: Window.EnsureContentReady()#
File: Window.Rendering.cs
The single entry point for all window content rendering. Rebuilds the window's CharacterBuffer via the DOM pipeline without ANSI serialization. Used by the Renderer for both normal windows and overlay windows.
internal CharacterBuffer? EnsureContentReady(List<Rectangle>? visibleRegions = null)
{
if (_state == WindowState.Minimized) return null;
lock (_lock)
{
if (PendingWork != FrameWork.None) // the window has work to do this frame
{
var availableWidth = Width - 2;
var availableHeight = Height - 2;
RebuildContentBufferOnly(availableWidth, availableHeight, visibleRegions);
bool isInRenderingPipeline = visibleRegions != null && visibleRegions.Count > 0;
// Off-screen (no viewport yet): re-raise so the window rebuilds once it gets one.
if (!isInRenderingPipeline) Request(Invalidation.Repaint);
}
return _renderer?.Buffer;
}
}
The window is rendered only when PendingWork != FrameWork.None. RebuildContentBufferOnly
consumes the pending work — an atomic snapshot-and-clear back to None — so a request that
arrives after the consume simply leaves PendingWork non-None again and is picked up on the next
frame. See The Invalidation Model below.
A convenience wrapper RenderAndGetVisibleContent() exists for test code and diagnostics — it calls EnsureContentReady() internally then serializes the buffer to ANSI strings via buffer.ToLines(). No separate code path exists; the buffer is always the source of truth.
The Invalidation Model#
A window tracks pending work with a single signal: PendingWork. Callers never set it directly —
they declare an intent through Invalidate:
Invalidation (the request) |
Meaning | Cost on the next frame |
|---|---|---|
Repaint |
Appearance only — a color, selection, or the content of existing cells changed. Layout is unaffected. | Paint only; the Measure pass is skipped and the cached layout reused. |
Relayout |
Size or position changed. | Full Measure + Arrange + Paint. |
public enum Invalidation { Repaint = 1, Relayout = 2 } // what a caller requests
public enum FrameWork { None = 0, Repaint = 1, Relayout = 2 } // the window's pending state
public FrameWork Window.PendingWork { get; } // read-only; the render engine consults this
Accumulation. Each Invalidate folds its intent into PendingWork through a lock-free monotone
max-join: Relayout dominates Repaint dominates None. Many invalidations in a single frame
therefore coalesce to the strongest intent requested — a burst of Repaints plus one Relayout
resolves to Relayout, and nothing weaker can pull it back down. Because the fold is a single atomic
operation, Invalidate is also the one call safe to make directly from a background thread (a
playback or PTY-read loop can invalidate its control without marshalling to the UI thread).
Consume. The render reads PendingWork, does the work, and atomically resets it to None. A
request that races the consume either lands before it (handled this frame) or after it (leaves
PendingWork non-None, handled next frame) — it can never be lost.
Propagation. A control's Invalidate(work) calls Container.Invalidate(work, this), which bubbles
up the container hierarchy to the Window. BaseControl.SetProperty<T> issues this automatically on
every property change, so self-invalidation is built in across the control library.
The Three-Stage Layout Pipeline#
File: Window.cs:2500-2803
DOM-based rendering follows the classic layout model:
Stage 1: Measure
File: Window.cs:2550-2600
Each control calculates its desired size given available space. Measure is gated on the frame's
intent: it runs on a Relayout frame (or when a node is structurally dirty / NeedsMeasure) and is
skipped on a Repaint frame, where the cached DesiredSize from the previous layout is reused.
Arrange and Paint still run so the buffer stays correct. This is the payoff of the
invalidation model: an appearance-only change never pays for a re-measure.
private void MeasureStage(LayoutRect availableSpace)
{
foreach (var control in _controls)
{
var desiredSize = control.Measure(availableSpace.Width, availableSpace.Height);
control._measuredSize = desiredSize;
}
}
Considerations:
- Text controls measure string lengths (with ANSI stripping)
- Container controls recursively measure children
- Scrollable controls report virtual size vs viewport size
Stage 2: Arrange
File: Window.cs:2602-2670
Assigns final positions and sizes to controls based on layout rules.
private void ArrangeStage(LayoutRect finalBounds)
{
int currentY = finalBounds.Y;
foreach (var control in _controls)
{
var controlBounds = new LayoutRect(
finalBounds.X,
currentY,
finalBounds.Width,
control._measuredSize.Height
);
control.Arrange(controlBounds);
currentY += control._measuredSize.Height;
}
}
Layout strategies:
- Stack layout: Controls stacked vertically (default)
- Column layout: Side-by-side using
ColumnContainer - Absolute positioning: Fixed X/Y coordinates
- Fill: Control expands to fill available space
Stage 3: Paint
File: Window.cs:2672-2803
Controls draw themselves into the window's CharacterBuffer.
private void PaintStage(CharacterBuffer buffer)
{
foreach (var control in _controls)
{
var controlBounds = control.ArrangedBounds;
control.PaintDOM(buffer, controlBounds, _clipRect, defaultForeground, defaultBackground);
}
}
CharacterBuffer key methods:
// Full Cell (preserves flags: IsWideContinuation, Combiners, Decorations)
// Use for parsed markup cells and cell copies between buffers
void SetCell(int x, int y, Cell cell);
// Narrow character (clears all flags, assumes width-1)
// Use for literal characters: borders, padding, fill chars, track chars
void SetNarrowCell(int x, int y, char character, Color fg, Color bg);
void SetNarrowCell(int x, int y, Rune character, Color fg, Color bg);
// Text rendering with automatic wide character and combiner handling
void WriteString(int x, int y, string text, Color fg, Color bg);
// Bulk fill
void FillRect(LayoutRect rect, char c, Color fg, Color bg);
Note The old
SetCell(x, y, Rune, Color, Color)overload was renamed toSetNarrowCellto make misuse a compile error. UseSetCell(Cell)for parsed/copied cells,SetNarrowCellfor literal narrow characters.
Stage 3.5: Pre/Post Buffer Paint Hooks (Compositor Effects)
File: Windows/WindowRenderer.cs
Two compositor-style hooks allow buffer manipulation at different points in the rendering pipeline:
public bool RebuildContentBuffer(...)
{
// Stage 1-2: Measure, Arrange
RebuildDOMTree();
PerformDOMLayout();
// Clear buffer with background color
_buffer.Clear(backgroundColor);
// ◄── PRE-PAINT HOOK POINT (backgrounds, fractals)
if (PreBufferPaint != null && _buffer != null)
{
var dirtyRegion = new LayoutRect(0, 0, _buffer.Width, _buffer.Height);
PreBufferPaint.Invoke(_buffer, dirtyRegion, clipRect);
}
// Stage 3: Paint controls ON TOP of pre-paint content
PaintDOMWithoutClear(clipRect);
// ◄── POST-PAINT HOOK POINT (effects, overlays)
if (PostBufferPaint != null && _buffer != null)
{
var dirtyRegion = new LayoutRect(0, 0, _buffer.Width, _buffer.Height);
PostBufferPaint.Invoke(_buffer, dirtyRegion, clipRect);
}
// Buffer is now ready — cells flow directly to ConsoleBuffer
// (ToLines() is only called when AnsiLines diagnostic layer is enabled)
return true;
}
PreBufferPaint - Fires after buffer clear, before controls paint:
- Custom backgrounds: Animated patterns, gradients
- Full-buffer graphics: Fractals, visualizations
- Controls render ON TOP of pre-paint content
PostBufferPaint - Fires after controls paint, before buffer is consumed by the driver:
- Transitions: Fade in/out, slide, wipe effects
- Filters: Blur, desaturate, brightness adjustments
- Overlays: Glow effects, highlights, custom decorations
- Capture: Screenshots, recording via
BufferSnapshot
API:
// Pre-paint: custom backgrounds (controls render on top)
window.PreBufferPaint += (buffer, dirtyRegion, clipRect) =>
{
// Draw animated fractal or pattern
RenderFractal(buffer);
};
// Post-paint: effects applied to final content
window.PostBufferPaint += (buffer, dirtyRegion, clipRect) =>
{
// Apply fade or blur effect
ApplyFadeEffect(buffer, _fadeProgress);
};
Thread Safety: Both events fire within existing _renderLock, ensuring safe buffer manipulation.
Performance: Zero overhead when events not subscribed. Only process dirty regions for optimal performance.
See Compositor Effects for comprehensive examples and best practices.
5. Character Buffer System#
File: Layout/CharacterBuffer.cs
Window-level buffer storing character, foreground, and background color for each cell.
Buffer Structure#
public class CharacterBuffer
{
private Cell[,] _cells; // 2D array [col, row]
private int _width;
private int _height;
}
public struct Cell : IEquatable<Cell>
{
public Rune Character; // Unicode scalar value (supports emoji, CJK)
public Color Foreground;
public Color Background;
public TextDecoration Decorations;
public bool Dirty; // Changed since last render
public bool IsWideContinuation; // Right half of a wide character
public string? Combiners; // Zero-width combining marks (e.g., diacritics)
}
Key changes from the original char-based Cell:
Runereplaceschar— aSystem.Text.Runerepresents a full Unicode scalar value, enabling correct handling of emoji and characters above U+FFFF (which require surrogate pairs aschar)IsWideContinuationmarks the right-half placeholder cell for CJK/emoji characters that occupy 2 terminal columnsCombinersstores zero-width combining marks (diacritics, variation selectors) attached to the base characterTextDecorationsupports underline, strikethrough, and other text effects
Memory layout example (3x2 buffer):
Col 0 Col 1 Col 2
┌───────────┐ ┌───────────┐ ┌───────────┐
R0 │ 'H', W, B │ │ 'e', W, B │ │ 'l', W, B │
└───────────┘ └───────────┘ └───────────┘
┌───────────┐ ┌───────────┐ ┌───────────┐
R1 │ 'l', W, B │ │ 'o', W, B │ │ ' ', W, B │
└───────────┘ └───────────┘ └───────────┘
(W = White foreground, B = Black background)
Key Operations#
1. SetCell / SetNarrowCell (Individual Cell Update)
File: CharacterBuffer.cs
Two write APIs with distinct purposes:
SetCell(int x, int y, Cell cell)— preserves all flags (IsWideContinuation,Combiners,Decorations). Use for cells fromMarkupParser.Parse()or when copying cells between buffers.SetNarrowCell(int x, int y, char/Rune, Color fg, Color bg)— clears all flags, assumes width-1. Use for literal narrow characters: border chars, padding spaces, fill chars.
The old
SetCell(x, y, Rune, Color, Color)overload was renamed toSetNarrowCellto make misuse a compile error.
public void SetNarrowCell(int x, int y, Rune character, Color foreground, Color background)
{
if (x < 0 || x >= Width || y < 0 || y >= Height)
return;
CleanupWideCharAt(x, y); // Fix orphaned wide char pairs
ref var cell = ref _cells[x, y];
if (cell.Character != character ||
!cell.Foreground.Equals(foreground) ||
!cell.Background.Equals(background) ||
cell.IsWideContinuation ||
cell.Combiners != null)
{
cell.Character = character;
cell.Foreground = foreground;
cell.Background = background;
cell.Decorations = TextDecoration.None;
cell.IsWideContinuation = false;
cell.Combiners = null;
cell.Dirty = true;
}
}
Optimizations:
- Dirty tracking: only marks cell dirty if value actually changes
- Wide character cleanup: when overwriting a cell that is part of a wide character pair, the orphaned partner cell is cleaned up automatically
2. WriteString (Text Rendering with Wide Character Support)
File: CharacterBuffer.cs
The primary text rendering method. Handles wide characters and zero-width combiners automatically:
public void WriteString(int x, int y, string text, Color foreground, Color background)
{
int cx = x;
foreach (var rune in text.EnumerateRunes())
{
int runeWidth = UnicodeWidth.GetRuneWidth(rune);
if (runeWidth == 0)
{
// Zero-width: attach to previous cell as combiner
// (skips past continuation cells to find the base cell)
}
else if (runeWidth == 2)
{
// Wide char: write base cell + continuation cell
SetCell(cx, y, new Cell(rune, foreground, background));
SetCell(cx + 1, y, continuation with IsWideContinuation = true);
cx += 2;
}
else
{
SetCell(cx, y, new Cell(rune, foreground, background));
cx++;
}
}
}
Wide character handling:
- Uses
UnicodeWidth.GetRuneWidth()(backed by Wcwidth library) to determine display width - Width 0: zero-width combiners attached to the preceding base cell via
AppendCombiner() - Width 2: CJK/emoji characters write a base cell + a continuation cell marked
IsWideContinuation = true - Width 1: standard characters, one cell each
- If a wide char doesn't fit at the buffer edge, a space is written instead
3. FillRect (Bulk Operations)
File: CharacterBuffer.cs
Used for backgrounds, borders, clearing regions. Delegates to SetNarrowCell() per cell.
4. ToLines (ANSI Serialization — Diagnostic Only)
File: CharacterBuffer.cs
Converts buffer to ANSI-formatted strings. Not called during rendering. Cells flow directly from CharacterBuffer to ConsoleBuffer via SetCellsFromBuffer(). ToLines() is only invoked for:
- The
AnsiLinesdiagnostic layer (test snapshots and debugging) - The
RenderAndGetVisibleContent()convenience API (used by tests to get string-based output)
public List<string> ToLines()
{
var lines = new List<string>();
for (int y = 0; y < _height; y++)
{
var sb = new StringBuilder();
Color? currentFg = null;
Color? currentBg = null;
for (int x = 0; x < _width; x++)
{
var cell = _buffer[y, x];
// Optimization: Only emit ANSI codes when colors change
if (cell.Foreground != currentFg)
{
sb.Append(AnsiCodes.Foreground(cell.Foreground));
currentFg = cell.Foreground;
}
if (cell.Background != currentBg)
{
sb.Append(AnsiCodes.Background(cell.Background));
currentBg = cell.Background;
}
sb.Append(cell.Character);
}
lines.Add(sb.ToString());
}
return lines;
}
ANSI Optimization: Color Runs (used in diagnostic and test output)
- Tracks current foreground/background colors
- Only emits ANSI escape codes when colors change
- Typical reduction: 80% fewer ANSI codes vs naive approach
Example output:
\x1b[37;40mHello \x1b[31mWorld\x1b[37m!
^^^^^^^^^^ ^^^^
Set colors Change FG only
Alpha Compositing#
Controls write cells with Color.Transparent as the background to indicate "show whatever is
behind me." CharacterBuffer resolves this at paint time by using the background color already
present at that position in the buffer.
Paint order matters:
PreBufferPainthooks run first — gradient backgrounds and custom fills land in the buffer.- Controls paint on top via
PaintDOM. A cell written withColor.Transparentbackground inherits the gradient color at that cell from step 1. PostBufferPainthooks run last — can read or overwrite cells.
PreBufferPaint: gradient fills every cell (e.g. DarkBlue→Black vertically)
PaintDOM: SparklineControl writes bar chars with bg=Transparent
→ those cells display with the gradient color as their background
PostBufferPaint: (optional effects)
Background resolution chain in controls:
Controls expose BackgroundColor as Color?. null means "use the theme default or
Color.Transparent." Color.Transparent means "composite against the buffer." Any other
Color means "fill with this solid color."
// PaintDOM — typical pattern in a control
Color bgColor = ColorResolver.ResolveControlBackground(_backgroundColorValue, Container);
// bgColor is Color.Transparent when no explicit color is set → gradient shows through
This design keeps controls unaware of what sits behind them. The gradient (or any
PreBufferPaint content) shows through automatically for every control that does not set an
explicit opaque background.
6. Console Driver Layer#
Screen-Level Buffering: ConsoleBuffer#
File: Drivers/ConsoleBuffer.cs
Second level of double buffering, operating at the screen level. ConsoleBuffer is a pure cell-level buffer — all writes go through cell-level methods, never ANSI string parsing.
public class ConsoleBuffer
{
private Cell[,] _frontBuffer; // Currently displayed
private Cell[,] _backBuffer; // Being rendered
private int _width;
private int _height;
private struct Cell
{
public Rune Character; // Unicode scalar value
public string AnsiEscape; // Pre-formatted ANSI color string
public bool IsWideContinuation; // Right half of wide character
public string? Combiners; // Zero-width combining marks
}
}
Three Write Methods (Unified Cell API)#
All data enters ConsoleBuffer through exactly three cell-level methods.
Note the narrow-cell writers are named SetNarrowCell — they assume width-1 and clear
cell flags, so they are only for literal narrow characters (borders, padding, fill).
1. SetNarrowCell (Single Cell)
Used by borders (vertical, scrollbar), invisible borders. Accepts both char and Rune.
public void SetNarrowCell(int x, int y, Rune character, Color fg, Color bg)
{
// Fix wide char pair split: clean up orphaned base/continuation cells
if (_backBuffer[x, y].IsWideContinuation && x > 0)
_backBuffer[x - 1, y].Reset(); // Orphaned base
if (x + 1 < _width && _backBuffer[x + 1, y].IsWideContinuation)
_backBuffer[x + 1, y].Reset(); // Orphaned continuation
string ansi = FormatCellAnsi(fg, bg);
ref var cell = ref _backBuffer[x, y];
if (cell.Character != character || cell.AnsiEscape != ansi
|| cell.IsWideContinuation || cell.Combiners != null)
{
cell.Character = character;
cell.AnsiEscape = ansi;
cell.IsWideContinuation = false;
cell.Combiners = null;
}
}
2. FillCells (Horizontal Run)
Used by Renderer.FillRect (background fills), invisible border rows. Accepts both char and Rune.
public void FillCells(int x, int y, int width, Rune character, Color fg, Color bg)
{
// Fix wide char pair split at left and right boundaries
if (x > 0 && _backBuffer[x, y].IsWideContinuation)
_backBuffer[x - 1, y].Reset();
int rightEdge = x + maxWidth;
if (rightEdge < _width && _backBuffer[rightEdge, y].IsWideContinuation)
_backBuffer[rightEdge, y].Reset();
string ansi = FormatCellAnsi(fg, bg);
for (int i = 0; i < maxWidth; i++)
{
ref var cell = ref _backBuffer[x + i, y];
if (cell.Character != character || cell.AnsiEscape != ansi
|| cell.IsWideContinuation || cell.Combiners != null)
{
cell.Character = character;
cell.AnsiEscape = ansi;
cell.IsWideContinuation = false;
cell.Combiners = null;
}
}
}
3. SetCellsFromBuffer (Bulk Cell Copy from CharacterBuffer)
Used by window content, overlay content, border lines (top/bottom), status bars. Preserves wide character pairs and combiners from the source buffer.
public void SetCellsFromBuffer(int destX, int destY, CharacterBuffer source,
int srcX, int srcY, int width, Color fallbackBg)
{
// Fix wide char pair split at left and right boundaries
// (same orphan cleanup as FillCells)
for (int i = 0; i < maxWidth; i++)
{
var srcCell = source.GetCell(srcX + i, srcY);
string ansi = FormatCellAnsi(srcCell.Foreground, srcCell.Background);
ref var destCell = ref _backBuffer[destX + i, destY];
destCell.Character = srcCell.Character;
destCell.AnsiEscape = ansi;
destCell.IsWideContinuation = srcCell.IsWideContinuation;
destCell.Combiners = srcCell.Combiners;
}
}
ANSI color cache — exploits spatial locality (adjacent cells often share colors):
private string FormatCellAnsi(Color fg, Color bg)
{
if (fg.Equals(_lastCellFg) && bg.Equals(_lastCellBg))
return _lastCellAnsi; // Cache hit
_lastCellAnsi = $"\x1b[38;2;{fg.R};{fg.G};{fg.B};48;2;{bg.R};{bg.G};{bg.B}m";
_lastCellFg = fg;
_lastCellBg = bg;
return _lastCellAnsi;
}
Driver Interface (Cell-Level Only)#
File: Drivers/IConsoleDriver.cs
The driver interface exposes three cell-level output methods. No ANSI string writes exist:
public interface IConsoleDriver
{
void SetNarrowCell(int x, int y, char character, Color fg, Color bg); // char convenience
void FillCells(int x, int y, int width, char character, Color fg, Color bg); // char convenience
void WriteBufferRegion(int destX, int destY, CharacterBuffer source,
int srcX, int srcY, int width, Color fallbackBg);
// Note: char overloads wrap in Rune internally. All paths support
// full Unicode including wide characters and combining marks.
}
In Buffer mode, all three delegate to the corresponding ConsoleBuffer methods.
In Direct mode, they format inline ANSI strings and write immediately to stdout.
Render (Diff-Based Screen Output)#
File: ConsoleBuffer.cs
The final step: writing to the physical console. Builds the entire frame output as a single string for atomic write.
public void Render()
{
lock (_consoleLock)
{
// 1. Pre-process wide character dirty pair coherence
// When either half of a wide char pair changes, force both dirty
for (int y = 0; y < _height; y++)
for (int x = 1; x < _width; x++)
{
// If continuation changed but base is clean → force base dirty
// If front had continuation but back doesn't → force base dirty
}
// 2. Build entire screen in one string (atomic output)
_screenBuilder.Clear();
// 3. Choose rendering strategy per configured DirtyTrackingMode:
// - Cell: render only changed regions within lines (minimal output)
// - Line: render entire line when any cell changes
// - Smart: analyze each line and choose Cell vs Line dynamically
for (int y = 0; y < _height; y++)
{
// ... strategy-specific rendering ...
}
// 4. Single atomic write via raw libc write() on Unix
if (_screenBuilder.Length > 0)
WriteOutput(_screenBuilder);
}
}
Four levels of optimization:
- Wide character coherence: Pre-processes wide char pairs so both halves are re-emitted together
- Line-level dirty checking: Skip unchanged lines entirely
- Region diffing: Within dirty lines, only update changed regions (Cell/Smart modes)
- Atomic output: Single
WriteOutput()call eliminates flicker from multiple cursor moves
Three dirty tracking modes (configurable via ConsoleWindowSystemOptions.DirtyTrackingMode):
| Mode | Strategy | Best For |
|---|---|---|
| Cell | Render only changed cell ranges per line | Sparse updates (text input, cursor blink) |
| Line | Render entire line when any cell changes | Dense updates (full redraws, scrolling) |
| Smart | Per-line analysis: coverage > threshold or fragmented runs → Line mode, else Cell mode | General purpose (default) |
AppendRegionToBuilder (Wide Character Aware Output)#
File: ConsoleBuffer.cs
Appends a dirty region to the output string, handling wide characters and combiners:
private void AppendRegionToBuilder(int y, int startX, int endX, StringBuilder builder)
{
string lastOutputAnsi = string.Empty;
for (int x = startX; x <= endX && x < _width; x++)
{
ref var backCell = ref _backBuffer[x, y];
ref var frontCell = ref _frontBuffer[x, y];
frontCell.CopyFrom(backCell); // Sync buffers
// Skip continuation cells — terminal auto-advances for wide chars
if (backCell.IsWideContinuation) { /* emit combiners only */ continue; }
// Wide char ghost cleanup: if terminal had different content at x+1,
// emit space there first to clear old content, then reposition
bool isWideChar = x + 1 < _width && _backBuffer[x + 1, y].IsWideContinuation;
if (isWideChar) { /* clear ghost content at x+1 if needed */ }
// Emit ANSI only when color changes
if (backCell.AnsiEscape != lastOutputAnsi)
builder.Append(backCell.AnsiEscape);
builder.AppendRune(backCell.Character); // Rune → UTF-16 encoding
if (backCell.Combiners != null)
builder.Append(backCell.Combiners);
if (isWideChar) x++; // Skip continuation in loop
}
}
Wide character output handling:
- Continuation cells are skipped (terminal auto-advances past them)
- Ghost content at x+1 is cleared before emitting a wide char (prevents old content from persisting)
AppendRune()handles surrogate pair encoding for characters above U+FFFF- Combiners are appended after both base and continuation cells
Typical performance:
- Idle frame: 0 lines rendered (all clean)
- Text input: 1-2 lines rendered (input line + cursor)
- Window drag: 10-50 lines rendered (only affected regions)
- Full redraw: All lines rendered (rare, only on resize/theme change)
7. Console Driver Abstraction: NetConsoleDriver#
File: Drivers/NetConsoleDriver.cs:65-1027
Abstracts platform-specific console operations and manages console state.
Responsibilities#
- Console initialization (colors, cursor visibility, buffer size)
- Double-buffered rendering (back buffer, dirty-region diff)
- Input handling (background thread with blocking read)
- Resize detection (background thread with polling)
- Screen coordinate mapping
Two Render Modes#
Buffer Mode (Recommended)
File: NetConsoleDriver.cs:145-200
Uses ConsoleBuffer for double-buffered rendering.
public void Render() // Buffer mode
{
if (_renderMode == RenderMode.Buffer)
{
_consoleBuffer.Render();
}
}
Advantages:
- Diff-based rendering (minimal console I/O)
- Flicker-free updates
- Handles overlapping windows gracefully
- Best performance for complex UIs
Use when:
- Multiple overlapping windows
- Frequent UI updates
- Production applications
8. The loop and WithAsyncWindowThread#
Everything above runs on one thread: the UI thread, inside
ConsoleWindowSystem.Run(). Input is polled, animations advance, dirty windows
measure, arrange and paint, the compositor merges them, and the driver flushes
the diff - in that order, synchronously, once per frame.
WithAsyncWindowThread does not add a second render loop. It starts a delegate
on a thread-pool task (Task.Run) that belongs to one window and lives as long
as that window does. Nothing in the frame sequence above waits for it.
That is what makes it useful and what makes it dangerous. A slow query in that delegate cannot stall the frame, because the frame never joins it. For the same reason the delegate must not touch control state directly: the render loop reads that state without a lock, so a background write is a data race.
.WithAsyncWindowThread(async (win, ct) =>
{
var rows = await QueryAsync(ct); // off the UI thread - fine
windowSystem.EnqueueOnUIThread(() => // back on the UI thread
{
table.SetRows(rows); // control state: UI thread only
});
})
EnqueueOnUIThread queues the action onto the loop; it runs at the top of the
next frame, before measure. Invalidate is the one call that is safe to make
directly from the delegate, because it folds into the window's pending-work
accumulator atomically rather than touching layout.
See Threading & async for the full model.
9. File Reference Table#
Core Rendering Pipeline#
| Component | File | Key Methods | Line Range |
|---|---|---|---|
| Event Loop | ConsoleWindowSystem.cs |
Run(), ProcessOnce() |
— |
| Render Coordinator | Rendering/RenderCoordinator.cs |
UpdateDisplay(), coverage caching, status bar caching |
— |
| Window Renderer | Renderer.cs |
RenderWindow(), RenderVisibleWindowContentFromBuffer() |
— |
| Overlay Renderer | Renderer.cs |
RenderOverlayWindow(), RenderOverlayControlRegionsFromBuffer() |
— |
| Occlusion Culling | VisibleRegions.cs |
CalculateVisibleRegions() |
40-158 |
| Window Content | Window.Rendering.cs |
EnsureContentReady(), ContentBuffer, RenderAndGetVisibleContent() (test API) |
— |
| Layout Pipeline | Window.Layout.cs |
RebuildContentBufferOnly() |
— |
| DOM Rendering | Windows/WindowRenderer.cs |
RebuildContentBuffer() |
— |
| Character Buffer | Layout/CharacterBuffer.cs |
SetCell(), SetNarrowCell(), GetCell(), ToLines() (test/diagnostic) |
— |
| Console Buffer | Drivers/ConsoleBuffer.cs |
SetCell(), FillCells(), SetCellsFromBuffer(), Render() |
— |
| Console Driver | Drivers/NetConsoleDriver.cs |
SetNarrowCell(), FillCells(), WriteBufferRegion(), Render() |
— |
| Driver Interface | Drivers/IConsoleDriver.cs |
SetNarrowCell(), FillCells(), WriteBufferRegion() |
— |
Supporting Systems#
| Component | File | Key Methods | Line Range |
|---|---|---|---|
| Input Handling | NetConsoleDriver.cs |
InputLoop(), ProcessInput() |
560-680 |
| Resize Detection | NetConsoleDriver.cs |
ResizeLoop() |
720-800 |
| Border Rendering | Windows/BorderRenderer.cs |
RenderBorders(), BuildTopBorder(), BuildBottomBorder() |
— |
| Z-Order Management | ConsoleWindowSystem.cs |
BuildRenderList() |
2634-2670 |
| Performance Metrics | ConsoleWindowSystem.cs |
RenderStatusBar() |
2800-2850 |
Helper Classes#
| Component | File | Purpose |
|---|---|---|
| ANSI Codes | Rendering/AnsiCodes.cs |
ANSI escape sequence generation |
| Color Helpers | Helpers/ColorResolver.cs |
Color resolution and inheritance |
| Layout Rect | Models/ImmutableModels.cs |
Immutable rectangle structure |
| Markup Parsing | Parsing/MarkupParser.cs |
Markup parsing and text measurement |
| Unicode Width | Helpers/UnicodeWidth.cs |
Display width detection via Wcwidth (0/1/2 columns) |
| Scrollbar Helper | Helpers/ScrollbarHelper.cs |
Shared scrollbar geometry, drawing, and hit testing |
| StringBuilder Extensions | Helpers/StringBuilderExtensions.cs |
AppendRune() for Rune → UTF-16 StringBuilder output |
10. Common Patterns & Examples#
Pattern 1: Invalidating Content#
When you modify a window's content, call Invalidate() to trigger a redraw:
// Simple invalidation
window.AddControl(new MarkupControl("Hello"));
// AddControl() automatically calls Invalidate()
// Manual invalidation
window.Title = "New Title"; // Property setter calls Invalidate()
// Explicit invalidation
window.BackgroundColor = Color.Blue;
window.Invalidate(); // Force redraw
What happens:
Invalidate()setswindow.NeedsRedraw = true- Next frame:
ProcessOnce()detects dirty window UpdateDisplay()called- Window rendered with new content
Pattern 2: Custom Control Rendering#
Derive from BaseControl and override PaintDOM (declared by IDOMPaintable, not by
IWindowControl) to render custom controls. See
Creating Your Own Control for the full walkthrough.
public class CustomControl : BaseControl
{
public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutRect clipRect,
Color defaultForeground, Color defaultBackground)
{
SetActualBounds(bounds);
// Draw background
buffer.FillRect(bounds, ' ', Color.White, Color.DarkBlue);
// Draw text (supports full Unicode including CJK and emoji)
buffer.WriteString(bounds.X + 2, bounds.Y + 1, "Custom Control",
Color.Yellow, Color.DarkBlue);
// Draw border
for (int x = bounds.X; x < bounds.Right; x++)
{
// Literal narrow chars → SetNarrowCell (there is no 5-arg SetCell overload).
buffer.SetNarrowCell(x, bounds.Y, '─', Color.Gray, Color.DarkBlue);
buffer.SetNarrowCell(x, bounds.Bottom - 1, '─', Color.Gray, Color.DarkBlue);
}
}
}
Best practices:
- Respect
clipRectfor scrollable containers - Use
FillRect()for backgrounds (faster than per-char) - Cache expensive calculations (text measurements, layouts)
- Only draw within
bounds
Pattern 3: Efficient List Updates#
When updating list items, use targeted invalidation:
public class MyListControl : BaseControl
{
private List<string> _items = new();
public void AddItem(string item)
{
_items.Add(item);
Invalidate(Invalidation.Relayout); // an added row changes size → re-layout
}
public void UpdateItem(int index, string newValue)
{
if (index < 0 || index >= _items.Count)
return;
_items[index] = newValue;
Invalidate(Invalidation.Relayout); // text width may change → re-layout
}
}
Optimization:
- Batch updates: modify multiple items, then call
Invalidate()once — the max-join coalesces them. - Use
Invalidate(Invalidation.Repaint)for appearance-only changes (e.g. a selection or color change that doesn't move anything): the layout is reused and the Measure pass is skipped. - A burst of
Repaints plus oneRelayoutin the same frame resolves toRelayout— you never need to track "the strongest invalidation so far" yourself; the accumulator does it.
Pattern 4: Modal Windows#
Display modal windows that block interaction with other windows:
// Create and show modal
var modal = new Window(windowSystem)
{
Title = "Confirmation",
Width = 40,
Height = 10,
IsModal = true,
AlwaysOnTop = true
};
modal.AddControl(new MarkupControl("[yellow]Are you sure?[/]"));
modal.Placement = Placement.Center(40, 10);
windowSystem.AddWindow(modal);
// Check if modals active
if (windowSystem.ModalStateService.HasModals)
{
// Background windows won't receive input
}
// Close modal
windowSystem.CloseWindow(modal);
Behavior:
- Modal windows have highest Z-order
- Input blocked to non-modal windows
- Escape key typically closes modal (customizable)
Pattern 5: Real-Time Updates#
For controls that update continuously (clocks, progress bars):
public class ClockControl : BaseControl
{
private string _currentTime = "";
public ClockControl()
{
// Update clock every second
var timer = new System.Threading.Timer(_ =>
{
_currentTime = DateTime.Now.ToString("HH:mm:ss");
// Fixed-width text → appearance-only; Repaint skips Measure. Safe from the timer thread.
Invalidate(Invalidation.Repaint);
}, null, 0, 1000);
}
public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutRect clipRect,
Color defaultForeground, Color defaultBackground)
{
SetActualBounds(bounds);
buffer.WriteStringClipped(bounds.X, bounds.Y, _currentTime,
Color.Green, Color.Black, clipRect);
}
}
Considerations:
- Timer callbacks run on background thread
Invalidate()is thread-safe- Rendering occurs on main thread (thread-safe by design)
- Avoid excessive update rates (>60 FPS wasted)
Pattern 6: Measuring Text Width#
When calculating text widths for layout, account for both markup tags and wide characters:
using SharpConsoleUI.Parsing;
using SharpConsoleUI.Helpers;
public int MeasureMarkupWidth(string markupText)
{
// Strip markup tags: "[red]Hello[/]" → "Hello"
int visualWidth = MarkupParser.StripLength(markupText);
return visualWidth;
}
public int MeasureStringWidth(string plainText)
{
// Accounts for wide characters (CJK = 2 columns, combining marks = 0)
return UnicodeWidth.GetStringWidth(plainText);
}
Why needed:
- Markup tags don't consume screen space (
"[red]Hi[/]"= 2 columns, not 10) - CJK characters occupy 2 terminal columns (
"日本"= 4 columns, not 2) - Zero-width combiners don't consume space (
"é"as e + combining accent = 1 column) - Use for centering, alignment, truncation
Pattern 7: Handling Overlapping Windows#
The system handles overlaps automatically, but you can optimize:
// Windows with AlwaysOnTop don't participate in occlusion culling
var tooltip = new Window(windowSystem)
{
AlwaysOnTop = true, // Rendered last, always visible
IsModal = false // Doesn't block input
};
// Background windows can be skipped if fully occluded
// System calculates this automatically via VisibleRegions
Automatic optimizations:
- Fully occluded windows skip content rendering
- Partially occluded windows render only visible regions
- Z-order ensures correct visual stacking
11. Debugging & Troubleshooting#
Rendering Issues#
Problem: Window content not updating
Check:
- Is
Invalidate()being called? - Is
window.Visibletrue? - Is window minimized?
- Is window fully occluded by others?
Debug:
// Force redraw
window.Invalidate();
windowSystem.FullRedraw();
// Check state using LogService (NEVER use Console.WriteLine - it corrupts UI!)
var logService = windowSystem.LogService;
logService.Log(LogLevel.Debug, "Window",
$"NeedsRedraw: {window.NeedsRedraw}, Visible: {window.Visible}, Z-Order: {window.ZOrder}");
// Or write to a debug window
var debugWindow = new Window(windowSystem) { Title = "Debug Info" };
debugWindow.AddControl(new MarkupControl($"NeedsRedraw: {window.NeedsRedraw}"));
debugWindow.AddControl(new MarkupControl($"Visible: {window.Visible}"));
debugWindow.AddControl(new MarkupControl($"Z-Order: {window.ZOrder}"));
Problem: Flickering
Likely causes:
- Excessive invalidations (batch updates)
- Console output interference (check for
Console.WriteLine()or console logging providers - these WILL corrupt the UI!)
Problem: Performance degradation
Enable metrics:
windowSystem.ShowPerformanceMetrics = true;
Watch for:
- FPS < 30: Too much rendering work
- Dirty chars > 10,000: Excessive invalidations
- Frame time > 16ms: Missing 60 FPS target
Logging#
Method 1: Environment Variables
Enable debug logging via environment:
export SHARPCONSOLEUI_DEBUG_LOG=/tmp/consoleui.log
export SHARPCONSOLEUI_DEBUG_LEVEL=Debug
dotnet run
Method 2: Programmatic Initialization (Recommended for Debugging)
Initialize logging directly in your code:
var windowSystem = new ConsoleWindowSystem(RenderMode.Buffer);
// Enable file logging programmatically
windowSystem.LogService.EnableFileLogging("/tmp/debug.log");
windowSystem.LogService.MinimumLevel = LogLevel.Debug; // or LogLevel.Trace for maximum detail
// Your application code...
var window = new Window(windowSystem) { Title = "Test" };
// Log custom debug information
windowSystem.LogService.Log(LogLevel.Debug, "MyApp", "Window created");
// Access logs programmatically
var recentLogs = windowSystem.LogService.GetRecentLogs(50);
foreach (var entry in recentLogs)
{
// Process log entries (store, analyze, etc.)
}
Method 3: Subscribe to Log Events
Real-time log monitoring:
windowSystem.LogService.LogAdded += (sender, entry) =>
{
// Write to your own file, database, or monitoring system
File.AppendAllText("/tmp/myapp.log",
$"[{entry.Timestamp:HH:mm:ss.fff}] [{entry.Level}] {entry.Category}: {entry.Message}\n");
};
Log Categories to Search For:
[System]- Overall system lifecycle[Render]- Rendering operations[Window]- Window lifecycle (create, close, show, hide)[WindowState]- Window state management[Focus]- Focus changes[Modal]- Modal window handling[Interaction]- Drag/resize events[Input]- Keyboard/mouse input
Example Log Output:
[14:23:45.123] [Debug] Window: Window created - ID: win_001, Title: 'Main Window'
[14:23:45.134] [Trace] Render: UpdateDisplay started
[14:23:45.136] [Debug] Render: Building render list - 3 windows
[14:23:45.138] [Trace] Render: Rendering window 'Main Window' (Z-Order: 0)
[14:23:45.142] [Trace] Render: Visible regions calculated - 2 regions
[14:23:45.145] [Debug] Render: Frame completed - 2.1ms, 143 dirty chars
Conclusion#
The SharpConsoleUI rendering pipeline is designed for performance, correctness, and maintainability:
- Performance: Double buffering, dirty tracking, occlusion culling minimize CPU and I/O
- Correctness: Multi-pass rendering, locking hierarchy, diff-based output ensure visual accuracy
- Maintainability: Clear separation of concerns (Coordinator → Renderer → Window → Driver)
Key takeaways:
- Rendering is lazy: Only dirty windows render, only dirty lines write to console
- Occlusion is expensive but worth it: Rectangle subtraction adds complexity but saves 50%+ rendering
- Double buffering at two levels: Window buffers for composition, screen buffer for output
- Three-pass rendering guarantees order: Normal → Active → AlwaysOnTop
- Locking is simple: One lock per layer, strict hierarchy, brief critical sections
For new contributors:
- Start at
ConsoleWindowSystem.UpdateDisplay()to understand flow - Read
Renderer.RenderWindow()to see per-window logic - Study
CharacterBufferandConsoleBufferto understand buffering - Review
VisibleRegionsonly if working on occlusion culling
For debugging:
- Enable
ShowPerformanceMetricsfor real-time stats - Enable debug logging for detailed operation traces
- Check lock contention if experiencing stuttering
The pipeline in one sentence:
Application invalidates windows → Event loop detects dirty windows → RenderCoordinator orchestrates multi-pass rendering → Renderer calculates visible regions per window → DOM layout pipeline paints to CharacterBuffer (with full Unicode/wide character support) → All data enters ConsoleBuffer through cell-level methods only (SetCell, FillCells, SetCellsFromBuffer — no ANSI parsing) → Wide character coherence pre-processing ensures atomic pair updates → Smart dirty tracking selects optimal Cell/Line strategy per line → Single atomic write to console.