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.
200 lines
4.5 KiB
Go
200 lines
4.5 KiB
Go
package tui
|
|
|
|
import (
|
|
"sort"
|
|
|
|
"github.com/andydixon/mapsize/internal/inventory"
|
|
"github.com/andydixon/mapsize/internal/snapshot"
|
|
)
|
|
|
|
// kids is a cached, size-sorted child list (zero-size entries removed).
|
|
type kids struct {
|
|
ids []inventory.NodeID
|
|
sizes []int64
|
|
total int64
|
|
}
|
|
|
|
// sizeOf returns the size that drives layout: the filtered size when a
|
|
// filter is active, otherwise the aggregate in the current size mode.
|
|
// Callers hold the tree read lock.
|
|
func (m *Model) sizeOf(id inventory.NodeID) int64 {
|
|
if r := m.search.result; r != nil {
|
|
return r.Size(id)
|
|
}
|
|
n := m.tree.Node(id)
|
|
if n.Flags&inventory.FlagDeleted != 0 {
|
|
return 0
|
|
}
|
|
return n.Total(m.sizeMode)
|
|
}
|
|
|
|
// kidsOf returns the sorted children of id, cached until data changes.
|
|
func (m *Model) kidsOf(id inventory.NodeID) *kids {
|
|
if m.kidsVer != m.dataVer {
|
|
clear(m.kidsCache)
|
|
m.kidsVer = m.dataVer
|
|
}
|
|
if k, ok := m.kidsCache[id]; ok {
|
|
return k
|
|
}
|
|
k := &kids{}
|
|
m.tree.Children(id, func(c inventory.NodeID, _ *inventory.Node) {
|
|
if s := m.sizeOf(c); s > 0 {
|
|
k.ids = append(k.ids, c)
|
|
k.sizes = append(k.sizes, s)
|
|
k.total += s
|
|
}
|
|
})
|
|
sort.Sort(byKidSize{k})
|
|
m.kidsCache[id] = k
|
|
return k
|
|
}
|
|
|
|
type byKidSize struct{ *kids }
|
|
|
|
func (b byKidSize) Len() int { return len(b.ids) }
|
|
func (b byKidSize) Less(i, j int) bool {
|
|
if b.sizes[i] != b.sizes[j] {
|
|
return b.sizes[i] > b.sizes[j]
|
|
}
|
|
return b.ids[i] < b.ids[j]
|
|
}
|
|
func (b byKidSize) Swap(i, j int) {
|
|
b.ids[i], b.ids[j] = b.ids[j], b.ids[i]
|
|
b.sizes[i], b.sizes[j] = b.sizes[j], b.sizes[i]
|
|
}
|
|
|
|
// selectedNode returns the selected inventory node (NoNode for groups).
|
|
func (m *Model) selectedNode() inventory.NodeID {
|
|
if m.sel == noSel || isGroup(m.sel) || m.sel >= int64(m.tree.Len()) {
|
|
return inventory.NoNode
|
|
}
|
|
return inventory.NodeID(m.sel)
|
|
}
|
|
|
|
// ensureSelection keeps the selection meaningful after any change: if the
|
|
// selected node is no longer a direct, visible child of the zoom root it
|
|
// falls back to the visible ancestor, then the group containing it, then
|
|
// the largest child. Tree read lock held.
|
|
func (m *Model) ensureSelection() {
|
|
k := m.kidsOf(m.zoom)
|
|
if len(k.ids) == 0 {
|
|
m.sel = noSel
|
|
return
|
|
}
|
|
if !m.userSel {
|
|
// Until the user picks something, follow the largest block (it
|
|
// changes while a scan is running).
|
|
m.sel = int64(k.ids[0])
|
|
return
|
|
}
|
|
if isGroup(m.sel) {
|
|
if groupParent(m.sel) == m.zoom {
|
|
return
|
|
}
|
|
m.sel = noSel
|
|
}
|
|
id := m.selectedNode()
|
|
if id != inventory.NoNode {
|
|
// Walk up to the direct child of the zoom root.
|
|
for cur := id; cur != inventory.NoNode; cur = m.tree.Node(cur).Parent {
|
|
if m.tree.Node(cur).Parent == m.zoom {
|
|
if m.sizeOf(cur) > 0 {
|
|
m.sel = int64(cur)
|
|
return
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
m.sel = int64(k.ids[0])
|
|
}
|
|
|
|
// visibleSel maps the selection onto the treemap blocks: a node collapsed
|
|
// into the LOD group selects the group.
|
|
func (m *Model) visibleSel(blocks map[int64]bool) int64 {
|
|
if blocks[m.sel] {
|
|
return m.sel
|
|
}
|
|
if !isGroup(m.sel) && blocks[groupID(m.zoom)] {
|
|
return groupID(m.zoom)
|
|
}
|
|
return m.sel
|
|
}
|
|
|
|
// zoomTo makes dir the treemap root.
|
|
func (m *Model) zoomTo(dir inventory.NodeID) {
|
|
if dir == m.zoom {
|
|
return
|
|
}
|
|
prev := m.zoom
|
|
m.zoom = dir
|
|
// Coming back out, keep the directory we left selected.
|
|
if m.tree.IsAncestor(dir, prev) {
|
|
m.sel = int64(prev)
|
|
} else {
|
|
m.sel = noSel
|
|
}
|
|
m.userSel = m.sel != noSel
|
|
m.zoomed = true
|
|
m.dirty = true
|
|
}
|
|
|
|
func (m *Model) setView(i int) {
|
|
if i >= 0 && i < len(m.views) {
|
|
m.view = i
|
|
m.dirty = true
|
|
}
|
|
}
|
|
|
|
// colorOf returns the base colour for a block.
|
|
func (m *Model) colorOf(id int64) Color {
|
|
t := m.theme
|
|
if isGroup(id) {
|
|
return t.Group
|
|
}
|
|
nid := inventory.NodeID(id)
|
|
n := m.tree.Node(nid)
|
|
if d := m.opts.Diff; d != nil {
|
|
switch d.StatusOf(nid, m.sizeMode) {
|
|
case snapshot.Added:
|
|
return t.DiffNew
|
|
case snapshot.Grew:
|
|
old, _ := d.OldSize(nid, m.sizeMode)
|
|
return t.DiffSame.Mix(t.DiffGrow, ratio(n.Total(m.sizeMode)-old, old))
|
|
case snapshot.Shrank:
|
|
old, _ := d.OldSize(nid, m.sizeMode)
|
|
return t.DiffSame.Mix(t.DiffShrink, ratio(old-n.Total(m.sizeMode), old))
|
|
}
|
|
return t.DiffSame
|
|
}
|
|
if !n.IsDir() {
|
|
if t.Ext != nil {
|
|
if c, ok := t.Ext[m.tree.ExtName(n)]; ok {
|
|
return c
|
|
}
|
|
}
|
|
return t.Cat[n.Cat]
|
|
}
|
|
cs := m.tree.CatSizes(nid)
|
|
best, bestV := -1, int64(0)
|
|
for i, v := range cs {
|
|
if v > bestV {
|
|
best, bestV = i, v
|
|
}
|
|
}
|
|
if best < 0 {
|
|
return t.EmptyDir
|
|
}
|
|
return t.Cat[best]
|
|
}
|
|
|
|
// ratio maps a relative change to 0.35..1 for colour intensity.
|
|
func ratio(delta, base int64) float64 {
|
|
if base <= 0 {
|
|
return 1
|
|
}
|
|
r := float64(delta) / float64(base)
|
|
return min(1, 0.35+r*0.65)
|
|
}
|