Files
RTSP-Streamer/tui
Levi Woodard 1ea3a5ac0a Rebuild the interactive configurator on opentui behind a JSON bridge
The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.

Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:

  config export           the config, plus limits like max_tiles
  config apply (stdin)    merge cameras/layouts/active_layout, validate, save
                          atomically, reload the daemon
  discover --json         Protect discovery, writing nothing

Two properties of that split are deliberate:

- The controller password never crosses the bridge. It's json:"-" on the way
  out, and apply only merges the three keys the TUI edits, so it can't be
  clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
  no longer overwrite a `views import`, a `layout set`, or a hand edit made in
  the meantime.

Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.

Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.

Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.

The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.

Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.

Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:22:48 -06:00
..

rtsp-streamer configurator (opentui)

The interactive configurator, built with opentui. It replaces the Bubble Tea TUI that lived in internal/tui.

Launch it the usual way:

rtsp-streamer tui

Why this is a separate executable

opentui is a native core written in Zig with TypeScript bindings — there are no Go bindings. So this half of the tool is TypeScript compiled by Bun, and it ships as rtsp-streamer-tui next to the Go binary. rtsp-streamer tui finds and execs it.

The trade-off is size: the Go binary is ~13 MB, and this one is ~120 MB because Bun embeds its own runtime plus opentui's native library. Nothing needs to be installed on the target, but it is a large artifact for an SD card. (make tui-pi comes out ~108 MB — defining OPENTUI_LIBC lets Bun embed only the glibc native package instead of both glibc and musl.)

If it is missing or misbehaving, the previous Go implementation is still there:

rtsp-streamer tui --legacy

Division of labour

Everything that is not presentation stays in Go. This process holds no credentials and never writes the config file itself — it shells back to rtsp-streamer for three things:

Command Purpose
config export read the config, plus limits like max_tiles
config apply (JSON on stdin) merge edits, validate, save atomically, reload the daemon
discover --json [--enable-rtsp=high,low] Protect discovery, writing nothing

Two properties of that split are worth keeping:

  • The controller password never crosses the bridge. It is json:"-" on the way out, and config apply only merges cameras, layouts and active_layout, so it cannot be clobbered on the way back in either.
  • config apply re-reads the file before merging. A configurator left open for an hour can no longer overwrite a views import, a layout set, or a hand edit made in the meantime — it only replaces the keys it owns.

Discovery is previewed rather than applied: discover --json writes nothing, the merge happens here, and nothing reaches disk until you press Save.

Layout

Path Contents
src/index.ts entry point: arg parsing, renderer setup, event loop
src/keys.ts keyboard routing (bindings carried over from the Go version)
src/state.ts the store — all state and mutations, ported from internal/tui
src/ui.ts renders the store into an opentui tree; grid mouse handling
src/bridge.ts subprocess calls into the Go binary
src/types.ts config schema + geometry helpers mirroring internal/config
src/theme.ts the lipgloss palette, resolved to hex
src/fixtures.ts sample config used by tests and the preview script
scripts/preview.ts render every screen to stdout as text

Working on it

make run-tui     # run from source against /tmp/rtsp-streamer.yaml, no compile
make test-tui    # bun test + tsc --noEmit
make tui         # compile bin/rtsp-streamer-tui for this machine
make tui-pi      # cross-compile bin/rtsp-streamer-tui-arm64 for a 64-bit Pi
bun run scripts/preview.ts   # dump every screen as text, no terminal needed

preview.ts is the quickest way to see a layout change — it uses opentui's test renderer, so it prints the screens without needing a TTY.

Two things to know before changing the grid editor

Both were bugs during the port, and neither is obvious from the code:

Do not overlap cell borders. Sharing a border between neighbouring cells looks like it should work, but a box draws its own corners, so every shared edge renders as where a lattice needs . Each cell is a self-contained box that tiles edge-to-edge.

Do not rebuild the tree during a drag. The renderer captures the renderable a drag started on and routes the rest of the gesture to it. render() destroys and recreates the whole subtree, so a rebuild mid-drag silently ends the drag after the first resize. Instead, suppressRender is set for the duration and the dragged tile's box is resized in place, with one full rebuild on release. For the same reason the grid's mouse handlers live on the persistent mount box, not on the grid box, and the grid's screen origin is captured on press rather than read live (a rebuilt box has no computed layout until the next frame, so screenX reads as 0).