198 lines
5.0 KiB
Go
198 lines
5.0 KiB
Go
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
|
|
}
|