Add Bubble Tea TUI with interactive treemap

Cell canvas renderer with frame cache and a perceptual 256-colour
quantizer; responsive geometry (large/medium/small/too-small);
squarified treemap with nested previews, LOD grouping, category
colouring and adaptive labels; spatial arrow navigation with stable
NodeID selection; zoom and breadcrumbs; modal system (info, help,
confirm, save); list, file-type, top-N, scan-info, duplicate and
comparison views; live search/filter; mouse support; move-to-trash.
This commit is contained in:
Andy Dixon
2026-10-02 08:25:35 +01:00
parent e8c831cb47
commit df8b10c6dd
35 changed files with 5294 additions and 25 deletions
+420
View File
@@ -0,0 +1,420 @@
package tui
import (
"context"
"fmt"
"math/rand"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
"github.com/andydixon/mapsize/internal/treemap"
)
const hostile = "evil\x1b]0;pwned\x07\x1b[2J.iso"
func testTree() *inventory.Tree {
t := inventory.New("/srv", inventory.KindDir)
add := func(p inventory.NodeID, name string, size int64) inventory.NodeID {
id := t.Add(p, name, inventory.KindFile)
n := t.Node(id)
n.Size, n.Alloc = size, size
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
n.MTime = 1_700_000_000_000_000_000
return id
}
dir := func(p inventory.NodeID, name string) inventory.NodeID {
id := t.Add(p, name, inventory.KindDir)
t.Node(id).Flags |= inventory.FlagScanned
return id
}
backups := dir(0, "backups")
for i := range 40 {
add(backups, fmt.Sprintf("dump-%02d.tar.gz", i), int64(1+i)<<30)
}
vms := dir(0, "vms")
add(vms, "ubuntu.qcow2", 86<<30)
add(vms, "win.vmdk", 40<<30)
media := dir(0, "media")
photos := dir(media, "photos 日本語")
for i := range 300 {
add(photos, fmt.Sprintf("img%03d.jpg", i), int64(i+1)<<20)
}
add(media, hostile, 9<<30)
add(media, "👨‍👩‍👧 family.mp4", 12<<30)
tiny := dir(0, "tiny")
for i := range 2000 {
add(tiny, fmt.Sprintf("f%d", i), 100)
}
add(0, "README", 10)
t.Recompute()
t.Stats.Complete = true
return t
}
func newTestModel(t *testing.T) *Model {
t.Helper()
tr := testTree()
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }},
nil, tr, func() {})
m.profile = colorprofile.TrueColor
return m
}
func send(m *Model, msgs ...tea.Msg) {
for _, msg := range msgs {
m.Update(msg)
}
}
func key(s string) tea.KeyPressMsg {
switch s {
case "up":
return tea.KeyPressMsg{Code: tea.KeyUp}
case "down":
return tea.KeyPressMsg{Code: tea.KeyDown}
case "left":
return tea.KeyPressMsg{Code: tea.KeyLeft}
case "right":
return tea.KeyPressMsg{Code: tea.KeyRight}
case "enter":
return tea.KeyPressMsg{Code: tea.KeyEnter}
case "esc":
return tea.KeyPressMsg{Code: tea.KeyEscape}
case "space":
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
case "backspace":
return tea.KeyPressMsg{Code: tea.KeyBackspace}
case "home":
return tea.KeyPressMsg{Code: tea.KeyHome}
case "tab":
return tea.KeyPressMsg{Code: tea.KeyTab}
}
r := []rune(s)[0]
return tea.KeyPressMsg{Code: r, Text: s}
}
func size(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }
// checkFrame verifies the rendered frame exactly fills w×h cells and that no
// filename control sequence leaked through.
func checkFrame(t *testing.T, m *Model, w, h int) string {
t.Helper()
content := m.View().Content
if strings.Contains(content, "\x1b]") || strings.Contains(content, "\x07") || strings.Contains(content, "\x1b[2J") {
t.Fatalf("terminal control sequence from a filename leaked into the frame")
}
lines := strings.Split(content, "\n")
if len(lines) != h {
t.Fatalf("%dx%d: frame has %d lines", w, h, len(lines))
}
for i, l := range lines {
if got := ansi.StringWidth(l); got != w {
t.Fatalf("%dx%d: line %d has width %d: %q", w, h, i, got, ansi.Strip(l))
}
}
return ansi.Strip(content)
}
func TestResizeTransitionsPreserveState(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40))
checkFrame(t, m, 120, 40)
// Zoom into media and select something that is not the default.
m.tree.RLock()
media := m.kidsOf(0)
m.tree.RUnlock()
var mediaID inventory.NodeID
for _, id := range media.ids {
if m.tree.Node(id).Name == "media" {
mediaID = id
}
}
m.sel, m.userSel = int64(mediaID), true
send(m, key("space"))
if m.zoom != mediaID {
t.Fatalf("zoom = %d want media %d", m.zoom, mediaID)
}
checkFrame(t, m, 120, 40)
send(m, key("right"))
sel := m.sel
for _, sz := range [][2]int{{80, 24}, {120, 40}, {300, 80}, {70, 20}, {180, 50}, {40, 10}, {39, 9}, {200, 60}} {
send(m, size(sz[0], sz[1]))
checkFrame(t, m, sz[0], sz[1])
if m.zoom != mediaID {
t.Fatalf("%v: zoom lost", sz)
}
if m.sel != sel && !isGroup(m.sel) {
t.Fatalf("%v: selection changed from %d to %d", sz, sel, m.sel)
}
}
// The layout must be recomputed for the new geometry, not stretched.
if m.tm == nil || m.tm.rect.W > 200 || m.tm.rect.H > 60 {
t.Fatalf("layout not recomputed for final size: %+v", m.tm.rect)
}
for _, b := range m.tm.blocks {
r := m.tm.rect
if b.X < r.X || b.Y < r.Y || b.X+b.W > r.X+r.W || b.Y+b.H > r.Y+r.H {
t.Fatalf("block %+v outside treemap %+v", b, r)
}
}
}
func TestResizeStorm(t *testing.T) {
m := newTestModel(t)
r := rand.New(rand.NewSource(1))
send(m, size(120, 40))
m.View()
send(m, key("right"), key("down"))
sel := m.sel
for i := 0; i < 300; i++ {
send(m, size(20+r.Intn(300), 5+r.Intn(90)))
if i%7 == 0 {
m.View() // renders interleave with resizes like the real event loop
}
}
send(m, size(150, 45))
checkFrame(t, m, 150, 45)
if m.sel != sel {
t.Fatalf("selection lost in resize storm")
}
}
func TestModalSurvivesResize(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40), key("enter"))
if len(m.modals) != 1 {
t.Fatal("Enter should open the info modal")
}
for _, sz := range [][2]int{{120, 40}, {60, 14}, {41, 10}, {300, 80}, {45, 12}} {
send(m, size(sz[0], sz[1]))
out := checkFrame(t, m, sz[0], sz[1])
if !strings.Contains(out, "ITEM INFORMATION") {
t.Fatalf("%v: modal title missing:\n%s", sz, out)
}
f := m.modals[0].(*modalFrame)
if f.viewH < 1 || f.viewH > sz[1] {
t.Fatalf("%v: modal viewport %d", sz, f.viewH)
}
}
// Scrolling in a tiny terminal must stay in range.
send(m, size(45, 12))
for range 100 {
send(m, key("down"))
}
checkFrame(t, m, 45, 12)
f := m.modals[0].(*modalFrame)
if f.scroll < 0 || f.scroll > f.contentN {
t.Fatalf("scroll out of range: %d of %d", f.scroll, f.contentN)
}
send(m, key("esc"))
if len(m.modals) != 0 {
t.Fatal("Esc should close the modal")
}
}
func TestSpatialNavigationUsesGeometry(t *testing.T) {
m := newTestModel(t)
send(m, size(160, 45))
m.View()
blocks := m.tm.blocks
for _, dir := range []string{"right", "down", "left", "up"} {
before := m.sel
send(m, key(dir))
m.View()
if m.sel == before {
continue // edge of the map
}
var a, b = findBlock(blocks, before), findBlock(blocks, m.sel)
switch dir {
case "right":
if b.X < a.X+a.W {
t.Fatalf("right moved to a block that is not to the right: %+v -> %+v", a, b)
}
case "left":
if b.X+b.W > a.X {
t.Fatalf("left moved wrongly: %+v -> %+v", a, b)
}
case "down":
if b.Y < a.Y+a.H {
t.Fatalf("down moved wrongly: %+v -> %+v", a, b)
}
case "up":
if b.Y+b.H > a.Y {
t.Fatalf("up moved wrongly: %+v -> %+v", a, b)
}
}
}
}
func findBlock(bs []treemap.Block, id int64) treemap.Block {
for _, b := range bs {
if b.ID == id {
return b
}
}
return treemap.Block{}
}
func TestZoomBreadcrumbsAndHome(t *testing.T) {
m := newTestModel(t)
send(m, size(100, 30))
m.tree.RLock()
var media, photos inventory.NodeID
m.tree.Walk(0, func(id inventory.NodeID, n *inventory.Node) bool {
switch n.Name {
case "media":
media = id
case "photos 日本語":
photos = id
}
return true
})
m.tree.RUnlock()
m.sel, m.userSel = int64(media), true
send(m, key("space"))
m.sel = int64(photos)
send(m, key("space"))
if m.zoom != photos {
t.Fatal("did not zoom into photos")
}
out := checkFrame(t, m, 100, 30)
if !strings.Contains(out, "media › photos 日本語") {
t.Fatalf("breadcrumbs missing:\n%s", out)
}
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
send(m, size(42, 12))
out = checkFrame(t, m, 42, 12)
if !strings.Contains(out, "photos") {
t.Fatalf("breadcrumb tail missing:\n%s", out)
}
send(m, key("backspace"))
if m.zoom != media || m.sel != int64(photos) {
t.Fatalf("backspace: zoom %d sel %d", m.zoom, m.sel)
}
send(m, key("home"))
if m.zoom != 0 {
t.Fatal("home did not return to root")
}
}
func TestFilterTransformsMap(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40), key("/"))
for _, r := range "ext = gz" {
send(m, tea.KeyPressMsg{Code: r, Text: string(r)})
}
if m.search.err != "" || m.search.query == nil {
t.Fatalf("query not parsed: %q", m.search.err)
}
msg := m.applyFilter()()
send(m, msg, key("enter"))
if m.search.result == nil {
t.Fatal("no filter result")
}
out := checkFrame(t, m, 120, 40)
if strings.Contains(out, "vms/") || !strings.Contains(out, "backups/") {
t.Fatalf("filter not applied to the map:\n%s", out)
}
if !strings.Contains(out, "FILTER") {
t.Fatal("filter indicator missing")
}
send(m, key("esc"))
if m.search.query != nil || m.search.result != nil {
t.Fatal("Esc should clear the filter")
}
out = checkFrame(t, m, 120, 40)
if !strings.Contains(out, "vms/") {
t.Fatalf("map not restored after clearing filter:\n%s", out)
}
}
func TestHostileNameSanitisedInModal(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40))
var id inventory.NodeID
m.tree.Walk(0, func(i inventory.NodeID, n *inventory.Node) bool {
if n.Name == hostile {
id = i
}
return true
})
m.openModal(newInfoModal(m, id))
out := checkFrame(t, m, 120, 40)
if !strings.Contains(out, `\x1b]0;pwned\x07`) {
t.Fatalf("escaped name not shown:\n%s", out)
}
}
func TestViewsRenderAtManySizes(t *testing.T) {
m := newTestModel(t)
for range m.views {
for _, sz := range [][2]int{{40, 10}, {80, 24}, {150, 45}, {91, 22}} {
send(m, size(sz[0], sz[1]))
checkFrame(t, m, sz[0], sz[1])
send(m, key("down"), key("down"))
checkFrame(t, m, sz[0], sz[1])
}
send(m, key("tab"))
}
send(m, key("?"))
send(m, size(60, 15))
checkFrame(t, m, 60, 15)
}
func TestLevelOfDetailGroupsTinyFiles(t *testing.T) {
m := newTestModel(t)
send(m, size(100, 30))
m.tree.RLock()
var tiny inventory.NodeID
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
if n.Name == "tiny" {
tiny = id
}
})
m.tree.RUnlock()
m.zoomTo(tiny)
m.View()
if len(m.tm.blocks) > 100*30/topMinArea+1 {
t.Fatalf("%d blocks for 2000 equal files: LOD not applied", len(m.tm.blocks))
}
if _, ok := m.tm.groups[groupID(tiny)]; !ok {
t.Fatal("expected a group block for small items")
}
}
func TestCtrlCQuitsWhenIdle(t *testing.T) {
m := newTestModel(t)
send(m, size(80, 24))
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil || !m.quit {
t.Fatal("Ctrl+C should quit when no scan runs")
}
}
func BenchmarkRenderFrame(b *testing.B) {
tr := testTree()
m := newModel(Options{}, nil, tr, func() {})
m.profile = colorprofile.TrueColor
send(m, size(200, 60))
for b.Loop() {
m.dirty = true
m.View()
}
}
func BenchmarkResizeRelayout(b *testing.B) {
tr := testTree()
m := newModel(Options{}, nil, tr, func() {})
i := 0
for b.Loop() {
send(m, size(100+i%100, 30+i%30))
m.View()
i++
}
}