adding to git.i0t.app
This commit is contained in:
197
internal/tui/gridedit.go
Normal file
197
internal/tui/gridedit.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
)
|
||||
|
||||
// maxGridDim caps each base-grid axis. 8x8 gives fine spanning granularity;
|
||||
// the number of *tiles* (cameras) is separately capped at config.MaxTiles.
|
||||
const maxGridDim = 8
|
||||
|
||||
// migrateToTiles converts a layout to the tile model in place so the grid
|
||||
// editor always works on tiles (legacy slot layouts are upgraded on open).
|
||||
func (m *model) migrateToTiles(idx int) {
|
||||
l := &m.cfg.Layouts[idx]
|
||||
l.Tiles = l.EffectiveTiles()
|
||||
l.Slots = nil
|
||||
}
|
||||
|
||||
func (m *model) curLayout() *config.Layout { return &m.cfg.Layouts[m.editLayout] }
|
||||
|
||||
// usedCameras is the set of camera names already placed in the current layout,
|
||||
// so the picker can flag ones you'd be adding twice.
|
||||
func (m *model) usedCameras() map[string]bool {
|
||||
used := map[string]bool{}
|
||||
for _, t := range m.curLayout().Tiles {
|
||||
if t.Camera != "" {
|
||||
used[t.Camera] = true
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func (m *model) gridDims() (cols, rows int) {
|
||||
cols, rows, err := m.curLayout().Dimensions()
|
||||
if err != nil || cols < 1 || rows < 1 {
|
||||
return 1, 1
|
||||
}
|
||||
return cols, rows
|
||||
}
|
||||
|
||||
// tileIndexAt returns the index of the tile covering (col,row), or -1.
|
||||
func (m *model) tileIndexAt(col, row int) int {
|
||||
for i, t := range m.curLayout().Tiles {
|
||||
cs, rs := t.Span()
|
||||
if col >= t.Col && col < t.Col+cs && row >= t.Row && row < t.Row+rs {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// regionFree reports whether the rectangle fits the grid and overlaps no tile
|
||||
// other than excludeIdx.
|
||||
func (m *model) regionFree(excludeIdx, col, row, colspan, rowspan int) bool {
|
||||
cols, rows := m.gridDims()
|
||||
if col < 0 || row < 0 || col+colspan > cols || row+rowspan > rows {
|
||||
return false
|
||||
}
|
||||
for i, t := range m.curLayout().Tiles {
|
||||
if i == excludeIdx {
|
||||
continue
|
||||
}
|
||||
cs, rs := t.Span()
|
||||
if col < t.Col+cs && col+colspan > t.Col && row < t.Row+rs && row+rowspan > t.Row {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *model) updateLayoutEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
cols, rows := m.gridDims()
|
||||
switch msg.String() {
|
||||
case "esc", "backspace":
|
||||
m.screen, m.cursor = screenLayouts, m.editLayout
|
||||
case "up", "k":
|
||||
if m.edRow > 0 {
|
||||
m.edRow--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.edRow < rows-1 {
|
||||
m.edRow++
|
||||
}
|
||||
case "left", "h":
|
||||
if m.edCol > 0 {
|
||||
m.edCol--
|
||||
}
|
||||
case "right", "l":
|
||||
if m.edCol < cols-1 {
|
||||
m.edCol++
|
||||
}
|
||||
case "enter", "a", " ":
|
||||
m.screen, m.cursor = screenCameraPicker, 0
|
||||
case "c":
|
||||
m.clearAt()
|
||||
case "L": // grow wider (toward the right)
|
||||
m.resizeTile(1, 0)
|
||||
case "H": // shrink narrower (from the right)
|
||||
m.resizeTile(-1, 0)
|
||||
case "J": // grow taller (toward the bottom)
|
||||
m.resizeTile(0, 1)
|
||||
case "K": // shrink shorter (from the bottom)
|
||||
m.resizeTile(0, -1)
|
||||
case "]":
|
||||
m.resizeGrid(1, 0)
|
||||
case "[":
|
||||
m.resizeGrid(-1, 0)
|
||||
case "}":
|
||||
m.resizeGrid(0, 1)
|
||||
case "{":
|
||||
m.resizeGrid(0, -1)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// assignCamera places the picker's choice at the cursor cell: updates the tile
|
||||
// there, creates a 1x1 tile on an empty cell, or clears when choice is empty.
|
||||
func (m *model) assignCamera(name string) {
|
||||
l := m.curLayout()
|
||||
idx := m.tileIndexAt(m.edCol, m.edRow)
|
||||
if name == "" {
|
||||
if idx >= 0 {
|
||||
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
|
||||
m.dirty = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if idx >= 0 {
|
||||
l.Tiles[idx].Camera = name
|
||||
m.dirty = true
|
||||
return
|
||||
}
|
||||
if len(l.Tiles) >= config.MaxTiles {
|
||||
m.setStatus(fmt.Sprintf("layout is full (%d cameras max)", config.MaxTiles), true)
|
||||
return
|
||||
}
|
||||
l.Tiles = append(l.Tiles, config.Tile{Camera: name, Col: m.edCol, Row: m.edRow, ColSpan: 1, RowSpan: 1})
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
func (m *model) clearAt() {
|
||||
l := m.curLayout()
|
||||
if idx := m.tileIndexAt(m.edCol, m.edRow); idx >= 0 {
|
||||
l.Tiles = append(l.Tiles[:idx], l.Tiles[idx+1:]...)
|
||||
m.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// resizeTile grows/shrinks the tile under the cursor by the given span deltas,
|
||||
// keeping it in-bounds and non-overlapping.
|
||||
func (m *model) resizeTile(dCol, dRow int) {
|
||||
idx := m.tileIndexAt(m.edCol, m.edRow)
|
||||
if idx < 0 {
|
||||
m.setStatus("no tile here — assign a camera first", true)
|
||||
return
|
||||
}
|
||||
t := &m.curLayout().Tiles[idx]
|
||||
cs, rs := t.Span()
|
||||
newCS, newRS := cs+dCol, rs+dRow
|
||||
if newCS < 1 || newRS < 1 {
|
||||
return
|
||||
}
|
||||
if !m.regionFree(idx, t.Col, t.Row, newCS, newRS) {
|
||||
m.setStatus("can't resize: would overlap or leave the grid", true)
|
||||
return
|
||||
}
|
||||
t.ColSpan, t.RowSpan = newCS, newRS
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// resizeGrid changes the base grid dimensions, refusing changes that would push
|
||||
// an existing tile out of bounds.
|
||||
func (m *model) resizeGrid(dCol, dRow int) {
|
||||
cols, rows := m.gridDims()
|
||||
newCols, newRows := cols+dCol, rows+dRow
|
||||
if newCols < 1 || newRows < 1 || newCols > maxGridDim || newRows > maxGridDim {
|
||||
return
|
||||
}
|
||||
for _, t := range m.curLayout().Tiles {
|
||||
cs, rs := t.Span()
|
||||
if t.Col+cs > newCols || t.Row+rs > newRows {
|
||||
m.setStatus("shrink blocked: a tile would fall outside the grid", true)
|
||||
return
|
||||
}
|
||||
}
|
||||
m.curLayout().Grid = fmt.Sprintf("%dx%d", newCols, newRows)
|
||||
if m.edCol > newCols-1 {
|
||||
m.edCol = newCols - 1
|
||||
}
|
||||
if m.edRow > newRows-1 {
|
||||
m.edRow = newRows - 1
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
62
internal/tui/render_test.go
Normal file
62
internal/tui/render_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
)
|
||||
|
||||
// TestRenderGrid prints a representative editor grid so the layout can be
|
||||
// eyeballed with `go test -run RenderGrid -v ./internal/tui`.
|
||||
func TestRenderGrid(t *testing.T) {
|
||||
lipgloss.SetColorProfile(0) // strip ANSI so the plain grid is readable in logs
|
||||
|
||||
cfg := &config.Config{
|
||||
Layouts: []config.Layout{{
|
||||
Name: "main-plus",
|
||||
Grid: "4x3",
|
||||
Tiles: []config.Tile{
|
||||
{Camera: "Front Door", Col: 0, Row: 0, ColSpan: 3, RowSpan: 3},
|
||||
{Camera: "Driveway West", Col: 3, Row: 0},
|
||||
{Camera: "Back Yard", Col: 3, Row: 1},
|
||||
{Camera: "G3 Flex", Col: 3, Row: 2},
|
||||
},
|
||||
}},
|
||||
}
|
||||
m := &model{cfg: cfg, editLayout: 0, edCol: 3, edRow: 0}
|
||||
t.Log("\n" + m.viewLayoutEdit())
|
||||
|
||||
// A long name (>cellW) must clip with an ellipsis, never produce the
|
||||
// U+FFFD replacement char from slicing a multi-byte rune mid-way.
|
||||
cols, rows := m.gridDims()
|
||||
grid := m.renderGrid(cols, rows)
|
||||
if strings.ContainsRune(grid, '<27>') {
|
||||
t.Errorf("grid contains a broken multi-byte character:\n%s", grid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderPicker prints the camera picker (with the live grid and "already
|
||||
// placed" markers) for a layout that has a long camera name.
|
||||
func TestRenderPicker(t *testing.T) {
|
||||
lipgloss.SetColorProfile(0)
|
||||
cfg := &config.Config{
|
||||
Cameras: []config.Camera{
|
||||
{Name: "Driveway West"}, {Name: "Front Door"}, {Name: "Back Yard"}, {Name: "Garage"},
|
||||
},
|
||||
Layouts: []config.Layout{{
|
||||
Name: "quad", Grid: "2x2",
|
||||
Tiles: []config.Tile{
|
||||
{Camera: "Driveway West", Col: 0, Row: 0},
|
||||
{Camera: "Front Door", Col: 1, Row: 0},
|
||||
},
|
||||
}},
|
||||
}
|
||||
m := &model{cfg: cfg, editLayout: 0, edCol: 0, edRow: 1, cursor: 1, screen: screenCameraPicker}
|
||||
out := m.viewCameraPicker()
|
||||
if strings.ContainsRune(out, '<27>') {
|
||||
t.Errorf("picker contains a broken character:\n%s", out)
|
||||
}
|
||||
t.Log("\n" + out)
|
||||
}
|
||||
409
internal/tui/tui.go
Normal file
409
internal/tui/tui.go
Normal file
@@ -0,0 +1,409 @@
|
||||
// Package tui is the interactive Bubble Tea configurator. It is meant to be
|
||||
// run over SSH on the headless Pi: browse cameras, assign them to layout
|
||||
// slots, pick the active layout, discover cameras from UniFi Protect, and save
|
||||
// — signalling the running daemon to reload on save.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/protect"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
screenMenu screen = iota
|
||||
screenCameras
|
||||
screenLayouts
|
||||
screenLayoutEdit
|
||||
screenCameraPicker
|
||||
screenSetActive
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Padding(0, 1)
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
// helpStyle adapts to light/dark terminals so it stays legible on both.
|
||||
helpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "238", Dark: "251"}).MarginTop(1)
|
||||
// keyStyle accents the key names inside help lines.
|
||||
keyStyle = lipgloss.NewStyle().Bold(true).
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "26", Dark: "81"})
|
||||
// usedStyle marks cameras already placed in the current layout.
|
||||
usedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||
// cursorCellStyle highlights the selected grid cell in the layout editor.
|
||||
cursorCellStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("205"))
|
||||
)
|
||||
|
||||
// hkey formats "<key> <desc>" with the key accented, for help lines.
|
||||
func hkey(key, desc string) string {
|
||||
return keyStyle.Render(key) + " " + desc
|
||||
}
|
||||
|
||||
type model struct {
|
||||
cfgPath string
|
||||
cfg *config.Config
|
||||
dirty bool
|
||||
|
||||
screen screen
|
||||
cursor int
|
||||
|
||||
editLayout int // index into cfg.Layouts for edit/picker screens
|
||||
edCol, edRow int // grid-editor cursor position
|
||||
|
||||
status string
|
||||
isError bool
|
||||
}
|
||||
|
||||
// Run loads the config and starts the TUI event loop.
|
||||
func Run(cfgPath string) error {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
|
||||
_, err = tea.NewProgram(m, tea.WithAltScreen()).Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *model) Init() tea.Cmd { return nil }
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
type discoverMsg struct {
|
||||
cams []protect.Camera
|
||||
enabled int // count of cameras we turned RTSP on for
|
||||
err error
|
||||
}
|
||||
|
||||
type savedMsg struct {
|
||||
err error
|
||||
reloaded bool
|
||||
}
|
||||
|
||||
// discoverCmd fetches cameras. When enable is non-empty (e.g. "high"), it also
|
||||
// turns on RTSP in Protect for any camera that lacks an enabled channel.
|
||||
func (m *model) discoverCmd(enable string) tea.Cmd {
|
||||
cfg := m.cfg
|
||||
return func() tea.Msg {
|
||||
if cfg.Controller.Host == "" {
|
||||
return discoverMsg{err: fmt.Errorf("controller.host is not set")}
|
||||
}
|
||||
pw := cfg.Controller.ResolvePassword()
|
||||
if pw == "" {
|
||||
return discoverMsg{err: fmt.Errorf("no controller password available")}
|
||||
}
|
||||
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||
if err != nil {
|
||||
return discoverMsg{err: err}
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
|
||||
return discoverMsg{err: err}
|
||||
}
|
||||
cams, err := cl.Cameras(ctx)
|
||||
if err != nil {
|
||||
return discoverMsg{err: err}
|
||||
}
|
||||
enabled := 0
|
||||
if enable != "" {
|
||||
names, _ := cl.EnableMissing(ctx, cams, enable)
|
||||
enabled = len(names)
|
||||
}
|
||||
return discoverMsg{cams: cams, enabled: enabled}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- update ----
|
||||
|
||||
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case discoverMsg:
|
||||
return m.handleDiscover(msg)
|
||||
case savedMsg:
|
||||
if msg.err != nil {
|
||||
m.setStatus("save failed: "+msg.err.Error(), true)
|
||||
return m, nil
|
||||
}
|
||||
m.dirty = false
|
||||
if msg.reloaded {
|
||||
m.setStatus("saved and reloaded the running daemon", false)
|
||||
} else {
|
||||
m.setStatus("saved to "+m.cfgPath, false)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleDiscover(msg discoverMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
m.setStatus("discover failed: "+msg.err.Error(), true)
|
||||
return m, nil
|
||||
}
|
||||
cl, _ := protect.New(m.cfg.Controller.Host, m.cfg.Controller.RTSPPort, m.cfg.Controller.VerifyTLS)
|
||||
added, updated, skipped := 0, 0, 0
|
||||
for _, cam := range msg.cams {
|
||||
ch := cam.BestEnabledChannel()
|
||||
if ch == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
url := cl.StreamURL(ch.RTSPAlias)
|
||||
if ex := m.cameraByID(cam.ID); ex != nil {
|
||||
ex.Name, ex.RTSP = cam.Name, url
|
||||
updated++
|
||||
} else if ex := m.cfg.CameraByName(cam.Name); ex != nil {
|
||||
ex.ID, ex.RTSP = cam.ID, url
|
||||
updated++
|
||||
} else {
|
||||
m.cfg.Cameras = append(m.cfg.Cameras, config.Camera{ID: cam.ID, Name: cam.Name, RTSP: url})
|
||||
added++
|
||||
}
|
||||
}
|
||||
m.dirty = true
|
||||
enabledNote := ""
|
||||
if msg.enabled > 0 {
|
||||
enabledNote = fmt.Sprintf(", enabled RTSP on %d", msg.enabled)
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("discovered %d (%d new, %d updated, %d without RTSP%s)", len(msg.cams), added, updated, skipped, enabledNote), false)
|
||||
m.screen = screenCameras
|
||||
m.cursor = 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
switch m.screen {
|
||||
case screenMenu:
|
||||
return m.updateMenu(msg)
|
||||
case screenCameras:
|
||||
return m.updateCameras(msg)
|
||||
case screenLayouts:
|
||||
return m.updateLayouts(msg)
|
||||
case screenLayoutEdit:
|
||||
return m.updateLayoutEdit(msg)
|
||||
case screenCameraPicker:
|
||||
return m.updateCameraPicker(msg)
|
||||
case screenSetActive:
|
||||
return m.updateSetActive(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// menu -------------------------------------------------------------
|
||||
|
||||
const (
|
||||
menuCameras = iota
|
||||
menuLayouts
|
||||
menuSetActive
|
||||
menuDiscover
|
||||
menuDiscoverEnable
|
||||
menuSave
|
||||
menuQuit
|
||||
)
|
||||
|
||||
var menuItems = []string{
|
||||
menuCameras: "Cameras",
|
||||
menuLayouts: "Layouts",
|
||||
menuSetActive: "Set active layout",
|
||||
menuDiscover: "Discover from UniFi Protect",
|
||||
menuDiscoverEnable: "Discover + enable RTSP (high)",
|
||||
menuSave: "Save",
|
||||
menuQuit: "Quit",
|
||||
}
|
||||
|
||||
func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, len(menuItems))
|
||||
case "down", "j":
|
||||
m.moveCursor(1, len(menuItems))
|
||||
case "enter", "l", " ":
|
||||
switch m.cursor {
|
||||
case menuCameras:
|
||||
m.screen, m.cursor = screenCameras, 0
|
||||
case menuLayouts:
|
||||
m.screen, m.cursor = screenLayouts, 0
|
||||
case menuSetActive:
|
||||
m.screen, m.cursor = screenSetActive, 0
|
||||
case menuDiscover:
|
||||
m.setStatus("discovering…", false)
|
||||
return m, m.discoverCmd("")
|
||||
case menuDiscoverEnable:
|
||||
m.setStatus("discovering and enabling RTSP…", false)
|
||||
return m, m.discoverCmd("high")
|
||||
case menuSave:
|
||||
return m, m.save()
|
||||
case menuQuit:
|
||||
return m, m.quit()
|
||||
}
|
||||
case "q":
|
||||
return m, m.quit()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// cameras ----------------------------------------------------------
|
||||
|
||||
func (m *model) updateCameras(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
n := len(m.cfg.Cameras)
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, n)
|
||||
case "down", "j":
|
||||
m.moveCursor(1, n)
|
||||
case "d":
|
||||
if n > 0 {
|
||||
m.cfg.Cameras[m.cursor].Disabled = !m.cfg.Cameras[m.cursor].Disabled
|
||||
m.dirty = true
|
||||
}
|
||||
case "x":
|
||||
if n > 0 {
|
||||
m.cfg.Cameras = append(m.cfg.Cameras[:m.cursor], m.cfg.Cameras[m.cursor+1:]...)
|
||||
if m.cursor >= len(m.cfg.Cameras) && m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// layouts ----------------------------------------------------------
|
||||
|
||||
func (m *model) updateLayouts(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
n := len(m.cfg.Layouts)
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, n)
|
||||
case "down", "j":
|
||||
m.moveCursor(1, n)
|
||||
case "enter", "l":
|
||||
if n > 0 {
|
||||
m.editLayout = m.cursor
|
||||
m.migrateToTiles(m.editLayout)
|
||||
m.edCol, m.edRow = 0, 0
|
||||
m.screen, m.cursor = screenLayoutEdit, 0
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// (grid-editor update logic lives in gridedit.go)
|
||||
|
||||
// camera picker ----------------------------------------------------
|
||||
|
||||
// pickerOptions is "(empty)" followed by enabled camera names.
|
||||
func (m *model) pickerOptions() []string {
|
||||
opts := []string{"(empty)"}
|
||||
for _, c := range m.cfg.Cameras {
|
||||
if c.Disabled {
|
||||
continue
|
||||
}
|
||||
opts = append(opts, c.Name)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (m *model) updateCameraPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
opts := m.pickerOptions()
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenLayoutEdit, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, len(opts))
|
||||
case "down", "j":
|
||||
m.moveCursor(1, len(opts))
|
||||
case "enter", "l":
|
||||
choice := ""
|
||||
if m.cursor > 0 {
|
||||
choice = opts[m.cursor]
|
||||
}
|
||||
m.assignCamera(choice)
|
||||
m.screen, m.cursor = screenLayoutEdit, 0
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// set active -------------------------------------------------------
|
||||
|
||||
func (m *model) updateSetActive(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
n := len(m.cfg.Layouts)
|
||||
switch msg.String() {
|
||||
case "esc", "backspace", "h":
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
case "up", "k":
|
||||
m.moveCursor(-1, n)
|
||||
case "down", "j":
|
||||
m.moveCursor(1, n)
|
||||
case "enter", "l":
|
||||
if n > 0 {
|
||||
m.cfg.ActiveLayout = m.cfg.Layouts[m.cursor].Name
|
||||
m.dirty = true
|
||||
m.setStatus("active layout set to "+m.cfg.ActiveLayout, false)
|
||||
m.screen, m.cursor = screenMenu, 0
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// helpers ----------------------------------------------------------
|
||||
|
||||
func (m *model) moveCursor(delta, n int) {
|
||||
if n == 0 {
|
||||
m.cursor = 0
|
||||
return
|
||||
}
|
||||
m.cursor = (m.cursor + delta + n) % n
|
||||
}
|
||||
|
||||
func (m *model) cameraByID(id string) *config.Camera {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range m.cfg.Cameras {
|
||||
if m.cfg.Cameras[i].ID == id {
|
||||
return &m.cfg.Cameras[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) setStatus(s string, isErr bool) {
|
||||
m.status, m.isError = s, isErr
|
||||
}
|
||||
|
||||
func (m *model) save() tea.Cmd {
|
||||
cfgPath, cfg := m.cfgPath, m.cfg
|
||||
return func() tea.Msg {
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
return savedMsg{err: err}
|
||||
}
|
||||
// Best-effort: tell a running daemon to reload.
|
||||
_, err := ipc.Send(ipc.Request{Cmd: "reload"})
|
||||
return savedMsg{reloaded: err == nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) quit() tea.Cmd {
|
||||
return tea.Quit
|
||||
}
|
||||
267
internal/tui/view.go
Normal file
267
internal/tui/view.go
Normal file
@@ -0,0 +1,267 @@
|
||||
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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user