A standalone virtual terminal engine in C — parser, grid, scrollback, reflow, charsets, kitty keyboard protocol — with no external dependencies. Extracted from bloom-terminal, where it replaces libvterm.
- VT100 / VT220 / xterm parser — CSI / OSC / DCS / ESC dispatch, DEC modes, scrolling regions, charsets (incl. DEC special graphics line drawing), origin mode, tab-stop manipulation (TBC).
- UAX-aware grid — Unicode #11 East Asian Width and #29 grapheme cluster widths computed at insertion time and stored on each cell. ZWJ sequences, regional indicators, skin-tone modifiers, and VS16 emoji presentation all carry the right cell width without per-renderer peek-ahead.
- Grapheme arena — full clusters are interned; cells reference them by id, so there is no hardcoded codepoint cap (libvterm caps at 6).
- Scrollback — paged ring buffer; default 1000 lines, configurable.
- Reflow — recomputes wrap on resize; preserves cursor row.
- Sixel graphics — DCS sixel images (
DCS q … ST) are decoded and stored in the engine, anchored to an absolute grid line so they scroll, enter scrollback, and clear with the text they sit on. Decode covers RLE, RGB and DEC HLS color, P2 transparency, and raster attributes; capability is advertised via DA1 (4), DECSET 80/1070/8452, and XTSMGRAPHICS. The host declares cell pixel size withbvt_set_cell_pixels()and fetches images to draw withbvt_get_sixels(). - Damage tracking — the changed region is accumulated as input is
parsed; the consumer calls
bvt_damage_flush()at a controlled time (typically once per frame, before rendering) to receive it via thedamagecallback and repaint only what changed. A cursor-only move (which dirties no grid cell) is folded in — flush damages the old and new cursor cells. - Kitty keyboard protocol — flags 0x1 (Disambiguate) and 0x8 (Report all keys as escape codes) are fully implemented. Push/pop/set/query of the flag stack works. Flags 0x2/0x4/0x10 are accepted on the stack but currently no-ops (waiting on a concrete consumer).
- Zero external dependencies — libc only.
For the project status — what is byte-identical to libvterm, accepted
divergences, and the deferred items — see FOLLOWUPS.md.
Bounded allocation and a zero-allocation steady-state hot path are explicit design goals. Concretely:
- Pluggable allocator. All heap traffic goes through a
BvtAllocator(malloc/realloc/free function pointers) supplied atbvt_new()time. Callers can route everything to an arena, a pool, or a tracked allocator without patching the library. PassNULLto use libc. - Paged grids. Each
BvtPageowns a contiguous cell buffer plus a per-row flag byte. A terminal has at most two pages live at once (the primary grid and, while in altscreen, the alternate). Page allocation happens onbvt_new, on resize, and on altscreen entry — never frombvt_input_write. - Paged scrollback. A doubly linked ring of pages, 64 rows per page, default 1000 lines total. Allocations happen only when a page fills; the ring evicts the oldest page when capacity is reached, so scrollback memory is bounded by the configured line count regardless of input.
- Per-page interning. A style intern table keyed by SGR pen and a
grapheme arena keyed by codepoint sequence both live on the page.
Cells store a 32-bit
style_idand 32-bitgrapheme_id, so repeated styles and clusters cost one slot regardless of how many cells use them. Single-codepoint clusters usecpdirectly withgrapheme_id == 0and hit the arena zero times. Unlike libvterm's hard-coded 6-codepoint cell cap, clusters here are arbitrary length. - Hot path.
bvt_input_writedoes no allocation in steady state — all hot-path structures (parser scratch, OSC accumulator up to 4 KiB, cursor cluster buffer up to 16 codepoints) are inline inBvtTerm. The intern tables grow geometrically and amortize to zero; the grapheme arena reuses existing entries on hash hit. - Sixel store. Decoded images live in a dense record array
(swap-remove deletion, no fragmentation) with their pixel buffers drawn
from a small best-fit free-list pool, so animation that streams
same-size frames recycles one buffer instead of churning the heap. A
global live-byte budget evicts the oldest image first (the same order
they scroll off), and a per-dimension clamp bounds any single image.
The store is allocated lazily on the first sixel and freed by
bvt_free. - Lifecycle.
bvt_new/bvt_freeis the only ownership pair the consumer manages.bvt_freereleases every page, intern table, arena, tab-stop bitmap, and the sixel store through the same allocator that produced them.
BvtTerm itself is not internally synchronized; callers own all locking.
Standard GNU autotools workflow:
./autogen.sh # writes ./version, runs autoreconf -fi
./configure --prefix=$HOME/.local
make
make check
make installOptional targets:
make format # clang-format on src/, include/, tests/; prettier on *.md
make bear # produce compile_commands.json for clangd
make distcheck # build, test, and verify the dist tarball is self-containedcc app.c $(pkg-config --cflags --libs bloom-vt)bloom-vt.pc installs to ${libdir}/pkgconfig/.
#include <bloom-vt/bloom_vt.h>
#include <stdio.h>
int main(void)
{
BvtTerm *vt = bvt_new(24, 80); /* 24 rows x 80 cols */
const char *bytes = "hello\033[31m world\033[0m\n";
bvt_input_write(vt, (const uint8_t *)bytes, strlen(bytes));
int rows, cols;
bvt_get_dimensions(vt, &rows, &cols);
for (int c = 0; c < cols; c++) {
const BvtCell *cell = bvt_get_cell(vt, 0, c);
if (!cell || cell->width == 0) continue;
putchar(cell->cp ? (int)cell->cp : ' ');
}
putchar('\n');
bvt_free(vt);
return 0;
}The full API is in include/bloom-vt/bloom_vt.h.
The default 16-color ANSI palette is CharmTone, the palette behind charm.land — with thanks to the folks at Charmbracelet for making the terminal a more colorful place. 🌸
MIT. See COPYING.