package tui import ( "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/lwoodard/rtsp-streamer/internal/config" ) // maxGridDim caps each base-grid axis; see config.MaxGridDim, which the CLI // and the opentui configurator share. const maxGridDim = config.MaxGridDim // 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 "q": // cycle this tile's stream quality (high/low/auto) m.cycleQuality() 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 } } // cycleQuality steps the tile under the cursor through auto → its camera's // available stream qualities (high/low/...). Small tiles → pick low; big tiles // → high, to keep the Pi's decode load down. func (m *model) cycleQuality() { 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] opts := []string{""} // "" = auto (best available) if cam := m.cfg.CameraByName(t.Camera); cam != nil { opts = append(opts, cam.AvailableQualities()...) } cur := 0 for i, q := range opts { if q == t.Quality { cur = i break } } t.Quality = opts[(cur+1)%len(opts)] m.dirty = true label := t.Quality if label == "" { label = "auto" } m.setStatus("tile quality: "+label, false) } // 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 }