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,408 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/brand"
|
||||
"github.com/andydixon/mapsize/internal/inventory"
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// Minimum usable terminal size.
|
||||
const minW, minH = 40, 10
|
||||
|
||||
type sizeClass int
|
||||
|
||||
const (
|
||||
tooSmall sizeClass = iota
|
||||
small
|
||||
medium
|
||||
large
|
||||
)
|
||||
|
||||
// geometry is the panel layout for one terminal size. It is recomputed from
|
||||
// scratch for every frame, so resizing can never leave stale geometry.
|
||||
type geometry struct {
|
||||
class sizeClass
|
||||
tabs, crumbs treemap.Rect // tabs is empty in the small layout
|
||||
main, side, info, stat treemap.Rect
|
||||
}
|
||||
|
||||
func computeGeometry(w, h int) geometry {
|
||||
g := geometry{}
|
||||
switch {
|
||||
case w < minW || h < minH:
|
||||
g.class = tooSmall
|
||||
return g
|
||||
case w >= 150 && h >= 36:
|
||||
g.class = large
|
||||
case w >= 90 && h >= 22:
|
||||
g.class = medium
|
||||
default:
|
||||
g.class = small
|
||||
}
|
||||
y := 0
|
||||
if g.class >= medium {
|
||||
g.tabs = treemap.Rect{X: 0, Y: 0, W: w, H: 1}
|
||||
y = 1
|
||||
}
|
||||
g.crumbs = treemap.Rect{X: 0, Y: y, W: w, H: 1}
|
||||
y++
|
||||
bottom := h - 1
|
||||
g.stat = treemap.Rect{X: 0, Y: h - 1, W: w, H: 1}
|
||||
if g.class >= medium {
|
||||
g.info = treemap.Rect{X: 0, Y: h - 2, W: w, H: 1}
|
||||
bottom = h - 2
|
||||
}
|
||||
g.main = treemap.Rect{X: 0, Y: y, W: w, H: bottom - y}
|
||||
if g.class == large {
|
||||
sw := min(40, w/5)
|
||||
g.side = treemap.Rect{X: 0, Y: y, W: sw, H: g.main.H}
|
||||
g.main.X, g.main.W = sw, w-sw
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func (m *Model) render() string {
|
||||
t := m.theme
|
||||
cv := NewCanvas(m.w, m.h, t.base())
|
||||
m.hits = m.hits[:0]
|
||||
g := computeGeometry(m.w, m.h)
|
||||
if g.class == tooSmall {
|
||||
m.renderTooSmall(cv)
|
||||
return cv.Render(m.profile == colorprofile.ANSI256)
|
||||
}
|
||||
m.tree.RLock()
|
||||
defer m.tree.RUnlock()
|
||||
|
||||
m.ensureSelection()
|
||||
if g.tabs.W > 0 {
|
||||
m.paintTabs(cv, g.tabs)
|
||||
}
|
||||
m.paintCrumbs(cv, g.crumbs)
|
||||
if !g.side.Empty() {
|
||||
m.paintSide(cv, g.side)
|
||||
}
|
||||
m.views[m.view].paint(m, cv, g.main)
|
||||
if !g.info.Empty() {
|
||||
m.paintInfoLine(cv, g.info)
|
||||
}
|
||||
m.paintStatus(cv, g.stat, g.class)
|
||||
if g.class == small && m.search.editing {
|
||||
m.paintSearch(cv, g.stat)
|
||||
}
|
||||
if len(m.modals) > 0 {
|
||||
m.paintModal(cv)
|
||||
}
|
||||
return cv.Render(m.profile == colorprofile.ANSI256)
|
||||
}
|
||||
|
||||
func (m *Model) renderTooSmall(cv *Canvas) {
|
||||
st := m.theme.muted()
|
||||
lines := []string{"Terminal too small", fmt.Sprintf("%d×%d — need %d×%d", m.w, m.h, minW, minH), "q quits"}
|
||||
y0 := max(0, (m.h-len(lines))/2)
|
||||
for i, l := range lines {
|
||||
cv.TextCentered(0, y0+i, m.w, l, st)
|
||||
}
|
||||
}
|
||||
|
||||
// paintTabs draws the view tabs and mode chips.
|
||||
func (m *Model) paintTabs(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.header())
|
||||
badge := " ◧ " + brand.Name + " "
|
||||
if t.ASCII {
|
||||
badge = " " + brand.Name + " "
|
||||
}
|
||||
x := cv.Text(r.X, r.Y, badge, r.W, Style{FG: t.textOn(t.Accent), BG: t.Accent, Attr: Bold | m.monoRev()})
|
||||
x++
|
||||
for i, v := range m.views {
|
||||
label := " " + v.name() + " "
|
||||
st := Style{FG: t.Muted, BG: t.HeaderBg}
|
||||
if i == m.view {
|
||||
st = Style{FG: t.Fg, BG: t.Bg, Attr: Bold | Underline}
|
||||
}
|
||||
if x+textutil.Width(label) >= r.W-30 {
|
||||
break
|
||||
}
|
||||
w := cv.Text(x, r.Y, label, r.W-x, st)
|
||||
idx := i
|
||||
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.setView(idx); return nil }})
|
||||
x += w
|
||||
}
|
||||
// Right-aligned chips.
|
||||
var chips []struct {
|
||||
s string
|
||||
bg Color
|
||||
}
|
||||
add := func(s string, bg Color) {
|
||||
chips = append(chips, struct {
|
||||
s string
|
||||
bg Color
|
||||
}{s, bg})
|
||||
}
|
||||
if m.opts.ReadOnly {
|
||||
add("READ ONLY", t.Err)
|
||||
}
|
||||
if m.snapshot {
|
||||
add("SNAPSHOT", t.Accent)
|
||||
}
|
||||
if m.opts.Diff != nil {
|
||||
add("COMPARE", t.DiffNew)
|
||||
}
|
||||
if m.search.query != nil {
|
||||
add("FILTER", t.Warn)
|
||||
}
|
||||
if len(m.tree.Stats.Excludes) > 0 {
|
||||
add(fmt.Sprintf("EXCL %d", len(m.tree.Stats.Excludes)), t.Faint)
|
||||
}
|
||||
add(strings.ToUpper(m.sizeMode.String()), t.Faint)
|
||||
rx := r.X + r.W
|
||||
for i := len(chips) - 1; i >= 0; i-- {
|
||||
s := " " + chips[i].s + " "
|
||||
w := textutil.Width(s)
|
||||
if rx-w-1 < x {
|
||||
break
|
||||
}
|
||||
rx -= w
|
||||
cv.Text(rx, r.Y, s, w, Style{FG: t.textOn(chips[i].bg), BG: chips[i].bg, Attr: Bold | m.monoRev()})
|
||||
rx--
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) monoRev() Attr {
|
||||
if m.theme.Mono {
|
||||
return Reverse
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// paintCrumbs draws "/ › home › andy" breadcrumbs, truncated from the left,
|
||||
// with the zoom root's size on the right.
|
||||
func (m *Model) paintCrumbs(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.header())
|
||||
tr := m.tree
|
||||
z := tr.Node(m.zoom)
|
||||
size := m.sizeOf(m.zoom)
|
||||
right := " " + textutil.Size(size)
|
||||
if root := m.sizeOf(tr.Root()); m.zoom != tr.Root() && root > 0 {
|
||||
right += " " + textutil.Percent(size, root) + " of scan"
|
||||
}
|
||||
if m.search.result != nil {
|
||||
right += fmt.Sprintf(" (%s matches)", textutil.Count(m.search.result.Count))
|
||||
}
|
||||
if z.IsDir() {
|
||||
right += fmt.Sprintf(" %s files", textutil.Count(int64(z.Files)))
|
||||
}
|
||||
right += " "
|
||||
rw := textutil.Width(right)
|
||||
if rw > r.W/2 {
|
||||
right = " " + textutil.SizeCompact(size) + " "
|
||||
rw = textutil.Width(right)
|
||||
}
|
||||
cv.TextRight(r.X, r.Y, r.W, right, Style{FG: t.HeaderFg, BG: t.HeaderBg, Attr: Bold})
|
||||
|
||||
sep := " › "
|
||||
if t.ASCII {
|
||||
sep = " > "
|
||||
}
|
||||
chain := tr.Ancestors(m.zoom)
|
||||
type seg struct {
|
||||
id inventory.NodeID
|
||||
s string
|
||||
}
|
||||
segs := make([]seg, len(chain))
|
||||
for i, id := range chain {
|
||||
segs[i] = seg{id, textutil.Sanitize(tr.Node(id).Name)}
|
||||
}
|
||||
avail := r.W - rw - 2
|
||||
// Drop leading segments until the rest fits; keep at least the last.
|
||||
total := func(from int) int {
|
||||
w := 0
|
||||
for i := from; i < len(segs); i++ {
|
||||
w += textutil.Width(segs[i].s)
|
||||
if i > from {
|
||||
w += textutil.Width(sep)
|
||||
}
|
||||
}
|
||||
if from > 0 {
|
||||
w += textutil.Width("…" + sep)
|
||||
}
|
||||
return w
|
||||
}
|
||||
from := 0
|
||||
for from < len(segs)-1 && total(from) > avail {
|
||||
from++
|
||||
}
|
||||
x := r.X + 1
|
||||
if from > 0 {
|
||||
x += cv.Text(x, r.Y, "…"+sep, avail, Style{FG: t.Muted, BG: t.HeaderBg})
|
||||
}
|
||||
for i := from; i < len(segs); i++ {
|
||||
if i > from {
|
||||
x += cv.Text(x, r.Y, sep, r.X+1+avail-x, Style{FG: t.Faint, BG: t.HeaderBg})
|
||||
}
|
||||
st := Style{FG: t.Muted, BG: t.HeaderBg}
|
||||
if i == len(segs)-1 {
|
||||
st = Style{FG: t.Fg, BG: t.HeaderBg, Attr: Bold}
|
||||
}
|
||||
s := segs[i].s
|
||||
if i == len(segs)-1 {
|
||||
s = textutil.TruncateLeft(s, max(1, r.X+1+avail-x))
|
||||
}
|
||||
w := cv.Text(x, r.Y, s, r.X+1+avail-x, st)
|
||||
id := segs[i].id
|
||||
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.zoomTo(id); return nil }})
|
||||
x += w
|
||||
}
|
||||
}
|
||||
|
||||
// paintInfoLine describes the selection (or shows the search editor).
|
||||
func (m *Model) paintInfoLine(cv *Canvas, r treemap.Rect) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.panel())
|
||||
if m.search.editing {
|
||||
m.paintSearch(cv, r)
|
||||
return
|
||||
}
|
||||
if m.search.query != nil {
|
||||
m.paintFilterSummary(cv, r)
|
||||
return
|
||||
}
|
||||
cv.Text(r.X+1, r.Y, m.describeSelection(), r.W-2, t.panel())
|
||||
}
|
||||
|
||||
func (m *Model) describeSelection() string {
|
||||
id := m.selectedNode()
|
||||
tr := m.tree
|
||||
if isGroup(m.sel) {
|
||||
if g, ok := m.groupInfo(m.sel); ok {
|
||||
return fmt.Sprintf("▸ %s smaller items · %s · %s of %s · Enter lists them",
|
||||
textutil.Count(int64(len(g.ids))), textutil.Size(g.size), textutil.Percent(g.size, m.sizeOf(m.zoom)),
|
||||
textutil.Sanitize(tr.Node(m.zoom).Name))
|
||||
}
|
||||
}
|
||||
if id == inventory.NoNode {
|
||||
return "Nothing selected"
|
||||
}
|
||||
n := tr.Node(id)
|
||||
parts := []string{"▸ " + textutil.Sanitize(n.Name)}
|
||||
parts = append(parts, textutil.Size(m.sizeOf(id)))
|
||||
if p := n.Parent; p != inventory.NoNode {
|
||||
parts = append(parts, textutil.Percent(m.sizeOf(id), m.sizeOf(p))+" of parent")
|
||||
}
|
||||
if n.IsDir() {
|
||||
parts = append(parts, textutil.Count(int64(n.Files))+" files", textutil.Count(int64(n.Dirs))+" dirs")
|
||||
if n.Flags&inventory.FlagScanned == 0 && m.scanning {
|
||||
parts = append(parts, "scanning…")
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, kindLabel(tr, n))
|
||||
}
|
||||
if n.MTime > 0 {
|
||||
parts = append(parts, "modified "+time.Unix(0, n.MTime).Format("2006-01-02"))
|
||||
}
|
||||
if n.Errors > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d errors", n.Errors))
|
||||
}
|
||||
if m.opts.Diff != nil {
|
||||
parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" since "+"snapshot")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func kindLabel(tr *inventory.Tree, n *inventory.Node) string {
|
||||
switch n.Kind {
|
||||
case inventory.KindFile:
|
||||
return n.Cat.String()
|
||||
case inventory.KindSymlink:
|
||||
return "symlink"
|
||||
}
|
||||
return n.Kind.String()
|
||||
}
|
||||
|
||||
var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// paintStatus draws the live status bar.
|
||||
func (m *Model) paintStatus(cv *Canvas, r treemap.Rect, class sizeClass) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.status())
|
||||
help := " ? help "
|
||||
if class == small {
|
||||
help = " ? "
|
||||
}
|
||||
hw := cv.Text(r.X+r.W-textutil.Width(help), r.Y, help, r.W, Style{FG: t.Muted, BG: t.StatusBg})
|
||||
avail := r.W - hw - 1
|
||||
if m.toast != "" {
|
||||
cv.Text(r.X+1, r.Y, m.toast, avail-1, m.toastStyle)
|
||||
return
|
||||
}
|
||||
st := &m.tree.Stats
|
||||
root := m.tree.Node(m.tree.Root())
|
||||
files := st.Files + st.Symlinks + st.Others
|
||||
var parts []struct {
|
||||
s string
|
||||
fg Color
|
||||
}
|
||||
add := func(s string, fg Color) {
|
||||
parts = append(parts, struct {
|
||||
s string
|
||||
fg Color
|
||||
}{s, fg})
|
||||
}
|
||||
switch {
|
||||
case m.scanning:
|
||||
frame := spinner[int(time.Now().UnixMilli()/100)%len(spinner)]
|
||||
if t.ASCII {
|
||||
frame = "*"
|
||||
}
|
||||
add(frame+" scanning", t.Accent)
|
||||
case st.Cancelled:
|
||||
add("✗ cancelled", t.Warn)
|
||||
case m.snapshot:
|
||||
add("◉ snapshot", t.Accent)
|
||||
default:
|
||||
add("✓ complete", t.OK)
|
||||
}
|
||||
add(textutil.Count(files)+" files", t.StatusFg)
|
||||
add(textutil.Size(root.Total(m.sizeMode)), t.StatusFg)
|
||||
if m.scanning {
|
||||
el := st.Elapsed()
|
||||
p := m.scanner.Progress()
|
||||
add(textutil.Count(int64(float64(p.Entries)/max(el.Seconds(), 0.001)))+"/s", t.StatusFg)
|
||||
if class != small {
|
||||
add(fmt.Sprintf("%d workers", p.Workers), t.Muted)
|
||||
add(fmt.Sprintf("queue %s", textutil.Count(p.Pending)), t.Muted)
|
||||
}
|
||||
add(textutil.Duration(el), t.Muted)
|
||||
} else if !m.snapshot && class != small {
|
||||
add(textutil.Duration(st.Elapsed()), t.Muted)
|
||||
}
|
||||
if e := st.TotalErrors(); e > 0 {
|
||||
add(fmt.Sprintf("%s errors", textutil.Count(e)), t.Err)
|
||||
}
|
||||
if st.Excluded > 0 {
|
||||
add(fmt.Sprintf("%s excluded", textutil.Count(st.Excluded)), t.Warn)
|
||||
}
|
||||
if st.Incomplete() && !m.scanning && class != small {
|
||||
add("totals incomplete", t.Warn)
|
||||
}
|
||||
x := r.X + 1
|
||||
for i, p := range parts {
|
||||
s := p.s
|
||||
if i > 0 {
|
||||
s = " " + s
|
||||
}
|
||||
if x+textutil.Width(s) > r.X+avail {
|
||||
break
|
||||
}
|
||||
x += cv.Text(x, r.Y, s, avail-x, Style{FG: p.fg, BG: t.StatusBg, Attr: map[bool]Attr{true: Bold}[i == 0]})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user