diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go new file mode 100644 index 0000000..5bbedeb --- /dev/null +++ b/internal/tui/mouse.go @@ -0,0 +1,132 @@ +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}) +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go new file mode 100644 index 0000000..ac76847 --- /dev/null +++ b/internal/tui/mouse_test.go @@ -0,0 +1,33 @@ +package tui + +import ( + "testing" + + "github.com/lwoodard/rtsp-streamer/internal/config" +) + +func TestCellAtMouse(t *testing.T) { + m := &model{ + cfg: &config.Config{Layouts: []config.Layout{{Name: "g", Grid: "3x3"}}}, + editLayout: 0, + } + cases := []struct { + x, y int + wantCol, wRow int + ok bool + }{ + {1, 3, 0, 0, true}, // first content row, first cell + {12, 3, 1, 0, true}, // second column (x past the divider at 11) + {1, 6, 0, 1, true}, // second grid row + {23, 9, 2, 2, true}, // third column, third row + {1, 1, 0, 0, false}, // in the title/blank header area + {1, 12, 0, 0, false}, // below the 3x3 grid + } + for _, tc := range cases { + col, row, ok := m.cellAtMouse(tc.x, tc.y) + if ok != tc.ok || (ok && (col != tc.wantCol || row != tc.wRow)) { + t.Errorf("cellAtMouse(%d,%d) = (%d,%d,%v), want (%d,%d,%v)", + tc.x, tc.y, col, row, ok, tc.wantCol, tc.wRow, tc.ok) + } + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 77a6f7b..9ef8d9c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -60,6 +60,11 @@ type model struct { editLayout int // index into cfg.Layouts for edit/picker screens edCol, edRow int // grid-editor cursor position + // Mouse drag state for tmux-style tile resizing in the grid editor. + dragging bool + dragTile int + dragMoved bool + status string isError bool } @@ -71,7 +76,7 @@ func Run(cfgPath string) error { return err } m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu} - _, err = tea.NewProgram(m, tea.WithAltScreen()).Run() + _, err = tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run() return err } @@ -141,6 +146,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.setStatus("saved to "+m.cfgPath, false) } return m, nil + case tea.MouseMsg: + return m.handleMouse(msg) case tea.KeyMsg: return m.handleKey(msg) } diff --git a/internal/tui/view.go b/internal/tui/view.go index 9efe1a1..cea52b5 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -106,6 +106,7 @@ func (m *model) viewLayoutEdit() string { b.WriteString(m.renderGrid(cols, rows)) b.WriteString("\n\n") b.WriteString(strings.Join([]string{ + hkey("click", "assign cell") + " " + hkey("drag a tile", "resize") + " (mouse)", 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"),