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>
This commit is contained in:
Levi Woodard
2026-07-29 12:22:48 -06:00
parent 81193f524c
commit 1ea3a5ac0a
28 changed files with 3267 additions and 83 deletions

34
tui/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

99
tui/README.md Normal file
View File

@@ -0,0 +1,99 @@
# rtsp-streamer configurator (opentui)
The interactive configurator, built with [opentui](https://opentui.com). It
replaces the Bubble Tea TUI that lived in `internal/tui`.
Launch it the usual way:
```bash
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:
```bash
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
```bash
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).

65
tui/bun.lock Normal file
View File

@@ -0,0 +1,65 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "tui",
"dependencies": {
"@opentui/core": "^0.4.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
}
}

23
tui/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "rtsp-streamer-tui",
"version": "0.1.0",
"description": "opentui configurator for rtsp-streamer",
"module": "src/index.ts",
"type": "module",
"private": true,
"scripts": {
"start": "bun run src/index.ts",
"typecheck": "tsc --noEmit",
"test": "bun test",
"build": "bun build --compile src/index.ts --outfile ../bin/rtsp-streamer-tui"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@opentui/core": "^0.4.5"
}
}

42
tui/scripts/preview.ts Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bun
/**
* Render each screen to plain text and print it, without a terminal.
*
* This is a development aid, not a test — it exists so a change to the layout
* can be eyeballed in one command (`bun run scripts/preview.ts`) instead of
* being driven by hand over SSH. src/ui.test.ts asserts the parts that matter.
*/
import { createTestRenderer } from "@opentui/core/testing"
import { Store } from "../src/state.ts"
import { UI } from "../src/ui.ts"
import { sampleDoc } from "../src/fixtures.ts"
import type { Screen } from "../src/state.ts"
const SCREENS: Screen[] = ["menu", "cameras", "layouts", "layoutEdit", "cameraPicker", "setActive"]
const setup = await createTestRenderer({ width: 96, height: 30 })
try {
const store = new Store()
store.hydrate(sampleDoc())
const ui = new UI(setup.renderer, store)
store.subscribe(() => ui.render())
for (const screen of SCREENS) {
if (screen === "layoutEdit" || screen === "cameraPicker") {
// Land on a layout with spanning tiles so the interesting case is shown.
store.openEditor(0)
store.setCell(3, 1)
store.screen = screen
} else {
store.screen = screen
}
store.cursor = 0
ui.render()
await setup.flush()
console.log(`\n===== ${screen} =====`)
console.log(setup.captureCharFrame().replace(/ +$/gm, ""))
}
} finally {
setup.renderer.destroy()
}

116
tui/src/bridge.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Calls into the Go binary. Every piece of domain logic lives there; this
* module is only transport.
*
* Each bridge command prints one JSON document on stdout even when it fails,
* so we parse stdout regardless of exit status and read `ok` from the payload.
* A non-zero exit with unparseable stdout means something went wrong before the
* command ran (missing binary, bad flag) — that becomes a thrown Error.
*/
import type { ApplyResult, Config, DiscoverDoc, ExportDoc } from "./types.ts"
/**
* Path to the rtsp-streamer binary. `rtsp-streamer tui` sets
* RTSP_STREAMER_BIN to its own path so a locally-built binary is not shadowed
* by an installed one; standalone runs fall back to $PATH.
*/
const GO_BIN = process.env.RTSP_STREAMER_BIN || "rtsp-streamer"
export class BridgeError extends Error {}
interface RunResult {
stdout: string
stderr: string
exitCode: number
}
async function run(args: string[], stdin?: string): Promise<RunResult> {
let proc
try {
proc = Bun.spawn([GO_BIN, ...args], {
stdin: stdin === undefined ? "ignore" : new TextEncoder().encode(stdin),
stdout: "pipe",
stderr: "pipe",
})
} catch (cause) {
throw new BridgeError(`cannot run ${GO_BIN}: ${cause instanceof Error ? cause.message : String(cause)}`)
}
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
return { stdout, stderr, exitCode }
}
/**
* Run a bridge command and parse its JSON. Throws BridgeError when there is no
* JSON to read, using stderr for the message since that is where cobra reports
* usage and startup failures.
*/
async function runJSON<T>(args: string[], stdin?: string): Promise<T> {
const { stdout, stderr, exitCode } = await run(args, stdin)
const text = stdout.trim()
if (!text) {
const detail = cleanStderr(stderr) || `exit status ${exitCode}`
throw new BridgeError(detail)
}
try {
return JSON.parse(text) as T
} catch {
throw new BridgeError(cleanStderr(stderr) || `unexpected output from \`${args.join(" ")}\`: ${text.slice(0, 200)}`)
}
}
/**
* Tidy a Go-side error for re-display. The CLI prefixes its failures with
* "error: "; callers here add their own prefix, so keeping Go's would render as
* "error: error: invalid config ...".
*/
function cleanStderr(stderr: string): string {
return stderr.trim().replace(/^error:\s*/, "")
}
/** The config path to operate on, threaded through as an explicit --config. */
export interface BridgeOptions {
configPath?: string
}
function withConfig(args: string[], opts: BridgeOptions): string[] {
return opts.configPath ? ["--config", opts.configPath, ...args] : args
}
/** Load the config and the constants the editor needs. */
export function exportConfig(opts: BridgeOptions = {}): Promise<ExportDoc> {
return runJSON<ExportDoc>(withConfig(["config", "export"], opts))
}
/**
* Save the edited cameras/layouts/active layout and reload a running daemon.
*
* Only these three keys are sent: Go merges them onto a fresh read of the file,
* so the controller section (including its password) and anything changed on
* disk while the TUI was open are left alone.
*/
export function applyConfig(cfg: Config, opts: BridgeOptions = {}): Promise<ApplyResult> {
const payload = JSON.stringify({
cameras: cfg.cameras,
layouts: cfg.layouts,
active_layout: cfg.active_layout,
})
return runJSON<ApplyResult>(withConfig(["config", "apply"], opts), payload)
}
/**
* Discover cameras from UniFi Protect without writing anything.
*
* `enable` is a comma-separated quality list ("high,low") to switch RTSP on for
* in Protect as a side effect of discovery; empty just reports what is already
* enabled.
*/
export function discover(enable: string, opts: BridgeOptions = {}): Promise<DiscoverDoc> {
const args = ["discover", "--json"]
if (enable) args.push(`--enable-rtsp=${enable}`)
return runJSON<DiscoverDoc>(withConfig(args, opts))
}

50
tui/src/fixtures.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* A sample export document, shared by the tests and the preview script.
*
* Shaped to cover the cases that have actually caused trouble: a spanning tile,
* an empty cell, a tile pinned to a quality versus one left on auto, a disabled
* camera (which must not appear in the picker), a camera with only a substream,
* and a legacy slots-only layout that has to be upgraded on open.
*/
import type { ExportDoc } from "./types.ts"
export function sampleDoc(): ExportDoc {
return {
config_path: "/tmp/rtsp-streamer-preview.yaml",
max_tiles: 16,
max_grid_dim: 8,
qualities: ["high", "medium", "low"],
daemon_running: true,
config: {
controller: { host: "192.168.1.1", username: "viewer", rtsp_port: 7441 },
display: {},
player: {},
cameras: [
{ id: "c1", name: "Driveway", streams: { high: "rtsps://x/dw-hi", low: "rtsps://x/dw-lo" } },
{ id: "c2", name: "Front Door", streams: { high: "rtsps://x/fd-hi", low: "rtsps://x/fd-lo" } },
{ id: "c3", name: "Back Yard", streams: { low: "rtsps://x/by-lo" } },
{ id: "c4", name: "Side Gate", streams: { high: "rtsps://x/sg-hi" }, disabled: true },
{ name: "Shed (manual)", rtsp: "rtsp://10.0.0.9:554/stream1" },
],
layouts: [
{
name: "main-plus",
grid: "4x3",
tiles: [
// A 3x2 hero tile with a column of small tiles down the right.
{ camera: "Driveway", col: 0, row: 0, colspan: 3, rowspan: 2, quality: "high" },
{ camera: "Front Door", col: 3, row: 0, colspan: 1, rowspan: 1, quality: "low" },
{ camera: "Back Yard", col: 3, row: 2, colspan: 1, rowspan: 1 },
// (3,1) and the bottom-left cells stay empty on purpose.
],
},
{ name: "quad", grid: "2x2", tiles: [{ camera: "Driveway", col: 0, row: 0 }] },
// Legacy row-major form; openEditor() upgrades it to tiles.
{ name: "legacy", grid: "2x1", slots: ["Front Door", "Back Yard"] },
],
active_layout: "main-plus",
clock: {},
},
}
}

99
tui/src/index.ts Normal file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bun
/**
* rtsp-streamer configurator — an opentui port of the Bubble Tea TUI that used
* to live in internal/tui.
*
* Meant to be driven over SSH on the headless Pi: browse cameras, place them
* into layout slots, pick the active layout, discover cameras from UniFi
* Protect, and save — which signals the running daemon to reload.
*
* All config and Protect work is delegated to the Go binary (see bridge.ts), so
* this process holds no credentials and never writes the config file itself.
*/
import { createCliRenderer, type KeyEvent } from "@opentui/core"
import { BridgeError } from "./bridge.ts"
import { handleKey } from "./keys.ts"
import { Store } from "./state.ts"
import { UI } from "./ui.ts"
function parseArgs(argv: string[]): { configPath?: string; help: boolean } {
let configPath: string | undefined
let help = false
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === "--config" || a === "-c") configPath = argv[++i]
else if (a?.startsWith("--config=")) configPath = a.slice("--config=".length)
else if (a === "--help" || a === "-h") help = true
}
return { configPath, help }
}
const USAGE = `rtsp-streamer configurator (opentui)
Usage: rtsp-streamer-tui [--config PATH]
Normally launched as \`rtsp-streamer tui\`, which passes --config and points
RTSP_STREAMER_BIN at itself.
Environment:
RTSP_STREAMER_BIN path to the rtsp-streamer binary (default: from $PATH)
RTSP_STREAMER_CONFIG default config path when --config is not given
`
async function main(): Promise<number> {
const { configPath, help } = parseArgs(process.argv.slice(2))
if (help) {
process.stdout.write(USAGE)
return 0
}
// Load before taking over the terminal: a bad config or a missing Go binary
// should print a plain error, not a broken frame in the alternate screen.
const store = new Store()
try {
await store.load(configPath)
} catch (e) {
const msg = e instanceof BridgeError || e instanceof Error ? e.message : String(e)
process.stderr.write(`error: ${msg}\n`)
return 1
}
const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 })
const ui = new UI(renderer, store)
const unsubscribe = store.subscribe(() => ui.render())
ui.render()
let done = false
const finish = () => {
if (done) return
done = true
unsubscribe()
renderer.destroy()
}
renderer.keyInput.on("keypress", (key: KeyEvent) => {
if (done) return
// Ctrl+C always exits, even mid-edit.
if (key.ctrl && key.name === "c") {
finish()
return
}
try {
handleKey(store, ui, key)
} catch (e) {
store.setStatus(`error: ${e instanceof Error ? e.message : String(e)}`, true)
}
if (store.quitRequested) finish()
})
renderer.on("resize", () => ui.render())
await new Promise<void>((resolve) => {
renderer.on("destroy", () => resolve())
})
return 0
}
process.exitCode = await main()

105
tui/src/keys.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* Keyboard routing.
*
* The bindings are carried over verbatim from the Bubble Tea version — vim
* motions alongside the arrows, shifted HJKL to resize a tile, `q` meaning
* "quality" inside the editor but "quit" at the menu — so existing muscle
* memory keeps working. `S` saves from anywhere, which is new.
*
* Matching is on `key.sequence` for printable keys because it preserves case
* (`key.name` reports "l" for both `l` and `L`, which would collapse "move
* right" into "make wider"), and on `key.name` for the named keys.
*/
import type { KeyEvent } from "@opentui/core"
import type { Store } from "./state.ts"
/** What the key handler needs from the UI: menu activation. */
export interface MenuActivator {
activateMenu(idx: number): void
}
export function handleKey(store: Store, ui: MenuActivator, key: KeyEvent): void {
// Ignore input while a discover or save is in flight, rather than queueing
// keys that would act on state the bridge call is about to replace.
if (store.busy) return
const name = key.name
const seq = key.sequence
const back = name === "escape" || name === "backspace"
switch (store.screen) {
case "menu": {
const n = store.listLength()
if (name === "up" || seq === "k") store.moveCursor(-1, n)
else if (name === "down" || seq === "j") store.moveCursor(1, n)
else if (name === "return" || name === "space" || seq === "l") ui.activateMenu(store.cursor)
else if (seq === "q") store.requestQuit()
else if (seq === "S") void store.save()
return
}
case "cameras": {
const n = store.listLength()
if (back || seq === "h") store.go("menu")
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
else if (name === "down" || seq === "j") store.moveCursor(1, n)
else if (seq === "d") store.toggleCameraDisabled()
else if (seq === "x") store.deleteCamera()
else if (seq === "S") void store.save()
return
}
case "layouts": {
const n = store.listLength()
if (back || seq === "h") store.go("menu")
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
else if (name === "down" || seq === "j") store.moveCursor(1, n)
else if (name === "return" || seq === "l") store.openEditor(store.cursor)
else if (seq === "S") void store.save()
return
}
case "setActive": {
const n = store.listLength()
if (back || seq === "h") store.go("menu")
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
else if (name === "down" || seq === "j") store.moveCursor(1, n)
else if (name === "return" || seq === "l") store.setActiveLayout(store.cursor)
return
}
case "cameraPicker": {
const n = store.listLength()
if (back || seq === "h") store.go("layoutEdit")
else if (name === "up" || seq === "k") store.moveCursor(-1, n)
else if (name === "down" || seq === "j") store.moveCursor(1, n)
else if (name === "return" || seq === "l") store.choosePickerOption(store.cursor)
return
}
case "layoutEdit": {
// Returning to the layout list puts the cursor back on the layout that
// was being edited, not at the top.
if (back) store.go("layouts", store.editLayout)
else if (name === "up" || seq === "k") store.moveCell(0, -1)
else if (name === "down" || seq === "j") store.moveCell(0, 1)
else if (name === "left" || seq === "h") store.moveCell(-1, 0)
else if (name === "right" || seq === "l") store.moveCell(1, 0)
else if (name === "return" || name === "space" || seq === "a") store.go("cameraPicker")
else if (seq === "c") store.clearAt()
else if (seq === "q") store.cycleQuality()
// Shifted motions resize the tile; unshifted move the cursor.
else if (seq === "L") store.resizeTile(1, 0)
else if (seq === "H") store.resizeTile(-1, 0)
else if (seq === "J") store.resizeTile(0, 1)
else if (seq === "K") store.resizeTile(0, -1)
else if (seq === "]") store.resizeGrid(1, 0)
else if (seq === "[") store.resizeGrid(-1, 0)
else if (seq === "}") store.resizeGrid(0, 1)
else if (seq === "{") store.resizeGrid(0, -1)
else if (seq === "S") void store.save()
return
}
}
}

309
tui/src/state.test.ts Normal file
View File

@@ -0,0 +1,309 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { sampleDoc } from "./fixtures.ts"
import { handleKey } from "./keys.ts"
import { PICKER_EMPTY, Store } from "./state.ts"
import type { KeyEvent } from "@opentui/core"
/** Minimal KeyEvent stand-in; handleKey only reads name/sequence. */
function key(seq: string, name = seq): KeyEvent {
return { name, sequence: seq } as KeyEvent
}
/** Collects menu activations so key tests don't need the real UI. */
const noopUI = { activateMenu: () => {} }
let store: Store
beforeEach(() => {
store = new Store()
store.hydrate(sampleDoc())
})
describe("cursor movement", () => {
test("wraps around both ends", () => {
store.screen = "menu"
store.cursor = 0
store.moveCursor(-1, 7)
expect(store.cursor).toBe(6)
store.moveCursor(1, 7)
expect(store.cursor).toBe(0)
})
test("stays at 0 for an empty list", () => {
store.moveCursor(1, 0)
expect(store.cursor).toBe(0)
})
})
describe("openEditor", () => {
test("upgrades a legacy slots layout to tiles", () => {
// Index 2 of the fixture is the slots-only "legacy" layout.
store.openEditor(2)
const l = store.layout!
expect(l.slots).toBeUndefined()
expect(l.tiles).toEqual([
{ camera: "Front Door", col: 0, row: 0, colspan: 1, rowspan: 1 },
{ camera: "Back Yard", col: 1, row: 0, colspan: 1, rowspan: 1 },
])
expect(store.screen).toBe("layoutEdit")
})
test("resets the cell cursor", () => {
store.edCol = 3
store.edRow = 2
store.openEditor(1)
expect([store.edCol, store.edRow]).toEqual([0, 0])
})
})
describe("grid cursor", () => {
beforeEach(() => store.openEditor(0)) // main-plus, 4x3
test("clamps at the grid edges", () => {
store.moveCell(-1, -1)
expect([store.edCol, store.edRow]).toEqual([0, 0])
store.setCell(3, 2)
store.moveCell(1, 1)
expect([store.edCol, store.edRow]).toEqual([3, 2])
})
test("finds a spanning tile from any of its cells", () => {
store.setCell(2, 1) // inside the 3x2 Driveway tile
expect(store.tileIndexAtCursor()).toBe(0)
store.setCell(3, 1) // the empty cell
expect(store.tileIndexAtCursor()).toBe(-1)
})
})
describe("assignCamera", () => {
beforeEach(() => store.openEditor(1)) // quad, 2x2, one tile at (0,0)
test("creates a 1x1 tile on an empty cell", () => {
store.setCell(1, 1)
store.assignCamera("Front Door")
expect(store.layout!.tiles).toContainEqual({
camera: "Front Door",
col: 1,
row: 1,
colspan: 1,
rowspan: 1,
})
expect(store.dirty).toBe(true)
})
test("retargets an existing tile without changing its span", () => {
store.setCell(0, 0)
store.layout!.tiles![0]!.colspan = 2
store.assignCamera("Back Yard")
expect(store.layout!.tiles![0]).toMatchObject({ camera: "Back Yard", colspan: 2 })
expect(store.layout!.tiles!.length).toBe(1)
})
test("an empty choice removes the tile", () => {
store.setCell(0, 0)
store.assignCamera("")
expect(store.layout!.tiles).toEqual([])
})
test("refuses to exceed maxTiles", () => {
store.maxTiles = 1 // the layout already has one tile
store.setCell(1, 1)
store.assignCamera("Front Door")
expect(store.layout!.tiles!.length).toBe(1)
expect(store.isError).toBe(true)
expect(store.status).toContain("layout is full")
})
})
describe("camera picker", () => {
test("omits disabled cameras and leads with the empty sentinel", () => {
// "Side Gate" is disabled in the fixture.
expect(store.pickerOptions()).toEqual([
PICKER_EMPTY,
"Driveway",
"Front Door",
"Back Yard",
"Shed (manual)",
])
})
test("index 0 clears the cell rather than assigning a camera named '(empty)'", () => {
store.openEditor(1)
store.setCell(0, 0)
store.choosePickerOption(0)
expect(store.layout!.tiles).toEqual([])
expect(store.screen).toBe("layoutEdit")
})
test("reports cameras already placed in this layout", () => {
store.openEditor(0)
expect(store.usedCameras()).toEqual(new Set(["Driveway", "Front Door", "Back Yard"]))
})
})
describe("resizeTile", () => {
beforeEach(() => store.openEditor(0)) // main-plus 4x3
test("grows into free space", () => {
// Front Door is 1x1 at (3,0) and (3,1) is empty, so it can grow downward.
store.setCell(3, 0)
store.resizeTile(0, 1)
expect(store.layout!.tiles![1]).toMatchObject({ camera: "Front Door", rowspan: 2 })
expect(store.dirty).toBe(true)
})
test("refuses to grow past the last row", () => {
store.setCell(3, 2) // Back Yard, on the bottom row of a 3-row grid
store.resizeTile(0, 1)
expect(store.status).toContain("can't resize")
expect(store.layout!.tiles![2]!.rowspan ?? 1).toBe(1)
})
test("refuses to overlap a neighbour", () => {
store.setCell(0, 0) // the 3x2 hero tile
store.resizeTile(1, 0) // would run into Front Door at (3,0)
expect(store.status).toContain("can't resize")
expect(store.layout!.tiles![0]!.colspan).toBe(3)
})
test("shrinks and never goes below 1", () => {
store.setCell(0, 0)
store.resizeTile(-1, 0)
expect(store.layout!.tiles![0]!.colspan).toBe(2)
store.resizeTile(-1, 0)
store.resizeTile(-1, 0) // already at 1
expect(store.layout!.tiles![0]!.colspan).toBe(1)
})
test("complains when there is no tile under the cursor", () => {
store.setCell(3, 1) // empty
store.resizeTile(1, 0)
expect(store.isError).toBe(true)
expect(store.status).toContain("assign a camera first")
})
})
describe("resizeGrid", () => {
beforeEach(() => store.openEditor(0)) // 4x3
test("grows and shrinks the base grid", () => {
store.resizeGrid(1, 0)
expect(store.layout!.grid).toBe("5x3")
store.resizeGrid(-1, 0)
expect(store.layout!.grid).toBe("4x3")
})
test("refuses a shrink that would orphan a tile", () => {
store.resizeGrid(-1, 0) // Front Door sits at col 3
expect(store.layout!.grid).toBe("4x3")
expect(store.status).toContain("shrink blocked")
})
test("respects maxGridDim", () => {
store.maxGridDim = 4
store.resizeGrid(1, 0)
expect(store.layout!.grid).toBe("4x3")
})
test("pulls the cell cursor back inside after a shrink", () => {
store.layout!.tiles = [] // nothing to block the shrink
store.setCell(3, 2)
store.resizeGrid(-1, -1)
expect(store.layout!.grid).toBe("3x2")
expect([store.edCol, store.edRow]).toEqual([2, 1])
})
})
describe("cycleQuality", () => {
beforeEach(() => store.openEditor(0))
test("cycles auto → the camera's available qualities → auto", () => {
store.setCell(3, 2) // Back Yard: only a low stream, currently auto
expect(store.layout!.tiles![2]!.quality).toBeUndefined()
store.cycleQuality()
expect(store.layout!.tiles![2]!.quality).toBe("low")
store.cycleQuality()
expect(store.layout!.tiles![2]!.quality).toBeUndefined()
expect(store.status).toContain("auto")
})
test("only offers qualities the camera actually has", () => {
store.setCell(0, 0) // Driveway: high + low, starts pinned to high
store.cycleQuality()
expect(store.layout!.tiles![0]!.quality).toBe("low")
store.cycleQuality()
expect(store.layout!.tiles![0]!.quality).toBeUndefined() // never "medium"
})
test("complains on an empty cell", () => {
store.setCell(3, 1)
store.cycleQuality()
expect(store.isError).toBe(true)
})
})
describe("cameras screen", () => {
test("toggles disabled", () => {
store.screen = "cameras"
store.cursor = 0
store.toggleCameraDisabled()
expect(store.cfg.cameras[0]!.disabled).toBe(true)
store.toggleCameraDisabled()
expect(store.cfg.cameras[0]!.disabled).toBe(false)
})
test("deleting the last row moves the cursor back", () => {
store.screen = "cameras"
store.cursor = 4 // the final camera
store.deleteCamera()
expect(store.cfg.cameras.length).toBe(4)
expect(store.cursor).toBe(3)
})
})
describe("setActiveLayout", () => {
test("sets the name and returns to the menu", () => {
store.setActiveLayout(1)
expect(store.cfg.active_layout).toBe("quad")
expect(store.screen).toBe("menu")
expect(store.dirty).toBe(true)
})
})
describe("key routing", () => {
test("q quits at the menu but cycles quality in the editor", () => {
store.screen = "menu"
handleKey(store, noopUI, key("q"))
expect(store.quitRequested).toBe(true)
const s2 = new Store()
s2.hydrate(sampleDoc())
s2.openEditor(0)
s2.setCell(3, 2)
handleKey(s2, noopUI, key("q"))
expect(s2.quitRequested).toBe(false)
expect(s2.layout!.tiles![2]!.quality).toBe("low")
})
test("l moves the cell cursor while L widens the tile", () => {
store.openEditor(1) // quad, tile at (0,0)
handleKey(store, noopUI, key("l"))
expect(store.edCol).toBe(1)
store.setCell(0, 0)
handleKey(store, noopUI, key("L"))
expect(store.layout!.tiles![0]!.colspan).toBe(2)
})
test("escape from the editor returns to the layout it was editing", () => {
store.openEditor(2)
handleKey(store, noopUI, key("\x1b", "escape"))
expect(store.screen).toBe("layouts")
expect(store.cursor).toBe(2)
})
test("input is ignored while a bridge call is in flight", () => {
store.screen = "cameras"
store.busy = true
handleKey(store, noopUI, key("x"))
expect(store.cfg.cameras.length).toBe(5)
})
})

476
tui/src/state.ts Normal file
View File

@@ -0,0 +1,476 @@
/**
* Application state and every mutation on it — the port of the Bubble Tea
* model from internal/tui/tui.go and internal/tui/gridedit.go.
*
* This is deliberately a plain observable object rather than anything reactive.
* The original was an Elm-style model where each keypress produced a new state
* and the whole view was re-derived; keeping that shape makes the port readable
* next to the Go it came from, and the UI layer just rebuilds on notify().
*
* Nothing here touches the filesystem or Protect: saving and discovery go
* through the bridge, so validation and atomic writes stay in Go.
*/
import { applyConfig, BridgeError, discover, exportConfig } from "./bridge.ts"
import {
availableQualities,
cameraByName,
effectiveTiles,
layoutDimensions,
regionFree,
tileIndexAt,
tileSpan,
type Config,
type ExportDoc,
type Layout,
type Quality,
type Tile,
} from "./types.ts"
export type Screen = "menu" | "cameras" | "layouts" | "layoutEdit" | "cameraPicker" | "setActive"
export const MENU_ITEMS = [
"Cameras",
"Layouts",
"Set active layout",
"Discover from UniFi Protect",
"Discover + enable RTSP (hi+lo)",
"Save",
"Quit",
] as const
export const enum MenuItem {
Cameras = 0,
Layouts = 1,
SetActive = 2,
Discover = 3,
DiscoverEnable = 4,
Save = 5,
Quit = 6,
}
/** The "(empty)" sentinel at the top of the camera picker. */
export const PICKER_EMPTY = "(empty)"
export class Store {
configPath = ""
maxTiles = 16
maxGridDim = 8
qualities: Quality[] = ["high", "medium", "low"]
daemonRunning = false
cfg: Config = {
controller: {},
display: {},
player: {},
cameras: [],
layouts: [],
active_layout: "",
clock: {},
}
dirty = false
screen: Screen = "menu"
cursor = 0
/** Index into cfg.layouts for the editor and picker screens. */
editLayout = 0
/** Grid-editor cursor position. */
edCol = 0
edRow = 0
status = ""
isError = false
/** Set while a bridge call is in flight, so keys don't queue up behind it. */
busy = false
/** Set when the user asks to quit; the entry point watches for it. */
quitRequested = false
private listeners = new Set<() => void>()
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
private notify(): void {
for (const fn of this.listeners) fn()
}
setStatus(status: string, isError = false): void {
this.status = status
this.isError = isError
this.notify()
}
// ---- loading ----
async load(configPath?: string): Promise<void> {
this.hydrate(await exportConfig({ configPath }))
}
/** Seed state from an export document. Split out so tests can skip the bridge. */
hydrate(doc: ExportDoc): void {
this.configPath = doc.config_path
this.maxTiles = doc.max_tiles
this.maxGridDim = doc.max_grid_dim
this.qualities = doc.qualities
this.daemonRunning = doc.daemon_running
this.cfg = doc.config
// A config file that has never been written comes back with null lists.
this.cfg.cameras ??= []
this.cfg.layouts ??= []
this.notify()
}
// ---- navigation ----
/** Wrap-around cursor movement, as the Go moveCursor. */
moveCursor(delta: number, n: number): void {
this.cursor = n === 0 ? 0 : (this.cursor + delta + n) % n
this.notify()
}
go(screen: Screen, cursor = 0): void {
this.screen = screen
this.cursor = cursor
this.notify()
}
/** Rows on the current list screen — used for cursor bounds and mouse hits. */
listLength(): number {
switch (this.screen) {
case "menu":
return MENU_ITEMS.length
case "cameras":
return this.cfg.cameras.length
case "layouts":
case "setActive":
return this.cfg.layouts.length
case "cameraPicker":
return this.pickerOptions().length
default:
return 0
}
}
// ---- cameras ----
toggleCameraDisabled(): void {
const cam = this.cfg.cameras[this.cursor]
if (!cam) return
cam.disabled = !cam.disabled
this.dirty = true
this.notify()
}
deleteCamera(): void {
if (!this.cfg.cameras[this.cursor]) return
this.cfg.cameras.splice(this.cursor, 1)
if (this.cursor >= this.cfg.cameras.length && this.cursor > 0) this.cursor--
this.dirty = true
this.notify()
}
// ---- layouts ----
get layout(): Layout | undefined {
return this.cfg.layouts[this.editLayout]
}
/**
* Open the grid editor on a layout, upgrading it to the tile model first so
* the editor never has to deal with legacy slot layouts.
*/
openEditor(idx: number): void {
const l = this.cfg.layouts[idx]
if (!l) return
l.tiles = effectiveTiles(l)
delete l.slots
this.editLayout = idx
this.edCol = 0
this.edRow = 0
this.go("layoutEdit")
}
gridDims(): [cols: number, rows: number] {
const l = this.layout
return l ? layoutDimensions(l) : [1, 1]
}
private tiles(): Tile[] {
const l = this.layout
if (!l) return []
l.tiles ??= []
return l.tiles
}
/** Camera names already placed in the layout being edited. */
usedCameras(): Set<string> {
return new Set(this.tiles().filter((t) => t.camera).map((t) => t.camera))
}
tileIndexAtCursor(): number {
return tileIndexAt(this.tiles(), this.edCol, this.edRow)
}
moveCell(dCol: number, dRow: number): void {
const [cols, rows] = this.gridDims()
this.edCol = Math.min(Math.max(this.edCol + dCol, 0), cols - 1)
this.edRow = Math.min(Math.max(this.edRow + dRow, 0), rows - 1)
this.notify()
}
setCell(col: number, row: number): void {
this.edCol = col
this.edRow = row
this.notify()
}
/** Remove the tile under the cursor, if any. */
clearAt(): void {
const idx = this.tileIndexAtCursor()
if (idx < 0) return
this.tiles().splice(idx, 1)
this.dirty = true
this.notify()
}
/**
* Step the tile under the cursor through auto → its camera's available
* qualities. Small tiles want "low" and big ones "high" on a Pi, but that is
* the operator's call; this just cycles.
*/
cycleQuality(): void {
const idx = this.tileIndexAtCursor()
if (idx < 0) {
this.setStatus("no tile here — assign a camera first", true)
return
}
const t = this.tiles()[idx]!
// "" is auto: the camera's best available stream.
const opts: Quality[] = ["", ...availableQualities(cameraByName(this.cfg, t.camera), this.qualities)]
const cur = Math.max(0, opts.indexOf(t.quality ?? ""))
const next = opts[(cur + 1) % opts.length]!
if (next === "") delete t.quality
else t.quality = next
this.dirty = true
this.setStatus(`tile quality: ${next === "" ? "auto" : next}`)
}
/** Grow or shrink the tile under the cursor, in-bounds and non-overlapping. */
resizeTile(dCol: number, dRow: number): void {
const idx = this.tileIndexAtCursor()
if (idx < 0) {
this.setStatus("no tile here — assign a camera first", true)
return
}
const tiles = this.tiles()
const t = tiles[idx]!
const [cs, rs] = tileSpan(t)
const newCS = cs + dCol
const newRS = rs + dRow
if (newCS < 1 || newRS < 1) return
const [cols, rows] = this.gridDims()
if (!regionFree(tiles, cols, rows, idx, t.col, t.row, newCS, newRS)) {
this.setStatus("can't resize: would overlap or leave the grid", true)
return
}
t.colspan = newCS
t.rowspan = newRS
this.dirty = true
this.notify()
}
/**
* Resize the base grid, refusing shrinks that would push a tile out of
* bounds rather than silently dropping it.
*/
resizeGrid(dCol: number, dRow: number): void {
const l = this.layout
if (!l) return
const [cols, rows] = this.gridDims()
const newCols = cols + dCol
const newRows = rows + dRow
if (newCols < 1 || newRows < 1 || newCols > this.maxGridDim || newRows > this.maxGridDim) return
for (const t of this.tiles()) {
const [cs, rs] = tileSpan(t)
if (t.col + cs > newCols || t.row + rs > newRows) {
this.setStatus("shrink blocked: a tile would fall outside the grid", true)
return
}
}
l.grid = `${newCols}x${newRows}`
this.edCol = Math.min(this.edCol, newCols - 1)
this.edRow = Math.min(this.edRow, newRows - 1)
this.dirty = true
this.notify()
}
/**
* Resize a tile by dragging to (col,row): spans are measured from the tile's
* origin, so a drag only ever grows toward the bottom-right. Returns whether
* anything changed, which the mouse handler uses to tell a drag from a click.
*/
dragResize(tileIdx: number, col: number, row: number): boolean {
const tiles = this.tiles()
const t = tiles[tileIdx]
if (!t) return false
const newCS = col - t.col + 1
const newRS = row - t.row + 1
if (newCS < 1 || newRS < 1) return false
const [cols, rows] = this.gridDims()
if (!regionFree(tiles, cols, rows, tileIdx, t.col, t.row, newCS, newRS)) return false
if (t.colspan === newCS && t.rowspan === newRS) return false
t.colspan = newCS
t.rowspan = newRS
this.edCol = col
this.edRow = row
this.dirty = true
this.notify()
return true
}
// ---- camera picker ----
/** "(empty)" followed by every enabled camera's name. */
pickerOptions(): string[] {
return [PICKER_EMPTY, ...this.cfg.cameras.filter((c) => !c.disabled).map((c) => c.name)]
}
/**
* Place the picker's choice at the cursor cell: retarget the tile there,
* create a 1x1 tile on an empty cell, or clear the cell for "(empty)".
*/
assignCamera(name: string): void {
const tiles = this.tiles()
const idx = this.tileIndexAtCursor()
if (!name) {
if (idx >= 0) {
tiles.splice(idx, 1)
this.dirty = true
}
return
}
if (idx >= 0) {
tiles[idx]!.camera = name
this.dirty = true
return
}
if (tiles.length >= this.maxTiles) {
this.setStatus(`layout is full (${this.maxTiles} cameras max)`, true)
return
}
tiles.push({ camera: name, col: this.edCol, row: this.edRow, colspan: 1, rowspan: 1 })
this.dirty = true
}
choosePickerOption(idx: number): void {
const opts = this.pickerOptions()
if (idx < 0 || idx >= opts.length) return
// Index 0 is the "(empty)" sentinel, which clears the cell.
this.assignCamera(idx === 0 ? "" : opts[idx]!)
this.go("layoutEdit")
}
// ---- set active ----
setActiveLayout(idx: number): void {
const l = this.cfg.layouts[idx]
if (!l) return
this.cfg.active_layout = l.name
this.dirty = true
this.setStatus(`active layout set to ${l.name}`)
this.go("menu")
}
// ---- bridge-backed actions ----
async save(): Promise<void> {
if (this.busy) return
this.busy = true
this.setStatus("saving…")
try {
const res = await applyConfig(this.cfg, { configPath: this.configPath })
if (!res.ok) {
this.setStatus(`save failed: ${res.error ?? "unknown error"}`, true)
return
}
this.dirty = false
this.daemonRunning = res.reloaded
this.setStatus(
res.reloaded ? "saved and reloaded the running daemon" : `saved to ${res.path ?? this.configPath}`,
)
} catch (e) {
this.setStatus(`save failed: ${errText(e)}`, true)
} finally {
this.busy = false
this.notify()
}
}
/**
* Discover cameras from Protect and merge them into the catalog.
*
* `enable` is a comma-separated quality list to switch RTSP on for as a side
* effect. The merge mirrors the Go TUI's handleDiscover: match on Protect id
* first, then on name, so renaming a camera in Protect updates the existing
* entry instead of creating a duplicate. Nothing is written until Save.
*/
async runDiscover(enable: string): Promise<void> {
if (this.busy) return
this.busy = true
this.setStatus(enable ? "discovering and enabling RTSP (high+low)…" : "discovering…")
try {
const doc = await discover(enable, { configPath: this.configPath })
if (!doc.ok) {
this.setStatus(`discover failed: ${doc.error ?? "unknown error"}`, true)
return
}
let added = 0
let updated = 0
for (const found of doc.cameras) {
const existing =
(found.id ? this.cfg.cameras.find((c) => c.id && c.id === found.id) : undefined) ??
cameraByName(this.cfg, found.name)
if (existing) {
existing.id = found.id
existing.name = found.name
existing.streams = found.streams
// Discovery supersedes the legacy single-URL field.
delete existing.rtsp
updated++
} else {
this.cfg.cameras.push({ id: found.id, name: found.name, streams: found.streams })
added++
}
}
this.dirty = true
// Cameras Protect returned but that had no RTSP-enabled channel come
// back as warnings rather than cameras, so they count as skipped here.
const skipped = doc.warnings?.filter((w) => w.includes("no RTSP-enabled channel")).length ?? 0
const parts = [`${doc.cameras.length} usable`, `${added} new`, `${updated} updated`]
if (skipped) parts.push(`${skipped} without RTSP`)
if (doc.enabled) parts.push(`enabled ${doc.enabled} channels`)
this.setStatus(`discovered ${parts.join(", ")} — press Save to write`)
this.screen = "cameras"
this.cursor = 0
} catch (e) {
this.setStatus(`discover failed: ${errText(e)}`, true)
} finally {
this.busy = false
this.notify()
}
}
requestQuit(): void {
this.quitRequested = true
this.notify()
}
}
function errText(e: unknown): string {
if (e instanceof BridgeError) return e.message
return e instanceof Error ? e.message : String(e)
}

33
tui/src/theme.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Colors, carried over from the lipgloss palette the Bubble Tea configurator
* used so the tool still looks like itself.
*
* The old styles used ANSI 256 indices (62, 205, 240, 35, 203…). Those are
* resolved here to hex, because opentui takes explicit colors and hex renders
* identically across the terminals this runs in — which in practice means an
* SSH session into the Pi.
*/
export const theme = {
/** Titles: the lipgloss 62 purple. */
title: "#5f5fd7",
/** Cursor / selection: 205 pink. */
cursor: "#ff5faf",
/** Secondary text: 240 grey. */
dim: "#585858",
/** Success and "already placed" markers: 35 green. */
ok: "#00af5f",
/** Errors: 203 red. */
err: "#ff5f5f",
/** Key names inside help lines: 81 cyan. */
key: "#5fd7ff",
/** Help text. */
help: "#c6c6c6",
/** Border of an unfocused panel. */
border: "#3a3a3a",
/** Text drawn on top of the cursor highlight. */
cursorText: "#000000",
/** A tile with a camera assigned. */
tile: "#87d7ff",
/** An empty grid cell. */
empty: "#444444",
} as const

118
tui/src/types.test.ts Normal file
View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import {
availableQualities,
effectiveTiles,
layoutDimensions,
regionFree,
tileIndexAt,
tileSpan,
type Layout,
} from "./types.ts"
describe("layoutDimensions", () => {
test("parses COLSxROWS", () => {
expect(layoutDimensions({ name: "a", grid: "4x3" })).toEqual([4, 3])
})
test("tolerates whitespace and case", () => {
expect(layoutDimensions({ name: "a", grid: " 2X2 " })).toEqual([2, 2])
})
// Go rejects these on save; the editor must stay usable rather than crash.
test.each(["", "4", "4x", "ax3", "0x3", "-1x2", "4x3x2"])("falls back to 1x1 on %p", (grid) => {
expect(layoutDimensions({ name: "a", grid })).toEqual([1, 1])
})
})
describe("tileSpan", () => {
test("normalizes absent and zero spans to 1", () => {
expect(tileSpan({ camera: "a", col: 0, row: 0 })).toEqual([1, 1])
expect(tileSpan({ camera: "a", col: 0, row: 0, colspan: 0, rowspan: 0 })).toEqual([1, 1])
expect(tileSpan({ camera: "a", col: 0, row: 0, colspan: 3, rowspan: 2 })).toEqual([3, 2])
})
})
describe("effectiveTiles", () => {
test("normalizes spans without mutating the input", () => {
const l: Layout = { name: "a", grid: "2x2", tiles: [{ camera: "cam", col: 0, row: 0 }] }
expect(effectiveTiles(l)).toEqual([{ camera: "cam", col: 0, row: 0, colspan: 1, rowspan: 1 }])
expect(l.tiles![0]!.colspan).toBeUndefined()
})
test("upgrades legacy row-major slots, skipping blanks", () => {
const l: Layout = { name: "a", grid: "2x2", slots: ["one", "", "three"] }
expect(effectiveTiles(l)).toEqual([
{ camera: "one", col: 0, row: 0, colspan: 1, rowspan: 1 },
{ camera: "three", col: 0, row: 1, colspan: 1, rowspan: 1 },
])
})
test("prefers tiles over slots when both are present", () => {
const l: Layout = {
name: "a",
grid: "2x2",
tiles: [{ camera: "fromTiles", col: 1, row: 1 }],
slots: ["fromSlots"],
}
expect(effectiveTiles(l).map((t) => t.camera)).toEqual(["fromTiles"])
})
})
describe("tileIndexAt", () => {
const tiles = [
{ camera: "big", col: 0, row: 0, colspan: 3, rowspan: 2 },
{ camera: "small", col: 3, row: 0 },
]
test("finds a tile through the whole span, not just its origin", () => {
expect(tileIndexAt(tiles, 0, 0)).toBe(0)
expect(tileIndexAt(tiles, 2, 1)).toBe(0)
expect(tileIndexAt(tiles, 3, 0)).toBe(1)
})
test("returns -1 on an empty cell", () => {
expect(tileIndexAt(tiles, 3, 1)).toBe(-1)
expect(tileIndexAt(tiles, 0, 2)).toBe(-1)
})
})
describe("regionFree", () => {
const tiles = [
{ camera: "a", col: 0, row: 0, colspan: 2, rowspan: 1 },
{ camera: "b", col: 2, row: 0, colspan: 1, rowspan: 1 },
]
test("rejects regions leaving the grid", () => {
expect(regionFree(tiles, 4, 2, -1, 3, 0, 2, 1)).toBe(false)
expect(regionFree(tiles, 4, 2, -1, 0, 1, 1, 3)).toBe(false)
expect(regionFree(tiles, 4, 2, -1, -1, 0, 1, 1)).toBe(false)
})
test("rejects regions overlapping another tile", () => {
expect(regionFree(tiles, 4, 2, -1, 1, 0, 1, 1)).toBe(false)
})
test("ignores the excluded tile, so a tile can grow in place", () => {
// Tile "a" growing from 2x1 to 2x2 overlaps only itself.
expect(regionFree(tiles, 4, 2, 0, 0, 0, 2, 2)).toBe(true)
// ...but it cannot grow into "b".
expect(regionFree(tiles, 4, 2, 0, 0, 0, 3, 1)).toBe(false)
})
test("accepts a free region", () => {
expect(regionFree(tiles, 4, 2, -1, 3, 0, 1, 2)).toBe(true)
})
})
describe("availableQualities", () => {
const qualities = ["high", "medium", "low"]
test("lists only present streams, best first", () => {
expect(availableQualities({ name: "c", streams: { low: "u", high: "u" } }, qualities)).toEqual(["high", "low"])
})
test("is empty for a camera with no streams map", () => {
expect(availableQualities({ name: "c", rtsp: "rtsp://x" }, qualities)).toEqual([])
expect(availableQualities(undefined, qualities)).toEqual([])
})
})

190
tui/src/types.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* The config schema as it crosses the bridge, plus the pure helpers that go
* with it.
*
* These mirror internal/config/config.go. The Go side stays authoritative:
* it validates and saves, so nothing here needs to re-implement validation.
* What we do need locally are the geometry helpers the editor consults on
* every keystroke — grid dimensions, tile spans, hit-testing — which would be
* absurd to shell out for.
*/
export type Quality = string
export interface Camera {
id?: string
name: string
rtsp?: string
streams?: Record<Quality, string>
disabled?: boolean
}
export interface Tile {
camera: string
col: number
row: number
colspan?: number
rowspan?: number
/** Empty/absent means "the camera's best available stream". */
quality?: Quality
}
export interface Layout {
name: string
/** Base grid, "COLSxROWS" — e.g. "4x3". */
grid: string
tiles?: Tile[]
/** Legacy one-camera-per-cell model, upgraded by effectiveTiles(). */
slots?: string[]
protect_view?: string
}
export interface Controller {
host?: string
username?: string
password_env?: string
verify_tls?: boolean
rtsp_port?: number
}
export interface Config {
controller: Controller
display: { width?: number; height?: number }
player: Record<string, unknown>
cameras: Camera[]
layouts: Layout[]
active_layout: string
view_refresh_seconds?: number
clock: Record<string, unknown>
}
/** The document `rtsp-streamer config export` prints. */
export interface ExportDoc {
config_path: string
max_tiles: number
max_grid_dim: number
qualities: Quality[]
daemon_running: boolean
config: Config
}
export interface DiscoveredCamera {
id: string
name: string
streams: Record<Quality, string>
}
export interface DiscoverDoc {
ok: boolean
error?: string
cameras: DiscoveredCamera[]
enabled: number
warnings?: string[]
}
export interface ApplyResult {
ok: boolean
error?: string
path?: string
reloaded: boolean
reload_error?: string
}
// ---- geometry helpers (ports of the config.Layout / config.Tile methods) ----
/** Tile spans with zero/absent values normalized to 1, as config.Tile.Span. */
export function tileSpan(t: Tile): [colspan: number, rowspan: number] {
return [Math.max(1, t.colspan ?? 1), Math.max(1, t.rowspan ?? 1)]
}
/**
* Parse a layout's base grid. Falls back to 1x1 on anything unparseable, which
* matches what the Go editor did — a broken `grid` string should not make the
* editor unusable, and Go rejects it on save anyway.
*/
export function layoutDimensions(l: Layout): [cols: number, rows: number] {
const parts = (l.grid ?? "").trim().toLowerCase().split("x")
if (parts.length !== 2) return [1, 1]
const cols = Number.parseInt(parts[0]!, 10)
const rows = Number.parseInt(parts[1]!, 10)
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) {
return [1, 1]
}
return [cols, rows]
}
/**
* The layout's tiles with spans normalized, upgrading a legacy row-major
* `slots` list to 1x1 tiles when `tiles` is empty. Mirrors
* config.Layout.EffectiveTiles.
*/
export function effectiveTiles(l: Layout): Tile[] {
if (l.tiles && l.tiles.length > 0) {
return l.tiles.map((t) => {
const [colspan, rowspan] = tileSpan(t)
return { ...t, colspan, rowspan }
})
}
const [cols] = layoutDimensions(l)
const out: Tile[] = []
;(l.slots ?? []).forEach((camera, i) => {
if (!camera) return
out.push({ camera, col: i % cols, row: Math.floor(i / cols), colspan: 1, rowspan: 1 })
})
return out
}
/** Index of the tile covering (col,row), or -1. */
export function tileIndexAt(tiles: Tile[], col: number, row: number): number {
return tiles.findIndex((t) => {
const [cs, rs] = tileSpan(t)
return col >= t.col && col < t.col + cs && row >= t.row && row < t.row + rs
})
}
/**
* Whether the rectangle fits the grid and overlaps no tile except excludeIdx.
* Mirrors the Go editor's regionFree.
*/
export function regionFree(
tiles: Tile[],
cols: number,
rows: number,
excludeIdx: number,
col: number,
row: number,
colspan: number,
rowspan: number,
): boolean {
if (col < 0 || row < 0 || col + colspan > cols || row + rowspan > rows) return false
return tiles.every((t, i) => {
if (i === excludeIdx) return true
const [cs, rs] = tileSpan(t)
const overlaps = col < t.col + cs && col + colspan > t.col && row < t.row + rs && row + rowspan > t.row
return !overlaps
})
}
export function cameraByName(cfg: Config, name: string): Camera | undefined {
return cfg.cameras.find((c) => c.name === name)
}
/** Qualities this camera actually has, best first. Mirrors AvailableQualities. */
export function availableQualities(cam: Camera | undefined, qualities: Quality[]): Quality[] {
if (!cam?.streams) return []
return qualities.filter((q) => !!cam.streams![q])
}
/** Short label for a quality, for the compact tile footer. */
export function qualityAbbrev(q: Quality): string {
switch (q) {
case "high":
return "hi"
case "medium":
return "med"
case "low":
return "lo"
default:
return q
}
}

191
tui/src/ui.test.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Render and mouse tests against a real (in-memory) renderer.
*
* The mouse cases matter most: pointer-to-cell mapping is the one bit of
* coordinate arithmetic left in the UI, and it is invisible until someone
* clicks the wrong tile over SSH.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { sampleDoc } from "./fixtures.ts"
import { Store, type Screen } from "./state.ts"
import { UI } from "./ui.ts"
let setup: TestRendererSetup
let store: Store
let ui: UI
/** Grid geometry, mirrored from ui.ts so tests fail loudly if it drifts. */
const CELL_H = 4
const CELL_W_MAX = 16
/** The mount box has padding 1, so content starts one row/column in. */
const PAD = 1
beforeEach(async () => {
// 96 wide / 4 columns puts cellW at the 16-column cap, keeping the maths here
// simple and matching what the preview script shows.
setup = await createTestRenderer({ width: 96, height: 34 })
store = new Store()
store.hydrate(sampleDoc())
ui = new UI(setup.renderer, store)
store.subscribe(() => ui.render())
ui.render()
})
afterEach(() => setup.renderer.destroy())
async function frame(): Promise<string> {
ui.render()
await setup.flush()
return setup.captureCharFrame()
}
/**
* Reads store.screen without letting TypeScript narrow it to whatever literal
* the test last assigned, so a screen change can be asserted.
*/
function currentScreen(): Screen {
return store.screen
}
/** Centre of a grid cell in absolute terminal coordinates. */
function cellCentre(col: number, row: number): [x: number, y: number] {
const titleRows = 2 // title line plus its bottom margin
return [
PAD + col * CELL_W_MAX + Math.floor(CELL_W_MAX / 2),
PAD + titleRows + row * CELL_H + Math.floor(CELL_H / 2),
]
}
describe("menu", () => {
test("lists every entry and marks a live daemon", async () => {
const out = await frame()
expect(out).toContain("rtsp-streamer configurator")
expect(out).toContain("Cameras")
expect(out).toContain("Discover + enable RTSP (hi+lo)")
expect(out).toContain("daemon live")
})
test("shows an unsaved-changes hint once something is edited", async () => {
expect(await frame()).not.toContain("unsaved changes")
store.setActiveLayout(1)
expect(await frame()).toContain("unsaved changes")
})
})
describe("cameras", () => {
test("shows available qualities, disabled state, and manual URLs", async () => {
store.screen = "cameras"
const out = await frame()
expect(out).toContain("Driveway")
expect(out).toContain("[high,low]")
expect(out).toContain("[disabled]")
expect(out).toContain("rtsp://10.0.0.9:554/stream1")
})
})
describe("layouts", () => {
test("marks the active layout and counts cameras", async () => {
store.screen = "layouts"
const out = await frame()
expect(out).toContain("main-plus")
expect(out).toContain("3 cameras")
expect(out).toContain("active")
})
})
describe("grid editor", () => {
beforeEach(() => store.openEditor(0)) // main-plus, 4x3
test("titles each tile and notes its span and quality", async () => {
const out = await frame()
expect(out).toContain('Edit "main-plus" — 4x3 grid, 3/16 cameras')
expect(out).toContain("Driveway")
expect(out).toContain("3x2 hi") // the hero tile's span and pinned quality
expect(out).toContain("auto") // Back Yard, unpinned
})
test("a spanning tile is drawn as one box, not repeated per cell", async () => {
const out = await frame()
// "Driveway" spans 3x2 but is titled once.
expect(out.split("Driveway").length - 1).toBe(1)
})
test("resizing a tile is reflected in the next frame", async () => {
store.setCell(3, 0) // Front Door, 1x1
store.resizeTile(0, 1)
expect(await frame()).toContain("1x2 lo")
})
test("the grid shrinks to fit a narrow terminal", async () => {
setup.resize(50, 34)
const out = await frame()
// 4 columns must still fit within the width rather than overflowing.
for (const line of out.split("\n")) expect(line.trimEnd().length).toBeLessThanOrEqual(50)
})
})
describe("mouse", () => {
test("clicking a list row selects and activates it", async () => {
store.screen = "layouts"
await frame()
// Row 1 of the list: title plus its margin (2 rows), then one row each.
await setup.mockMouse.click(PAD + 4, PAD + 2 + 1)
// Activating resets `cursor` for the new screen, so the layout that was
// opened is what identifies the row that got clicked.
expect(currentScreen()).toBe("layoutEdit")
expect(store.editLayout).toBe(1)
expect(store.layout!.name).toBe("quad")
})
test("clicking an empty cell opens the picker for that cell", async () => {
store.openEditor(0)
await frame()
const [x, y] = cellCentre(3, 1) // the empty cell
await setup.mockMouse.click(x, y)
expect(store.screen).toBe("cameraPicker")
expect([store.edCol, store.edRow]).toEqual([3, 1])
})
test("clicking inside a spanning tile targets that tile", async () => {
store.openEditor(0)
await frame()
const [x, y] = cellCentre(2, 1) // inside the 3x2 hero tile
await setup.mockMouse.click(x, y)
expect([store.edCol, store.edRow]).toEqual([2, 1])
})
test("dragging a tile resizes it instead of opening the picker", async () => {
store.openEditor(1) // quad 2x2, one 1x1 tile at (0,0)
await frame()
const from = cellCentre(0, 0)
const to = cellCentre(1, 1)
await setup.mockMouse.drag(from[0], from[1], to[0], to[1])
expect(store.layout!.tiles![0]).toMatchObject({ colspan: 2, rowspan: 2 })
// A drag must not fall through to the click handler.
expect(store.screen).toBe("layoutEdit")
})
test("a click outside the grid does not move the cell cursor", async () => {
store.openEditor(0)
store.setCell(1, 1)
await frame()
await setup.mockMouse.click(90, 30) // help text area, past the grid
expect([store.edCol, store.edRow]).toEqual([1, 1])
expect(store.screen).toBe("layoutEdit")
})
})
describe("camera picker", () => {
test("shows the grid for context and flags placed cameras", async () => {
store.openEditor(0)
store.setCell(3, 1)
store.screen = "cameraPicker"
const out = await frame()
expect(out).toContain("cell (col 3, row 1)")
expect(out).toContain("(empty)")
expect(out).toContain("already placed") // Driveway et al are in this layout
expect(out).not.toContain("Side Gate") // disabled
})
})

648
tui/src/ui.ts Normal file
View File

@@ -0,0 +1,648 @@
/**
* 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("") + "…"
}

30
tui/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}