Add mouse support to the TUI (tmux-style tile resize)
Enable mouse in the Bubble Tea program. In the layout grid editor: click a cell to assign a camera, or press on a tile and drag to resize its span (grid-snapped). Click-to-select on the menu/list screens and the camera picker. Works over SSH; edits the config, so it never fights the daemon geometry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
internal/tui/mouse.go
Normal file
132
internal/tui/mouse.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
33
internal/tui/mouse_test.go
Normal file
33
internal/tui/mouse_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,11 @@ type model struct {
|
|||||||
editLayout int // index into cfg.Layouts for edit/picker screens
|
editLayout int // index into cfg.Layouts for edit/picker screens
|
||||||
edCol, edRow int // grid-editor cursor position
|
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
|
status string
|
||||||
isError bool
|
isError bool
|
||||||
}
|
}
|
||||||
@@ -71,7 +76,7 @@ func Run(cfgPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m := &model{cfgPath: cfgPath, cfg: cfg, screen: screenMenu}
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +146,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.setStatus("saved to "+m.cfgPath, false)
|
m.setStatus("saved to "+m.cfgPath, false)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case tea.MouseMsg:
|
||||||
|
return m.handleMouse(msg)
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
return m.handleKey(msg)
|
return m.handleKey(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (m *model) viewLayoutEdit() string {
|
|||||||
b.WriteString(m.renderGrid(cols, rows))
|
b.WriteString(m.renderGrid(cols, rows))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(strings.Join([]string{
|
b.WriteString(strings.Join([]string{
|
||||||
|
hkey("click", "assign cell") + " " + hkey("drag a tile", "resize") + " (mouse)",
|
||||||
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
|
hkey("←↑↓→/hjkl", "move") + " " + hkey("enter", "assign") + " " + hkey("c", "clear"),
|
||||||
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
|
"resize tile " + hkey("L/H", "wider/narrower") + " " + hkey("J/K", "taller/shorter"),
|
||||||
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
|
"base grid " + hkey("] [", "cols") + " " + hkey("} {", "rows") + " " + hkey("esc", "back"),
|
||||||
|
|||||||
Reference in New Issue
Block a user