-
Notifications
You must be signed in to change notification settings - Fork 0
Rendering Cell Buffer and Renderer
Cursorial.Rendering is the lowest layer with a real TUI abstraction: you paint into a grid of
Cells — a CellBuffer — and hand successive buffers to a stateful FrameRenderer that diffs
them and emits the minimal stream of escape sequences to bring the terminal up to date. Reach for
it when you've outgrown raw output writers and want a "draw the screen, let the
renderer figure out the bytes" model, but you don't need the full widget/layout stack of
Cursorial.UI.
It sits directly on top of Cursorial.Core's byte-emitting writers and assumes nothing about
widgets, layout, or focus. If you've used a double-buffered console library before, the shape is
familiar — back buffer, front buffer, diff — but with grapheme-aware width accounting, wide-glyph
consistency, alpha compositing, and capability-aware color quantization built in.
await using var session = await TerminalSession.OpenAsync();
var caps = session.Capabilities;
var buffer = new CellBuffer(columns: 80, rows: 24, caps);
var renderer = new FrameRenderer(caps.Output);
buffer.Write(0, 0, "Hello, terminal 👋",
Style.Default.WithForeground(Color.FromRgb(64, 224, 208))
.WithAttributes(TextAttributes.Bold));
var frame = new ArrayBufferWriter<byte>();
renderer.Render(buffer, frame); // first call: full redraw
await session.Output.Writer.WriteAsync(frame.WrittenMemory);
await session.Output.Writer.FlushAsync();On the next frame, mutate the same buffer, clear your scratch writer, and call Render again — the
renderer emits only the cells that changed.
A Cell is a readonly record struct of three things:
-
Grapheme(string?) — the user-visible glyph as UTF-16.nullor empty is a blank cell rendered as a space (so the background color still paints). For emoji / CJK / accented letters this is one grapheme cluster, not onechar. -
Kind(CellKind) —Single,WideLeft, orWideContinuation. -
Style— theCursorial.Outputstyle value (foreground, background, attributes, underline shape & color).
default(Cell) (also Cell.Blank) is a single-width blank with default styling — the natural
"empty" of a freshly allocated buffer.
A grapheme that occupies two terminal columns (CJK, fullwidth forms, most emoji) is stored as a
WideLeft cell at the left position and a WideContinuation placeholder immediately to its right.
The renderer emits the glyph once, at the WideLeft position — that single terminal operation
paints both columns — and skips the continuation entirely (moving the cursor into the right half
of a wide glyph is undefined on most terminals). You almost never construct these by hand; Set /
Write produce them for you and keep them consistent.
A 2D grid (stored as a flat Cell[] indexed row * Columns + column) plus cursor state. It owns
its storage and reuses it across frames. The constructor takes dimensions and an optional
TerminalCapabilities — passing the negotiated capabilities lets the buffer seed an RGB default
background so alpha compositing has something to blend against.
Four ways to put content in, ordered from highest-level to lowest:
| Method | Coordinate order | Grapheme-aware? | Blending? | Use when |
|---|---|---|---|---|
Write(column, row, text, style) |
column, row | yes — advances per cluster | yes | Writing a run of text on one row. Stops at the right edge and at the first C0/C1 control character; returns columns written. |
Set(column, row, grapheme, style) |
column, row | yes — one cluster | yes | Placing a single grapheme; handles wide-cell width + adjacent-cell cleanup. Returns 1 or 2. |
Fill(cell) / Fill(region, cell)
|
— / Rect
|
no (cell is given) | yes | Painting a region (or the whole buffer) with one cell. |
this[column, row] (indexer) |
column, row | no | no | Raw read/write. Bypasses wide-cell handling and blending — you own correctness. |
Write is single-row by contract: it stops at the first newline/tab/control rather than storing
junk. Split into lines yourself, or use the drawing layer's
multi-line text.
buffer.Write(2, 1, "中文 mixed with 👨👩👧 clusters", style); // wide + ZWJ clusters advance correctly
buffer.Set(0, 0, "█", barStyle); // single cluster
buffer.Fill(new Rect(0, 0, buffer.Columns, buffer.Rows), Cell.Blank); // clear to blankOther members:
-
Clear()resets every cell to blank (does not apply blending) and drops fragments / dirty marks.ClearCells(region)blanks just a rectangle, leaving fragments alone. -
Resize(columns, rows)reallocates and clears; cursor state is preserved. (The renderer treats a dimension change as a full-redraw trigger, so you don't have to callResetafter a resize.) -
Cursor state lives on the buffer as plain properties —
CursorRow,CursorColumn(both 0-based),CursorVisible,CursorShape. The renderer emits cursor moves / visibility / shape as a separate end-of-frame concern. Don't try to encode the cursor as a cell attribute.
One FrameRenderer instance per output target. It holds the previously emitted frame (the front
buffer), the SGR style and cursor position it believes the terminal is currently at, and the cursor
visibility/shape last emitted. The renderer is the sole owner of SGR and cursor state across
frames — if you interleave raw output that mutates those behind its back, the next diff will be
wrong. (Reset() exists for exactly that: it forgets the front buffer and forces a full redraw on
the next render, re-syncing from scratch.)
Render(CellBuffer back, IBufferWriter<byte> output) emits one of two things:
-
A full redraw — clear screen, SGR reset, then every non-blank cell — when there's no prior
frame, the dimensions changed (a resize), or
ForceFullRedrawis set. - A per-cell delta otherwise — for each cell that differs from the front buffer, a cursor move, an SGR delta (only the parameters that actually changed), and the grapheme bytes.
Because the renderer targets any IBufferWriter<byte>, the same call feeds both a live session sink
and an ArrayBufferWriter<byte> scratch buffer you flush as one coordinated frame.
var renderer = new FrameRenderer(session.Capabilities.Output);
var frame = new ArrayBufferWriter<byte>();
while (running)
{
UpdateBuffer(buffer); // mutate cells for this frame
frame.Clear();
renderer.Render(buffer, frame);
if (frame.WrittenCount > 0) // empty delta == nothing changed
{
await session.Output.Writer.WriteAsync(frame.WrittenMemory);
await session.Output.Writer.FlushAsync();
}
}
renderer.Close(frame); // restore terminal autowrap, erase any image fragmentsCall Close(output) when you're done rendering to the target — it restores the terminal's
default autowrap state (the renderer disables autowrap per frame) and erases any lingering image
fragments.
Construct the renderer with an OutputCapabilities (the FrameRenderer(OutputCapabilities?)
overload — session.Capabilities.Output) and it holds a StyleQuantizer that runs each cell's
style through before diffing and emission. On a non-truecolor terminal, RGB cells snap to the
256-color cube or 16-color palette; extended underline shapes fall back to Single; unsupported
attributes drop. Crucially, the front-buffer snapshot stores the quantized form, so a stable frame
still produces an empty delta even on a 16-color terminal. The no-capability constructor preserves
raw styles untouched — handy for tests or when you quantize upstream yourself.
The renderer automatically detects when a frame is the previous one shifted up or down by a few rows and emits a hardware scroll (SU/SD) instead of redrawing each row — a big win for log views and scrolling content. This is built in and bounded to small shifts; nothing to opt into.
A readonly record struct of tunables; the default-constructed value is "normal behavior".
-
ForceFullRedraw— treat everyRenderas a full redraw. Debug/profiling knob; disables the diff without changing the API. -
RestrictToDirtyRegions— opt in to dirty-region-exclusive emission. Whentrueand the back buffer has marked regions (CellBuffer.MarkDirty(...)→DirtyRegions), the diff only considers cells inside the union of those regions — you take on the contract that you marked every cell you changed. Off by default (the safe choice): dirty marks can arise as side effects, so a stray mark can't silently drop unrelated changes. Cells inside a region still diff against the front buffer, so a marked-but-unchanged cell isn't re-emitted. -
OrderedDither— ordered (Bayer) dithering of RGB colors during quantization on reduced-color terminals, trading a stipple for perceived depth so gradients don't band. No-op at truecolor. (Turning it on disables scroll detection, since the dither phase is position-dependent.)
var renderer = new FrameRenderer(caps.Output, new FrameRendererOptions(OrderedDither: true));buffer.View(offsetColumn, offsetRow, columns, rows) (or View(Rect)) returns a CellBufferView: a
small readonly struct that translates view-local (0, 0) to a backing-buffer offset and clips
every write to its rectangle. Hand a widget a view and it draws in its own coordinate space without
knowing where it sits on the surface — and can't scribble outside its box.
CellBufferView panel = buffer.View(10, 2, 30, 8); // a 30×8 window at column 10, row 2
panel.Write(0, 0, "Panel-local origin", style); // lands at buffer (10, 2)
panel.Set(40, 0, "x", style); // clipped — silently dropped (outside the view)
CellBufferView inner = panel.View(1, 1, 28, 6); // sub-views compose & clip against the parentViews are coordinate/clip filters, not separate state containers: cursor, blending stack, and
fragments all forward to the underlying buffer. View clamps negative offsets/dimensions and clips
against the parent's bounds, so a sub-view asking for more than its parent has is trimmed rather than
escaping. The clipping Set/Write/Fill silently drop out-of-range writes; the indexer instead
validates and throws (the "I know this is in bounds" form), mirroring CellBuffer's own
indexer-vs-Set split.
CellBuffer carries a stack of blending modes. Every Set / Fill composes the source style's
colors against the existing cell's colors through CurrentBlendingMode — the top of the stack, or
BlendingModes.Default (source-over) when empty. The raw indexer and Clear() bypass it.
buffer.PushBlendingMode(BlendingModes.Multiply);
buffer.Fill(shadowRegion, shadowCell); // darkens whatever's underneath
buffer.PopBlendingMode(); // pop on empty throws — pair themBuilt-in modes (in Cursorial.Output.BlendingModes): SourceOver / Default, Multiply,
Screen, Overlay, Darken, Lighten, Plus. Custom modes implement IBlendingMode (one pure
Blend(source, backdrop) method) and plug in cleanly.
Two rules worth internalizing:
- RGB-on-RGB only. Blending and alpha compositing engage only when both source and backdrop colors are RGB. Palette and default colors short-circuit to "return source" — round-tripping them through RGB would be lossy and surprising.
-
Alpha is consumed at composite time. Each
Colorcarries an alpha byte (Color.FromRgba(...)orWithAlpha). The pipeline runs the active blending mode to get a blended color (modes treat both operands as opaque), then linearly mixes that with the backdrop using the source's alpha:result = blended·α + backdrop·(1−α). Stored cells always end up opaque — the terminal can't render a translucent SGR color — so alpha is a compositing input, never a rendered output. With an empty blend stack this collapses to the classic linear alpha blend.
-
Output writers & style primitives — the
Style,Color, andSgrEncoderlayer the buffer sits on. - Rich text & images — markup, formatted text, images, and sized text painted into a buffer.
- Drawing: scenes, brushes & charts — the retained-scene compositor built on the cell buffer.
-
Capability negotiation — where the
OutputCapabilitiesthat drive quantization come from.
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative