How OpenTUI works. Learn about terminal internals, buffers,
and rendering.
Document
OTUI/INT-26A
Zig native core + TypeScript bindings
Section 01
How terminals work
A terminal acts as a grid of cells. Each
cell displays a character with specific colors and styles.
Unlike graphical apps that draw pixels, terminal apps write
bytes to stdout. The terminal reads these
bytes to decide what to show.
Interactive Terminal Grid
Click a cell to see its full data structure. Toggle
"Data" mode to see raw values.
Cell Data Structure(0, 0)
Character (u32)
'H'= 0x0048
Foreground ([4]f32 RGBA)
[1.0, 1.0, 1.0, 1.0]
Background ([4]f32 RGBA)
[0.0, 0.0, 0.0, 1.0]
Attributes (u32)
0x00000000= NONE
SoA Memory Layout (separate arrays per field)
char[]
fg[]
bg[]
attrs[]
Text and control bytes
Everything in a terminal is
text plus control bytes. Many controls use
escape sequences starting with ESC (0x1B). Simpler control
bytes like CR, LF, and TAB handle cursor movement and
layout.
Your App
OpenTUI
→
Output
stdout
→
PTY
Pseudo-terminal
→
Terminal
Interprets bytes
→
Display
Pixels on screen
Section 02
ANSI escape codes
Control sequences tell the terminal what to do. The most
common type is the CSI sequence. It starts with
\x1b[ (ESC + [) and ends with a command
character. Other sequences start with
\x1b] (OSC) or \x1bP (DCS).
OpenTUI uses a
Structure of Arrays (SoA) layout. This
means it stores each field in a separate array, rather than
grouping fields into a structure for each cell. This layout
improves cache speed and lets the CPU process data faster.
Cell structure
Each cell in the buffer
char
u32
Unicode codepoint or encoded grapheme ID
fg
[4]f32
RGBA foreground (0.0 - 1.0)
bg
[4]f32
RGBA background (0.0 - 1.0)
attributes
u32
Packed: text attrs (8 bits) + link ID (24 bits)
Attribute bit packing
Text attributes pack into the
lower 8 bits of a u32
using bit flags. The remaining 24 bits store a link ID. This
saves space and allows fast bitwise comparison.
// Structure of Arrays - what OpenTUI usespub constOptimizedBuffer = struct {
buffer: struct {
char: []u32, // All characters contiguous
fg: []RGBA, // All foregrounds contiguous
bg: []RGBA, // All backgrounds contiguous
attributes: []u32, // All attributes contiguous
},
width: u32,
height: u32,
// ...
};
// Index calculation: row-major orderfncoordsToIndex(x: u32, y: u32) u32 {
return y * self.width + x;
}
Section 04
Grapheme clusters
One character on screen can use multiple Unicode codepoints.
For example, emojis with skin tones or flags use several
codepoints but appear as one symbol. These groups are called
grapheme clusters. OpenTUI stores these
clusters in a separate pool and refers to them by ID.
Grapheme Examples
A
Simple ASCII (1 codepoint, 1 cell)
U+0041
0x41
direct
中
CJK character (1 codepoint, 2 cells wide)
U+4E2D
0x4E2D
start
CONT
continuation
👨👩👧👦
Family emoji (7 codepoints, 2 cells wide)
U+1F468U+200DU+1F469U+200DU+1F467U+200DU+1F466
0x8XXXXXXX
grapheme start
0xCXXXXXXX
continuation
Grapheme ID encoding
When a character is a grapheme cluster, the
char field stores a pool ID instead of a
codepoint. The top 2 bits indicate the type:
Grapheme byte sequences live in a slab allocator with size
classes: 8, 16, 32, 64, and 128 bytes. The 26-bit ID encodes
the class (3 bits), generation (7 bits), and slot index (16
bits).
ID bit layout
class
3 bits
generation
7 bits
slot_index
16 bits
Section 05
Differential rendering
OpenTUI uses double buffering. It draws to
a "next" buffer, then compares that with the "current"
buffer. It only writes the changed cells to the terminal.
This is important because a standard 80x24 terminal contains
1,920 cells.
Why speed matters
Terminals are character devices, not block
devices. Unlike a GPU that updates a whole screen at once, a
terminal sends one byte at a time. This happens over a
serial connection.
The program does not know about the terminal emulator. It
only sees stdout. Characters, cursor movements, and color
codes all flow through this serial pipe. This uses
bandwidth. Look at the cell skip logic in
renderer.zig:611-626:
if (!force) {
const charEqual = currentCell.?.char == nextCell.?.char;
const attrEqual = currentCell.?.attributes == nextCell.?.attributes;
if (charEqual and attrEqual and
buf.rgbaEqual(currentCell.?.fg, nextCell.?.fg, colorEpsilon) and
buf.rgbaEqual(currentCell.?.bg, nextCell.?.bg, colorEpsilon))
{
// Cell unchanged - skip it entirely, write zero bytescontinue;
}
}
When a cell hasn't changed, the renderer writes nothing—no
cursor move, no character, no color codes. The
colorEpsilon comparison even skips cells where
colors are nearly identical, avoiding churn from
floating-point precision.
~
Full redraws are slow
A 232x55 terminal has 12,760 cells. Updating 60 frames
per second sends about 765 KB/s of data just for
characters. Adding colors increases this to megabytes
per second. This causes lag on slow connections.
fnprepareRenderFrame(self: *CliRenderer, force: bool) void {
var currentFg: ?RGBA = null;
var currentBg: ?RGBA = null;
for (0..self.height) |y| {
for (0..self.width) |x| {
const currentCell = self.currentRenderBuffer.get(x, y);
const nextCell = self.nextRenderBuffer.get(x, y);
// Skip if unchangedif (!force and cellsEqual(currentCell, nextCell)) {
continue;
}
// Only emit color codes if color changedif (!colorsMatch(currentFg, nextCell.fg)) {
ansi.moveToOutput(writer, x + 1, y + 1);
ansi.fgColorOutput(writer, nextCell.fg);
currentFg = nextCell.fg;
}
// Write character
writer.writeAll(nextCell.char);
// Update current buffer to match
self.currentRenderBuffer.set(x, y, nextCell);
}
}
}
i
Out-of-band vs in-band
GPU rendering uses out-of-band commands (system calls to
the GPU driver). Terminal rendering is entirely
in-band—control sequences are mixed with content in the
same byte stream. Terminal emulators parse this stream
and translate it to pixel rendering.
Section 06
The game loop
OpenTUI renders frames like a game engine. In each frame, it
runs callbacks, calculates layout, draws to the buffer,
checks for changes, and outputs them. This loop handles both
animations and static UI.
Frame Timeline
Playback slows the visualization only; target FPS stays
accurate.
0ms1000ms2000ms
Inside a framewaiting...
Animations
Callbacks
Lifecycle
Layout
Render
Output
work: -wait: -= 33ms target (30 FPS)
delay = max(1, targetFrameTime - frameTime)
Mode:IDLE
Frames:0
Playback FPS:0
Two rendering modes
Rendering all the time wastes resources because most UIs
stay static. OpenTUI uses two modes to save power:
On-demand (IDLE)
State changes call requestRender().
One frame executes, then loop stops.
No CPU usage when nothing changes.
Continuous (RUNNING)
Animations call
requestAnimationFrame().
Loop runs at target FPS (default 30).
Stops automatically when animations end.
The six phases
1
Animations
→
2
Callbacks
→
3
Lifecycle
→
4
Layout
→
5
Render
→
6
Output
Section 07
Architecture
OpenTUI combines TypeScript and Zig. TypeScript manages
components and easy-to-use APIs. Zig handles fast rendering.
The two languages talk through a Foreign Function Interface
(FFI).
Layer Stack with FFI Boundary
TS
Component Layer
Renderables, layout props, event handling,
React-like API
↓↑
draw(), setCell()events, layout
TS
Renderer (TypeScript)
Game loop, Yoga layout, frame scheduling,
animation callbacks
↓↑
render(), bufferPtrstats, capabilities
FFI BOUNDARY (bun:ffi)
Zig
OptimizedBuffer
Cell storage, alpha blending, scissor clipping,
grapheme pool
Terminals also use escape sequences for input. Regular keys
send raw UTF-8 bytes. Special keys and mouse actions send
multi-byte sequences. OpenTUI turns these bytes into events.
Input Parsing Demo
Click here, then press any key
1
Physical Key Press
-
2
Bytes from stdin (may arrive separately)
-
3
Parsed Event
-
Parser State Machine
GROUND
ESCAPE
CSI_ENTRY
CSI_PARAM
CSI_FINAL
Escape sequences like arrow keys arrive as multiple
bytes. The parser accumulates them before emitting an
event.
Move or click mouse here to see SGR events
-
Mouse event parsing
Mouse events use SGR (Select Graphic Rendition) extended
format. The escape sequence encodes button, position, and
modifiers. This demo shows pixel positions for clarity; real
SGR events report 1-based cell coordinates.
textSGR Mouse Format
// Mouse click at x=10, y=5\x1b[<0;10;5M// Button down\x1b[<0;10;5m// Button up (lowercase m)// Button code bits:// Bits 0-1: button (0=left, 1=middle, 2=right)// Bit 2: Shift held// Bit 3: Alt held// Bit 4: Ctrl held// Bit 5: Motion event// Bit 6: Scroll event
Section 09
FFI boundary
Zig pointers become opaque ptr types in
TypeScript. You can pass them around, but you cannot read
them directly from JavaScript.
OTUI_DEBUG_FFI=true - Log all FFI calls OTUI_TRACE_FFI=true - Full tracing with
timing
Section 10
Renderable lifecycle
You need to understand the lifecycle to build custom
components. This diagram shows the process from creation to
deletion.
Interactive Lifecycle Diagram
1
Construction
Yoga node created, event handlers attached
produces: YogaNode, eventMap
new Box()
renderable instance
2
Add to Tree
parent.add(child) - Yoga node inserted
produces: parent-child link, dirty layout
parent.add()
each frame (if dirty)
3
Lifecycle Pass
onLifecyclePass() - pre-render updates
produces: state updates, animations
loop phase 3
computed dimensions
4
Layout Pass
calculateLayout() → updateFromLayout()
produces: x, y, width, height
loop phase 4
layout computed
5
Render Pass
renderBefore → renderSelf → renderAfter
produces: cells in buffer
loop phase 5
user action / unmount
6
Destruction
destroy() → destroySelf() → cleanup
frees: YogaNode, listeners, children
destroy()
Connection to Game Loop
Phase 3: Lifecycle
Triggers onLifecyclePass() on all registered
renderables
Phase 4: Layout
Yoga calculates dimensions, then
updateLayout propagates positions and
builds the render list. Each
parent's _getVisibleChildren()
controls which children enter the list. Excluded
children receive position sync only—no
rendering, no hit grid, no mouse events.
Phase 5: Render
Iterates the render list. Each entry gets
render() called, which runs
renderSelf and registers the
renderable in the hit grid via
addToHitGrid().
Section 11
Hit testing
The hit grid is an array of IDs the size of the screen. Each
cell holds the ID of the component at that spot. Mouse
events check this grid to find which component to target.
Hit Grid Visualization
Each cell shows the renderable ID at that position.
Click to see which component owns it.
Double buffering
The hit grid uses two buffers to prevent race conditions:
mouse events read from
currentHitGrid while rendering writes to
nextHitGrid. After the frame completes, the
buffers swap atomically.
Mouse click
checkHit(x, y)
→
Read
currentHitGrid
↔
Write
nextHitGrid
←
Render
addToHitGrid()
Render list gating
Hit grid registration happens inside render(),
which is only called for renderables in the
render list. The render list is built
during the layout pass (updateLayout), which
both propagates positions and collects renderables for
drawing. Each parent calls
_getVisibleChildren() to decide which children
to traverse. The default returns all children. A parent that
returns [] hides its entire subtree from the
render list.
Hidden children still exist in the tree and receive layout
position updates, but they have
no hit grid entry. Mouse clicks at their
screen position will resolve to the parent (or nothing), and
text selection cannot reach their content. This is how
ScrollBoxRenderable culls off-screen
children—they re-enter the render list when they
scroll into view.
Parent
_getVisibleChildren()
→
Included
render() + addToHitGrid()
Parent
_getVisibleChildren()
→
Excluded
updateFromLayout() only
Scissor clipping
Scissor Clipping Demo
The scissor region is fixed. Use ▲/▼ to scroll content.
Cells outside the scissor are faded—their hit ID becomes
0.
renderer.dumpBuffers(); // Writes buffer state to file
renderer.dumpHitGrid(); // Logs hit grid to console
renderer.dumpStdoutBuffer(); // Writes raw ANSI output
Test renderer
tsFrom testing/test-renderer.ts
const {
renderer,
mockInput,
mockMouse,
renderOnce,
captureCharFrame, // Get text content as string
captureSpans // Get styled spans with colors
} = await createTestRenderer({ width: 80, height: 24 });
await renderOnce();
const frame = captureCharFrame();
const spans = captureSpans();