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,221 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/andydixon/mapsize/internal/textutil"
|
||||
"github.com/andydixon/mapsize/internal/treemap"
|
||||
)
|
||||
|
||||
// span is a run of styled text; a line is a slice of spans.
|
||||
type span struct {
|
||||
s string
|
||||
st *Style // nil = modal default
|
||||
}
|
||||
|
||||
type line []span
|
||||
|
||||
func txt(s string) line { return line{{s, nil}} }
|
||||
func styled(s string, st Style) line { return line{{s, &st}} }
|
||||
func kv(k, v string, kst, vst Style) line { return line{{textutil.PadRight(k, 18), &kst}, {v, &vst}} }
|
||||
|
||||
// modal is an overlay. Modals receive all keys while open; Esc always
|
||||
// closes them (handled by the frame before the modal sees it, unless the
|
||||
// modal is editing text).
|
||||
type modal interface {
|
||||
title() string
|
||||
content(m *Model, width int) []line // rebuilt every frame, so resizes reflow
|
||||
footer(m *Model) string
|
||||
key(m *Model, k string) (closeModal bool, cmd tea.Cmd)
|
||||
width() int // preferred inner width
|
||||
}
|
||||
|
||||
// modalFrame wraps a modal with scroll state.
|
||||
type modalFrame struct {
|
||||
modal
|
||||
scroll int
|
||||
viewH int // content rows visible in the last paint
|
||||
contentN int
|
||||
}
|
||||
|
||||
func (m *Model) openModal(md modal) {
|
||||
m.modals = append(m.modals, &modalFrame{modal: md})
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
func (m *Model) closeModal() {
|
||||
if len(m.modals) > 0 {
|
||||
m.modals = m.modals[:len(m.modals)-1]
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
|
||||
// textInputModal is implemented by modals that consume printable keys.
|
||||
type textInputModal interface {
|
||||
editing() bool
|
||||
editor() *lineEditor
|
||||
}
|
||||
|
||||
func (m *Model) modalKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||
k := keyName(msg)
|
||||
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||
if ti, ok := f.modal.(textInputModal); ok && ti.editing() && k != "esc" && k != "enter" {
|
||||
ti.editor().key(msg)
|
||||
return nil
|
||||
}
|
||||
if ti, ok := f.modal.(textInputModal); !ok || !ti.editing() {
|
||||
page := max(f.viewH-1, 1)
|
||||
switch k {
|
||||
case "esc", "q":
|
||||
m.closeModal()
|
||||
return nil
|
||||
case "up", "k":
|
||||
f.scroll--
|
||||
case "down", "j":
|
||||
f.scroll++
|
||||
case "pgup":
|
||||
f.scroll -= page
|
||||
case "pgdown", "space":
|
||||
f.scroll += page
|
||||
case "home":
|
||||
f.scroll = 0
|
||||
case "end":
|
||||
f.scroll = f.contentN
|
||||
default:
|
||||
goto modalSpecific
|
||||
}
|
||||
f.clampScroll()
|
||||
return nil
|
||||
}
|
||||
modalSpecific:
|
||||
closeIt, cmd := f.key(m, k)
|
||||
if closeIt {
|
||||
// Close this modal specifically (it may have opened another).
|
||||
for i, x := range m.modals {
|
||||
if x == f {
|
||||
m.modals = append(m.modals[:i], m.modals[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
m.dirty = true
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (f *modalFrame) clampScroll() {
|
||||
f.scroll = max(0, min(f.scroll, f.contentN-f.viewH))
|
||||
}
|
||||
|
||||
// paintModal dims the base screen and draws the top modal centred,
|
||||
// clamped to the terminal with a one-cell margin. Content that does not fit
|
||||
// scrolls.
|
||||
func (m *Model) paintModal(cv *Canvas) {
|
||||
t := m.theme
|
||||
// Dim everything underneath.
|
||||
cv.Restyle(treemap.Rect{X: 0, Y: 0, W: cv.W, H: cv.H}, func(s Style) Style {
|
||||
if t.Mono {
|
||||
s.Attr |= Faint
|
||||
return s
|
||||
}
|
||||
s.FG = s.FG.Mix(t.Bg, 0.6)
|
||||
s.BG = s.BG.Mix(RGB(0, 0, 0), 0.45)
|
||||
s.Attr &^= Bold
|
||||
return s
|
||||
})
|
||||
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||
maxW := cv.W - 2
|
||||
w := min(f.width()+4, maxW)
|
||||
w = max(w, min(30, maxW))
|
||||
inner := w - 4
|
||||
lines := f.content(m, inner)
|
||||
foot := f.footer(m)
|
||||
chrome := 4 // border top/bottom, title rule
|
||||
if foot != "" {
|
||||
chrome += 2
|
||||
}
|
||||
h := min(len(lines)+chrome, cv.H-2)
|
||||
h = max(h, min(chrome+1, cv.H))
|
||||
x0, y0 := (cv.W-w)/2, (cv.H-h)/2
|
||||
r := treemap.Rect{X: x0, Y: y0, W: w, H: h}
|
||||
bg := t.PanelBg
|
||||
if bg == 0 {
|
||||
bg = t.Bg
|
||||
}
|
||||
base := Style{FG: t.Fg, BG: bg}
|
||||
// Drop shadow.
|
||||
if !t.Mono {
|
||||
cv.Restyle(treemap.Rect{X: x0 + 1, Y: y0 + h, W: w, H: 1}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
|
||||
cv.Restyle(treemap.Rect{X: x0 + w, Y: y0 + 1, W: 1, H: h}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
|
||||
}
|
||||
cv.Fill(r, " ", base)
|
||||
border := Style{FG: t.Accent, BG: bg, Attr: Bold}
|
||||
box := BoxRound
|
||||
if t.ASCII {
|
||||
box = BoxASCII
|
||||
}
|
||||
cv.Box(r, box, border)
|
||||
cv.Text(x0+2, y0+1, strings.ToUpper(f.title()), inner, Style{FG: t.Accent, BG: bg, Attr: Bold})
|
||||
rule := "─"
|
||||
if t.ASCII {
|
||||
rule = "-"
|
||||
}
|
||||
cv.HLine(x0+1, y0+2, w-2, rule, Style{FG: t.Faint, BG: bg})
|
||||
f.viewH = h - chrome
|
||||
f.contentN = len(lines)
|
||||
f.clampScroll()
|
||||
for i := 0; i < f.viewH && f.scroll+i < len(lines); i++ {
|
||||
x := x0 + 2
|
||||
for _, sp := range lines[f.scroll+i] {
|
||||
st := base
|
||||
if sp.st != nil {
|
||||
st = *sp.st
|
||||
if st.BG == 0 {
|
||||
st.BG = bg
|
||||
}
|
||||
}
|
||||
x += cv.Text(x, y0+3+i, sp.s, x0+2+inner-x, st)
|
||||
}
|
||||
}
|
||||
if len(lines) > f.viewH && f.viewH > 0 {
|
||||
ind := textutil.Count(int64(f.scroll+1)) + "–" + textutil.Count(int64(min(f.scroll+f.viewH, len(lines)))) +
|
||||
"/" + textutil.Count(int64(len(lines)))
|
||||
if f.scroll > 0 {
|
||||
ind = "▲ " + ind
|
||||
}
|
||||
if f.scroll+f.viewH < len(lines) {
|
||||
ind += " ▼"
|
||||
}
|
||||
cv.TextRight(x0+1, y0+1, w-3, ind, Style{FG: t.Muted, BG: bg})
|
||||
}
|
||||
if foot != "" {
|
||||
cv.HLine(x0+1, y0+h-3, w-2, rule, Style{FG: t.Faint, BG: bg})
|
||||
cv.Text(x0+2, y0+h-2, foot, inner, Style{FG: t.Muted, BG: bg})
|
||||
}
|
||||
m.hits = append(m.hits[:0], hit{r, func(*Model, bool) tea.Cmd { return nil }})
|
||||
}
|
||||
|
||||
// wrap breaks s into chunks of at most w cells (used for long paths).
|
||||
func wrap(s string, w int) []string {
|
||||
if w <= 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
cw := 0
|
||||
textutil.Clusters(s, func(c string, cwid int) bool {
|
||||
if cw+cwid > w {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
cw = 0
|
||||
}
|
||||
cur.WriteString(c)
|
||||
cw += cwid
|
||||
return true
|
||||
})
|
||||
if cur.Len() > 0 || len(out) == 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user