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