Files
RTSP-Streamer/internal/tui/gridedit.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

230 lines
5.9 KiB
Go

package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
// maxGridDim caps each base-grid axis; see config.MaxGridDim, which the CLI
// and the opentui configurator share.
const maxGridDim = config.MaxGridDim
// 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).
func (m *model) migrateToTiles(idx int) {
l := &m.cfg.Layouts[idx]
l.Tiles = l.EffectiveTiles()
l.Slots = nil
}
func (m *model) curLayout() *config.Layout { return &m.cfg.Layouts[m.editLayout] }
// usedCameras is the set of camera names already placed in the current layout,
// so the picker can flag ones you'd be adding twice.
func (m *model) usedCameras() map[string]bool {
used := map[string]bool{}
for _, t := range m.curLayout().Tiles {
if t.Camera != "" {
used[t.Camera] = true
}
}
return used
}
func (m *model) gridDims() (cols, rows int) {
cols, rows, err := m.curLayout().Dimensions()
if err != nil || cols < 1 || rows < 1 {
return 1, 1
}
return cols, rows
}
// tileIndexAt returns the index of the tile covering (col,row), or -1.
func (m *model) tileIndexAt(col, row int) int {
for i, t := range m.curLayout().Tiles {
cs, rs := t.Span()
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
return i
}
}
return -1
}
// regionFree reports whether the rectangle fits the grid and overlaps no tile
// other than excludeIdx.
func (m *model) regionFree(excludeIdx, col, row, colspan, rowspan int) bool {
cols, rows := m.gridDims()
if col < 0 || row < 0 || col+colspan > cols || row+rowspan > rows {
return false
}
for i, t := range m.curLayout().Tiles {
if i == excludeIdx {
continue
}
cs, rs := t.Span()
if col < t.Col+cs && col+colspan > t.Col && row < t.Row+rs && row+rowspan > t.Row {
return false
}
}
return true
}
func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
cols, rows := m.gridDims()
switch msg.String() {
case "esc", "backspace":
m.screen, m.cursor = screenLayouts, m.editLayout
case "up", "k":
if m.edRow > 0 {
m.edRow--
}
case "down", "j":
if m.edRow < rows-1 {
m.edRow++
}
case "left", "h":
if m.edCol > 0 {
m.edCol--
}
case "right", "l":
if m.edCol < cols-1 {
m.edCol++
}
case "enter", "a", " ":
m.screen, m.cursor = screenCameraPicker, 0
case "c":
m.clearAt()
case "q": // cycle this tile's stream quality (high/low/auto)
m.cycleQuality()
case "L": // grow wider (toward the right)
m.resizeTile(1, 0)
case "H": // shrink narrower (from the right)
m.resizeTile(-1, 0)
case "J": // grow taller (toward the bottom)
m.resizeTile(0, 1)
case "K": // shrink shorter (from the bottom)
m.resizeTile(0, -1)
case "]":
m.resizeGrid(1, 0)
case "[":
m.resizeGrid(-1, 0)
case "}":
m.resizeGrid(0, 1)
case "{":
m.resizeGrid(0, -1)
}
return m, nil
}
// assignCamera places the picker's choice at the cursor cell: updates the tile
// there, creates a 1x1 tile on an empty cell, or clears when choice is empty.
func (m *model) assignCamera(name string) {
l := m.curLayout()
idx := m.tileIndexAt(m.edCol, m.edRow)
if name == "" {
if idx >= 0 {
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
m.dirty = true
}
return
}
if idx >= 0 {
l.Tiles[idx].Camera = name
m.dirty = true
return
}
if len(l.Tiles) >= config.MaxTiles {
m.setStatus(fmt.Sprintf("layout is full (%d cameras max)", config.MaxTiles), true)
return
}
l.Tiles = append(l.Tiles, config.Tile{Camera: name, Col: m.edCol, Row: m.edRow, ColSpan: 1, RowSpan: 1})
m.dirty = true
}
func (m *model) clearAt() {
l := m.curLayout()
if idx := m.tileIndexAt(m.edCol, m.edRow); idx >= 0 {
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
m.dirty = true
}
}
// cycleQuality steps the tile under the cursor through auto → its camera's
// available stream qualities (high/low/...). Small tiles → pick low; big tiles
// → high, to keep the Pi's decode load down.
func (m *model) cycleQuality() {
idx := m.tileIndexAt(m.edCol, m.edRow)
if idx < 0 {
m.setStatus("no tile here — assign a camera first", true)
return
}
t := &m.curLayout().Tiles[idx]
opts := []string{""} // "" = auto (best available)
if cam := m.cfg.CameraByName(t.Camera); cam != nil {
opts = append(opts, cam.AvailableQualities()...)
}
cur := 0
for i, q := range opts {
if q == t.Quality {
cur = i
break
}
}
t.Quality = opts[(cur+1)%len(opts)]
m.dirty = true
label := t.Quality
if label == "" {
label = "auto"
}
m.setStatus("tile quality: "+label, false)
}
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
// keeping it in-bounds and non-overlapping.
func (m *model) resizeTile(dCol, dRow int) {
idx := m.tileIndexAt(m.edCol, m.edRow)
if idx < 0 {
m.setStatus("no tile here — assign a camera first", true)
return
}
t := &m.curLayout().Tiles[idx]
cs, rs := t.Span()
newCS, newRS := cs+dCol, rs+dRow
if newCS < 1 || newRS < 1 {
return
}
if !m.regionFree(idx, t.Col, t.Row, newCS, newRS) {
m.setStatus("can't resize: would overlap or leave the grid", true)
return
}
t.ColSpan, t.RowSpan = newCS, newRS
m.dirty = true
}
// resizeGrid changes the base grid dimensions, refusing changes that would push
// an existing tile out of bounds.
func (m *model) resizeGrid(dCol, dRow int) {
cols, rows := m.gridDims()
newCols, newRows := cols+dCol, rows+dRow
if newCols < 1 || newRows < 1 || newCols > maxGridDim || newRows > maxGridDim {
return
}
for _, t := range m.curLayout().Tiles {
cs, rs := t.Span()
if t.Col+cs > newCols || t.Row+rs > newRows {
m.setStatus("shrink blocked: a tile would fall outside the grid", true)
return
}
}
m.curLayout().Grid = fmt.Sprintf("%dx%d", newCols, newRows)
if m.edCol > newCols-1 {
m.edCol = newCols - 1
}
if m.edRow > newRows-1 {
m.edRow = newRows - 1
}
m.dirty = true
}