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.
355 lines
9.4 KiB
Go
355 lines
9.4 KiB
Go
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
|
||
}
|