A C11 reference implementation of the FlipCTL menu-UI core: declarative
wrappers that turn CLI tools into D-pad-navigable menus on a tiny LCD. It is
the production follow-up to the Python concept in
flipctl-proto and consumes
the same JSON manifest schema unchanged — the proto's demo-apps/*.json
run here as-is.
libc only. No third-party dependencies, nothing to compile but the standard
toolchain. make produces a single flipctld binary.
The proto validated the interaction model and manifest format using curses
as a deliberate stand-in for a real renderer (see its DESIGN.md). This
implementation does properly what the proto faked:
| Proto faked | flipctld does |
|---|---|
| curses renderer (can't do pixels) | a render abstraction with two real backends: ANSI terminal and a Linux framebuffer that mmaps /dev/fb0 at 32bpp and blits an 8x16 bitmap font |
| terminal escape codes for input, hand-decoded | a button-input abstraction with two sources: terminal keys and the kernel input layer (/dev/input/eventN, the way an embedded app reads its own front-panel buttons) |
json stdlib module |
a small strict JSON reader written here (~510 lines incl. full string unescaping) |
| reader thread + queue | non-blocking capture: a single poll() loop multiplexes the input source against the child's output pipe — no threads |
The navigation model is identical to the proto: Up/Down move, Right = OK/open, with a screen stack where OK pushes and back pops.
manifest.json ─▶ json.c (strict reader) ─▶ manifest.c (typed model + validation)
│
surface.h ◀── viewport.c ──┐ screen.c: menu / form / output / result
(render seam) │ (logic objects behind a small vtable)
├─ surface_ansi.c │ │ build_command(): {param} expansion,
└─ surface_fb.c draw through │ toggle→flag, empty tokens dropped
the viewport ▼
input.h ◀── main.c poll() loop ─────▶ runner.c: fork + execvp (no shell),
(input seam) multiplexes input fd stdout+stderr → pipe → ring buffer
├─ input_term.c and pipe fd │
└─ input_evdev.c ▼ feeds the output screen,
then the result summary
Every screen is a struct behind a vtable (handle_key, draw, plus optional
poll hooks). The render and input seams are the only things a new frontend
replaces — screen logic never names a backend.
json.[ch]— strict recursive-descent JSON DOM (objects, arrays, strings with\uXXXX/surrogates, numbers, booleans, null; trailing data rejected).manifest.[ch]— typed manifest model, form defaults, andbuild_command()(token-list templates,{param}substitution, toggle→flag, drop-if-empty).surface.[ch],surface_ansi.c,surface_fb.c— the render abstraction and its two backends.viewport.[ch]— the 40x20 drawing surface (the proto'sViewport): clipped text, rules, centered title/status bars.input.[ch],input_term.c,input_evdev.c— the button-input abstraction and its two sources.screen.[ch]— the four screens and the navigation verbs.runner.[ch]— process launch + non-blocking capture into a line ring.headless.c,main.c— CI pipeline and the wiring +poll()event loop.
src/font8x16.h embeds the IBM PC/VGA BIOS 8x16 ROM font (code page 437 glyph
order). Bitmap typefaces are not copyrightable in the US and this font has been
redistributed freely for decades; provenance is documented in the header.
make # -Wall -Wextra -Werror, clean
make clean# Interactive, ANSI terminal backend + terminal keys (default):
./flipctld manifests/ping.json
# Linux framebuffer backend (renders to /dev/fb0) + physical buttons:
./flipctld manifests/nmap.json --backend fb --input buttons --input-dev /dev/input/event3
# Headless: build argv, run, stream captured output (no TTY needed) — for CI:
./flipctld manifests/ping.json --headless custom --set host=127.0.0.1 --set count=1
./flipctld manifests/ping.json --headless localhost
# Just print the constructed argv:
./flipctld manifests/nmap.json --dry-run scan --set services=onKeys (terminal): arrows (Up/Down move, Right = OK/open, Left = back),
Enter = OK, q quits. On the device the same five events come from the
front-panel buttons via evdev.
If --backend fb is selected and /dev/fb0 is absent or not 32bpp, the program
prints a clear message and exits non-zero — it never crashes.
tools/capture.py drives flipctld in a pty and prints the rendered viewport
as ASCII (mirrors the proto's tools/capture.py):
python3 tools/capture.py manifests/ping.json # menu
python3 tools/capture.py manifests/nmap.json right # parameter form
python3 tools/capture.py manifests/ping.json down down right down right wait:3 right # run → resultUnchanged from the proto. A menu tree of leaves ({"label","action"}) and
submenus ({"label","items":[...]}); actions carry an optional form and a
command token list. Four field types — toggle (expands to its flag when
on), enum (cycle options), int (step within min/max), text. A token
that substitutes to empty is dropped from argv, so commands are always built as
a clean argument vector — there is no shell at any point.
Real now (was faked in the proto): pixel rendering via framebuffer, evdev button input, a hand-written JSON parser, thread-free non-blocking capture, and a clean render/input seam.
Still future work (matches the proto's DESIGN.md roadmap):
- Daemon + socket protocol. This is still a single process that owns the screen and runs one app. Real FlipCTL is a system daemon that owns the input devices and renderer, multiplexes several apps (the App-Switcher button), and speaks a small Unix-socket protocol to plugins. The manifest format here is the static half of that protocol; the dynamic half (custom screens, progress, partial updates) needs message types.
- Plugin process isolation. Plugins should run as separate processes with
per-action capability grants (
cap_net_rawfornmap -sS) rather than the current in-process exec. Long-running jobs should outlive a screen switch (a job table the UI re-attaches to), not be tied to the output screen. - Output post-processing (regex extractors so a 40-char screen shows "3 hosts
up", not truncated lines), richer validation, on-screen keyboard widget, and
apt-style packaging with a manifest directory scan as the root menu.