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

View File

@@ -2,6 +2,13 @@ BINARY := rtsp-streamer
PKG := ./cmd/rtsp-streamer PKG := ./cmd/rtsp-streamer
BINDIR := bin BINDIR := bin
# The interactive configurator is a separate executable: it is built with
# opentui, which is a TypeScript library, so Bun compiles it rather than Go.
# See tui/README.md.
TUIBINARY := rtsp-streamer-tui
TUIDIR := tui
BUN ?= $(shell command -v bun 2>/dev/null || echo $$HOME/.bun/bin/bun)
# Build metadata baked into the binary (see `rtsp-streamer version`). # Build metadata baked into the binary (see `rtsp-streamer version`).
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
@@ -14,13 +21,16 @@ LDFLAGS := -s -w \
# Install location. Override with `make install PREFIX=/opt`. # Install location. Override with `make install PREFIX=/opt`.
PREFIX ?= /usr/local PREFIX ?= /usr/local
DESTBIN := $(PREFIX)/bin/$(BINARY) DESTBIN := $(PREFIX)/bin/$(BINARY)
DESTTUI := $(PREFIX)/bin/$(TUIBINARY)
# Use sudo automatically when not already root, so `make install` / # Use sudo automatically when not already root, so `make install` /
# `make deploy` work whether or not you prefix them with sudo. # `make deploy` work whether or not you prefix them with sudo.
SUDO := $(shell [ "$$(id -u)" -eq 0 ] || echo sudo) SUDO := $(shell [ "$$(id -u)" -eq 0 ] || echo sudo)
.PHONY: all build pi pi32 test vet clean run-tui install deploy uninstall .PHONY: all build pi pi32 test test-tui vet clean run-tui tui tui-pi tui-deps install deploy uninstall
# Go only, so a plain `make` still works on a machine without Bun. The
# configurator is an explicit `make tui`.
all: build all: build
## build: compile for the host platform (native arch) ## build: compile for the host platform (native arch)
@@ -35,11 +45,42 @@ pi:
pi32: pi32:
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY)-armv7 $(PKG) GOOS=linux GOARCH=arm GOARM=7 go build -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINARY)-armv7 $(PKG)
## install: build for this machine and copy the binary to $(PREFIX)/bin ## tui-deps: install the configurator's node_modules (needs Bun)
## (auto-sudo). This is the location the running daemon uses. tui-deps:
@command -v $(BUN) >/dev/null 2>&1 || { \
echo "bun not found. Install it with: curl -fsSL https://bun.sh/install | bash"; \
exit 1; }
cd $(TUIDIR) && $(BUN) install
## tui: compile the opentui configurator to a standalone binary. Bun embeds its
## own runtime and opentui's native library, so the result needs nothing
## installed on the target — but it is ~120 MB, unlike the ~8 MB Go binary.
tui: tui-deps
cd $(TUIDIR) && $(BUN) build --compile src/index.ts --outfile ../$(BINDIR)/$(TUIBINARY)
## tui-pi: cross-compile the configurator for a 64-bit Pi (arm64), the analogue
## of `make pi`. Pulls in the arm64 native package first, since Bun embeds
## opentui's native library rather than resolving it at run time. Defining
## OPENTUI_LIBC lets Bun drop the musl branch and embed only glibc.
tui-pi:
cd $(TUIDIR) && $(BUN) install --os=linux --cpu=arm64
cd $(TUIDIR) && $(BUN) build --compile --target=bun-linux-arm64 \
--define process.env.OPENTUI_LIBC='"glibc"' \
src/index.ts --outfile ../$(BINDIR)/$(TUIBINARY)-arm64
## install: build for this machine and copy the binaries to $(PREFIX)/bin
## (auto-sudo). This is the location the running daemon uses. The configurator
## is installed alongside it, which is where `rtsp-streamer tui` looks first.
install: build install: build
$(SUDO) install -m 0755 $(BINDIR)/$(BINARY) $(DESTBIN) $(SUDO) install -m 0755 $(BINDIR)/$(BINARY) $(DESTBIN)
@echo "installed -> $(DESTBIN)" @echo "installed -> $(DESTBIN)"
@if [ -x "$(BINDIR)/$(TUIBINARY)" ]; then \
$(SUDO) install -m 0755 $(BINDIR)/$(TUIBINARY) $(DESTTUI); \
echo "installed -> $(DESTTUI)"; \
else \
echo "note: $(TUIBINARY) not built (run 'make tui'); 'rtsp-streamer tui' will"; \
echo " not start until it is. 'rtsp-streamer tui --legacy' works regardless."; \
fi
## deploy: install, then restart the running wall in place (no reboot). Run as ## deploy: install, then restart the running wall in place (no reboot). Run as
## the kiosk user so it reaches the daemon's control socket. Falls back to a ## the kiosk user so it reaches the daemon's control socket. Falls back to a
@@ -48,22 +89,28 @@ deploy: install
@$(DESTBIN) restart || echo "daemon not running; it will start on next boot" @$(DESTBIN) restart || echo "daemon not running; it will start on next boot"
@echo "run 'rtsp-streamer status' to check" @echo "run 'rtsp-streamer status' to check"
## uninstall: remove the installed binary ## uninstall: remove the installed binaries
uninstall: uninstall:
$(SUDO) rm -f $(DESTBIN) $(SUDO) rm -f $(DESTBIN) $(DESTTUI)
@echo "removed $(DESTBIN)" @echo "removed $(DESTBIN) and $(DESTTUI)"
## test: run unit tests ## test: run Go unit tests
test: test:
go test ./... go test ./...
## test-tui: run the configurator's tests (needs Bun)
test-tui: tui-deps
cd $(TUIDIR) && $(BUN) test && $(BUN) x tsc --noEmit
## vet: run go vet ## vet: run go vet
vet: vet:
go vet ./... go vet ./...
## run-tui: launch the configurator against a scratch config ## run-tui: launch the configurator from source against a scratch config, with
## no compile step — the fast loop while working on tui/.
run-tui: build run-tui: build
RTSP_STREAMER_CONFIG=/tmp/rtsp-streamer.yaml $(BINDIR)/$(BINARY) tui cd $(TUIDIR) && RTSP_STREAMER_BIN=$(CURDIR)/$(BINDIR)/$(BINARY) \
$(BUN) run src/index.ts --config /tmp/rtsp-streamer.yaml
## clean: remove build artifacts ## clean: remove build artifacts
clean: clean:

129
README.md
View File

@@ -49,6 +49,20 @@ Arch: `sudo pacman -S sway mpv foot`
To build: **Go 1.23+**. You can build on your dev machine and copy the binary, To build: **Go 1.23+**. You can build on your dev machine and copy the binary,
or build on the Pi itself. or build on the Pi itself.
The interactive configurator is a second binary built with
[Bun](https://bun.sh) (it's written in TypeScript — see
[the TUI section](#the-tui)). It is **optional**: `make` builds only the Go
binary, and everything except `rtsp-streamer tui` works without it. Install Bun
only if you want the configurator:
```sh
curl -fsSL https://bun.sh/install | bash # then: make tui
```
Neither Bun nor Node is needed at *run* time — `make tui` produces a standalone
executable. And `rtsp-streamer tui --legacy` is built into the Go binary, so a
box without Bun still has a configurator.
> ⚠️ Debian / Raspberry Pi OS ships **Go 1.19** via `apt`, which is too old > ⚠️ Debian / Raspberry Pi OS ships **Go 1.19** via `apt`, which is too old
> (`log/slog` needs 1.21+). Install a current Go into `~/.local/go` and put it > (`log/slog` needs 1.21+). Install a current Go into `~/.local/go` and put it
> first on `PATH` — do **not** rely on the system `go`: > first on `PATH` — do **not** rely on the system `go`:
@@ -71,6 +85,21 @@ make deploy # install + restart the kiosk session
rtsp-streamer version # prints the baked-in git commit / build date rtsp-streamer version # prints the baked-in git commit / build date
``` ```
The configurator is built separately (needs Bun; skip it and use
`rtsp-streamer tui --legacy`):
```sh
make tui # host arch -> bin/rtsp-streamer-tui (standalone, ~120 MB)
make tui-pi # arm64 -> bin/rtsp-streamer-tui-arm64 (the `make pi` analogue)
make test-tui # bun test + tsc --noEmit
make run-tui # run it from source against /tmp/rtsp-streamer.yaml
```
`make install` copies `bin/rtsp-streamer-tui` alongside the main binary when it
exists, which is where `rtsp-streamer tui` looks for it. If you cross-compiled
with `make tui-pi`, rename the `-arm64` artifact to `rtsp-streamer-tui` on the
target, or point `$RTSP_STREAMER_TUI` at it.
## Configure ## Configure
```sh ```sh
@@ -97,23 +126,35 @@ may not overlap. See `main-plus` in [`config.example.yaml`](config.example.yaml)
### The TUI ### The TUI
`rtsp-streamer tui` is a Bubble Tea configurator meant to be run over SSH: `rtsp-streamer tui` is an [opentui](https://opentui.com) configurator meant to be
run over SSH:
- **Cameras** — review discovered cameras, `d` disable, `x` delete. - **Cameras** — review discovered cameras, `d` disable, `x` delete.
- **Layouts** — edit an arrangement on a live ASCII grid preview. - **Layouts** — edit an arrangement on a live grid preview.
- **Set active layout** — choose what the wall shows. - **Set active layout** — choose what the wall shows.
- **Discover** / **Discover + enable RTSP (high)** — pull cameras from Protect. - **Discover** / **Discover + enable RTSP (hi+lo)** — pull cameras from Protect.
- **Save** — writes the config and tells a running daemon to reload live. - **Save** — writes the config and tells a running daemon to reload live.
In the **layout editor** the grid is drawn live as you edit: It is a separate executable (`rtsp-streamer-tui`) built by Bun, because opentui is
a TypeScript library — see [`tui/README.md`](tui/README.md) for the architecture
and the Go↔TS bridge. Build it with `make tui`; `make install` puts it alongside
the main binary. The previous Bubble Tea implementation is still available as
`rtsp-streamer tui --legacy`, which needs no Bun runtime.
In the **layout editor** the grid is drawn live as you edit, with a spanning tile
shown as a single box:
``` ```
+----------+----------+----------+----------+ ┌─Driveway─────────────────────────────────────┐┌─Front Door───┐
|Front Door| · | · |Driveway | │ 3x2 hi ││ lo
|3x3 | · | · | | ││
+----------+----------+----------+----------+ │ │└──────────────┘
| · | · | · |Back Yard | │┌──────────────┐
... │ ││ │
└──────────────────────────────────────────────┘└──────────────┘
┌──────────────┐┌──────────────┐┌──────────────┐┌─Back Yard────┐
│ ││ ││ ││ auto │
└──────────────┘└──────────────┘└──────────────┘└──────────────┘
``` ```
- **mouse (over SSH):** click a cell to assign a camera; **press on a tile and - **mouse (over SSH):** click a cell to assign a camera; **press on a tile and
@@ -127,6 +168,7 @@ In the **layout editor** the grid is drawn live as you edit:
- `esc` back - `esc` back
Elsewhere: arrows / `j`/`k` move (or click), `enter` selects, `esc` back, `q` quits. Elsewhere: arrows / `j`/`k` move (or click), `enter` selects, `esc` back, `q` quits.
`S` saves from any screen.
### Stream quality per tile ### Stream quality per tile
@@ -155,6 +197,44 @@ wall — stream URLs, tile geometry, player settings — against what's already
running and leaves the streams untouched when nothing material changed, so running and leaves the streams untouched when nothing material changed, so
saving an unrelated edit never blanks the screen. saving an unrelated edit never blanks the screen.
### JSON interface
Three commands speak JSON. The configurator in `tui/` is built on them, and
they're the supported way to script config changes:
```sh
rtsp-streamer config export # whole config as JSON on stdout
rtsp-streamer config apply < edits.json # merge, validate, save, reload
rtsp-streamer discover --json # Protect discovery, writes nothing
rtsp-streamer discover --json --enable-rtsp=high,low
```
`config apply` reads an object with any of `cameras`, `layouts` and
`active_layout`, and **merges** it into a fresh read of the file — absent keys are
left alone, and the `controller` section is never touched. So a one-key edit needs
nothing else in the payload:
```sh
echo '{"active_layout": "quad"}' | rtsp-streamer config apply
```
(That is exactly what `layout set quad` does, and it leaves cameras and layouts
untouched. `--json` output pairs well with `jq` if you have it.)
Two deliberate properties:
- **The controller password never appears in `config export`,** and `apply` can't
overwrite it. Secrets stay in the file (or `$RTSP_STREAMER_PASSWORD`).
- **`apply` re-reads before merging,** so a long-running editor can't clobber a
`views import`, a `layout set`, or a hand edit made in the meantime.
`discover --json` writes nothing, which makes discovery previewable — only
`--enable-rtsp` has a side effect, and it is on the *controller* (it switches
RTSP on for those channels in Protect).
Every one of the three prints a JSON document on stdout even when it fails, with
an `ok` field, and exits non-zero on failure.
## Clock overlay ## Clock overlay
An optional always-on-top clock can be rendered at a screen edge: An optional always-on-top clock can be rendered at a screen edge:
@@ -282,6 +362,14 @@ Then `sudo reboot` and the wall comes up on boot.
git pull && make deploy git pull && make deploy
``` ```
`make deploy` covers the daemon and the CLI. It does **not** rebuild the
configurator — that is a separate, slower build, and it only matters when
something under `tui/` changed:
```sh
git pull && make tui && make deploy
```
`make deploy` builds natively, copies to `/usr/local/bin` (auto-sudo — run it `make deploy` builds natively, copies to `/usr/local/bin` (auto-sudo — run it
*without* `sudo` so `go build` keeps your `PATH`), then runs *without* `sudo` so `go build` keeps your `PATH`), then runs
`rtsp-streamer restart`, which tells the running daemon to **re-exec itself in `rtsp-streamer restart`, which tells the running daemon to **re-exec itself in
@@ -306,7 +394,7 @@ rtsp-streamer discover --enable-rtsp=high # enable the High channel where miss
rtsp-streamer discover --enable-rtsp=low # or the Low substream rtsp-streamer discover --enable-rtsp=low # or the Low substream
``` ```
In the TUI, the menu item **"Discover + enable RTSP (high)"** does the same. In the TUI, the menu item **"Discover + enable RTSP (hi+lo)"** does the same.
Enabling preserves each channel's other encoder settings — it only flips the Enabling preserves each channel's other encoder settings — it only flips the
`isRtspEnabled` flag. Plain `discover` (no flag) never modifies your controller; `isRtspEnabled` flag. Plain `discover` (no flag) never modifies your controller;
it just skips cameras with no RTSP-enabled channel and reports which ones. it just skips cameras with no RTSP-enabled channel and reports which ones.
@@ -399,6 +487,22 @@ pegged, work through:
- **Build error `package log/slog is not in GOROOT`** — you're on Debian's Go - **Build error `package log/slog is not in GOROOT`** — you're on Debian's Go
1.19; install Go 1.23 into `~/.local/go` and prepend it to `PATH` (see Build). 1.19; install Go 1.23 into `~/.local/go` and prepend it to `PATH` (see Build).
- **`rtsp-streamer tui` says `rtsp-streamer-tui not found`** — the configurator
binary isn't built or isn't installed. Either build it (`make tui && make
install`, needs Bun) or use `rtsp-streamer tui --legacy`, which is compiled into
the Go binary and always available. `$RTSP_STREAMER_TUI` overrides the path.
- **`rtsp-streamer tui` exits immediately with a config error** — the configurator
loads the config through `rtsp-streamer config export` *before* taking over the
terminal, so an invalid config surfaces as a plain error rather than a broken
screen. Run `rtsp-streamer config validate` to see the same problem.
- **The configurator can't reach the Go binary** — it shells back to
`rtsp-streamer` for every config and Protect operation, resolved from
`$RTSP_STREAMER_BIN` (set automatically by `rtsp-streamer tui`) and otherwise
from `$PATH`. Running the compiled TUI directly from a shell where
`rtsp-streamer` isn't on `PATH` is the usual cause.
## Caveats & scope ## Caveats & scope
- **Unofficial API.** UniFi Protect has no public API; the local endpoints used - **Unofficial API.** UniFi Protect has no public API; the local endpoints used
@@ -431,6 +535,7 @@ internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff
internal/compositor/ sway control + tile geometry internal/compositor/ sway control + tile geometry
internal/daemon/ orchestrator + control socket + health loop internal/daemon/ orchestrator + control socket + health loop
internal/ipc/ control-socket protocol shared by daemon and CLI/TUI internal/ipc/ control-socket protocol shared by daemon and CLI/TUI
internal/tui/ Bubble Tea configurator (grid editor, mouse, quality) internal/tui/ legacy Bubble Tea configurator (`tui --legacy`)
tui/ opentui configurator (TypeScript/Bun) — see tui/README.md
deploy/ sway kiosk config, autologin, install.sh deploy/ sway kiosk config, autologin, install.sh
``` ```

257
cmd/rtsp-streamer/bridge.go Normal file
View File

@@ -0,0 +1,257 @@
// JSON bridge between the Go core and the opentui configurator in tui/.
//
// The TypeScript TUI owns presentation and editing; every piece of domain
// logic — the config schema, validation, atomic save, Protect discovery, the
// daemon reload handshake — stays here. The bridge is deliberately three
// commands with a stable JSON contract:
//
// config export → the current config, for the TUI to edit
// config apply (stdin) → merge the edited fields back, validate, save, reload
// discover --json → Protect discovery results, without touching disk
//
// Two properties are worth preserving if this contract ever changes:
//
// - The controller password never crosses the bridge. It is `json:"-"` on the
// way out, and `apply` merges only the fields the TUI edits, so it cannot
// be clobbered on the way back in either.
// - `apply` re-reads the config from disk before merging. A TUI session 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 actually owns.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/spf13/cobra"
)
// exportDoc is what `config export` emits: the config plus the few constants
// the TUI would otherwise have to hardcode and keep in sync with Go.
type exportDoc struct {
ConfigPath string `json:"config_path"`
MaxTiles int `json:"max_tiles"`
MaxGridDim int `json:"max_grid_dim"`
Qualities []string `json:"qualities"`
DaemonRunning bool `json:"daemon_running"`
Config *config.Config `json:"config"`
}
// applyDoc is the subset of the config the TUI is allowed to write. Pointer
// fields distinguish "absent" from "explicitly empty", so a payload that omits
// a key leaves the on-disk value alone rather than wiping it.
type applyDoc struct {
Cameras *[]config.Camera `json:"cameras"`
Layouts *[]config.Layout `json:"layouts"`
ActiveLayout *string `json:"active_layout"`
}
// applyResult reports the outcome of a save. Reloaded is best-effort: a
// missing daemon is normal (nothing is running yet) and not an error.
type applyResult struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Path string `json:"path,omitempty"`
Reloaded bool `json:"reloaded"`
ReloadError string `json:"reload_error,omitempty"`
}
// discoveredCamera is one camera from Protect, with its RTSP-enabled channels
// already resolved to full stream URLs. Merging into the catalog is the TUI's
// job, so it can show what changed before anything is written.
type discoveredCamera struct {
ID string `json:"id"`
Name string `json:"name"`
Streams map[string]string `json:"streams"`
}
type discoverDoc struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Cameras []discoveredCamera `json:"cameras"`
Enabled int `json:"enabled"`
Warnings []string `json:"warnings,omitempty"`
}
// emitJSON writes v as indented JSON on stdout. Every bridge command prints a
// JSON document on stdout whether it succeeded or failed, so the TUI can parse
// one shape and read `ok`; the exit code carries the same signal for shell use.
func emitJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// configExportCmd prints the current config as JSON for the opentui TUI.
func configExportCmd() *cobra.Command {
return &cobra.Command{
Use: "export",
Short: "Print the config as JSON (used by the opentui configurator)",
Long: "Print the config as JSON, with the controller password omitted.\n" +
"This is the read half of the bridge the tui/ configurator uses.",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
// A reachable control socket means a reload on save will land, so
// the TUI can say "saved and reloaded" rather than guessing.
_, ipcErr := ipc.Send(ipc.Request{Cmd: "status"})
return emitJSON(exportDoc{
ConfigPath: cfgPath,
MaxTiles: config.MaxTiles,
MaxGridDim: config.MaxGridDim,
Qualities: config.Qualities,
DaemonRunning: ipcErr == nil,
Config: cfg,
})
},
}
}
// configApplyCmd merges an edited camera/layout set from stdin into the config
// on disk, then asks a running daemon to reload.
func configApplyCmd() *cobra.Command {
return &cobra.Command{
Use: "apply",
Short: "Merge cameras/layouts/active_layout from stdin JSON, save, and reload",
Long: "Read a JSON object with any of \"cameras\", \"layouts\" and \"active_layout\"\n" +
"from stdin, merge it into the config on disk, validate, save atomically,\n" +
"and signal a running daemon to reload. Keys that are absent are left as\n" +
"they are on disk; the controller section is never touched.",
RunE: func(cmd *cobra.Command, _ []string) error {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
var doc applyDoc
if err := json.Unmarshal(raw, &doc); err != nil {
_ = emitJSON(applyResult{OK: false, Error: fmt.Sprintf("parsing stdin: %v", err)})
return errQuiet
}
// Re-read from disk so we merge onto current state, not onto
// whatever the TUI loaded when it started.
cfg, err := config.Load(cfgPath)
if err != nil {
_ = emitJSON(applyResult{OK: false, Error: err.Error()})
return errQuiet
}
if doc.Cameras != nil {
cfg.Cameras = *doc.Cameras
}
if doc.Layouts != nil {
cfg.Layouts = *doc.Layouts
}
if doc.ActiveLayout != nil {
cfg.ActiveLayout = *doc.ActiveLayout
}
cfg.Defaults()
if err := config.Save(cfgPath, cfg); err != nil {
_ = emitJSON(applyResult{OK: false, Error: err.Error()})
return errQuiet
}
res := applyResult{OK: true, Path: cfgPath}
if _, err := ipc.Send(ipc.Request{Cmd: "reload"}); err != nil {
res.ReloadError = err.Error()
} else {
res.Reloaded = true
}
return emitJSON(res)
},
}
}
// discoverJSON runs Protect discovery and prints the result without writing
// the config. The TUI merges the cameras itself and saves via `config apply`,
// so discovery stays previewable and undoable.
func discoverJSON(enableRTSP string) error {
// Always emit a well-formed camera list, even on failure, so the caller can
// read the document without special-casing a null.
fail := func(err error) error {
_ = emitJSON(discoverDoc{OK: false, Error: err.Error(), Cameras: []discoveredCamera{}})
return errQuiet
}
cfg, err := config.Load(cfgPath)
if err != nil {
return fail(err)
}
cl, ctx, err := loginClient(cfg)
if err != nil {
return fail(err)
}
cams, err := cl.Cameras(ctx)
if err != nil {
return fail(err)
}
var want []string
for _, q := range strings.Split(enableRTSP, ",") {
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
want = append(want, q)
}
}
doc := discoverDoc{OK: true, Cameras: []discoveredCamera{}}
for i := range cams {
cam := &cams[i]
for _, q := range want {
target := cam.ChannelByPreference(q)
if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") {
continue
}
alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID)
if err != nil {
doc.Warnings = append(doc.Warnings,
fmt.Sprintf("%s: enabling %q failed: %v", cam.Name, q, err))
continue
}
target.RTSPEnabled, target.RTSPAlias = true, alias
doc.Enabled++
}
streams := map[string]string{}
for _, ch := range cam.Channels {
if ch.RTSPEnabled && ch.RTSPAlias != "" {
streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias)
}
}
if len(streams) == 0 {
doc.Warnings = append(doc.Warnings,
fmt.Sprintf("%s: no RTSP-enabled channel (try enabling high+low)", cam.Name))
continue
}
doc.Cameras = append(doc.Cameras, discoveredCamera{
ID: cam.ID, Name: cam.Name, Streams: streams,
})
}
return emitJSON(doc)
}
// errQuiet signals "the failure is already reported as JSON on stdout, exit
// non-zero without printing a second Go-style error line".
var errQuiet = quietError{}
type quietError struct{}
func (quietError) Error() string { return "" }
// runTUI launches the opentui configurator, replacing the Go/Bubble Tea one.
// It is a separate executable (a Bun-compiled binary) because opentui is a
// TypeScript library; see tui/README.md.
func runTUI(ctx context.Context) error {
bin, err := findTUIBinary()
if err != nil {
return err
}
return execTUI(ctx, bin, cfgPath)
}

View File

@@ -59,10 +59,16 @@ func daemonCmd() *cobra.Command {
// discoverCmd queries UniFi Protect and merges cameras into the config. // discoverCmd queries UniFi Protect and merges cameras into the config.
func discoverCmd() *cobra.Command { func discoverCmd() *cobra.Command {
var enableRTSP string var enableRTSP string
var asJSON bool
c := &cobra.Command{ c := &cobra.Command{
Use: "discover", Use: "discover",
Short: "Discover cameras from UniFi Protect and update the config", Short: "Discover cameras from UniFi Protect and update the config",
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
// --json reports what discovery found and writes nothing, so the
// opentui configurator can preview the merge before saving.
if asJSON {
return discoverJSON(enableRTSP)
}
cfg, err := config.Load(cfgPath) cfg, err := config.Load(cfgPath)
if err != nil { if err != nil {
return err return err
@@ -148,6 +154,7 @@ func discoverCmd() *cobra.Command {
}, },
} }
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low") c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low")
c.Flags().BoolVar(&asJSON, "json", false, "print results as JSON without saving the config")
return c return c
} }
@@ -465,16 +472,37 @@ func statusCmd() *cobra.Command {
} }
} }
// tuiCmd launches the interactive configurator. // tuiCmd launches the interactive configurator: the opentui one by default,
// or the previous Bubble Tea implementation with --legacy. The legacy path is
// kept because on a headless Pi the TUI is the only config UI there is, and it
// needs no Bun runtime — useful if the compiled TUI is missing or misbehaves.
func tuiCmd() *cobra.Command { func tuiCmd() *cobra.Command {
return &cobra.Command{ var legacy bool
c := &cobra.Command{
Use: "tui", Use: "tui",
Aliases: []string{"config-ui"}, Aliases: []string{"config-ui"},
Short: "Interactive configurator (great over SSH)", Short: "Interactive configurator (great over SSH)",
Long: `Interactive configurator: browse cameras, place them into layout slots, pick
the active layout, discover cameras from UniFi Protect, and save — which
signals a running daemon to reload.
This runs the opentui configurator, a separate binary (rtsp-streamer-tui) built
with Bun because opentui is a TypeScript library. It is looked for at
$RTSP_STREAMER_TUI, then next to this executable, then on $PATH. Build it with
'make tui' and install it with 'make install'.
--legacy runs the previous Bubble Tea configurator instead. It is compiled into
this binary, so it needs no Bun runtime and always works — useful if the
configurator is missing or misbehaving.`,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
if legacy {
return tui.Run(cfgPath) return tui.Run(cfgPath)
}
return runTUI(cmd.Context())
}, },
} }
c.Flags().BoolVar(&legacy, "legacy", false, "use the previous Bubble Tea configurator")
return c
} }
// configCmd holds config-file utilities. // configCmd holds config-file utilities.
@@ -514,7 +542,7 @@ func configCmd() *cobra.Command {
}, },
} }
c.AddCommand(initCmd, pathCmd, editCmd) c.AddCommand(initCmd, pathCmd, editCmd, configExportCmd(), configApplyCmd())
return c return c
} }

View File

@@ -4,6 +4,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -52,7 +53,11 @@ func main() {
) )
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
// Bridge commands report their failure as JSON on stdout and return
// errQuiet, so we exit non-zero without a second error line.
if !errors.Is(err, errQuiet) {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
}
os.Exit(1) os.Exit(1)
} }
} }

View File

@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
)
// tuiBinaryName is the compiled opentui configurator (see tui/ and `make tui`).
const tuiBinaryName = "rtsp-streamer-tui"
// findTUIBinary locates the opentui configurator. It is a sibling binary rather
// than something embedded in this one, because opentui is a TypeScript library
// compiled by Bun.
//
// Search order, most specific first:
//
// 1. $RTSP_STREAMER_TUI — an explicit path, for development
// 2. next to this executable — how `make install` lays it out
// 3. $PATH
func findTUIBinary() (string, error) {
if p := os.Getenv("RTSP_STREAMER_TUI"); p != "" {
if isExecutableFile(p) {
return p, nil
}
return "", fmt.Errorf("RTSP_STREAMER_TUI=%s is not an executable file", p)
}
if exe, err := os.Executable(); err == nil {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
if cand := filepath.Join(filepath.Dir(exe), tuiBinaryName); isExecutableFile(cand) {
return cand, nil
}
}
if p, err := exec.LookPath(tuiBinaryName); err == nil {
return p, nil
}
return "", fmt.Errorf(
"%s not found — build it with `make tui` (needs Bun) and install it with\n"+
"`make install`, or point $RTSP_STREAMER_TUI at it.\n"+
"To use the previous Bubble Tea configurator instead, run: rtsp-streamer tui --legacy",
tuiBinaryName)
}
func isExecutableFile(path string) bool {
st, err := os.Stat(path)
return err == nil && !st.IsDir() && st.Mode()&0o111 != 0
}
// execTUI runs the configurator with this process's terminal, so it can take
// raw mode and the alternate screen directly. Its exit status becomes ours.
func execTUI(ctx context.Context, bin, cfg string) error {
c := exec.CommandContext(ctx, bin, "--config", cfg)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
// The TUI shells back to this binary for the bridge commands; tell it which
// one, so a locally-built binary is not shadowed by an installed one.
env := append(os.Environ(), "RTSP_STREAMER_CONFIG="+cfg)
if self, err := os.Executable(); err == nil {
env = append(env, "RTSP_STREAMER_BIN="+self)
}
c.Env = env
err := c.Run()
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// The TUI already reported whatever went wrong on its own terminal.
os.Exit(exitErr.ExitCode())
}
return err
}

View File

@@ -55,7 +55,7 @@ cameras:
# Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that # Layouts place cameras on a base grid (COLSxROWS). Cameras are "tiles" that
# may span multiple cells, so you can mix a big main view with small side # may span multiple cells, so you can mix a big main view with small side
# tiles. Up to 16 cameras per layout. Edit these interactively with # tiles. Up to 16 cameras per layout. Edit these interactively with
# `rtsp-streamer tui` (a live ASCII preview shows the arrangement). # `rtsp-streamer tui` (a live preview shows the arrangement as you edit).
layouts: layouts:
# Simple even grid: four 1x1 tiles on a 2x2. # Simple even grid: four 1x1 tiles on a 2x2.
- name: quad - name: quad

View File

@@ -19,6 +19,10 @@ set -euo pipefail
KIOSK_USER="${1:-kiosk}" KIOSK_USER="${1:-kiosk}"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN_SRC="${REPO_DIR}/bin/rtsp-streamer" BIN_SRC="${REPO_DIR}/bin/rtsp-streamer"
# The configurator is a separate, optional binary (built with Bun; see
# tui/README.md). Accept either the host build or the arm64 cross-build.
TUI_SRC="${REPO_DIR}/bin/rtsp-streamer-tui"
[ -x "$TUI_SRC" ] || TUI_SRC="${REPO_DIR}/bin/rtsp-streamer-tui-arm64"
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
echo "error: run as root (sudo)" >&2 echo "error: run as root (sudo)" >&2
@@ -33,6 +37,16 @@ fi
echo ">> installing binary to /usr/local/bin/rtsp-streamer" echo ">> installing binary to /usr/local/bin/rtsp-streamer"
install -m 0755 "$BIN_SRC" /usr/local/bin/rtsp-streamer install -m 0755 "$BIN_SRC" /usr/local/bin/rtsp-streamer
# Installed under the un-suffixed name, which is what `rtsp-streamer tui` looks
# for next to itself.
if [ -x "$TUI_SRC" ]; then
echo ">> installing configurator to /usr/local/bin/rtsp-streamer-tui"
install -m 0755 "$TUI_SRC" /usr/local/bin/rtsp-streamer-tui
else
echo ">> no configurator binary found (build it with 'make tui'); "
echo " 'rtsp-streamer tui --legacy' will still work."
fi
echo ">> installing sway kiosk config to /etc/rtsp-streamer/sway/config" echo ">> installing sway kiosk config to /etc/rtsp-streamer/sway/config"
install -d /etc/rtsp-streamer/sway install -d /etc/rtsp-streamer/sway
install -m 0644 "${REPO_DIR}/deploy/sway/config" /etc/rtsp-streamer/sway/config install -m 0644 "${REPO_DIR}/deploy/sway/config" /etc/rtsp-streamer/sway/config
@@ -73,7 +87,7 @@ cat <<EOF
Done. Next steps: Done. Next steps:
1. Put a config at ${HOME_DIR}/.config/rtsp-streamer/config.yaml 1. Put a config at ${HOME_DIR}/.config/rtsp-streamer/config.yaml
(as ${KIOSK_USER}: rtsp-streamer config init then edit it, or run (as ${KIOSK_USER}: rtsp-streamer config init then edit it, or run
rtsp-streamer tui). rtsp-streamer tui — add --legacy if the configurator wasn't installed).
2. Export the controller password for the daemon. Easiest: create 2. Export the controller password for the daemon. Easiest: create
${HOME_DIR}/.config/rtsp-streamer/env with RTSP_STREAMER_PASSWORD=... ${HOME_DIR}/.config/rtsp-streamer/env with RTSP_STREAMER_PASSWORD=...
and source it from ~/.bash_profile before sway starts. and source it from ~/.bash_profile before sway starts.

View File

@@ -14,36 +14,40 @@ import (
) )
// Config is the root document persisted to disk. // Config is the root document persisted to disk.
//
// The json tags mirror the yaml ones so the config can round-trip through the
// `config export` / `config apply` bridge (used by the opentui configurator in
// tui/) with exactly the same key names it has on disk.
type Config struct { type Config struct {
// Controller describes how to reach the UniFi Protect controller for // Controller describes how to reach the UniFi Protect controller for
// camera discovery. Optional if you only use manually-added cameras. // camera discovery. Optional if you only use manually-added cameras.
Controller Controller `yaml:"controller"` Controller Controller `yaml:"controller" json:"controller"`
// Display pins the output resolution used for grid geometry math. When // Display pins the output resolution used for grid geometry math. When
// zero, the daemon asks the compositor for the connected output's mode. // zero, the daemon asks the compositor for the connected output's mode.
Display Display `yaml:"display"` Display Display `yaml:"display" json:"display"`
// Player holds mpv tuning shared by every stream. // Player holds mpv tuning shared by every stream.
Player Player `yaml:"player"` Player Player `yaml:"player" json:"player"`
// Cameras is the discovered/known camera catalog. Populated by // Cameras is the discovered/known camera catalog. Populated by
// `rtsp-streamer discover` or edited by hand. Layouts reference cameras // `rtsp-streamer discover` or edited by hand. Layouts reference cameras
// by Name. // by Name.
Cameras []Camera `yaml:"cameras"` Cameras []Camera `yaml:"cameras" json:"cameras"`
// Layouts are named preset grids. // Layouts are named preset grids.
Layouts []Layout `yaml:"layouts"` Layouts []Layout `yaml:"layouts" json:"layouts"`
// ActiveLayout is the name of the layout the daemon renders. // ActiveLayout is the name of the layout the daemon renders.
ActiveLayout string `yaml:"active_layout"` ActiveLayout string `yaml:"active_layout" json:"active_layout"`
// ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the // ViewRefreshSeconds, when > 0, makes the daemon periodically re-sync the
// active layout from its linked UniFi Protect live view (ProtectView). // active layout from its linked UniFi Protect live view (ProtectView).
// Requires controller credentials available to the daemon. 0 = off. // Requires controller credentials available to the daemon. 0 = off.
ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty"` ViewRefreshSeconds int `yaml:"view_refresh_seconds,omitempty" json:"view_refresh_seconds,omitempty"`
// Clock overlays a live clock in a screen corner. // Clock overlays a live clock in a screen corner.
Clock Clock `yaml:"clock,omitempty"` Clock Clock `yaml:"clock,omitempty" json:"clock"`
} }
// Clock configures the on-screen clock overlay: a small always-on-top window // Clock configures the on-screen clock overlay: a small always-on-top window
@@ -51,24 +55,24 @@ type Config struct {
// so it stays legible on both bright (day) and dark (night) scenes. // so it stays legible on both bright (day) and dark (night) scenes.
type Clock struct { type Clock struct {
// Enabled turns the overlay on. // Enabled turns the overlay on.
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
// Timezone is an IANA name (e.g. "America/Denver"); "Local" or empty uses // Timezone is an IANA name (e.g. "America/Denver"); "Local" or empty uses
// the system timezone. DST is handled automatically. // the system timezone. DST is handled automatically.
Timezone string `yaml:"timezone,omitempty"` Timezone string `yaml:"timezone,omitempty" json:"timezone,omitempty"`
// Format is a Go time layout. Default "15:04:05" (24-hour with seconds). // Format is a Go time layout. Default "15:04:05" (24-hour with seconds).
// Examples: "3:04:05 PM", "Mon Jan 2 15:04". // Examples: "3:04:05 PM", "Mon Jan 2 15:04".
Format string `yaml:"format,omitempty"` Format string `yaml:"format,omitempty" json:"format,omitempty"`
// Corner places the overlay: bottom-right (default), bottom-left, // Corner places the overlay: bottom-right (default), bottom-left,
// top-right, top-left, bottom-center, top-center. The *-center positions // top-right, top-left, bottom-center, top-center. The *-center positions
// center the overlay horizontally and ignore Margin on that axis. // center the overlay horizontally and ignore Margin on that axis.
Corner string `yaml:"corner,omitempty"` Corner string `yaml:"corner,omitempty" json:"corner,omitempty"`
// FontSize is the glyph height in pixels (default 44). // FontSize is the glyph height in pixels (default 44).
FontSize int `yaml:"font_size,omitempty"` FontSize int `yaml:"font_size,omitempty" json:"font_size,omitempty"`
// Width/Height are the overlay window size in pixels (defaults 300x72). // Width/Height are the overlay window size in pixels (defaults 300x72).
Width int `yaml:"width,omitempty"` Width int `yaml:"width,omitempty" json:"width,omitempty"`
Height int `yaml:"height,omitempty"` Height int `yaml:"height,omitempty" json:"height,omitempty"`
// Margin is the gap from the screen edges in pixels (default 24). // Margin is the gap from the screen edges in pixels (default 24).
Margin int `yaml:"margin,omitempty"` Margin int `yaml:"margin,omitempty" json:"margin,omitempty"`
// BackgroundOpacity is the alpha of the overlay's backing box, 0.0 // BackgroundOpacity is the alpha of the overlay's backing box, 0.0
// (invisible) to 1.0 (solid black). Default 0.45. // (invisible) to 1.0 (solid black). Default 0.45.
// //
@@ -77,23 +81,28 @@ type Clock struct {
// alpha, so the compositor draws nothing and the clock silently disappears. // alpha, so the compositor draws nothing and the clock silently disappears.
// A small non-zero value gives the text a dark backing that also keeps it // A small non-zero value gives the text a dark backing that also keeps it
// legible over bright daytime scenes. // legible over bright daytime scenes.
BackgroundOpacity float64 `yaml:"background_opacity,omitempty"` BackgroundOpacity float64 `yaml:"background_opacity,omitempty" json:"background_opacity,omitempty"`
} }
// Controller holds UniFi Protect connection details. // Controller holds UniFi Protect connection details.
type Controller struct { type Controller struct {
Host string `yaml:"host"` // hostname or IP of the UniFi OS console Host string `yaml:"host" json:"host"` // hostname or IP of the UniFi OS console
Username string `yaml:"username"` // local Protect user with camera access Username string `yaml:"username" json:"username"` // local Protect user with camera access
// Password is read here only if PasswordEnv is empty. Prefer PasswordEnv // Password is read here only if PasswordEnv is empty. Prefer PasswordEnv
// so secrets stay out of the committed config file. // so secrets stay out of the committed config file.
Password string `yaml:"password,omitempty"` //
PasswordEnv string `yaml:"password_env,omitempty"` // json:"-" keeps the plaintext password out of `config export`: the
// opentui configurator never needs it (discovery runs in-process here) and
// `config apply` only merges the fields the TUI actually edits, so the
// secret never crosses the bridge in either direction.
Password string `yaml:"password,omitempty" json:"-"`
PasswordEnv string `yaml:"password_env,omitempty" json:"password_env,omitempty"`
// VerifyTLS toggles certificate verification. UniFi consoles ship a // VerifyTLS toggles certificate verification. UniFi consoles ship a
// self-signed cert by default, so this is false unless you install a // self-signed cert by default, so this is false unless you install a
// trusted cert. // trusted cert.
VerifyTLS bool `yaml:"verify_tls"` VerifyTLS bool `yaml:"verify_tls" json:"verify_tls"`
// RTSPPort is the Protect RTSPS port (7441 on current firmware). // RTSPPort is the Protect RTSPS port (7441 on current firmware).
RTSPPort int `yaml:"rtsp_port,omitempty"` RTSPPort int `yaml:"rtsp_port,omitempty" json:"rtsp_port,omitempty"`
} }
// ResolvePassword returns the effective password, preferring the env var. // ResolvePassword returns the effective password, preferring the env var.
@@ -108,54 +117,54 @@ func (c Controller) ResolvePassword() string {
// Display pins the render resolution. // Display pins the render resolution.
type Display struct { type Display struct {
Width int `yaml:"width,omitempty"` Width int `yaml:"width,omitempty" json:"width,omitempty"`
Height int `yaml:"height,omitempty"` Height int `yaml:"height,omitempty" json:"height,omitempty"`
} }
// Player is shared mpv configuration. // Player is shared mpv configuration.
type Player struct { type Player struct {
// HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m", // HWDec selects mpv's hardware decoder (e.g. "auto-safe", "v4l2m2m",
// "drm", "no"). "auto-safe" is a good default on the Pi 4. // "drm", "no"). "auto-safe" is a good default on the Pi 4.
HWDec string `yaml:"hwdec"` HWDec string `yaml:"hwdec" json:"hwdec"`
// Profile applies an mpv profile; "low-latency" trims buffering for live // Profile applies an mpv profile; "low-latency" trims buffering for live
// feeds. Empty disables it. // feeds. Empty disables it.
Profile string `yaml:"profile"` Profile string `yaml:"profile" json:"profile"`
// ExtraArgs are appended verbatim to every mpv invocation. // ExtraArgs are appended verbatim to every mpv invocation.
ExtraArgs []string `yaml:"extra_args,omitempty"` ExtraArgs []string `yaml:"extra_args,omitempty" json:"extra_args,omitempty"`
// MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims // MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims
// render/scale load; the bigger decode lever is using substreams. // render/scale load; the bigger decode lever is using substreams.
MaxFPS int `yaml:"max_fps,omitempty"` MaxFPS int `yaml:"max_fps,omitempty" json:"max_fps,omitempty"`
// Audio enables stream sound. Off by default: a wall of simultaneous // Audio enables stream sound. Off by default: a wall of simultaneous
// feeds is unwatchable with sound, and skipping the audio decoder saves // feeds is unwatchable with sound, and skipping the audio decoder saves
// CPU per stream. // CPU per stream.
Audio bool `yaml:"audio,omitempty"` Audio bool `yaml:"audio,omitempty" json:"audio,omitempty"`
// ResyncSeconds, when > 0, makes the daemon reconnect each stream to the // ResyncSeconds, when > 0, makes the daemon reconnect each stream to the
// live edge on this interval (staggered across tiles). Live RTSP can't be // live edge on this interval (staggered across tiles). Live RTSP can't be
// seeked, so latency that slowly accumulates when the Pi decodes a hair // seeked, so latency that slowly accumulates when the Pi decodes a hair
// behind real-time is only cleared by reopening the stream. Each tile is // behind real-time is only cleared by reopening the stream. Each tile is
// resynced about once per interval; e.g. 600 keeps drift well under a few // resynced about once per interval; e.g. 600 keeps drift well under a few
// seconds. 0 = off. // seconds. 0 = off.
ResyncSeconds int `yaml:"resync_seconds,omitempty"` ResyncSeconds int `yaml:"resync_seconds,omitempty" json:"resync_seconds,omitempty"`
// RestartBackoffSeconds is how long to wait before relaunching a stream // RestartBackoffSeconds is how long to wait before relaunching a stream
// that exited or stalled. // that exited or stalled.
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"` RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty" json:"restart_backoff_seconds,omitempty"`
} }
// Camera is one known RTSP source. // Camera is one known RTSP source.
type Camera struct { type Camera struct {
// ID is the UniFi Protect camera id, when discovered. Blank for manual // ID is the UniFi Protect camera id, when discovered. Blank for manual
// entries. // entries.
ID string `yaml:"id,omitempty"` ID string `yaml:"id,omitempty" json:"id,omitempty"`
// Name is the human label and the key layouts reference. Must be unique. // Name is the human label and the key layouts reference. Must be unique.
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
// RTSP is a single fully-resolved stream URL. Kept for backward // RTSP is a single fully-resolved stream URL. Kept for backward
// compatibility and manual entries; Streams takes precedence when present. // compatibility and manual entries; Streams takes precedence when present.
RTSP string `yaml:"rtsp,omitempty"` RTSP string `yaml:"rtsp,omitempty" json:"rtsp,omitempty"`
// Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a // Streams maps a quality ("high"|"medium"|"low") to its stream URL, so a
// tile can choose per-tile which to pull. Populated by discovery. // tile can choose per-tile which to pull. Populated by discovery.
Streams map[string]string `yaml:"streams,omitempty"` Streams map[string]string `yaml:"streams,omitempty" json:"streams,omitempty"`
// Disabled hides the camera from selection without deleting it. // Disabled hides the camera from selection without deleting it.
Disabled bool `yaml:"disabled,omitempty"` Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
} }
// Qualities in preference order, high to low. // Qualities in preference order, high to low.
@@ -208,36 +217,42 @@ func (c Camera) AvailableQualities() []string {
// more than this on a Pi 4 is impractical even with substreams. // more than this on a Pi 4 is impractical even with substreams.
const MaxTiles = 16 const MaxTiles = 16
// MaxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
// the number of *tiles* (cameras) is capped separately by MaxTiles. It lives
// here rather than in an editor so the CLI, the Go TUI and the opentui
// configurator all enforce the same ceiling.
const MaxGridDim = 8
// Layout is a named arrangement on a base grid. Cameras are placed as Tiles // Layout is a named arrangement on a base grid. Cameras are placed as Tiles
// that may span multiple grid cells (a big main view plus small side tiles, // that may span multiple grid cells (a big main view plus small side tiles,
// security-wall style). The older Slots form (one camera per cell, row-major) // security-wall style). The older Slots form (one camera per cell, row-major)
// is still accepted and is transparently upgraded to tiles. // is still accepted and is transparently upgraded to tiles.
type Layout struct { type Layout struct {
Name string `yaml:"name"` Name string `yaml:"name" json:"name"`
// Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and // Grid is the base grid "COLSxROWS", e.g. "4x3". Tiles are placed and
// sized in these cells. // sized in these cells.
Grid string `yaml:"grid"` Grid string `yaml:"grid" json:"grid"`
// Tiles is the placement model. Preferred over Slots. // Tiles is the placement model. Preferred over Slots.
Tiles []Tile `yaml:"tiles,omitempty"` Tiles []Tile `yaml:"tiles,omitempty" json:"tiles,omitempty"`
// Slots is the legacy one-camera-per-cell model (row-major). Kept for // Slots is the legacy one-camera-per-cell model (row-major). Kept for
// backward compatibility; EffectiveTiles converts it to tiles. // backward compatibility; EffectiveTiles converts it to tiles.
Slots []string `yaml:"slots,omitempty"` Slots []string `yaml:"slots,omitempty" json:"slots,omitempty"`
// ProtectView, when set, is the name of the UniFi Protect live view this // ProtectView, when set, is the name of the UniFi Protect live view this
// layout mirrors. `views import` sets it; the daemon re-syncs it on a timer // layout mirrors. `views import` sets it; the daemon re-syncs it on a timer
// when view_refresh_seconds > 0 and controller creds are available. // when view_refresh_seconds > 0 and controller creds are available.
ProtectView string `yaml:"protect_view,omitempty"` ProtectView string `yaml:"protect_view,omitempty" json:"protect_view,omitempty"`
} }
// Tile places one camera at a rectangular region of the base grid. // Tile places one camera at a rectangular region of the base grid.
type Tile struct { type Tile struct {
Camera string `yaml:"camera"` Camera string `yaml:"camera" json:"camera"`
Col int `yaml:"col"` Col int `yaml:"col" json:"col"`
Row int `yaml:"row"` Row int `yaml:"row" json:"row"`
ColSpan int `yaml:"colspan,omitempty"` // defaults to 1 ColSpan int `yaml:"colspan,omitempty" json:"colspan,omitempty"` // defaults to 1
RowSpan int `yaml:"rowspan,omitempty"` // defaults to 1 RowSpan int `yaml:"rowspan,omitempty" json:"rowspan,omitempty"` // defaults to 1
// Quality selects which stream to pull for this tile: "high"|"medium"| // Quality selects which stream to pull for this tile: "high"|"medium"|
// "low". Empty means the camera's best available (see Camera.StreamURL). // "low". Empty means the camera's best available (see Camera.StreamURL).
Quality string `yaml:"quality,omitempty"` Quality string `yaml:"quality,omitempty" json:"quality,omitempty"`
} }
// Span returns the tile's spans with zero values normalized to 1. // Span returns the tile's spans with zero values normalized to 1.

View File

@@ -7,9 +7,9 @@ import (
"github.com/lwoodard/rtsp-streamer/internal/config" "github.com/lwoodard/rtsp-streamer/internal/config"
) )
// maxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity; // maxGridDim caps each base-grid axis; see config.MaxGridDim, which the CLI
// the number of *tiles* (cameras) is separately capped at config.MaxTiles. // and the opentui configurator share.
const maxGridDim = 8 const maxGridDim = config.MaxGridDim
// migrateToTiles converts a layout to the tile model in place so the grid // migrateToTiles converts a layout to the tile model in place so the grid
// editor always works on tiles (legacy slot layouts are upgraded on open). // editor always works on tiles (legacy slot layouts are upgraded on open).

View File

@@ -1,7 +1,15 @@
// Package tui is the interactive Bubble Tea configurator. It is meant to be // Package tui is the legacy Bubble Tea configurator, reachable as
// run over SSH on the headless Pi: browse cameras, assign them to layout // `rtsp-streamer tui --legacy`. The default configurator is the opentui one in
// slots, pick the active layout, discover cameras from UniFi Protect, and save // tui/ (see tui/README.md); this one is kept as a fallback because it is part of
// — signalling the running daemon to reload on save. // the Go binary and needs no Bun runtime, and on a headless Pi the TUI is the
// only config UI there is.
//
// It is meant to be run over SSH: browse cameras, assign them to layout slots,
// pick the active layout, discover cameras from UniFi Protect, and save —
// signalling the running daemon to reload on save.
//
// New work belongs in tui/. Changes here should be limited to keeping it
// building and correct.
package tui package tui
import ( import (

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
}
}