Native Core

The Zig native core is where the performance-critical work happens. This page covers what it does and how, for anyone who wants to contribute to OpenTUI or understand why it is fast.

Overview

The native core lives in packages/core/src/zig/ (18K LOC across ~25 modules). It compiles to a shared library (.dylib/.so/.dll) that TypeScript loads via bun:ffi. The C ABI exports ~120+ functions through lib.zig.

Double-Buffered Renderer

renderer.zig (1393 LOC) maintains two cell grids: front (what the terminal currently shows) and back (what we want it to show).

Each render cycle:

  1. TypeScript sends draw commands via FFI (drawText, fillRect, drawBox, etc.) - these write to the back buffer
  2. The renderer diffs back vs front, cell by cell
  3. Only changed cells produce terminal escape sequences
  4. Buffers swap

A “cell” is one terminal position: a grapheme (which may be multi-byte Unicode) plus foreground color, background color, and style attributes (bold, italic, underline, etc.). Wide characters (CJK, emoji) occupy two cells.

This diff-based approach means a 200x50 terminal with 10,000 cells will typically emit updates for a few dozen to a few hundred cells per frame, not all 10,000.

Buffer and Hit Grid

buffer.zig (2247 LOC, the largest module) implements the cell grid and the hit testing grid. Key operations:

  • drawText - write a string to specific coordinates with styling
  • fillRect - fill a rectangular region with a color
  • drawBox - draw border characters around a region (single, double, rounded, bold, ASCII, custom)
  • pushScissorRect / popScissorRect - clip rendering to a region (how ScrollBox works)
  • pushOpacity / popOpacity - apply transparency to all draws in scope

The hit grid is a parallel grid that tracks which renderable ID owns each cell. When a mouse click comes in, the renderer looks up hitGrid[y][x] to find the target renderable in O(1). No tree traversal needed.

Rope Data Structure

rope.zig (1220 LOC) implements a persistent B-tree rope for text storage.

Why a rope instead of a string:

  • O(log n) insert/delete at any position, regardless of document size. A 100K-line file inserts as fast as a 10-line file.
  • Persistence - the rope uses structural sharing. After an edit, the old version still exists (just a few shared nodes). This makes undo/redo effectively free - you keep references to previous rope versions instead of storing inverse operations.
  • Markers - named positions that survive edits. When you insert text before a marker, the marker’s position updates automatically. This is how cursor position stays correct through edits without manual offset recalculation.

The implementation uses ~512-byte leaf nodes. Non-leaf nodes have a branching factor of ~32. Balance is maintained via split/merge operations during insert/delete.

UTF-8 and Grapheme Processing

utf8.zig (1948 LOC) handles Unicode. Two key concerns:

Character Width

A terminal cell can hold one “display unit.” But Unicode is complicated:

  • ASCII characters are 1 cell wide
  • CJK characters are 2 cells wide
  • Emoji can be 1 or 2 cells wide
  • Zero-width joiners create combined emoji (family emoji = multiple codepoints, 2 cells)
  • Combining marks attach to the previous character (zero width)

OpenTUI uses Unicode width tables and grapheme cluster segmentation to get this right.

SIMD Acceleration

For bulk text processing, utf8.zig uses SIMD vector operations. The hot path:

  1. Load 16 bytes at once into a SIMD register
  2. Check if all bytes are ASCII (all < 128)
  3. If yes, process all 16 as single-width characters in one operation
  4. If no, fall back to scalar processing for the non-ASCII chunk

Since most source code is predominantly ASCII, the SIMD path handles the majority of text. This matters for operations like “how many display columns does this line occupy” which runs on every text change.

grapheme.zig (599 LOC) implements Unicode grapheme cluster segmentation - determining where one “user-perceived character” ends and the next begins.

Text Editing Stack

Four modules build on top of each other:

TextBuffer (text-buffer.zig, 1180 LOC)

Styled text storage. A TextBuffer holds the rope plus styled segments - spans of text with foreground color, background color, and attributes. Operations:

  • Set content (replaces the rope)
  • Add/remove/update styled segments
  • Get content as string
  • Query what style applies at a given byte offset

TextBufferView (text-buffer-view.zig, 1371 LOC)

Viewport over a TextBuffer. Adds:

  • Word wrapping with a wrap cache (recomputed only when content or width changes)
  • Viewport windowing - show rows N through M of the wrapped text
  • Selection - start/end positions, with operations for selecting by word, line, or arbitrary range
  • Scrolling - scroll to line, scroll by delta, scroll to make a position visible

EditBuffer (edit-buffer.zig, 825 LOC)

Editable text. Adds:

  • Cursor - byte position in the text, with movement operations (left, right, up, down, word boundary, line boundary, home, end)
  • Insert/delete - at cursor position, with the rope handling efficient mid-document operations
  • Undo/redo - leverages rope persistence; each edit creates a new rope version while sharing structure with the old
  • Word boundaries - detecting where words start and end for Ctrl+Left/Right navigation

EditorView (editor-view.zig, 802 LOC)

Viewport + cursor management. Synchronizes the viewport scroll position with the cursor so the cursor is always visible. This is what the Textarea component uses.

Terminal I/O

terminal.zig (987 LOC) handles raw terminal interaction:

  • Setting up raw mode (character-at-a-time input)
  • Alternate screen buffer switching
  • Mouse mode (SGR extended mouse reporting)
  • Kitty keyboard protocol support
  • Cursor style and position control
  • Terminal capability detection (color depth, size, OSC 66 width queries)
  • Clipboard operations (OSC 52)

Event System

event-emitter.zig provides a typed event emitter. event-bus.zig provides a central event bus for cross-module communication. Both use Zig’s comptime generics to avoid runtime type checking.

Memory Management

mem-registry.zig implements a generational slab allocator that tracks objects by numeric handle. When TypeScript creates a TextBuffer via FFI, it receives a handle (just a number). All subsequent operations use that handle. When TypeScript calls destroyTextBuffer(handle), the Zig side deallocates.

Gotcha: If TypeScript fails to call destroy (e.g., a component unmounts without destroyRecursively()), the native memory leaks. The Zig allocator has no GC. The mem-registry module has debug logging to help track leaked handles.

Building the Native Core

cd packages/core
bun run build:native      # release build
bun run build:native:dev  # debug build (includes asserts, slower)

This invokes Zig’s build system (src/zig/build.zig). The output is a shared library that zig.ts loads.

Running native tests:

cd packages/core
bun run test:native                              # all tests
bun run test:native -Dtest-filter="rope"         # filtered
bun run bench:native                             # benchmarks

Performance Characteristics

The hot paths (text rendering, layout updates for text-heavy views) are entirely in Zig with zero JavaScript overhead. The FFI boundary adds constant overhead per call (~microseconds), which is why the design batches render commands rather than making per-cell FFI calls.

For a typical frame in OpenCode:

  • Yoga layout: ~1-2ms (JavaScript)
  • Render command collection: ~1ms (JavaScript)
  • Native rendering + diff: ~0.5-1ms (Zig)
  • Terminal write: depends on how many cells changed