Files
mapsize/internal/tui/mapview.go
T
Andy Dixon 352d4292df mapsize 1.1.0: nested treemap reaches deep folders
The map stopped nesting after a fixed 3 levels, so at / a chain like
mnt/raid/media used the whole budget on full-size wrappers and showed
empty boxes with only titles. Single-child directory chains now collapse
into one label, nesting depth is bounded by block size instead of a fixed
cap, and nested blocks show size and percentage of their parent.
2026-10-03 10:18:57 +01:00

392 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = 8 // block-size thresholds stop recursion long before this
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, m.chainEnd(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), 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, m.chainEnd(inventory.NodeID(b.ID)), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1)
}
}
}
// chainEnd follows directories whose only non-empty child is a directory
// (mnt → raid → media), so nesting spends its space on the level that
// actually branches instead of on full-size wrappers.
func (m *Model) chainEnd(id inventory.NodeID) inventory.NodeID {
for {
k := m.kidsOf(id)
if len(k.ids) != 1 || !m.tree.Node(k.ids[0]).IsDir() {
return id
}
id = k.ids[0]
}
}
// 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"
}
nid := inventory.NodeID(id)
n := m.tree.Node(nid)
name := textutil.Sanitize(n.Name)
if n.IsDir() && nid != m.tree.Root() {
name += "/"
for end := m.chainEnd(nid); nid != end; {
nid = m.kidsOf(nid).ids[0]
name += textutil.Sanitize(m.tree.Node(nid).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))
}
// parentSize is the size of the folder a block sits in.
func (m *Model) parentSize(id int64) int64 {
if isGroup(id) {
if g, ok := m.groupInfo(id); ok {
return m.sizeOf(g.parent)
}
return 0
}
if p := m.tree.Node(inventory.NodeID(id)).Parent; p != inventory.NoNode {
return m.sizeOf(p)
}
return 0
}
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 := max(0.38-0.1*float64(nb.depth-2), 0.1)
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 {
size := m.blockSize(nb.id)
label := textutil.SizeCompact(size)
if pct := textutil.Percent(size, m.parentSize(nb.id)); textutil.Width(label)+textutil.Width(pct)+3 <= lw {
label += " · " + pct
}
cv.Text(lx, ly+1, textutil.Truncate(label, 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
}