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

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

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

Two properties of that split are deliberate:

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

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

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

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

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

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

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

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

448 lines
12 KiB
Go

// Package tui is the legacy Bubble Tea configurator, reachable as
// `rtsp-streamer tui --legacy`. The default configurator is the opentui one in
// tui/ (see tui/README.md); this one is kept as a fallback because it is part of
// 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
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/lwoodard/rtsp-streamer/internal/protect"
)
type screen int
const (
screenMenu screen = iota
screenCameras
screenLayouts
screenLayoutEdit
screenCameraPicker
screenSetActive
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Padding(0, 1)
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
// helpStyle adapts to light/dark terminals so it stays legible on both.
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "238", Dark: "251"}).MarginTop(1)
// keyStyle accents the key names inside help lines.
keyStyle = lipgloss.NewStyle().Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "26", Dark: "81"})
// usedStyle marks cameras already placed in the current layout.
usedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
// cursorCellStyle highlights the selected grid cell in the layout editor.
cursorCellStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("205"))
)
// hkey formats "<key> <desc>" with the key accented, for help lines.
func hkey(key, desc string) string {
return keyStyle.Render(key) + " " + desc
}
type model struct {
cfgPath string
cfg *config.Config
dirty bool
screen screen
cursor int
editLayout int // index into cfg.Layouts for edit/picker screens
edCol, edRow int // grid-editor cursor position
// Mouse drag state for tmux-style tile resizing in the grid editor.
dragging bool
dragTile int
dragMoved bool
status string
isError bool
}
// Run loads the config and starts the TUI event loop.
func Run(cfgPath string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
_, err = tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
return err
}
func (m *model) Init() tea.Cmd { return nil }
// ---- messages ----
type discoverMsg struct {
cams []protect.Camera
enabled int // count of cameras we turned RTSP on for
err error
}
type savedMsg struct {
err error
reloaded bool
}
// discoverCmd fetches cameras. `enable` is a comma-separated list of qualities
// (e.g. "high,low") to ensure RTSP-enabled in Protect and record per camera.
func (m *model) discoverCmd(enable string) tea.Cmd {
cfg := m.cfg
return func() tea.Msg {
if cfg.Controller.Host == "" {
return discoverMsg{err: fmt.Errorf("controller.host is not set")}
}
pw := cfg.Controller.ResolvePassword()
if pw == "" {
return discoverMsg{err: fmt.Errorf("no controller password available")}
}
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return discoverMsg{err: err}
}
ctx := context.Background()
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
return discoverMsg{err: err}
}
cams, err := cl.Cameras(ctx)
if err != nil {
return discoverMsg{err: err}
}
var want []string
for _, q := range strings.Split(enable, ",") {
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
want = append(want, q)
}
}
enabled := 0
for i := range cams {
cam := &cams[i]
for _, q := range want {
target := cam.ChannelByPreference(q)
if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") {
continue
}
if alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID); err == nil {
target.RTSPEnabled, target.RTSPAlias = true, alias
enabled++
}
}
}
return discoverMsg{cams: cams, enabled: enabled}
}
}
// ---- update ----
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case discoverMsg:
return m.handleDiscover(msg)
case savedMsg:
if msg.err != nil {
m.setStatus("save failed: "+msg.err.Error(), true)
return m, nil
}
m.dirty = false
if msg.reloaded {
m.setStatus("saved and reloaded the running daemon", false)
} else {
m.setStatus("saved to "+m.cfgPath, false)
}
return m, nil
case tea.MouseMsg:
return m.handleMouse(msg)
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.setStatus("discover failed: "+msg.err.Error(), true)
return m, nil
}
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
added, updated, skipped := 0, 0, 0
for i := range msg.cams {
cam := &msg.cams[i]
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 {
skipped++
continue
}
entry := m.cameraByID(cam.ID)
if entry == nil {
entry = m.cfg.CameraByName(cam.Name)
}
if entry == nil {
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{})
entry = &m.cfg.Cameras[len(m.cfg.Cameras)-1]
added++
} else {
updated++
}
entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, ""
}
m.dirty = true
enabledNote := ""
if msg.enabled > 0 {
enabledNote = fmt.Sprintf(", enabled %d channels", msg.enabled)
}
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
m.screen = screenCameras
m.cursor = 0
return m, nil
}
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
}
switch m.screen {
case screenMenu:
return m.updateMenu(msg)
case screenCameras:
return m.updateCameras(msg)
case screenLayouts:
return m.updateLayouts(msg)
case screenLayoutEdit:
return m.updateLayoutEdit(msg)
case screenCameraPicker:
return m.updateCameraPicker(msg)
case screenSetActive:
return m.updateSetActive(msg)
}
return m, nil
}
// menu -------------------------------------------------------------
const (
menuCameras = iota
menuLayouts
menuSetActive
menuDiscover
menuDiscoverEnable
menuSave
menuQuit
)
var menuItems = []string{
menuCameras: "Cameras",
menuLayouts: "Layouts",
menuSetActive: "Set active layout",
menuDiscover: "Discover from UniFi Protect",
menuDiscoverEnable: "Discover + enable RTSP (hi+lo)",
menuSave: "Save",
menuQuit: "Quit",
}
func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
m.moveCursor(-1, len(menuItems))
case "down", "j":
m.moveCursor(1, len(menuItems))
case "enter", "l", " ":
switch m.cursor {
case menuCameras:
m.screen, m.cursor = screenCameras, 0
case menuLayouts:
m.screen, m.cursor = screenLayouts, 0
case menuSetActive:
m.screen, m.cursor = screenSetActive, 0
case menuDiscover:
m.setStatus("discovering…", false)
return m, m.discoverCmd("")
case menuDiscoverEnable:
m.setStatus("discovering and enabling RTSP (high+low)…", false)
return m, m.discoverCmd("high,low")
case menuSave:
return m, m.save()
case menuQuit:
return m, m.quit()
}
case "q":
return m, m.quit()
}
return m, nil
}
// cameras ----------------------------------------------------------
func (m *model) updateCameras(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.cfg.Cameras)
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenMenu, 0
case "up", "k":
m.moveCursor(-1, n)
case "down", "j":
m.moveCursor(1, n)
case "d":
if n > 0 {
m.cfg.Cameras[m.cursor].Disabled = !m.cfg.Cameras[m.cursor].Disabled
m.dirty = true
}
case "x":
if n > 0 {
m.cfg.Cameras = append(m.cfg.Cameras[:m.cursor], m.cfg.Cameras[m.cursor+1:]...)
if m.cursor >= len(m.cfg.Cameras) && m.cursor > 0 {
m.cursor--
}
m.dirty = true
}
}
return m, nil
}
// layouts ----------------------------------------------------------
func (m *model) updateLayouts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.cfg.Layouts)
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenMenu, 0
case "up", "k":
m.moveCursor(-1, n)
case "down", "j":
m.moveCursor(1, n)
case "enter", "l":
if n > 0 {
m.editLayout = m.cursor
m.migrateToTiles(m.editLayout)
m.edCol, m.edRow = 0, 0
m.screen, m.cursor = screenLayoutEdit, 0
}
}
return m, nil
}
// (grid-editor update logic lives in gridedit.go)
// camera picker ----------------------------------------------------
// pickerOptions is "(empty)" followed by enabled camera names.
func (m *model) pickerOptions() []string {
opts := []string{"(empty)"}
for _, c := range m.cfg.Cameras {
if c.Disabled {
continue
}
opts = append(opts, c.Name)
}
return opts
}
func (m *model) updateCameraPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
opts := m.pickerOptions()
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenLayoutEdit, 0
case "up", "k":
m.moveCursor(-1, len(opts))
case "down", "j":
m.moveCursor(1, len(opts))
case "enter", "l":
choice := ""
if m.cursor > 0 {
choice = opts[m.cursor]
}
m.assignCamera(choice)
m.screen, m.cursor = screenLayoutEdit, 0
}
return m, nil
}
// set active -------------------------------------------------------
func (m *model) updateSetActive(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
n := len(m.cfg.Layouts)
switch msg.String() {
case "esc", "backspace", "h":
m.screen, m.cursor = screenMenu, 0
case "up", "k":
m.moveCursor(-1, n)
case "down", "j":
m.moveCursor(1, n)
case "enter", "l":
if n > 0 {
m.cfg.ActiveLayout = m.cfg.Layouts[m.cursor].Name
m.dirty = true
m.setStatus("active layout set to "+m.cfg.ActiveLayout, false)
m.screen, m.cursor = screenMenu, 0
}
}
return m, nil
}
// helpers ----------------------------------------------------------
func (m *model) moveCursor(delta, n int) {
if n == 0 {
m.cursor = 0
return
}
m.cursor = (m.cursor + delta + n) % n
}
func (m *model) cameraByID(id string) *config.Camera {
if id == "" {
return nil
}
for i := range m.cfg.Cameras {
if m.cfg.Cameras[i].ID == id {
return &m.cfg.Cameras[i]
}
}
return nil
}
func (m *model) setStatus(s string, isErr bool) {
m.status, m.isError = s, isErr
}
func (m *model) save() tea.Cmd {
cfgPath, cfg := m.cfgPath, m.cfg
return func() tea.Msg {
if err := config.Save(cfgPath, cfg); err != nil {
return savedMsg{err: err}
}
// Best-effort: tell a running daemon to reload.
_, err := ipc.Send(ipc.Request{Cmd: "reload"})
return savedMsg{reloaded: err == nil}
}
}
func (m *model) quit() tea.Cmd {
return tea.Quit
}