From 8559616b65b804f0e6b699d620c516df8353d302 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 2 Oct 2026 08:36:23 +0100 Subject: [PATCH] Add mouse tests; tidy TUI idioms --- internal/tui/actions.go | 2 +- internal/tui/infoview.go | 8 ++++++-- internal/tui/render.go | 8 ++++++-- internal/tui/search.go | 9 +------- internal/tui/table.go | 14 ++++++++++++- internal/tui/tui_test.go | 36 ++++++++++++++++++++++++++++++++ internal/tui/views.go | 44 ++++++++++++++++++++-------------------- 7 files changed, 85 insertions(+), 36 deletions(-) diff --git a/internal/tui/actions.go b/internal/tui/actions.go index 973e774..eaabe4e 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -245,6 +245,6 @@ func (v *dupView) key(m *Model, k string) (bool, tea.Cmd) { } func (v *dupView) wheel(m *Model, up bool) tea.Cmd { - v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.rows)) + v.tb.scroll(up, len(v.rows)) return nil } diff --git a/internal/tui/infoview.go b/internal/tui/infoview.go index cbf69c6..6c31444 100644 --- a/internal/tui/infoview.go +++ b/internal/tui/infoview.go @@ -112,7 +112,11 @@ func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) { line("One filesystem", fmt.Sprint(st.OneFileSystem), value) line("Follow symlinks", st.Follow, value) line("Size mode", m.sizeMode.String(), value) - line("Units", map[bool]string{true: "SI (1000)", false: "IEC (1024)"}[textutil.SI], value) + units := "IEC (1024)" + if textutil.SI { + units = "SI (1000)" + } + line("Units", units, value) if root.Flags&inventory.FlagAllocUnknown != 0 { line("Note", "allocated size unavailable on this platform", Style{FG: t.Warn, BG: t.Bg}) } @@ -154,6 +158,6 @@ func (v *infoView) key(m *Model, k string) (bool, tea.Cmd) { } func (v *infoView) wheel(m *Model, up bool) tea.Cmd { - v.tb.move(map[bool]int{true: -3, false: 3}[up], len(m.tree.Stats.Errors)) + v.tb.scroll(up, len(m.tree.Stats.Errors)) return nil } diff --git a/internal/tui/render.go b/internal/tui/render.go index 3602996..cfcbb1e 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -313,7 +313,7 @@ func (m *Model) describeSelection() string { parts = append(parts, fmt.Sprintf("%d errors", n.Errors)) } if m.opts.Diff != nil { - parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" since "+"snapshot") + parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" vs old snapshot") } return strings.Join(parts, " ยท ") } @@ -403,6 +403,10 @@ func (m *Model) paintStatus(cv *Canvas, r treemap.Rect, class sizeClass) { if x+textutil.Width(s) > r.X+avail { break } - x += cv.Text(x, r.Y, s, avail-x, Style{FG: p.fg, BG: t.StatusBg, Attr: map[bool]Attr{true: Bold}[i == 0]}) + st := Style{FG: p.fg, BG: t.StatusBg} + if i == 0 { + st.Attr = Bold + } + x += cv.Text(x, r.Y, s, avail-x, st) } } diff --git a/internal/tui/search.go b/internal/tui/search.go index 7abd4ee..de31a62 100644 --- a/internal/tui/search.go +++ b/internal/tui/search.go @@ -241,17 +241,10 @@ func (m *Model) paintSearch(cv *Canvas, r treemap.Rect) { if s.ed.cur < len(text) { under = textutil.Sanitize(string(text[s.ed.cur])) } - cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: Reverse * Attr(boolInt(t.Mono))}) + cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: m.monoRev()}) cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt) } -func boolInt(b bool) int { - if b { - return 1 - } - return 0 -} - func (m *Model) paintFilterSummary(cv *Canvas, r treemap.Rect) { t := m.theme s := &m.search diff --git a/internal/tui/table.go b/internal/tui/table.go index c155c65..9da1d34 100644 --- a/internal/tui/table.go +++ b/internal/tui/table.go @@ -29,6 +29,15 @@ type table struct { height int // rows visible in the last paint } +// scroll moves the cursor three rows for a mouse wheel step. +func (tb *table) scroll(up bool, n int) { + if up { + tb.move(-3, n) + } else { + tb.move(3, n) + } +} + func (tb *table) move(d, n int) { if n == 0 { tb.cursor = 0 @@ -177,7 +186,10 @@ func (m *Model) paintBar(cv *Canvas, x, y, w int, frac float64, col Color, sel b ch = parts[eighths] } if t.ASCII { - ch = map[bool]string{true: "#", false: " "}[eighths >= 4] + ch = " " + if eighths >= 4 { + ch = "#" + } } eighths -= 8 st := Style{FG: col, BG: bg} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index a7bb7ec..8a0f3ba 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -418,3 +418,39 @@ func BenchmarkResizeRelayout(b *testing.B) { i++ } } + +func TestMouse(t *testing.T) { + m := newTestModel(t) + send(m, size(120, 40)) + m.View() + // Click the centre of a block other than the selected one. + var target treemap.Block + for _, b := range m.tm.blocks { + if b.ID != m.sel && b.ID >= 0 && m.tree.Node(inventory.NodeID(b.ID)).IsDir() && b.W > 4 { + target = b + break + } + } + click := tea.MouseClickMsg{X: target.X + target.W/2, Y: target.Y + target.H/2, Button: tea.MouseLeft} + send(m, click) + if m.sel != target.ID { + t.Fatalf("click selected %d, want %d", m.sel, target.ID) + } + m.View() + send(m, click) // second click within the double-click window + if m.zoom != inventory.NodeID(target.ID) { + t.Fatalf("double click should zoom into %d (zoom=%d)", target.ID, m.zoom) + } + m.View() + send(m, tea.MouseWheelMsg{X: 50, Y: 20, Button: tea.MouseWheelDown}) + if m.zoom != 0 { + t.Fatal("wheel down should zoom out") + } + // A modal swallows clicks; clicking outside closes it. + send(m, key("?")) + m.View() + send(m, tea.MouseClickMsg{X: 0, Y: 39, Button: tea.MouseLeft}) + if len(m.modals) != 0 { + t.Fatal("click outside modal should close it") + } +} diff --git a/internal/tui/views.go b/internal/tui/views.go index 356e4a1..c97c478 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -98,7 +98,7 @@ func (v *listView) key(m *Model, k string) (bool, tea.Cmd) { func (v *listView) wheel(m *Model, up bool) tea.Cmd { v.sync(m) - v.tb.move(map[bool]int{true: -3, false: 3}[up], len(m.kidsOf(m.zoom).ids)) + v.tb.scroll(up, len(m.kidsOf(m.zoom).ids)) v.sync(m) return nil } @@ -106,16 +106,16 @@ func (v *listView) wheel(m *Model, up bool) tea.Cmd { // ---- Extension statistics ------------------------------------------------ type extView struct { - tb table - cache []inventory.ExtStat - key_ [2]int + tb table + cache []inventory.ExtStat + cacheKey [2]int } func (*extView) name() string { return "Types" } func (v *extView) stats(m *Model) []inventory.ExtStat { k := [2]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode)} - if v.cache == nil || v.key_ != k { + if v.cache == nil || v.cacheKey != k { v.cache = m.tree.ExtStats(m.zoom) sort.Slice(v.cache, func(i, j int) bool { a, b := v.cache[i], v.cache[j] @@ -128,7 +128,7 @@ func (v *extView) stats(m *Model) []inventory.ExtStat { } return a.Ext < b.Ext }) - v.key_ = k + v.cacheKey = k } return v.cache } @@ -189,7 +189,7 @@ func (v *extView) key(m *Model, k string) (bool, tea.Cmd) { } func (v *extView) wheel(m *Model, up bool) tea.Cmd { - v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.stats(m))) + v.tb.scroll(up, len(v.stats(m))) return nil } @@ -212,12 +212,12 @@ var topNames = [...]string{"Largest files", "Largest directories (own files)", " "Newest large files", "Most files", "Sparse files", "Hard-linked files"} type topView struct { - tb table - mode topMode - ids []inventory.NodeID - vals []int64 - key_ [3]int - limit int + tb table + mode topMode + ids []inventory.NodeID + vals []int64 + cacheKey [3]int + limit int } func (*topView) name() string { return "Top" } @@ -226,10 +226,10 @@ const topLimit = 500 func (v *topView) compute(m *Model) { k := [3]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode), int(v.mode)} - if v.ids != nil && v.key_ == k { + if v.ids != nil && v.cacheKey == k { return } - v.key_ = k + v.cacheKey = k tr := m.tree isFile := func(n *inventory.Node) bool { return n.Kind == inventory.KindFile && n.Flags&(inventory.FlagHardlinkDup|inventory.FlagDeleted) == 0 @@ -378,16 +378,16 @@ func (v *topView) key(m *Model, k string) (bool, tea.Cmd) { } func (v *topView) wheel(m *Model, up bool) tea.Cmd { - v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.ids)) + v.tb.scroll(up, len(v.ids)) return nil } // ---- Snapshot comparison ----------------------------------------------------- type changesView struct { - tb table - rows []snapshot.Change - key_ int + tb table + rows []snapshot.Change + cacheKey int } func (*changesView) name() string { return "Changes" } @@ -395,9 +395,9 @@ func (*changesView) name() string { return "Changes" } func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) { t := m.theme d := m.opts.Diff - if v.rows == nil || v.key_ != int(m.sizeMode) { + if v.rows == nil || v.cacheKey != int(m.sizeMode) { v.rows = d.Changes(1000, m.sizeMode) - v.key_ = int(m.sizeMode) + v.cacheKey = int(m.sizeMode) } var maxAbs int64 = 1 for _, c := range v.rows { @@ -452,6 +452,6 @@ func (v *changesView) key(m *Model, k string) (bool, tea.Cmd) { } func (v *changesView) wheel(m *Model, up bool) tea.Cmd { - v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.rows)) + v.tb.scroll(up, len(v.rows)) return nil }