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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user