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
+354
View File
@@ -0,0 +1,354 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// tmLayout is the treemap geometry for one (zoom, rect, data version). It is
// recomputed from scratch whenever any of those change — after a resize the
// layout algorithm runs again for the new rectangle rather than scaling the
// old one.
type tmLayout struct {
zoom inventory.NodeID
rect treemap.Rect
ver int
blocks []treemap.Block // top level: navigable
nested []nested // previews inside directories: drawn only
groups map[int64]*group
visible map[int64]bool
inner map[int64]bool // top-level blocks with nested previews
}
type nested struct {
id int64
r treemap.Rect
depth int
alt bool
inner bool // has nested children drawn inside it
}
type group struct {
parent inventory.NodeID
ids []inventory.NodeID
size int64
}
// LOD parameters.
const (
topMinArea = 12 // cells: room for a 4×3 framed block
topMaxItems = 400
nestMinArea = 3
nestMaxItems = 120
maxNestDepth = 3
nestMinW = 4
nestMinH = 2
nestRecurseW = 14
nestRecurseH = 5
)
func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
if l := m.tm; l != nil && l.zoom == m.zoom && l.rect == r && l.ver == m.dataVer {
return l
}
l := &tmLayout{zoom: m.zoom, rect: r, ver: m.dataVer, groups: map[int64]*group{}, visible: map[int64]bool{}, inner: map[int64]bool{}}
l.blocks = m.layoutLevel(l, m.zoom, r, topMinArea, topMaxItems)
for _, b := range l.blocks {
l.visible[b.ID] = true
if b.ID < 0 || b.W < 3 || b.H < 2 {
continue
}
if n := m.tree.Node(inventory.NodeID(b.ID)); n.IsDir() {
in := b.Inset(1)
if in.W >= nestMinW && in.H >= nestMinH {
before := len(l.nested)
m.layoutNested(l, inventory.NodeID(b.ID), in, 2)
l.inner[b.ID] = len(l.nested) > before
}
}
}
m.tm = l
return l
}
// layoutLevel lays out the children of parent in r with level-of-detail
// grouping: children too small to be useful collapse into one group block.
func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect, minArea, maxItems int) []treemap.Block {
k := m.kidsOf(parent)
if len(k.ids) == 0 || r.Empty() {
return nil
}
sizes := make([]float64, len(k.sizes))
for i, s := range k.sizes {
sizes[i] = float64(s)
}
vis := treemap.Visible(sizes, float64(k.total), r.Area(), minArea, maxItems)
items := make([]treemap.Item, 0, vis+1)
for i := range vis {
items = append(items, treemap.Item{ID: int64(k.ids[i]), Size: sizes[i]})
}
if vis < len(k.ids) {
g := &group{parent: parent, ids: k.ids[vis:]}
for _, s := range k.sizes[vis:] {
g.size += s
}
gid := groupID(parent)
l.groups[gid] = g
items = append(items, treemap.Item{ID: gid, Size: float64(g.size)})
}
return treemap.Squarify(items, r)
}
func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rect, depth int) {
// Deeper levels need bigger blocks to stay legible rather than noisy.
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems)
for i, b := range blocks {
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
idx := len(l.nested)
l.nested = append(l.nested, nb)
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
l.nested[idx].inner = true
m.layoutNested(l, inventory.NodeID(b.ID), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1)
}
}
}
// groupInfo returns the LOD group for a group ID in the current layout.
func (m *Model) groupInfo(id int64) (*group, bool) {
if m.tm == nil {
return nil, false
}
g, ok := m.tm.groups[id]
return g, ok
}
type mapView struct{}
func (*mapView) name() string { return "Map" }
func (v *mapView) paint(m *Model, cv *Canvas, r treemap.Rect) {
t := m.theme
l := m.layoutTreemap(r)
if len(l.blocks) == 0 {
msg := "Empty directory"
switch {
case m.search.query != nil:
msg = "No matches here — Esc clears the filter"
case m.scanning:
msg = "Scanning…"
case !m.tree.Node(m.zoom).IsDir():
msg = textutil.Sanitize(m.tree.Node(m.zoom).Name) + " — " + textutil.Size(m.sizeOf(m.zoom))
}
cv.TextCentered(r.X, r.Y+r.H/2, r.W, msg, t.muted())
return
}
sel := m.visibleSel(l.visible)
total := m.sizeOf(m.zoom)
for _, b := range l.blocks {
m.paintTop(cv, b, b.ID == sel, b.ID == m.hover, total)
}
for _, nb := range l.nested {
m.paintNested(cv, nb)
}
for _, b := range l.blocks {
id := b.ID
m.hits = append(m.hits, hit{b.Rect, func(m *Model, double bool) tea.Cmd { return m.clickBlock(id, double) }})
}
}
// blockName returns a sanitised display name for a block.
func (m *Model) blockName(id int64) string {
if isGroup(id) {
if g, ok := m.groupInfo(id); ok {
return textutil.Count(int64(len(g.ids))) + " smaller items"
}
return "smaller items"
}
n := m.tree.Node(inventory.NodeID(id))
name := textutil.Sanitize(n.Name)
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() {
name += "/"
}
return name
}
func (m *Model) blockSize(id int64) int64 {
if isGroup(id) {
if g, ok := m.groupInfo(id); ok {
return g.size
}
return 0
}
return m.sizeOf(inventory.NodeID(id))
}
func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
t := m.theme
base := m.colorOf(b.ID)
fill := base.Mix(t.Bg, 0.62)
fillCh := " "
if isGroup(b.ID) {
fillCh = "░"
if t.ASCII {
fillCh = "."
}
}
name := m.blockName(b.ID)
size := m.blockSize(b.ID)
if b.W < 3 || b.H < 2 {
// Too small for a frame: a solid tile.
st := Style{FG: base, BG: base.Mix(t.Bg, 0.3)}
ch := fillCh
if sel {
st = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
ch = "◆"
if t.ASCII {
ch = "#"
}
} else if t.Mono {
ch = "▒"
}
cv.Fill(b.Rect, " ", st)
cv.Text(b.X+(b.W-1)/2, b.Y+(b.H-1)/2, ch, 1, st)
return
}
cv.Fill(b.Rect, fillCh, Style{FG: base.Mix(t.Bg, 0.35), BG: fill})
frame := Style{FG: base.Mix(t.Bg, 0.15), BG: fill}
box := t.box()
title := Style{FG: base.Mix(RGB(255, 255, 255), 0.35), BG: fill, Attr: Bold}
switch {
case sel:
box = t.selBox()
frame = Style{FG: t.Sel, BG: fill, Attr: Bold}
title = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
case hov:
frame.FG = base.Mix(RGB(255, 255, 255), 0.5)
}
if t.Mono && !sel {
title.Attr = Bold
}
cv.Box(b.Rect, box, frame)
cv.Text(b.X+1, b.Y, textutil.Truncate(" "+name+" ", b.W-2), b.W-2, title)
label := textutil.Size(size)
if pct := textutil.Percent(size, total); b.W-2 >= textutil.Width(label)+textutil.Width(pct)+5 {
label += " · " + pct
} else if b.W-2 < textutil.Width(label)+2 {
label = textutil.SizeCompact(size)
}
label = " " + label + " "
if b.H >= 3 && textutil.Width(label) <= b.W-2 {
cv.TextRight(b.X+1, b.Y+b.H-1, b.W-2, label, Style{FG: frame.FG, BG: fill, Attr: frame.Attr})
}
// Interior labels for blocks without nested previews.
in := b.Inset(1)
if in.Empty() || m.hasNested(b.ID) {
return
}
n := inventory.NoNode
if !isGroup(b.ID) {
n = inventory.NodeID(b.ID)
}
lines := []string{}
if n != inventory.NoNode {
nd := m.tree.Node(n)
if !nd.IsDir() {
lines = append(lines, kindLabel(m.tree, nd))
} else if nd.Files > 0 {
lines = append(lines, textutil.Count(int64(nd.Files))+" files")
}
}
if b.H < 4 || in.W < 6 {
return
}
y0 := in.Y + (in.H-len(lines))/2
for i, s := range lines {
cv.TextCentered(in.X, y0+i, in.W, s, Style{FG: base.Mix(t.Fg, 0.4), BG: fill})
}
}
func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id] }
func (m *Model) paintNested(cv *Canvas, nb nested) {
t := m.theme
base := m.colorOf(nb.id)
f := 0.38 - 0.1*float64(nb.depth-2)
if nb.alt {
f += 0.07
}
bg := base.Mix(t.Bg, f)
shadow := bg.Mix(RGB(0, 0, 0), 0.45)
st := Style{FG: t.textOn(bg), BG: bg}
ch := " "
if isGroup(nb.id) {
ch = "░"
if t.ASCII {
ch = "."
}
st.FG = bg.Mix(t.Fg, 0.25)
}
r := nb.r
cv.Fill(r, ch, st)
if t.Mono {
// No colour: draw a light outline so neighbours stay distinguishable.
if r.W >= 2 && r.H >= 2 {
cv.Box(r, BoxRound, Style{Attr: Faint})
}
} else {
if r.W >= 4 {
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, " ", Style{BG: shadow})
}
if r.H >= 3 {
cv.Fill(treemap.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, " ", Style{BG: shadow})
}
}
lw := r.W - 1
if t.Mono {
lw = r.W - 2
}
if lw < 5 {
return
}
lx := r.X
if t.Mono {
lx++
}
ly := r.Y
if t.Mono && r.H >= 3 {
ly++
}
name := m.blockName(nb.id)
if textutil.Width(name) > lw && lw < 8 {
return // a stub like "te…" is noise, not information
}
cv.Text(lx, ly, textutil.Truncate(name, lw), lw, Style{FG: st.FG, BG: bg, Attr: Bold})
if !nb.inner && r.H >= 3 && lw >= 5 {
cv.Text(lx, ly+1, textutil.Truncate(textutil.SizeCompact(m.blockSize(nb.id)), lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg})
}
}
func (v *mapView) key(m *Model, k string) (bool, tea.Cmd) {
dirs := map[string]treemap.Direction{"left": treemap.Left, "right": treemap.Right, "up": treemap.Up, "down": treemap.Down,
"h": treemap.Left, "l": treemap.Right, "k": treemap.Up, "j": treemap.Down}
if d, ok := dirs[k]; ok {
if l := m.tm; l != nil {
if id, ok := treemap.Neighbour(l.blocks, m.visibleSel(l.visible), d); ok {
m.sel = id
}
}
return true, nil
}
return false, nil
}
func (v *mapView) wheel(m *Model, up bool) tea.Cmd {
if up {
return m.zoomIntoSelection()
}
m.zoomOut()
return nil
}