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,207 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// column describes one table column.
|
||||
type column struct {
|
||||
title string
|
||||
width int // 0 = flexible (takes the remaining space)
|
||||
right bool
|
||||
}
|
||||
|
||||
// tableRow is one rendered row: cell strings plus optional per-cell styles.
|
||||
type tableRow struct {
|
||||
cells []string
|
||||
styles []*Style // nil = default
|
||||
bar float64 // 0..1 for an optional bar column (-1 = none)
|
||||
barCol Color
|
||||
}
|
||||
|
||||
// table is a scrollable list with a cursor, shared by the list-style views.
|
||||
type table struct {
|
||||
cursor, offset int
|
||||
height int // rows visible in the last paint
|
||||
}
|
||||
|
||||
func (tb *table) move(d, n int) {
|
||||
if n == 0 {
|
||||
tb.cursor = 0
|
||||
return
|
||||
}
|
||||
tb.cursor = min(max(tb.cursor+d, 0), n-1)
|
||||
}
|
||||
|
||||
// key handles navigation keys common to all tables.
|
||||
func (tb *table) key(k string, n int) bool {
|
||||
page := max(tb.height-1, 1)
|
||||
switch k {
|
||||
case "up", "k":
|
||||
tb.move(-1, n)
|
||||
case "down", "j":
|
||||
tb.move(1, n)
|
||||
case "pgup", "ctrl+b":
|
||||
tb.move(-page, n)
|
||||
case "pgdown", "ctrl+f":
|
||||
tb.move(page, n)
|
||||
case "end", "G":
|
||||
tb.move(n, n)
|
||||
case "g":
|
||||
tb.cursor = 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows []tableRow, title string) {
|
||||
t := m.theme
|
||||
cv.Fill(r, " ", t.base())
|
||||
if r.H < 3 {
|
||||
return
|
||||
}
|
||||
y := r.Y
|
||||
if title != "" {
|
||||
cv.Text(r.X+1, y, title, r.W-2, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||
y++
|
||||
}
|
||||
// Resolve flexible width.
|
||||
fixed := 0
|
||||
for _, c := range cols {
|
||||
fixed += c.width + 1
|
||||
}
|
||||
flex := max(r.W-2-fixed, 8)
|
||||
x := r.X + 1
|
||||
hdr := Style{FG: t.Muted, BG: t.Bg, Attr: Bold}
|
||||
for _, c := range cols {
|
||||
w := c.width
|
||||
if w == 0 {
|
||||
w = flex
|
||||
}
|
||||
s := c.title
|
||||
if c.right {
|
||||
s = textutil.PadLeft(s, w)
|
||||
}
|
||||
cv.Text(x, y, s, w, hdr)
|
||||
x += w + 1
|
||||
}
|
||||
y++
|
||||
cv.HLine(r.X+1, y, r.W-2, "─", Style{FG: t.Faint, BG: t.Bg})
|
||||
y++
|
||||
tb.height = r.Y + r.H - y
|
||||
if tb.cursor >= len(rows) {
|
||||
tb.cursor = max(len(rows)-1, 0)
|
||||
}
|
||||
if tb.cursor < tb.offset {
|
||||
tb.offset = tb.cursor
|
||||
}
|
||||
if tb.cursor >= tb.offset+tb.height {
|
||||
tb.offset = tb.cursor - tb.height + 1
|
||||
}
|
||||
tb.offset = max(0, min(tb.offset, len(rows)-tb.height))
|
||||
for i := tb.offset; i < len(rows) && y < r.Y+r.H; i++ {
|
||||
row := rows[i]
|
||||
sel := i == tb.cursor
|
||||
base := t.base()
|
||||
if sel {
|
||||
base = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||
cv.Fill(treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, " ", base)
|
||||
}
|
||||
x := r.X + 1
|
||||
for ci, c := range cols {
|
||||
w := c.width
|
||||
if w == 0 {
|
||||
w = flex
|
||||
}
|
||||
s := ""
|
||||
if ci < len(row.cells) {
|
||||
s = row.cells[ci]
|
||||
}
|
||||
st := base
|
||||
if !sel && ci < len(row.styles) && row.styles[ci] != nil {
|
||||
st = *row.styles[ci]
|
||||
}
|
||||
if c.title == "" && row.bar >= 0 && c.width > 0 {
|
||||
m.paintBar(cv, x, y, w, row.bar, row.barCol, sel)
|
||||
} else if c.right {
|
||||
cv.Text(x, y, textutil.PadLeft(s, w), w, st)
|
||||
} else {
|
||||
cv.Text(x, y, textutil.Truncate(s, w), w, st)
|
||||
}
|
||||
x += w + 1
|
||||
}
|
||||
idx := i
|
||||
m.hits = append(m.hits, hit{treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, func(m *Model, double bool) tea.Cmd {
|
||||
tb.cursor = idx
|
||||
if double {
|
||||
return m.handleKeyName("enter")
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
y++
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Nothing to show", t.muted())
|
||||
}
|
||||
if len(rows) > tb.height && tb.height > 0 {
|
||||
pos := textutil.Count(int64(tb.cursor+1)) + "/" + textutil.Count(int64(len(rows)))
|
||||
cv.TextRight(r.X, r.Y, r.W-1, pos, t.muted())
|
||||
}
|
||||
}
|
||||
|
||||
// paintBar draws a proportional bar with eighth-block precision.
|
||||
func (m *Model) paintBar(cv *Canvas, x, y, w int, frac float64, col Color, sel bool) {
|
||||
t := m.theme
|
||||
if col == 0 {
|
||||
col = t.Accent
|
||||
}
|
||||
bg := t.Bg.Mix(t.Fg, 0.08)
|
||||
if sel {
|
||||
bg = t.Sel.Mix(RGB(0, 0, 0), 0.25)
|
||||
}
|
||||
eighths := int(frac*float64(w*8) + 0.5)
|
||||
parts := []string{"", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}
|
||||
for i := 0; i < w; i++ {
|
||||
ch := " "
|
||||
switch {
|
||||
case eighths >= 8:
|
||||
ch = "█"
|
||||
case eighths > 0:
|
||||
ch = parts[eighths]
|
||||
}
|
||||
if t.ASCII {
|
||||
ch = map[bool]string{true: "#", false: " "}[eighths >= 4]
|
||||
}
|
||||
eighths -= 8
|
||||
st := Style{FG: col, BG: bg}
|
||||
if t.Mono {
|
||||
st = Style{}
|
||||
}
|
||||
cv.Text(x+i, y, ch, 1, st)
|
||||
}
|
||||
}
|
||||
|
||||
// handleKeyName lets mouse actions reuse key handling (lock already held).
|
||||
func (m *Model) handleKeyName(k string) tea.Cmd {
|
||||
if handled, cmd := m.views[m.view].key(m, k); handled {
|
||||
return cmd
|
||||
}
|
||||
switch k {
|
||||
case "enter":
|
||||
return m.inspectSelection()
|
||||
case "space":
|
||||
return m.zoomIntoSelection()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plural(n int, s string) string {
|
||||
if n == 1 {
|
||||
return "1 " + s
|
||||
}
|
||||
return textutil.Count(int64(n)) + " " + s + "s"
|
||||
}
|
||||
Reference in New Issue
Block a user