Files
mapsize/internal/tui/render.go
T
Andy Dixon df8b10c6dd 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.
2026-10-02 08:25:35 +01:00

409 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 (
"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]})
}
}