adding to git.i0t.app

This commit is contained in:
Levi Woodard
2026-07-01 18:21:14 -05:00
commit 55a8ea4bee
26 changed files with 3534 additions and 0 deletions

197
internal/tui/gridedit.go Normal file
View 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
}

View 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
View 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
View 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]) + "…"
}