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:
34
tui/.gitignore
vendored
Normal file
34
tui/.gitignore
vendored
Normal 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
99
tui/README.md
Normal 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
65
tui/bun.lock
Normal 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
23
tui/package.json
Normal 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
42
tui/scripts/preview.ts
Normal 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
116
tui/src/bridge.ts
Normal 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
50
tui/src/fixtures.ts
Normal 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
99
tui/src/index.ts
Normal 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
105
tui/src/keys.ts
Normal 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
309
tui/src/state.test.ts
Normal 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
476
tui/src/state.ts
Normal 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
33
tui/src/theme.ts
Normal 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
118
tui/src/types.test.ts
Normal 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
190
tui/src/types.ts
Normal 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
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
|
||||
})
|
||||
})
|
||||
648
tui/src/ui.ts
Normal file
648
tui/src/ui.ts
Normal 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
30
tui/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user