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
+303
View File
@@ -0,0 +1,303 @@
package tui
import (
"strconv"
"strings"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// Color is 0 for the terminal default, otherwise 0x01RRGGBB.
type Color uint32
// RGB builds a colour.
func RGB(r, g, b uint8) Color { return Color(1<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)) }
// Hex parses "#rrggbb".
func Hex(s string) (Color, bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return Color(1<<24 | uint32(v)), true
}
func (c Color) rgb() (r, g, b float64) {
return float64(c>>16&0xff) / 255, float64(c>>8&0xff) / 255, float64(c&0xff) / 255
}
// Mix blends c towards o by t (0..1). Default colours are returned as-is.
func (c Color) Mix(o Color, t float64) Color {
if c == 0 || o == 0 {
return c
}
r1, g1, b1 := c.rgb()
r2, g2, b2 := o.rgb()
f := func(a, b float64) uint8 { return uint8((a + (b-a)*t) * 255) }
return RGB(f(r1, r2), f(g1, g2), f(b1, b2))
}
// Luma returns perceived brightness 0..1.
func (c Color) Luma() float64 {
r, g, b := c.rgb()
return 0.299*r + 0.587*g + 0.114*b
}
// Attr is a text attribute bit set.
type Attr uint8
const (
Bold Attr = 1 << iota
Faint
Italic
Underline
Reverse
)
// Style is a cell style.
type Style struct {
FG, BG Color
Attr Attr
}
type cell struct {
s string // "" marks the continuation of a wide cluster
st Style
}
// Canvas is an in-memory cell grid that every view paints into. It is
// serialised once per frame.
type Canvas struct {
W, H int
c []cell
}
// NewCanvas creates a canvas filled with spaces in style st.
func NewCanvas(w, h int, st Style) *Canvas {
cv := &Canvas{W: max(w, 0), H: max(h, 0)}
cv.c = make([]cell, cv.W*cv.H)
for i := range cv.c {
cv.c[i] = cell{" ", st}
}
return cv
}
func (cv *Canvas) in(x, y int) bool { return x >= 0 && y >= 0 && x < cv.W && y < cv.H }
// put writes one cluster of width w at (x, y), repairing any wide cluster it
// overlaps so the grid never holds half a character.
func (cv *Canvas) put(x, y int, s string, w int, st Style) {
if !cv.in(x, y) || x+w > cv.W {
return
}
row := y * cv.W
for i := x; i < x+w; i++ {
c := &cv.c[row+i]
if c.s == "" && i > 0 && i == x { // overwriting the tail of a wide cluster
cv.c[row+i-1].s = " "
}
if c.s != "" && textutil.Width(c.s) > 1 && i+1 < cv.W { // overwriting its head
if n := &cv.c[row+i+1]; n.s == "" {
n.s = " "
}
}
}
cv.c[row+x] = cell{s, st}
for i := 1; i < w; i++ {
cv.c[row+x+i] = cell{"", st}
}
}
// Text draws s at (x, y), clipped to maxW cells and the canvas. Control
// characters are replaced as a second line of defence (callers sanitise
// untrusted strings first). It returns the number of cells used.
func (cv *Canvas) Text(x, y int, s string, maxW int, st Style) int {
if y < 0 || y >= cv.H {
return 0
}
limit := min(x+maxW, cv.W)
cx := x
textutil.Clusters(s, func(c string, w int) bool {
if r := c[0]; r < 0x20 || r == 0x7f || (len(c) > 1 && c[0] == 0xc2 && c[1] < 0xa0) {
c, w = "?", 1
}
if cx+w > limit {
// A wide cluster that does not fit leaves a blank cell.
for ; cx < limit; cx++ {
if cx >= 0 {
cv.put(cx, y, " ", 1, st)
}
}
return false
}
if cx >= 0 {
cv.put(cx, y, c, w, st)
}
cx += w
return true
})
return cx - x
}
// TextCentered draws s centred within [x, x+w).
func (cv *Canvas) TextCentered(x, y, w int, s string, st Style) {
s = textutil.Truncate(s, w)
cv.Text(x+(w-textutil.Width(s))/2, y, s, w, st)
}
// TextRight draws s right-aligned ending at x+w.
func (cv *Canvas) TextRight(x, y, w int, s string, st Style) {
s = textutil.Truncate(s, w)
cv.Text(x+w-textutil.Width(s), y, s, w, st)
}
// Fill fills r with the cluster ch.
func (cv *Canvas) Fill(r treemap.Rect, ch string, st Style) {
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
cv.put(x, y, ch, 1, st)
}
}
}
// Restyle applies fn to the style of every cell in r.
func (cv *Canvas) Restyle(r treemap.Rect, fn func(Style) Style) {
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
c := &cv.c[y*cv.W+x]
c.st = fn(c.st)
}
}
}
// Box glyph sets.
type BoxChars struct{ H, V, TL, TR, BL, BR string }
var (
BoxLight = BoxChars{"─", "│", "┌", "┐", "└", "┘"}
BoxRound = BoxChars{"─", "│", "╭", "╮", "╰", "╯"}
BoxDouble = BoxChars{"═", "║", "╔", "╗", "╚", "╝"}
BoxHeavy = BoxChars{"━", "┃", "┏", "┓", "┗", "┛"}
BoxASCII = BoxChars{"-", "|", "+", "+", "+", "+"}
BoxASCIISel = BoxChars{"=", "#", "#", "#", "#", "#"}
)
// Box draws a border around r (which must be at least 2×2).
func (cv *Canvas) Box(r treemap.Rect, b BoxChars, st Style) {
if r.W < 2 || r.H < 2 {
return
}
x1, y1 := r.X+r.W-1, r.Y+r.H-1
for x := r.X + 1; x < x1; x++ {
cv.put(x, r.Y, b.H, 1, st)
cv.put(x, y1, b.H, 1, st)
}
for y := r.Y + 1; y < y1; y++ {
cv.put(r.X, y, b.V, 1, st)
cv.put(x1, y, b.V, 1, st)
}
cv.put(r.X, r.Y, b.TL, 1, st)
cv.put(x1, r.Y, b.TR, 1, st)
cv.put(r.X, y1, b.BL, 1, st)
cv.put(x1, y1, b.BR, 1, st)
}
// HLine draws a horizontal rule.
func (cv *Canvas) HLine(x, y, w int, ch string, st Style) {
for i := x; i < x+w; i++ {
cv.put(i, y, ch, 1, st)
}
}
// Cell returns the text and style at (x, y) (for tests).
func (cv *Canvas) Cell(x, y int) (string, Style) {
if !cv.in(x, y) {
return "", Style{}
}
c := cv.c[y*cv.W+x]
return c.s, c.st
}
// Line returns the plain text of row y (for tests).
func (cv *Canvas) Line(y int) string {
var b strings.Builder
for x := 0; x < cv.W; x++ {
b.WriteString(cv.c[y*cv.W+x].s)
}
return b.String()
}
func sgr(b *strings.Builder, st Style, p256 bool) {
b.WriteString("\x1b[0")
if st.Attr&Bold != 0 {
b.WriteString(";1")
}
if st.Attr&Faint != 0 {
b.WriteString(";2")
}
if st.Attr&Italic != 0 {
b.WriteString(";3")
}
if st.Attr&Underline != 0 {
b.WriteString(";4")
}
if st.Attr&Reverse != 0 {
b.WriteString(";7")
}
col := func(c Color, base string) {
if c == 0 {
return
}
if p256 {
b.WriteString(";" + base + ";5;")
b.WriteString(strconv.Itoa(int(quant256(c))))
return
}
b.WriteString(";" + base + ";2;")
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
b.WriteByte(';')
b.WriteString(strconv.Itoa(int(c >> 8 & 0xff)))
b.WriteByte(';')
b.WriteString(strconv.Itoa(int(c & 0xff)))
}
col(st.FG, "38")
col(st.BG, "48")
b.WriteByte('m')
}
// String serialises the canvas as lines of text with SGR styling. Colours
// are emitted as 24-bit, or as 256-colour indices from our own quantizer
// when p256 is set; Bubble Tea downsamples further (16 colours, none) to
// the terminal's profile.
func (cv *Canvas) String() string { return cv.Render(false) }
// Render serialises the canvas; see String.
func (cv *Canvas) Render(p256 bool) string {
var b strings.Builder
b.Grow(cv.W * cv.H * 4)
for y := 0; y < cv.H; y++ {
if y > 0 {
b.WriteByte('\n')
}
var cur Style
first := true
for x := 0; x < cv.W; x++ {
c := cv.c[y*cv.W+x]
if c.s == "" {
continue
}
if first || c.st != cur {
sgr(&b, c.st, p256)
cur, first = c.st, false
}
b.WriteString(c.s)
}
b.WriteString("\x1b[0m")
}
return b.String()
}