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:
Andy Dixon
2026-10-02 08:25:35 +01:00
parent e8c831cb47
commit df8b10c6dd
35 changed files with 5294 additions and 25 deletions
+267
View File
@@ -0,0 +1,267 @@
package tui
import (
"context"
"fmt"
"time"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/filter"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// lineEditor is a minimal single-line text editor.
type lineEditor struct {
buf []rune
cur int
}
func (e *lineEditor) String() string { return string(e.buf) }
func (e *lineEditor) set(s string) { e.buf, e.cur = []rune(s), len([]rune(s)) }
// key applies an editing key; it reports whether the text changed.
func (e *lineEditor) key(msg tea.KeyPressMsg) (changed, handled bool) {
switch msg.Keystroke() {
case "left", "ctrl+b":
e.cur = max(0, e.cur-1)
case "right", "ctrl+f":
e.cur = min(len(e.buf), e.cur+1)
case "home", "ctrl+a":
e.cur = 0
case "end", "ctrl+e":
e.cur = len(e.buf)
case "backspace", "ctrl+h":
if e.cur > 0 {
e.buf = append(e.buf[:e.cur-1], e.buf[e.cur:]...)
e.cur--
return true, true
}
case "delete", "ctrl+d":
if e.cur < len(e.buf) {
e.buf = append(e.buf[:e.cur], e.buf[e.cur+1:]...)
return true, true
}
case "ctrl+u":
e.buf, e.cur = e.buf[e.cur:], 0
return true, true
case "ctrl+k":
e.buf = e.buf[:e.cur]
return true, true
case "ctrl+w", "alt+backspace":
i := e.cur
for i > 0 && e.buf[i-1] == ' ' {
i--
}
for i > 0 && e.buf[i-1] != ' ' {
i--
}
e.buf = append(e.buf[:i], e.buf[e.cur:]...)
e.cur = i
return true, true
default:
txt := msg.Text
if txt == "" || msg.Mod&(tea.ModCtrl|tea.ModAlt) != 0 {
return false, false
}
var rs []rune
for _, r := range txt {
if r >= 0x20 && r != 0x7f {
rs = append(rs, r)
}
}
if len(rs) == 0 || len(e.buf)+len(rs) > 1024 {
return false, true
}
e.buf = append(e.buf[:e.cur], append(rs, e.buf[e.cur:]...)...)
e.cur += len(rs)
return true, true
}
return false, true
}
type searchState struct {
editing bool
ed lineEditor
query *filter.Query
result *filter.Result
err string
gen int
busy bool
appliedAt time.Time
cancel context.CancelFunc
}
func (s *searchState) begin() {
s.editing = true
if s.query != nil {
s.ed.set(s.query.String())
}
}
func (m *Model) searchKey(msg tea.KeyPressMsg) tea.Cmd {
s := &m.search
switch msg.Keystroke() {
case "esc":
s.editing = false
m.clearFilter()
return nil
case "enter":
s.editing = false
if s.err != "" {
return m.warn("Invalid query: " + s.err)
}
return nil
}
changed, _ := s.ed.key(msg)
if changed {
return m.queryChanged()
}
return nil
}
// queryChanged parses the editor contents and schedules a debounced apply.
func (m *Model) queryChanged() tea.Cmd {
s := &m.search
s.gen++
text := s.ed.String()
if text == "" {
s.err = ""
m.dropFilter()
return nil
}
q, err := filter.Parse(text, time.Now())
if err != nil {
s.err = err.Error()
return nil
}
s.err = ""
s.query = q
gen := s.gen
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return filterDebounceMsg{gen} })
}
// setFilter replaces the filter with q and applies it immediately.
func (m *Model) setFilter(q string) tea.Cmd {
m.search.ed.set(q)
m.search.editing = false
m.queryChanged()
if m.search.err != "" {
return m.warn("Invalid query: " + m.search.err)
}
return m.applyFilter()
}
// applyFilter evaluates the current query in the background. The tree is
// read-locked by the worker; stale results are discarded by generation.
func (m *Model) applyFilter() tea.Cmd {
s := &m.search
if s.query == nil {
return nil
}
if s.cancel != nil {
s.cancel()
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.busy = true
s.appliedAt = time.Now()
t, q, mode, gen := m.tree, s.query, m.sizeMode, s.gen
return func() tea.Msg {
t.RLock()
res, err := filter.Apply(ctx, t, q, mode)
t.RUnlock()
return filterMsg{gen: gen, res: res, err: err}
}
}
func (m *Model) filterResult(msg filterMsg) tea.Cmd {
s := &m.search
if msg.gen != s.gen || s.query == nil {
return nil
}
s.busy = false
if msg.err != nil {
return nil
}
s.result = msg.res
m.invalidate()
return nil
}
// dropFilter removes the active filter but keeps the editor open.
func (m *Model) dropFilter() {
s := &m.search
if s.cancel != nil {
s.cancel()
}
s.query, s.result, s.busy = nil, nil, false
m.invalidate()
}
func (m *Model) clearFilter() {
m.dropFilter()
m.search.ed.set("")
m.search.err = ""
m.search.gen++
}
func (m *Model) paintSearch(cv *Canvas, r treemap.Rect) {
t := m.theme
s := &m.search
st := Style{FG: t.Fg, BG: t.PanelBg}
cv.Fill(r, " ", st)
x := r.X + 1
x += cv.Text(x, r.Y, "/ ", 2, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
status, statusSt := "", Style{FG: t.Muted, BG: t.PanelBg}
switch {
case s.err != "":
status, statusSt.FG = "✗ "+s.err, t.Err
case s.busy:
status = "filtering…"
case s.result != nil:
status = fmt.Sprintf("%s matches · %s", textutil.Count(s.result.Count), textutil.Size(s.result.Total))
case len(s.ed.buf) == 0:
status = "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear"
}
sw := min(textutil.Width(status), r.W/2)
avail := r.W - (x - r.X) - sw - 3
// Scroll the text so the cursor stays visible.
text := s.ed.buf
start := 0
if s.ed.cur > avail-1 {
start = s.ed.cur - avail + 1
}
vis := string(text[start:min(len(text), start+avail)])
cv.Text(x, r.Y, textutil.Sanitize(vis), avail, st)
cx := x + textutil.Width(textutil.Sanitize(string(text[start:s.ed.cur])))
under := " "
if s.ed.cur < len(text) {
under = textutil.Sanitize(string(text[s.ed.cur]))
}
cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: Reverse * Attr(boolInt(t.Mono))})
cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt)
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func (m *Model) paintFilterSummary(cv *Canvas, r treemap.Rect) {
t := m.theme
s := &m.search
x := r.X + 1
x += cv.Text(x, r.Y, " FILTER ", 8, Style{FG: t.textOn(t.Warn), BG: t.Warn, Attr: Bold | m.monoRev()})
x += cv.Text(x+1, r.Y, textutil.Sanitize(s.query.String()), r.W/2, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}) + 1
info := "filtering…"
if s.result != nil && !s.busy {
info = fmt.Sprintf("%s matches · %s of %s · / edit · Esc clear", textutil.Count(s.result.Count),
textutil.Size(s.result.Total), textutil.Size(m.tree.Node(0).Total(m.sizeMode)))
}
cv.Text(x+2, r.Y, info, r.X+r.W-x-3, Style{FG: t.Muted, BG: t.PanelBg})
}