diff --git a/cmd/roborev/tui/handlers.go b/cmd/roborev/tui/handlers.go index 6b4fe33c0..37f00ced7 100644 --- a/cmd/roborev/tui/handlers.go +++ b/cmd/roborev/tui/handlers.go @@ -44,6 +44,12 @@ func (m model) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } switch msg.Button { case tea.MouseButtonWheelUp: + if m.currentView == viewColumnOptions { + if m.colOptionsIdx > 0 { + m.colOptionsIdx-- + } + return m, nil + } if m.currentView == viewTasks { if m.fixSelectedIdx > 0 { m.fixSelectedIdx-- @@ -52,6 +58,12 @@ func (m model) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m.handleUpKey() case tea.MouseButtonWheelDown: + if m.currentView == viewColumnOptions { + if m.colOptionsIdx < len(m.colOptionsList)-1 { + m.colOptionsIdx++ + } + return m, nil + } if m.currentView == viewTasks { if m.fixSelectedIdx < len(m.fixJobs)-1 { m.fixSelectedIdx++ @@ -65,6 +77,8 @@ func (m model) handleMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.handleQueueMouseClick(msg.X, msg.Y) case viewTasks: m.handleTasksMouseClick(msg.Y) + case viewColumnOptions: + return m.handleColumnOptionsMouseClick(msg.Y) } return m, nil default: diff --git a/cmd/roborev/tui/handlers_queue.go b/cmd/roborev/tui/handlers_queue.go index ef16a73dc..83cf18c54 100644 --- a/cmd/roborev/tui/handlers_queue.go +++ b/cmd/roborev/tui/handlers_queue.go @@ -254,45 +254,88 @@ func (m model) handleColumnOptionsInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case " ", "enter": - if m.colOptionsIdx >= 0 && m.colOptionsIdx < len(m.colOptionsList) { - opt := &m.colOptionsList[m.colOptionsIdx] - if opt.id == colOptionBorders { - opt.enabled = !opt.enabled - m.colBordersOn = opt.enabled - m.colOptionsDirty = true - m.queueColGen++ - m.taskColGen++ - } else if opt.id == colOptionMouse { - opt.enabled = !opt.enabled - m.mouseEnabled = opt.enabled - m.colOptionsDirty = true - return m, mouseCaptureCmd(m.currentView, m.mouseEnabled) - } else if opt.id == colOptionTasksWorkflow { - opt.enabled = !opt.enabled - m.tasksEnabled = opt.enabled - m.colOptionsDirty = true - } else if m.colOptionsReturnView == viewTasks { - // Tasks view: no visibility toggle (all columns always shown) - return m, nil - } else { - opt.enabled = !opt.enabled - if opt.enabled { - delete(m.hiddenColumns, opt.id) - } else { - if m.hiddenColumns == nil { - m.hiddenColumns = map[int]bool{} - } - m.hiddenColumns[opt.id] = true - } - m.colOptionsDirty = true - m.queueColGen++ + return m.toggleColumnOption(m.colOptionsIdx) + } + return m, nil +} + +// toggleColumnOption toggles the option at the given index. +func (m model) toggleColumnOption(idx int) (tea.Model, tea.Cmd) { + if idx < 0 || idx >= len(m.colOptionsList) { + return m, nil + } + opt := &m.colOptionsList[idx] + if opt.id == colOptionBorders { + opt.enabled = !opt.enabled + m.colBordersOn = opt.enabled + m.colOptionsDirty = true + m.queueColGen++ + m.taskColGen++ + } else if opt.id == colOptionMouse { + opt.enabled = !opt.enabled + m.mouseEnabled = opt.enabled + m.colOptionsDirty = true + return m, mouseCaptureCmd(m.currentView, m.mouseEnabled) + } else if opt.id == colOptionTasksWorkflow { + opt.enabled = !opt.enabled + m.tasksEnabled = opt.enabled + m.colOptionsDirty = true + } else if m.colOptionsReturnView == viewTasks { + // Tasks view: no visibility toggle (all columns always shown) + return m, nil + } else { + opt.enabled = !opt.enabled + if opt.enabled { + delete(m.hiddenColumns, opt.id) + } else { + if m.hiddenColumns == nil { + m.hiddenColumns = map[int]bool{} } + m.hiddenColumns[opt.id] = true } - return m, nil + m.colOptionsDirty = true + m.queueColGen++ } return m, nil } +// handleColumnOptionsMouseClick handles mouse clicks in the column options modal. +// The layout is: title (line 0), blank (line 1), then one line per option, +// with a separator blank line inserted before the first sentinel option (borders). +func (m model) handleColumnOptionsMouseClick(y int) (tea.Model, tea.Cmd) { + // Find the index of the separator (blank line before borders toggle). + separatorAt := -1 + for i, opt := range m.colOptionsList { + if opt.id == colOptionBorders && i > 0 { + separatorAt = i + break + } + } + + // Options start at row 2 (after title + blank line). + row := y - 2 + if row < 0 { + return m, nil + } + + // Adjust for the separator line. + if separatorAt >= 0 { + if row == separatorAt { + return m, nil // clicked the blank separator line + } + if row > separatorAt { + row-- // account for the separator blank line + } + } + + if row < 0 || row >= len(m.colOptionsList) { + return m, nil + } + + m.colOptionsIdx = row + return m.toggleColumnOption(row) +} + // syncColumnOrderFromOptions updates m.columnOrder or m.taskColumnOrder // from the current colOptionsList (excluding the borders toggle). func (m *model) syncColumnOrderFromOptions() { diff --git a/cmd/roborev/tui/queue_test.go b/cmd/roborev/tui/queue_test.go index 3f6ddd8b2..f672a299c 100644 --- a/cmd/roborev/tui/queue_test.go +++ b/cmd/roborev/tui/queue_test.go @@ -1739,6 +1739,96 @@ func TestColumnOptionsToggle(t *testing.T) { assert.False(t, m.hiddenColumns[colRef], "expected colRef removed from hiddenColumns") } +func TestColumnOptionsMouseClick(t *testing.T) { + m := newTuiModel("localhost:7373") + m.jobs = []storage.ReviewJob{makeJob(1)} + m.currentView = viewQueue + m.hiddenColumns = map[int]bool{} + m.mouseEnabled = true + + // Open column options modal. + m, _ = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + require.Equal(t, viewColumnOptions, m.currentView) + + // First option is at row 2 (title=0, blank=1, first option=2). + firstOpt := m.colOptionsList[0] + assert.True(t, firstOpt.enabled, "expected first column enabled initially") + + // Click on the first option row to toggle it off. + m, _ = updateModel(t, m, mouseLeftClick(5, 2)) + assert.False(t, m.colOptionsList[0].enabled, "expected first column disabled after click") + assert.Equal(t, 0, m.colOptionsIdx, "expected cursor on clicked row") + + // Click again to toggle it back on. + m, _ = updateModel(t, m, mouseLeftClick(5, 2)) + assert.True(t, m.colOptionsList[0].enabled, "expected first column re-enabled after second click") + + // Click on the second option. + m, _ = updateModel(t, m, mouseLeftClick(5, 3)) + assert.Equal(t, 1, m.colOptionsIdx, "expected cursor moved to second row") +} + +func TestColumnOptionsMouseClickSentinel(t *testing.T) { + m := newTuiModel("localhost:7373") + m.jobs = []storage.ReviewJob{makeJob(1)} + m.currentView = viewQueue + m.hiddenColumns = map[int]bool{} + m.mouseEnabled = true + + m, _ = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + require.Equal(t, viewColumnOptions, m.currentView) + + // Find the borders option (first sentinel, has a separator line before it). + bordersIdx := -1 + for i, opt := range m.colOptionsList { + if opt.id == colOptionBorders { + bordersIdx = i + break + } + } + require.NotEqual(t, -1, bordersIdx) + + // Borders is at row = 2 + bordersIdx + 1 (separator line). + bordersRow := 2 + bordersIdx + 1 + initialBorders := m.colBordersOn + + // Click the separator line (row just before borders) — should be a no-op. + separatorRow := 2 + bordersIdx + prevIdx := m.colOptionsIdx + prevLastEnabled := m.colOptionsList[bordersIdx-1].enabled + m, _ = updateModel(t, m, mouseLeftClick(5, separatorRow)) + assert.Equal(t, prevIdx, m.colOptionsIdx, "separator click should not move cursor") + assert.Equal(t, prevLastEnabled, m.colOptionsList[bordersIdx-1].enabled, + "separator click should not toggle adjacent option") + + // Click the actual borders row. + m, _ = updateModel(t, m, mouseLeftClick(5, bordersRow)) + assert.Equal(t, bordersIdx, m.colOptionsIdx) + assert.NotEqual(t, initialBorders, m.colBordersOn, "expected borders toggled") +} + +func TestColumnOptionsMouseWheel(t *testing.T) { + m := newTuiModel("localhost:7373") + m.jobs = []storage.ReviewJob{makeJob(1)} + m.currentView = viewQueue + m.hiddenColumns = map[int]bool{} + m.mouseEnabled = true + + m, _ = updateModel(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + require.Equal(t, viewColumnOptions, m.currentView) + assert.Equal(t, 0, m.colOptionsIdx) + + m, _ = updateModel(t, m, mouseWheelDown()) + assert.Equal(t, 1, m.colOptionsIdx) + + m, _ = updateModel(t, m, mouseWheelUp()) + assert.Equal(t, 0, m.colOptionsIdx) + + // Wheel up at top should stay at 0. + m, _ = updateModel(t, m, mouseWheelUp()) + assert.Equal(t, 0, m.colOptionsIdx) +} + func TestMouseDisabledIgnoresQueueMouseInput(t *testing.T) { m := newTuiModel("http://localhost") m.currentView = tuiViewQueue