268 lines
7.2 KiB
Go
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]) + "…"
|
|
}
|