/** * 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("") + "…" }