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