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:
191
tui/src/ui.test.ts
Normal file
191
tui/src/ui.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user