Files
RTSP-Streamer/internal/tui/view.go
2026-07-01 18:21:14 -05:00

268 lines
7.2 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/lwoodard/rtsp-streamer/internal/config"
)
func (m *model) View() string {
var b strings.Builder
switch m.screen {
case screenMenu:
b.WriteString(m.viewMenu())
case screenCameras:
b.WriteString(m.viewCameras())
case screenLayouts:
b.WriteString(m.viewLayouts())
case screenLayoutEdit:
b.WriteString(m.viewLayoutEdit())
case screenCameraPicker:
b.WriteString(m.viewCameraPicker())
case screenSetActive:
b.WriteString(m.viewSetActive())
}
if m.status != "" {
style := okStyle
if m.isError {
style = errStyle
}
b.WriteString("\n\n" + style.Render(m.status))
}
return b.String()
}
func (m *model) list(title string, rows []string, help string) string {
var b strings.Builder
b.WriteString(titleStyle.Render(title) + "\n\n")
if len(rows) == 0 {
b.WriteString(dimStyle.Render(" (nothing here yet)") + "\n")
}
for i, row := range rows {
cursor := " "
line := row
if i == m.cursor {
cursor = cursorStyle.Render("▸ ")
line = cursorStyle.Render(row)
}
b.WriteString(cursor + line + "\n")
}
b.WriteString(helpStyle.Render(help))
return b.String()
}
func (m *model) viewMenu() string {
dirty := ""
if m.dirty {
dirty = dimStyle.Render(" (unsaved changes)")
}
rows := make([]string, len(menuItems))
copy(rows, menuItems)
rows[menuSave] = fmt.Sprintf("Save%s", dirty)
return m.list("rtsp-streamer configurator", rows,
"↑/↓ move · enter select · q quit")
}
func (m *model) viewCameras() string {
rows := make([]string, 0, len(m.cfg.Cameras))
for _, c := range m.cfg.Cameras {
state := ""
if c.Disabled {
state = dimStyle.Render(" [disabled]")
}
src := dimStyle.Render(truncate(c.RTSP, 48))
rows = append(rows, fmt.Sprintf("%-24s %s%s", c.Name, src, state))
}
return m.list(fmt.Sprintf("Cameras (%d)", len(m.cfg.Cameras)), rows,
"d toggle disabled · x delete · esc back")
}
func (m *model) viewLayouts() string {
rows := make([]string, 0, len(m.cfg.Layouts))
for _, l := range m.cfg.Layouts {
active := ""
if l.Name == m.cfg.ActiveLayout {
active = okStyle.Render(" ●active")
}
rows = append(rows, fmt.Sprintf("%-16s %-6s %d cameras%s", l.Name, l.Grid, len(l.EffectiveTiles()), active))
}
return m.list("Layouts", rows, "enter edit · esc back")
}
// cell dimensions for the ASCII preview.
const (
cellW = 10 // inner width in characters
cellH = 2 // inner height in text rows
)
func (m *model) viewLayoutEdit() string {
l := m.cfg.Layouts[m.editLayout]
cols, rows := m.gridDims()
var b strings.Builder
title := fmt.Sprintf("Edit %q — %dx%d grid, %d/%d cameras", l.Name, cols, rows, len(l.Tiles), 16)
b.WriteString(titleStyle.Render(title) + "\n\n")
b.WriteString(m.renderGrid(cols, rows))
b.WriteString("\n\n")
b.WriteString(strings.Join([]string{
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
}, "\n"))
return b.String()
}
// renderGrid draws the base grid with tiles, camera labels, continuation marks
// for spanned cells, and a highlighted cursor cell — a live picture of the wall.
func (m *model) renderGrid(cols, rows int) string {
l := m.cfg.Layouts[m.editLayout]
horiz := "+" + strings.Repeat(strings.Repeat("-", cellW)+"+", cols)
var b strings.Builder
for r := 0; r < rows; r++ {
b.WriteString(horiz + "\n")
for line := 0; line < cellH; line++ {
b.WriteString("|")
for c := 0; c < cols; c++ {
b.WriteString(m.renderCell(l, c, r, line) + "|")
}
b.WriteString("\n")
}
}
b.WriteString(horiz)
return b.String()
}
// renderCell returns one text line of one grid cell. It first builds a plain
// string of exactly cellW characters, then styles the whole cell, so ANSI
// escapes never throw off column alignment.
func (m *model) renderCell(l config.Layout, c, r, line int) string {
idx := m.tileIndexAtLayout(l, c, r)
isCursor := c == m.edCol && r == m.edRow
plain := strings.Repeat(" ", cellW)
dim := false
if idx >= 0 {
t := l.Tiles[idx]
isOrigin := c == t.Col && r == t.Row
switch {
case isOrigin && line == 0:
label := t.Camera
if label == "" {
label = "(no cam)"
}
plain = padTo(truncate(label, cellW), cellW)
case isOrigin && line == 1:
cs, rs := t.Span()
if cs > 1 || rs > 1 {
plain = padTo(fmt.Sprintf("%dx%d", cs, rs), cellW)
}
dim = true
default: // continuation cell of a spanned tile
plain = center("·", cellW)
dim = true
}
}
switch {
case isCursor:
return cursorCellStyle.Render(plain)
case dim:
return dimStyle.Render(plain)
default:
return plain
}
}
// tileIndexAtLayout is tileIndexAt against an explicit layout value (used by
// the renderer, which holds a copy).
func (m *model) tileIndexAtLayout(l config.Layout, col, row int) int {
for i, t := range l.Tiles {
cs, rs := t.Span()
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
return i
}
}
return -1
}
func (m *model) viewCameraPicker() string {
l := m.cfg.Layouts[m.editLayout]
cols, rows := m.gridDims()
used := m.usedCameras()
opts := m.pickerOptions()
var b strings.Builder
b.WriteString(titleStyle.Render(fmt.Sprintf("Assign to %q — cell (col %d, row %d)", l.Name, m.edCol, m.edRow)) + "\n\n")
// Show the live grid so you can see what's already placed while choosing.
b.WriteString(m.renderGrid(cols, rows) + "\n\n")
for i, o := range opts {
cursor := " "
var label string
switch {
case i == 0: // the "(empty)" option
label = dimStyle.Render(o)
case used[o]:
label = o + usedStyle.Render(" ● already placed")
default:
label = o
}
if i == m.cursor {
cursor = cursorStyle.Render("▸ ")
if i != 0 && !used[o] {
label = cursorStyle.Render(o)
}
}
b.WriteString(cursor + label + "\n")
}
b.WriteString("\n" + hkey("enter", "choose") + " " + hkey("(empty)", "clears cell") + " " + hkey("esc", "back"))
return b.String()
}
func (m *model) viewSetActive() string {
rows := make([]string, 0, len(m.cfg.Layouts))
for _, l := range m.cfg.Layouts {
mark := ""
if l.Name == m.cfg.ActiveLayout {
mark = okStyle.Render(" ●current")
}
rows = append(rows, fmt.Sprintf("%-16s %s%s", l.Name, l.Grid, mark))
}
return m.list("Set active layout", rows, "enter select · esc back")
}
// The string helpers below count runes, not bytes, so multi-byte characters
// (e.g. the ellipsis) are never sliced mid-character into invalid UTF-8.
// padTo left-justifies s in a field of n columns (single-width runes assumed).
func padTo(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
return s + strings.Repeat(" ", n-len(r))
}
// center centers s within n columns.
func center(s string, n int) string {
r := []rune(s)
if len(r) >= n {
return string(r[:n])
}
left := (n - len(r)) / 2
return strings.Repeat(" ", left) + s + strings.Repeat(" ", n-len(r)-left)
}
// truncate shortens s to at most n columns, using an ellipsis when it clips.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
if n <= 1 {
return string(r[:n])
}
return string(r[:n-1]) + "…"
}