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.
222 lines
5.4 KiB
Go
222 lines
5.4 KiB
Go
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
|
||
}
|