Skip to content

Commit 4af4abd

Browse files
cpcloudclaude
andauthored
feat(ui): double-click row to drill down, click selects column (#647)
## Summary - Replace single-click-on-selected-row drilldown with double-click detection (300ms threshold) - Single click now selects both the row and the column of the clicked cell - Same double-click pattern applied to dashboard overlay row clicks - Added `selectClickedColumn` to map click X position to column header zones closes #625 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 02a61d0 commit 4af4abd

File tree

3 files changed

+283
-17
lines changed

3 files changed

+283
-17
lines changed

internal/app/model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ type Model struct {
204204
magMode bool // easter egg: display numbers as order-of-magnitude
205205
confirmHardDelete bool // true while waiting for y/n on permanent delete
206206
hardDeleteID uint // entity ID pending permanent deletion
207+
lastRowClick rowClickState
208+
lastDashClick rowClickState
207209
cur locale.Currency
208210
status statusMsg
209211
projectTypes []data.ProjectType

internal/app/mouse.go

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@ package app
55

66
import (
77
"fmt"
8+
"time"
89

910
tea "github.com/charmbracelet/bubbletea"
1011
)
1112

13+
// doubleClickThreshold is the maximum duration between two clicks on the
14+
// same row for them to count as a double-click.
15+
const doubleClickThreshold = 300 * time.Millisecond
16+
17+
// rowClickState tracks the last row click for double-click detection.
18+
type rowClickState struct {
19+
at time.Time
20+
row int
21+
}
22+
1223
// Zone ID prefixes for clickable UI regions.
1324
const (
1425
zoneTab = "tab-"
@@ -92,7 +103,7 @@ func (m *Model) handleLeftClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
92103
}
93104
}
94105

95-
// Row click.
106+
// Row click: single click selects row+column, double-click drills down.
96107
if tab := m.effectiveTab(); tab != nil {
97108
total := len(tab.CellRows)
98109
if total > 0 {
@@ -112,18 +123,19 @@ func (m *Model) handleLeftClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
112123
start, end := visibleRange(total, height, cursor)
113124
for i := start; i < end; i++ {
114125
if m.zones.Get(fmt.Sprintf("%s%d", zoneRow, i)).InBounds(msg) {
115-
if i == cursor {
116-
// Click on already-selected row: drilldown/enter.
117-
if m.mode == modeNormal {
118-
if err := m.handleNormalEnter(); err != nil {
119-
m.setStatusError(err.Error())
120-
}
121-
if m.mode == modeForm {
122-
return m, m.formInitCmd()
123-
}
126+
now := time.Now()
127+
isDouble := m.lastRowClick.row == i &&
128+
!m.lastRowClick.at.IsZero() &&
129+
now.Sub(m.lastRowClick.at) <= doubleClickThreshold
130+
if isDouble && m.mode == modeNormal {
131+
m.lastRowClick = rowClickState{}
132+
if err := m.handleNormalEnter(); err != nil {
133+
m.setStatusError(err.Error())
124134
}
125135
} else {
126136
tab.Table.SetCursor(i)
137+
m.selectClickedColumn(tab, msg)
138+
m.lastRowClick = rowClickState{at: now, row: i}
127139
}
128140
return m, nil
129141
}
@@ -208,14 +220,20 @@ func (m *Model) handleHintClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
208220

209221
// handleOverlayClick handles clicks within an active overlay.
210222
func (m *Model) handleOverlayClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
211-
// Dashboard row clicks.
223+
// Dashboard row clicks: single click selects, double-click jumps.
212224
if m.dashboardVisible() {
213225
for i := range m.dash.nav {
214226
if m.zones.Get(fmt.Sprintf("%s%d", zoneDashRow, i)).InBounds(msg) {
215-
if i == m.dash.cursor {
227+
now := time.Now()
228+
isDouble := m.lastDashClick.row == i &&
229+
!m.lastDashClick.at.IsZero() &&
230+
now.Sub(m.lastDashClick.at) <= doubleClickThreshold
231+
if isDouble {
232+
m.lastDashClick = rowClickState{}
216233
m.dashJump()
217234
} else {
218235
m.dash.cursor = i
236+
m.lastDashClick = rowClickState{at: now, row: i}
219237
}
220238
return m, nil
221239
}
@@ -224,6 +242,29 @@ func (m *Model) handleOverlayClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
224242
return m, nil
225243
}
226244

245+
// selectClickedColumn updates the tab's column cursor to match the column
246+
// zone the click's X coordinate falls within. Column header zones (col-N)
247+
// share the same X ranges as body cells, so we reuse them.
248+
func (m *Model) selectClickedColumn(tab *Tab, msg tea.MouseMsg) {
249+
vSpecs, _, _, _, _ := visibleProjection(tab)
250+
width := m.effectiveWidth()
251+
normalSep := m.styles.TableSeparator().Render(" │ ")
252+
vp := computeTableViewport(tab, width, normalSep, m.cur.Symbol())
253+
for i := range vSpecs {
254+
z := m.zones.Get(fmt.Sprintf("%s%d", zoneCol, i))
255+
if z == nil || z.IsZero() {
256+
continue
257+
}
258+
if msg.X >= z.StartX && msg.X <= z.EndX {
259+
if i < len(vp.VisToFull) {
260+
tab.ColCursor = vp.VisToFull[i]
261+
m.updateTabViewport(tab)
262+
}
263+
return
264+
}
265+
}
266+
}
267+
227268
// handleScroll scrolls the active surface by delta lines.
228269
func (m *Model) handleScroll(delta int) (tea.Model, tea.Cmd) {
229270
// Overlay scroll.

internal/app/mouse_test.go

Lines changed: 228 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
package app
55

66
import (
7+
"fmt"
78
"testing"
9+
"time"
810

911
tea "github.com/charmbracelet/bubbletea"
1012
zone "github.com/lrstanley/bubblezone"
@@ -33,6 +35,27 @@ func requireZone(t *testing.T, m *Model, id string) *zone.ZoneInfo {
3335
return z
3436
}
3537

38+
// drilldownColX returns the X coordinate of the drilldown column's header
39+
// zone. This is needed because row clicks also select the column, so tests
40+
// that expect drilldown must click at the drilldown column's X position.
41+
func drilldownColX(t *testing.T, m *Model, tab *Tab) int {
42+
t.Helper()
43+
m.View()
44+
width := m.effectiveWidth()
45+
normalSep := m.styles.TableSeparator().Render(" \u2502 ")
46+
vp := computeTableViewport(tab, width, normalSep, m.cur.Symbol())
47+
for vi, fi := range vp.VisToFull {
48+
if fi < len(tab.Specs) && tab.Specs[fi].Kind == cellDrilldown {
49+
z := m.zones.Get(fmt.Sprintf("%s%d", zoneCol, vi))
50+
if z != nil && !z.IsZero() {
51+
return z.StartX
52+
}
53+
}
54+
}
55+
t.Skip("drilldown column zone not rendered")
56+
return 0
57+
}
58+
3659
// TestTabClickSwitchesTab verifies that clicking on a tab changes the
3760
// active tab, simulating a real user left-click on tab zone markers.
3861
func TestTabClickSwitchesTab(t *testing.T) {
@@ -79,6 +102,31 @@ func TestRowClickMovesCursor(t *testing.T) {
79102
assert.Equal(t, 1, tab.Table.Cursor(), "clicking row-1 should move cursor to row 1")
80103
}
81104

105+
// TestRowClickSelectsColumn verifies that clicking on a cell within a row
106+
// also moves the column cursor to the clicked column.
107+
func TestRowClickSelectsColumn(t *testing.T) {
108+
t.Parallel()
109+
m := newTestModelWithDemoData(t, 42)
110+
111+
tab := m.effectiveTab()
112+
require.NotNil(t, tab)
113+
require.Greater(t, len(tab.CellRows), 1, "need at least 2 rows")
114+
require.Greater(t, len(tab.Specs), 1, "need at least 2 columns")
115+
116+
tab.Table.SetCursor(0)
117+
tab.ColCursor = 0
118+
119+
// Get the second column header zone for its X range.
120+
colZone := requireZone(t, m, "col-1")
121+
// Get a row zone for its Y range.
122+
rowZone := requireZone(t, m, "row-1")
123+
124+
// Click at the X of column 1, Y of row 1.
125+
sendClick(m, colZone.StartX, rowZone.StartY)
126+
assert.Equal(t, 1, tab.Table.Cursor(), "clicking should move row cursor to row 1")
127+
assert.NotEqual(t, 0, tab.ColCursor, "clicking in column 1 area should move column cursor")
128+
}
129+
82130
// TestScrollWheelMovesCursor verifies that scroll wheel events move the
83131
// table cursor like j/k.
84132
func TestScrollWheelMovesCursor(t *testing.T) {
@@ -232,9 +280,9 @@ func TestMouseNoOpOnRelease(t *testing.T) {
232280
assert.Equal(t, before, m.active, "mouse release should not change state")
233281
}
234282

235-
// TestSelectedRowClickDrillsDown verifies that clicking an already-selected
236-
// row triggers drilldown (same as pressing enter).
237-
func TestSelectedRowClickDrillsDown(t *testing.T) {
283+
// TestDoubleClickRowDrillsDown verifies that double-clicking a row triggers
284+
// drilldown (same as pressing enter).
285+
func TestDoubleClickRowDrillsDown(t *testing.T) {
238286
t.Parallel()
239287
m := newTestModelWithDemoData(t, 42)
240288

@@ -255,10 +303,115 @@ func TestSelectedRowClickDrillsDown(t *testing.T) {
255303
}
256304

257305
tab.Table.SetCursor(0)
306+
colX := drilldownColX(t, m, tab)
258307
z := requireZone(t, m, "row-0")
259308

260-
sendClick(m, z.StartX, z.StartY)
261-
assert.True(t, m.inDetail(), "clicking selected row should trigger drilldown")
309+
// First click selects (already selected, but records the click).
310+
sendClick(m, colX, z.StartY)
311+
assert.False(t, m.inDetail(), "single click should not trigger drilldown")
312+
313+
// Second click within threshold triggers drilldown.
314+
z = requireZone(t, m, "row-0")
315+
sendClick(m, colX, z.StartY)
316+
assert.True(t, m.inDetail(), "double-click should trigger drilldown")
317+
}
318+
319+
// TestSingleClickOnSelectedRowDoesNotDrill verifies that a single click on
320+
// an already-selected row does not trigger drilldown.
321+
func TestSingleClickOnSelectedRowDoesNotDrill(t *testing.T) {
322+
t.Parallel()
323+
m := newTestModelWithDemoData(t, 42)
324+
325+
tab := m.effectiveTab()
326+
require.NotNil(t, tab)
327+
require.NotEmpty(t, tab.CellRows)
328+
329+
hasDrilldown := false
330+
for i, spec := range tab.Specs {
331+
if spec.Kind == cellDrilldown {
332+
tab.ColCursor = i
333+
hasDrilldown = true
334+
break
335+
}
336+
}
337+
if !hasDrilldown {
338+
t.Skip("no drilldown column available")
339+
}
340+
341+
tab.Table.SetCursor(0)
342+
colX := drilldownColX(t, m, tab)
343+
z := requireZone(t, m, "row-0")
344+
345+
sendClick(m, colX, z.StartY)
346+
assert.False(t, m.inDetail(), "single click on selected row should not drill down")
347+
}
348+
349+
// TestDoubleClickExpiredDoesNotDrill verifies that two clicks with too much
350+
// time between them do not trigger drilldown.
351+
func TestDoubleClickExpiredDoesNotDrill(t *testing.T) {
352+
t.Parallel()
353+
m := newTestModelWithDemoData(t, 42)
354+
355+
tab := m.effectiveTab()
356+
require.NotNil(t, tab)
357+
require.NotEmpty(t, tab.CellRows)
358+
359+
hasDrilldown := false
360+
for i, spec := range tab.Specs {
361+
if spec.Kind == cellDrilldown {
362+
tab.ColCursor = i
363+
hasDrilldown = true
364+
break
365+
}
366+
}
367+
if !hasDrilldown {
368+
t.Skip("no drilldown column available")
369+
}
370+
371+
tab.Table.SetCursor(0)
372+
colX := drilldownColX(t, m, tab)
373+
z := requireZone(t, m, "row-0")
374+
375+
sendClick(m, colX, z.StartY)
376+
// Simulate an expired click by backdating the recorded time.
377+
m.lastRowClick.at = m.lastRowClick.at.Add(-time.Second)
378+
379+
z = requireZone(t, m, "row-0")
380+
sendClick(m, colX, z.StartY)
381+
assert.False(t, m.inDetail(), "expired double-click should not trigger drilldown")
382+
}
383+
384+
// TestDoubleClickDifferentRowDoesNotDrill verifies that clicking two
385+
// different rows in quick succession does not trigger drilldown.
386+
func TestDoubleClickDifferentRowDoesNotDrill(t *testing.T) {
387+
t.Parallel()
388+
m := newTestModelWithDemoData(t, 42)
389+
390+
tab := m.effectiveTab()
391+
require.NotNil(t, tab)
392+
require.Greater(t, len(tab.CellRows), 1, "need at least 2 rows")
393+
394+
hasDrilldown := false
395+
for i, spec := range tab.Specs {
396+
if spec.Kind == cellDrilldown {
397+
tab.ColCursor = i
398+
hasDrilldown = true
399+
break
400+
}
401+
}
402+
if !hasDrilldown {
403+
t.Skip("no drilldown column available")
404+
}
405+
406+
tab.Table.SetCursor(0)
407+
colX := drilldownColX(t, m, tab)
408+
z0 := requireZone(t, m, "row-0")
409+
sendClick(m, colX, z0.StartY)
410+
411+
z1 := requireZone(t, m, "row-1")
412+
sendClick(m, colX, z1.StartY)
413+
assert.False(t, m.inDetail(), "clicking different rows should not trigger drilldown")
414+
assert.Equal(t, 1, tab.Table.Cursor(), "second click should select row 1")
262415
}
263416

264417
// TestDashboardScrollWheel verifies that scroll wheel events in the
@@ -281,6 +434,76 @@ func TestDashboardScrollWheel(t *testing.T) {
281434
assert.Equal(t, 0, m.dash.cursor, "scroll up in dashboard should move cursor back")
282435
}
283436

437+
// TestDashboardRowClickSelects verifies that a single click on a dashboard
438+
// row selects it without jumping.
439+
func TestDashboardRowClickSelects(t *testing.T) {
440+
t.Parallel()
441+
m := newTestModelWithDemoData(t, 42)
442+
443+
sendKey(m, "D")
444+
if !m.dashboardVisible() {
445+
t.Skip("dashboard has no data to display")
446+
}
447+
require.Greater(t, len(m.dash.nav), 1, "need multiple dashboard nav items")
448+
449+
m.dash.cursor = 0
450+
// Render once to populate all zones including overlay.
451+
m.View()
452+
oz := m.zones.Get(zoneOverlay)
453+
if oz == nil || oz.IsZero() {
454+
t.Skip("overlay zone not rendered")
455+
}
456+
z := requireZone(t, m, "dash-1")
457+
458+
sendClick(m, z.StartX, z.StartY)
459+
assert.True(t, m.dashboardVisible(), "single click should not close dashboard")
460+
assert.Equal(t, 1, m.dash.cursor, "single click should move dashboard cursor")
461+
}
462+
463+
// TestDashboardDoubleClickJumps verifies that double-clicking a dashboard
464+
// row jumps to the item (closes the dashboard and switches tabs).
465+
func TestDashboardDoubleClickJumps(t *testing.T) {
466+
t.Parallel()
467+
m := newTestModelWithDemoData(t, 42)
468+
469+
sendKey(m, "D")
470+
if !m.dashboardVisible() {
471+
t.Skip("dashboard has no data to display")
472+
}
473+
require.Greater(t, len(m.dash.nav), 1, "need multiple dashboard nav items")
474+
475+
// Find a jumpable (non-header, non-info-only) row.
476+
jumpIdx := -1
477+
for i, nav := range m.dash.nav {
478+
if !nav.IsHeader && !nav.InfoOnly {
479+
jumpIdx = i
480+
break
481+
}
482+
}
483+
if jumpIdx < 0 {
484+
t.Skip("no jumpable dashboard row")
485+
}
486+
487+
m.dash.cursor = 0
488+
// Render once to populate all zones including overlay.
489+
m.View()
490+
oz := m.zones.Get(zoneOverlay)
491+
if oz == nil || oz.IsZero() {
492+
t.Skip("overlay zone not rendered")
493+
}
494+
z := requireZone(t, m, fmt.Sprintf("dash-%d", jumpIdx))
495+
496+
// First click selects.
497+
sendClick(m, z.StartX, z.StartY)
498+
require.True(t, m.dashboardVisible(), "single click should keep dashboard open")
499+
require.Equal(t, jumpIdx, m.dash.cursor)
500+
501+
// Second click within threshold jumps.
502+
z = requireZone(t, m, fmt.Sprintf("dash-%d", jumpIdx))
503+
sendClick(m, z.StartX, z.StartY)
504+
assert.False(t, m.dashboardVisible(), "double-click should close dashboard and jump")
505+
}
506+
284507
// TestDashboardDismissOnOutsideClick verifies that clicking outside the
285508
// dashboard overlay dismisses it.
286509
func TestDashboardDismissOnOutsideClick(t *testing.T) {

0 commit comments

Comments
 (0)