Files
RTSP-Streamer/internal/tui/mouse.go
Levi Woodard 98ddf1645c Smoother daemon: no-op reloads, batched placement, muted audio, graceful teardown
- Skip stream restarts when a reload resolves to the identical wall
  (same URLs, tile geometry, player settings) — saving an unrelated
  config edit no longer blanks the screen.
- Replace per-tile placement polling (a swaymsg fork per tile per second,
  plus 150ms get_tree polling per tile at startup) with one shared loop:
  a single get_tree snapshot per tick, re-placing only drifted windows,
  relaxing to a 2s tick once settled.
- Mute streams by default (--no-audio) to skip an audio decoder per
  stream; opt back in with player.audio: true.
- Graceful, parallel mpv teardown via cmd.Cancel/WaitDelay, fixing the
  double-Wait race between Stop and Supervise and cutting worst-case
  layout switches from ~2s x N streams to ~2s total.
- status: probe mpv IPC outside the daemon mutex so a slow probe can't
  block layout switches.
- View sync: reuse the Protect session across ticks (re-login only on
  expiry) and pick up view_refresh_seconds changes without a restart.
- Health strikes keyed by player, not slot, so they never carry across
  layout switches; prune stale entries.
- New `rtsp-streamer reload` CLI; fix dead sort in `layout ls`; back off
  on persistent control-socket accept errors; fsync config before the
  atomic rename (SD-card power-cut safety); bump IPC client deadline;
  gofmt stragglers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 07:38:01 -05:00

133 lines
3.8 KiB
Go

package tui
import tea "github.com/charmbracelet/bubbletea"
// Layout offsets for hit-testing the rendered views (see view.go). Every
// screen renders a title line then a blank line before its content, so content
// starts at headerRows. The grid is drawn with a border between cells, hence
// the +1s.
const (
headerRows = 2 // title line + blank line
cellStride = cellW + 1 // cell inner width + right border column
blockH = cellH + 1 // cell content rows + top border row
)
// handleMouse routes mouse events per screen. Enables click-to-select on the
// list screens and tmux-style click/drag editing in the layout grid.
func (m *model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch m.screen {
case screenMenu, screenCameras, screenLayouts, screenSetActive:
return m.mouseList(msg)
case screenLayoutEdit:
return m.mouseGridEdit(msg)
case screenCameraPicker:
return m.mousePicker(msg)
}
return m, nil
}
// cellAtMouse maps a screen position to a grid cell in the editor/picker.
func (m *model) cellAtMouse(x, y int) (col, row int, ok bool) {
cols, rows := m.gridDims()
if y < headerRows || x < 0 {
return 0, 0, false
}
row = (y - headerRows) / blockH
col = x / cellStride
if row < 0 || row >= rows || col < 0 || col >= cols {
return 0, 0, false
}
return col, row, true
}
// gridLineCount is how many terminal rows renderGrid emits.
func (m *model) gridLineCount() int {
_, rows := m.gridDims()
return rows*blockH + 1
}
// listLen is the number of selectable rows on the current list screen.
func (m *model) listLen() int {
switch m.screen {
case screenMenu:
return len(menuItems)
case screenCameras:
return len(m.cfg.Cameras)
case screenLayouts, screenSetActive:
return len(m.cfg.Layouts)
}
return 0
}
// mouseList: left-click a row to select and activate it (same as pressing enter).
func (m *model) mouseList(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if msg.Action != tea.MouseActionRelease || msg.Button != tea.MouseButtonLeft {
return m, nil
}
idx := msg.Y - headerRows
if idx < 0 || idx >= m.listLen() {
return m, nil
}
m.cursor = idx
return m.handleKey(tea.KeyMsg{Type: tea.KeyEnter})
}
// mouseGridEdit implements tmux-style editing: click a cell to assign a camera,
// or press on a tile and drag to resize its span (grid-snapped).
func (m *model) mouseGridEdit(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if msg.Button != tea.MouseButtonLeft {
return m, nil
}
col, row, ok := m.cellAtMouse(msg.X, msg.Y)
switch msg.Action {
case tea.MouseActionPress:
if !ok {
return m, nil
}
m.edCol, m.edRow = col, row
m.dragTile = m.tileIndexAt(col, row)
m.dragging = m.dragTile >= 0
m.dragMoved = false
case tea.MouseActionMotion:
if !ok || !m.dragging {
return m, nil
}
t := &m.curLayout().Tiles[m.dragTile]
newCS, newRS := col-t.Col+1, row-t.Row+1
// Only grow/shrink toward the bottom-right of the tile's origin.
if newCS >= 1 && newRS >= 1 && m.regionFree(m.dragTile, t.Col, t.Row, newCS, newRS) {
t.ColSpan, t.RowSpan = newCS, newRS
m.edCol, m.edRow = col, row
m.dragMoved = true
m.dirty = true
}
case tea.MouseActionRelease:
wasDrag := m.dragging && m.dragMoved
m.dragging = false
m.dragMoved = false
// A plain click (no drag) opens the camera picker for that cell.
if ok && !wasDrag {
m.edCol, m.edRow = col, row
m.screen, m.cursor = screenCameraPicker, 0
}
}
return m, nil
}
// mousePicker: left-click an option to choose it.
func (m *model) mousePicker(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if msg.Action != tea.MouseActionRelease || msg.Button != tea.MouseButtonLeft {
return m, nil
}
// Options are drawn below the grid and a blank separator line.
optStart := headerRows + m.gridLineCount() + 1
idx := msg.Y - optStart
if idx < 0 || idx >= len(m.pickerOptions()) {
return m, nil
}
m.cursor = idx
return m.handleKey(tea.KeyMsg{Type: tea.KeyEnter})
}