A curses-style TUI library for the TI-84 Plus CE. Panels, widgets, scenes, dialogs, colors and text styles for CE C programs.
titrmlib is a TUI framework for the TI-84 Plus CE, written in C for the CE C toolchain (CEdev) on top of graphx. It is a sibling of Tincan and TINCLIB (Wi-Fi/networking libraries for the same calculator, talking to an ESP board over serial), and gives those and other CE apps composable UI pieces in place of hand-written graphx code for every screen.
src/titrm.hUpdate this section when a stage finishes or the release state changes.
Stage 1 (core library): complete as of 2026-09-23.
term_run loop, panel tree,
focus and widgets (text, list, input, log, progress). Rendering has been
optimized; frame times are in Performance.tests/) and 12 hardware tests in CEmu’s
autotester (tests/hw/), passing on OS 5.3 and on arTIfiCE-jailbroken
OS 5.8.5 ROMs.docs/ (index
docs/api.md). GitHub Pages publishes the repo root from main at
https://gavinhsmith.github.io/titrmlib/, with README.md as the front page
and settings in _config.yml..github/workflows/ci.yml): unit tests, generated-file drift (font and
docs), and CEdev builds of the examples and hardware tests..github/workflows/release.yml): pushing a v* tag runs CI,
checks that TITRM_VERSION in src/titrm.h matches the tag, and creates a
draft release with titrmlib-<tag>.zip (sources and licenses). The
maintainer adds the changes and publishes it.Version: TITRM_VERSION is "0.4.2". v0.1.0 through v0.4.2 are
released; v0.3.0 added code page 437, v0.4.0 the formatting, v0.4.1 smaller
memory use and v0.4.2 term_snprintf.
The ASCII glyphs have been checked on a real TI-84 Plus CE; the CP437 glyphs added in v0.3.0 and the styles added in v0.4.0 have only been checked in CEmu.
v0.4.0 (formatting): bold, italic, underline and strikethrough
attributes, inline style escapes (TERM_S_BOLD etc.) and tab stops.
Phase 2: complete, merged to main in PR #1 and released as v0.2.0.
The design and build order are in DESIGN.md. It added retained
panels and change-based rendering, the key queue (keypadc input), app-driven
focus, the new events, scenes, overlays, the revised widget set with the demo
rebuilt on it, and color.
Breaking API changes are fine until v1.0.
titrm.h.term_panel_print and every other panel output must stay inside the
panel’s content area, at any depth in the tree.gfx_SetFontData is fixed at an
8 px advance, which would lose the narrow grid.| File | Role |
|---|---|
src/titrm.h |
The public API |
src/titrm.c |
Cell grid, glyph drawing, panel tree, layout, focus, key translation, run loop |
src/titrm_widgets.c |
Text (also logs), button, checkbox, list, input and progress widgets |
src/titrm_internal.h |
Cell, panel and context structs, shared by the two .c files |
src/titrm_font.c, titrm_chars.h, FONT.md |
Generated by tools/gen_font.py; never edit by hand |
docs/ |
API reference generated from the doc comments in src/titrm.h by Doxygen + moxygen (make docs); never edit by hand. Every public declaration needs a /** */ comment inside its section’s @defgroup; regenerate after changing the header |
term_cell_t) is a glyph code, foreground and
background palette colors, and a style byte (the TERM_ATTR_* bits other
than REVERSE).iw × ih cells (p->cells,
sized by layout, keeping what fits on resize). Output writes into them and
marks the context dirty; panels with children hold no cells. Widgets
rebuild their cells from state when marked stale (term_panel_touch).frame rebuilds stale widgets
(render_stale), then for each row composes the active scene and its
overlays into row_buf (compose_row: borders, then leaf cells, children
over parents) and flush_row draws the cells that differ from shown.
There is no second copy of the screen; nothing is reprinted per frame.fg/bg palette indices, copied into cells as
they are written (set_cell; TERM_ATTR_REVERSE swaps them). Children
start with their parent’s colors, and a container whose background differs
from its parent’s fills its area when composed.draw_cell writes into gfx_vbuffer directly, copying
each cell row from row_pixels, a table of 6-pixel patterns for each of 64
masks. There is one table per color pair, for white on black and the five
most recent other pairs, built from the white-on-black one with
(pixel & (fg ^ bg)) ^ bg. Box-drawing characters and blocks
(0xB3–0xDF) stretch into the gaps between cells so lines join up.STRIKE_ROW) fill it, gap column included, so they join.
Box drawing ignores them. Inline, ESC then 0x40 | bits sets the style:
term_panel_putc keeps the pending ESC in p->esc and sets p->attr;
text_layout and put_str track a style per character, reset at each
line, and add it to the widget’s attribute.\t, \n, \r and ESC
(0x1B) are control codes, so their CP437 glyphs can’t be printed; tab stops
are every TERM_TAB (4) columns. The glyphs titrmlib uses
are named TERM_CH_* (as characters) and TERM_S_* (as string literals).
Box-drawing glyphs are generated from their line weights in gen_font.py.ctx->root is the first,
ctx->scene the active one; only it is composed and gets events. Layout
covers every scene, so hidden scenes can be printed into. A scene root keeps
its handler in root.handler.owner scene, kept bottom to top in
ctx->overlays. Their rect is set when they open and layout leaves it. A
frame composes the active scene, then its overlays in order, each over a
blanked rect. Closing gives focus back to root.restore under the rules in
titrm.h; freeing a panel clears any root.restore that points at it.dispatch(): the active
scene’s handler, then the global one, stopping at the first that returns
true. Scene enter/leave go only to that scene’s handler.TERM_MAX_PANELS, shared by all scenes. term_split sizes are
TERM_FIXED, TERM_PERCENT, TERM_FILL and TERM_FILL_WEIGHT. Fixed and
percent sizes are taken first, then fill panels share the rest by weight,
with the rounding remainder on the last one. All children of a panel share
one direction.layout_dirty. It stores each panel’s outer rect (x y w h)
and content rect (ix iy iw ih). term_put and term_panel_putc read the
content rect for every character.term_focus, focusable panels only).
titrmlib only moves it to give it back when an overlay closes, and to clear
it when the focused panel is hidden, destroyed or no longer in the active
scene (or one of its overlays), which sends TERM_EV_FOCUS_LOST. [vars] is an
ordinary key, TERM_KEY_VARS.kb_Scan, ~1.3 ms), not
os_GetCSC (~19 ms per call). poll_keys finds new presses by comparing
scans, repeats held arrows and [del], and queues scan codes. It runs every
loop and after each row flush redraws, so keys during slow frames are kept.p->attr, showing focus with
p->focus_attr. A focused panel’s keys go to its custom key_fn, then its
built-in widget, then become TERM_EV_SUBMIT if submit is set, then go
along the handler chain. Text, button and checkbox share the u.text
storage: an owned, growable buffer capped at limit bytes.term_run blocks until term_quit. Each pass polls the keypad,
handles every queued key (focused widget first, then the app’s update
callback), fires ticks from clock(), and runs a frame, which does nothing
if nothing changed.Code that is cheap on a desktop can be slow here:
int is 24 bits. Multiplies by non-constants and many shifts compile to
library calls (__imulu, __bshl). Hot paths use row-pointer tables and
uint8_t arithmetic to avoid them.term_cell_t by value: clang builds
them as a 32-bit integer with __lshl/__ladd calls per byte. Write the
fields through a pointer (set_cell).obj/<program>/lto.s when a hot path is slow.printf family: it links
nanoprintf (~8 KB). Use term_panel_printf, term_text_appendf and
term_snprintf, which share term_vformat.shown
(6.4 KB), the panel pool (~83 bytes per panel, TERM_MAX_PANELS) and the
pixel tables (2.3 KB); widget buffers are on the heap. Don’t add another
screen-sized buffer or put buffers in the panel struct. The .map file in
bin/<program>/ lists every symbol’s size.Update times (the app’s printing plus the frame) are measured by the perf
hardware test against fixed budgets:
| Update | Emulated time | Budget |
|---|---|---|
| Nothing changed | ~1 ms | 5 ms |
| One row changed | ~48 ms | 50 ms (the phase 2 goal) |
| Every cell changed | ~466 ms | 600 ms |
| Every cell changed, in color | ~466 ms | 600 ms |
Most of a full-screen update is drawing 1,590 cells at ~0.2 ms each, plus a
keypad scan per row. The widgets hardware test runs at the autotester’s
default key speed, so it catches lost keys if frames or polling regress.
Details are in CONTRIBUTING.md. In short:
make -C tests) build the library natively against
tests/stubs/, where gfx_vbuffer is an in-memory framebuffer and
kb_Scan presses scripted keys (stub_keys, stub_hold).
test_titrm.c includes titrm.c directly so it can read what’s on
screen (shown).make hw-test, needs AUTOTESTER_ROM) run in CEmu’s
cemu-autotester and use nothing else to drive the emulator. When a change
alters screens on purpose, re-record with make hw-record and open the
PNGs before trusting the new CRCs.