Files
RTSP-Streamer/tui/src/ui.ts
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

649 lines
23 KiB
TypeScript

/**
* The view layer: builds an opentui renderable tree from the store.
*
* Two things changed shape versus the lipgloss version this replaces:
*
* - The grid is real bordered boxes, absolutely positioned on a character
* lattice, instead of hand-assembled "+---+" strings. Spanning tiles are
* just a wider box, so the continuation-cell "·" markers are gone — a 2x2
* tile now looks like one 2x2 box.
* - Hit-testing is the framework's job. The list screens attach onMouseDown
* per row, so the header-offset arithmetic that internal/tui/mouse.go
* needed (headerRows, cellStride, blockH) is gone. The grid still converts
* pointer coordinates to a cell, but in one place, against the grid
* container's own screen position rather than a hardcoded layout guess.
*
* The tree is rebuilt on every state change. At this size (a few dozen boxes)
* that is far cheaper than the terminal repaint it triggers, and it keeps the
* Elm-ish "view is a pure function of state" property the Go version had.
*/
import {
Box,
BoxRenderable,
Text,
TextAttributes,
type CliRenderer,
type MouseEvent,
type Renderable,
type VChild,
} from "@opentui/core"
import { MENU_ITEMS, MenuItem, PICKER_EMPTY, type Store } from "./state.ts"
import { theme } from "./theme.ts"
import { effectiveTiles, layoutDimensions, qualityAbbrev, tileIndexAt, tileSpan, type Layout } from "./types.ts"
/**
* Cell geometry, in character units.
*
* Each cell is a self-contained box: two border rows plus two content rows, and
* boxes tile edge-to-edge without overlapping. Overlapping them by one cell to
* share a border looks tempting but renders wrong — a box draws its own corners,
* so every shared edge came out as `┌` where a lattice needs `┬`. Adjacent
* borders cost one column per cell and read cleanly as a card grid.
*/
const CELL_H = 4
const CELL_W_MIN = 8
const CELL_W_MAX = 16
/**
* Anything that can appear as a child: a Box vnode, a Text vnode, a bare
* renderable, or nothing. Box() and Text() return different vnode types, so
* mixed children need the union rather than either one.
*/
type VNode = VChild
export class UI {
/** Persistent container; its subtree is swapped wholesale on each render. */
private readonly mount: BoxRenderable
/** The grid container, kept to map pointer coordinates onto cells. */
private gridBox: Renderable | null = null
// Drag state for tmux-style tile resizing.
private dragTile = -1
private dragging = false
private dragMoved = false
/**
* Set when a drag actually resized something, and cleared by the release that
* follows. Without it the release after a drag would be indistinguishable
* from a plain click and would pop the camera picker open every time.
*/
private justDragged = false
/**
* Whether the press that started this gesture landed on the grid. A click on
* a list row activates on press and re-renders, which can put the grid under
* the pointer in time to catch the release — this keeps that release from
* being read as a grid click.
*/
private pressedOnGrid = false
/**
* The grid's screen origin, captured on press and reused for the rest of the
* gesture.
*
* It cannot be read live during a drag: resizing notifies the store, which
* rebuilds the subtree, and the replacement grid box has no computed layout
* until the next frame — so screenX/screenY read as 0 and every subsequent
* drag step maps to the wrong cell (or out of bounds, and is dropped). The
* grid cannot move mid-gesture, so one reading at press time is correct.
*/
private gridOrigin: [x: number, y: number] | null = null
/**
* Suppresses the full subtree rebuild while a drag is in progress.
*
* The renderer captures the renderable a drag started on and routes the rest
* of the gesture to it. Rebuilding destroys that renderable, which silently
* ends the drag after the first resize — so during a drag the dragged tile's
* box is resized in place instead, and the rebuild happens once on release.
*/
private suppressRender = false
constructor(
private readonly renderer: CliRenderer,
private readonly store: Store,
) {
// A real renderable, not a vnode: render() calls getChildren()/remove() on
// it every frame, and a vnode only queues those for instantiation time.
this.mount = new BoxRenderable(renderer, {
id: "mount",
flexDirection: "column",
padding: 1,
width: "100%",
})
renderer.root.add(this.mount)
this.installMouse()
}
render(): void {
if (this.suppressRender) return
// Swap the subtree. destroyRecursively releases the native buffers behind
// each renderable; without it a long editing session leaks them.
//
// It also detaches the child from this box, so calling remove() first is
// not just redundant — the destroy then tries to detach an already-orphaned
// renderable, throws, and leaves the tree in a state where mouse
// hit-testing silently stops reaching the new subtree.
for (const child of [...this.mount.getChildren()]) {
child.destroyRecursively()
}
this.gridBox = null
this.mount.add(this.screen())
// The grid needs its computed screen position for pointer mapping, so
// resolve it after the tree is attached and laid out.
this.gridBox = this.mount.findDescendantById("grid") ?? null
}
private screen(): VNode {
const s = this.store
switch (s.screen) {
case "menu":
return this.menuScreen()
case "cameras":
return this.camerasScreen()
case "layouts":
return this.layoutsScreen()
case "layoutEdit":
return this.layoutEditScreen()
case "cameraPicker":
return this.cameraPickerScreen()
case "setActive":
return this.setActiveScreen()
}
}
// ---- shared chrome ----
private title(text: string): VNode {
return Box(
{ paddingX: 1, marginBottom: 1 },
Text({ content: text, fg: theme.title, attributes: TextAttributes.BOLD }),
)
}
/** A help line: key names accented, descriptions dim. */
private help(pairs: [key: string, desc: string][], label = ""): VNode {
const items: VNode[] = []
if (label) items.push(Text({ content: label, fg: theme.dim }))
for (const [key, desc] of pairs) {
items.push(
Box(
{ flexDirection: "row" },
Text({ content: key, fg: theme.key, attributes: TextAttributes.BOLD }),
Text({ content: ` ${desc}`, fg: theme.help }),
),
)
}
return Box({ flexDirection: "row", gap: 2, flexWrap: "wrap" }, ...items)
}
/** The status line, plus an unsaved-changes hint so Save is never a surprise. */
private footer(): VNode {
const s = this.store
const rows: VNode[] = []
if (s.status) {
rows.push(Text({ content: s.status, fg: s.isError ? theme.err : theme.ok }))
}
if (s.dirty) {
rows.push(Text({ content: "unsaved changes — press S to save", fg: theme.dim }))
}
if (rows.length === 0) return Box({})
return Box({ flexDirection: "column", marginTop: 1 }, ...rows)
}
/**
* A selectable list. Each row is its own box with a mouse handler, so
* clicking a row selects and activates it exactly like pressing enter.
*/
private list(rows: VNode[], onActivate: (idx: number) => void): VNode {
const s = this.store
if (rows.length === 0) {
return Box({ flexDirection: "column" }, Text({ content: " (nothing here yet)", fg: theme.dim }))
}
return Box(
{ flexDirection: "column" },
...rows.map((row, i) =>
Box(
{
flexDirection: "row",
backgroundColor: i === s.cursor ? theme.cursor : undefined,
onMouseDown: (e: MouseEvent) => {
e.stopPropagation()
s.cursor = i
onActivate(i)
},
},
Text({
content: i === s.cursor ? "▸ " : " ",
fg: i === s.cursor ? theme.cursorText : theme.dim,
bg: i === s.cursor ? theme.cursor : undefined,
}),
row,
),
),
)
}
/** Row text, inverted while selected so it stays legible on the highlight. */
private rowText(content: string, idx: number, fg: string = theme.help): VNode {
const selected = idx === this.store.cursor
return Text({
content,
fg: selected ? theme.cursorText : fg,
bg: selected ? theme.cursor : undefined,
})
}
// ---- menu ----
private menuScreen(): VNode {
const s = this.store
const rows = MENU_ITEMS.map((item, i) => {
const parts: VNode[] = [this.rowText(item, i, i === MenuItem.Quit ? theme.dim : theme.help)]
if (i === MenuItem.Save && s.dirty) {
parts.push(this.rowText(" (unsaved changes)", i, theme.dim))
}
if (i === MenuItem.Save && s.daemonRunning) {
parts.push(this.rowText(" ● daemon live", i, theme.ok))
}
return Box({ flexDirection: "row" }, ...parts)
})
return Box(
{ flexDirection: "column" },
this.title("rtsp-streamer configurator"),
this.list(rows, (i) => this.activateMenu(i)),
Box({ marginTop: 1 }, this.help([["↑/↓", "move"], ["enter", "select"], ["q", "quit"]])),
this.footer(),
)
}
activateMenu(idx: number): void {
const s = this.store
switch (idx) {
case MenuItem.Cameras:
s.go("cameras")
break
case MenuItem.Layouts:
s.go("layouts")
break
case MenuItem.SetActive:
s.go("setActive")
break
case MenuItem.Discover:
void s.runDiscover("")
break
case MenuItem.DiscoverEnable:
void s.runDiscover("high,low")
break
case MenuItem.Save:
void s.save()
break
case MenuItem.Quit:
s.requestQuit()
break
}
}
// ---- cameras ----
private camerasScreen(): VNode {
const s = this.store
const rows = s.cfg.cameras.map((c, i) => {
const quals = Object.keys(c.streams ?? {})
const detail = quals.length > 0 ? `[${quals.join(",")}]` : truncate(c.rtsp ?? "(no stream)", 48)
const parts: VNode[] = [
this.rowText(pad(c.name, 24), i),
this.rowText(` ${detail}`, i, theme.dim),
]
if (c.disabled) parts.push(this.rowText(" [disabled]", i, theme.dim))
return Box({ flexDirection: "row" }, ...parts)
})
return Box(
{ flexDirection: "column" },
this.title(`Cameras (${s.cfg.cameras.length})`),
// Clicking a camera row toggles it; there is nothing to drill into.
this.list(rows, () => s.toggleCameraDisabled()),
Box(
{ marginTop: 1 },
this.help([["d", "toggle disabled"], ["x", "delete"], ["esc", "back"]]),
),
this.footer(),
)
}
// ---- layouts ----
private layoutsScreen(): VNode {
const s = this.store
const rows = s.cfg.layouts.map((l, i) => {
const parts: VNode[] = [
this.rowText(pad(l.name, 16), i),
this.rowText(pad(l.grid, 6), i, theme.dim),
this.rowText(`${effectiveTiles(l).length} cameras`, i, theme.dim),
]
if (l.name === s.cfg.active_layout) parts.push(this.rowText(" ●active", i, theme.ok))
if (l.protect_view) parts.push(this.rowText(`${l.protect_view}`, i, theme.dim))
return Box({ flexDirection: "row", gap: 1 }, ...parts)
})
return Box(
{ flexDirection: "column" },
this.title("Layouts"),
this.list(rows, (i) => s.openEditor(i)),
Box({ marginTop: 1 }, this.help([["enter", "edit"], ["esc", "back"]])),
this.footer(),
)
}
// ---- set active ----
private setActiveScreen(): VNode {
const s = this.store
const rows = s.cfg.layouts.map((l, i) => {
const parts: VNode[] = [this.rowText(pad(l.name, 16), i), this.rowText(l.grid, i, theme.dim)]
if (l.name === s.cfg.active_layout) parts.push(this.rowText(" ●current", i, theme.ok))
return Box({ flexDirection: "row", gap: 1 }, ...parts)
})
return Box(
{ flexDirection: "column" },
this.title("Set active layout"),
this.list(rows, (i) => s.setActiveLayout(i)),
Box({ marginTop: 1 }, this.help([["enter", "select"], ["esc", "back"]])),
this.footer(),
)
}
// ---- layout editor ----
private layoutEditScreen(): VNode {
const s = this.store
const l = s.layout
if (!l) return Box({}, Text({ content: "no such layout", fg: theme.err }))
const [cols, rows] = s.gridDims()
const tiles = l.tiles ?? []
return Box(
{ flexDirection: "column" },
this.title(`Edit "${l.name}" — ${cols}x${rows} grid, ${tiles.length}/${s.maxTiles} cameras`),
this.grid(l, { interactive: true }),
Box(
{ flexDirection: "column", marginTop: 1, gap: 0 },
this.help([["click", "assign cell"], ["drag a tile", "resize"]], "mouse"),
this.help([["←↑↓→/hjkl", "move"], ["enter", "assign"], ["c", "clear"], ["q", "quality"]]),
this.help([["L/H", "wider/narrower"], ["J/K", "taller/shorter"]], "resize tile"),
this.help([["] [", "cols"], ["} {", "rows"], ["esc", "back"]], "base grid"),
),
this.footer(),
)
}
/**
* Cell width for a grid of `cols` columns, fitted to the terminal so an 8-wide
* grid does not overflow. Shared by the renderer and the mouse handlers, which
* must agree on the lattice exactly.
*/
private cellWidth(cols: number): number {
return clamp(Math.floor((this.renderer.width - 4) / cols), CELL_W_MIN, CELL_W_MAX)
}
/**
* Draw the base grid: one absolutely-positioned box per cell, with tiles
* drawn over the empty cells so a spanning tile reads as a single box.
*/
private grid(l: Layout, opts: { interactive: boolean }): VNode {
const s = this.store
const [cols, rows] = layoutDimensions(l)
const tiles = effectiveTiles(l)
const cellW = this.cellWidth(cols)
const children: VNode[] = []
// Empty cells first, so the cursor highlight paints over them.
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (tileIndexAt(tiles, c, r) >= 0) continue
const isCursor = opts.interactive && c === s.edCol && r === s.edRow
children.push(
Box({
position: "absolute",
left: c * cellW,
top: r * CELL_H,
width: cellW,
height: CELL_H,
borderStyle: "single",
borderColor: isCursor ? theme.cursor : theme.empty,
zIndex: isCursor ? 2 : 0,
}),
)
}
}
tiles.forEach((t, i) => {
const [cs, rs] = tileSpan(t)
// A spanning tile is "under the cursor" anywhere in its rectangle.
const isCursor =
opts.interactive &&
s.edCol >= t.col &&
s.edCol < t.col + cs &&
s.edRow >= t.row &&
s.edRow < t.row + rs
const width = cs * cellW
const label = t.camera || "(no cam)"
const notes: string[] = []
if (cs > 1 || rs > 1) notes.push(`${cs}x${rs}`)
notes.push(t.quality ? qualityAbbrev(t.quality) : "auto")
children.push(
Box(
{
// Tagged so a drag can resize this box in place; the index matches
// the store's tile index because effectiveTiles preserves order.
...(opts.interactive ? { id: `tile-${i}` } : {}),
position: "absolute",
left: t.col * cellW,
top: t.row * CELL_H,
width,
height: rs * CELL_H,
borderStyle: isCursor ? "double" : "single",
borderColor: isCursor ? theme.cursor : theme.tile,
title: truncate(label, Math.max(1, width - 4)),
titleColor: isCursor ? theme.cursor : theme.tile,
zIndex: isCursor ? 2 : 1,
paddingX: 1,
},
Text({ content: truncate(notes.join(" "), Math.max(1, width - 3)), fg: theme.dim }),
),
)
})
// No mouse handlers here: they live on the persistent mount box, because
// this one is destroyed and rebuilt on every state change. See installMouse.
return Box(
{
// id lets the mouse handlers find this box to read its screen origin.
id: "grid",
width: cols * cellW,
height: rows * CELL_H,
flexShrink: 0,
},
...children,
)
}
// ---- camera picker ----
private cameraPickerScreen(): VNode {
const s = this.store
const l = s.layout
if (!l) return Box({}, Text({ content: "no such layout", fg: theme.err }))
const used = s.usedCameras()
const opts = s.pickerOptions()
const rows = opts.map((name, i) => {
if (i === 0) return Box({ flexDirection: "row" }, this.rowText(PICKER_EMPTY, i, theme.dim))
const parts: VNode[] = [this.rowText(name, i)]
if (used.has(name)) parts.push(this.rowText(" ● already placed", i, theme.ok))
return Box({ flexDirection: "row" }, ...parts)
})
return Box(
{ flexDirection: "column" },
this.title(`Assign to "${l.name}" — cell (col ${s.edCol}, row ${s.edRow})`),
// Show the grid while choosing, so what is already placed stays visible.
this.grid(l, { interactive: false }),
Box({ marginTop: 1 }, ...[this.list(rows, (i) => s.choosePickerOption(i))]),
Box(
{ marginTop: 1 },
this.help([["enter", "choose"], [PICKER_EMPTY, "clears cell"], ["esc", "back"]]),
),
this.footer(),
)
}
// ---- grid mouse handling ----
/**
* Install the grid's mouse handlers on the persistent mount box, once.
*
* They cannot live on the grid box itself. A drag is routed to the renderable
* the press landed on, and resizing a tile notifies the store, which rebuilds
* the subtree and destroys that renderable — so every drag event after the
* first resize was delivered to a dead box and dropped. The mount box outlives
* every render, so it keeps receiving the whole gesture.
*
* The cost is that these see presses anywhere on screen, so each one checks
* that the editor is open and that the pointer is actually over the grid.
* List rows stop propagation, so they never reach here.
*/
private installMouse(): void {
this.mount.onMouseDown = (e: MouseEvent) => this.gridMouseDown(e)
this.mount.onMouseDrag = (e: MouseEvent) => this.gridMouseDrag(e)
this.mount.onMouseUp = (e: MouseEvent) => this.gridMouseUp(e)
// The final leg of a drag arrives here, not as another drag event, so it
// has to be applied before the drag state is torn down.
this.mount.onMouseDragEnd = (e: MouseEvent) => {
this.gridMouseDrag(e)
const moved = this.dragMoved
this.justDragged = moved
this.endDrag()
// Catch up on the rebuild that was suppressed for the duration of the
// drag, so labels, spans and the cursor highlight are all consistent.
if (moved) this.render()
}
}
/** The grid's dimensions for the layout currently being edited, or null. */
private gridGeometry(): { cols: number; rows: number; cellW: number } | null {
const l = this.store.layout
if (!l || this.store.screen !== "layoutEdit") return null
const [cols, rows] = layoutDimensions(l)
return { cols, rows, cellW: this.cellWidth(cols) }
}
/**
* Map absolute pointer coordinates onto a grid cell, relative to the grid's
* origin as captured at press time (see gridOrigin).
*/
private cellAt(e: MouseEvent, cols: number, rows: number, cellW: number): [col: number, row: number] | null {
const origin = this.gridOrigin
if (!origin) return null
const col = Math.floor((e.x - origin[0]) / cellW)
const row = Math.floor((e.y - origin[1]) / CELL_H)
if (col < 0 || col >= cols || row < 0 || row >= rows) return null
return [col, row]
}
private gridMouseDown(e: MouseEvent): void {
const geo = this.gridGeometry()
if (!geo) return
// Read the origin from the live grid box while its layout is current, and
// hold it for the rest of the gesture.
const box = this.gridBox
if (!box) return
this.gridOrigin = [box.screenX, box.screenY]
const cell = this.cellAt(e, geo.cols, geo.rows, geo.cellW)
if (!cell) return
e.stopPropagation()
this.justDragged = false
this.pressedOnGrid = true
this.store.setCell(cell[0], cell[1])
// Only a cell that already holds a tile can be dragged; dragging an empty
// cell would have nothing to resize.
this.dragTile = this.store.tileIndexAtCursor()
this.dragging = this.dragTile >= 0
this.dragMoved = false
}
private gridMouseDrag(e: MouseEvent): void {
if (!this.dragging) return
const geo = this.gridGeometry()
if (!geo) return
const cell = this.cellAt(e, geo.cols, geo.rows, geo.cellW)
if (!cell) return
e.stopPropagation()
// Apply the resize without rebuilding the subtree, then reflect it by
// resizing the live box. Rebuilding here would destroy the renderable the
// renderer captured for this drag and the gesture would end silently.
this.suppressRender = true
const changed = this.store.dragResize(this.dragTile, cell[0], cell[1])
this.suppressRender = false
if (!changed) return
this.dragMoved = true
const tile = this.store.layout?.tiles?.[this.dragTile]
const box = this.mount.findDescendantById(`tile-${this.dragTile}`)
if (tile && box) {
const [cs, rs] = tileSpan(tile)
box.width = cs * geo.cellW
box.height = rs * CELL_H
}
}
private gridMouseUp(e: MouseEvent): void {
// A drag may have been torn down by onMouseDragEnd already, so consult
// justDragged as well as the live drag state.
const wasDrag = this.justDragged || (this.dragging && this.dragMoved)
const pressed = this.pressedOnGrid
this.justDragged = false
this.pressedOnGrid = false
this.endDrag()
// Ignore a release whose press went somewhere else — it is the tail of
// someone else's click, not a click on this grid.
if (wasDrag || !pressed) return
const geo = this.gridGeometry()
if (!geo) return
const cell = this.cellAt(e, geo.cols, geo.rows, geo.cellW)
if (!cell) return
e.stopPropagation()
// A plain click (no drag) opens the picker for that cell.
this.store.setCell(cell[0], cell[1])
this.store.go("cameraPicker")
}
private endDrag(): void {
this.dragging = false
this.dragMoved = false
this.dragTile = -1
}
}
// ---- small string helpers ----
function clamp(n: number, lo: number, hi: number): number {
return Math.min(Math.max(n, lo), hi)
}
/** Left-justify to n columns. Counts code points, not UTF-16 units. */
function pad(s: string, n: number): string {
const chars = [...s]
if (chars.length >= n) return chars.slice(0, n).join("")
return s + " ".repeat(n - chars.length)
}
/** Shorten to at most n columns, with an ellipsis when it clips. */
export function truncate(s: string, n: number): string {
const chars = [...s]
if (chars.length <= n) return s
if (n <= 1) return chars.slice(0, n).join("")
return chars.slice(0, n - 1).join("") + "…"
}