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,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})
|
||||
}
|
||||
Reference in New Issue
Block a user