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
}