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
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/inventory"
)
const doubleClick = 400 * time.Millisecond
func (m *Model) mouseClick(ev tea.Mouse) tea.Cmd {
t := m.tree
t.RLock()
defer t.RUnlock()
m.dirty = true
if len(m.modals) > 0 {
// Clicking outside the modal closes it.
if len(m.hits) > 0 && !m.hits[0].r.Contains(ev.X, ev.Y) {
m.closeModal()
}
return nil
}
if ev.Button == tea.MouseRight {
for _, h := range m.hits {
if h.r.Contains(ev.X, ev.Y) {
h.fn(m, false)
return m.inspectSelection()
}
}
return nil
}
if ev.Button != tea.MouseLeft {
return nil
}
for i := len(m.hits) - 1; i >= 0; i-- {
h := m.hits[i]
if h.r.Contains(ev.X, ev.Y) {
key := int64(ev.Y)<<32 | int64(i)
double := time.Since(m.lastClick) < doubleClick && m.lastClickI == key
m.lastClick, m.lastClickI = time.Now(), key
if double {
m.lastClickI = noSel
}
return h.fn(m, double)
}
}
return nil
}
// clickBlock selects a treemap block; a double click zooms (or inspects).
func (m *Model) clickBlock(id int64, double bool) tea.Cmd {
m.sel = id
if !double {
return nil
}
if !isGroup(id) && !m.tree.Node(inventory.NodeID(id)).IsDir() {
return m.inspectSelection()
}
return m.zoomIntoSelection()
}
func (m *Model) mouseWheel(ev tea.Mouse) tea.Cmd {
t := m.tree
t.RLock()
defer t.RUnlock()
m.dirty = true
up := ev.Button == tea.MouseWheelUp
if ev.Button != tea.MouseWheelUp && ev.Button != tea.MouseWheelDown {
return nil
}
if len(m.modals) > 0 {
f := m.modals[len(m.modals)-1].(*modalFrame)
if up {
f.scroll -= 3
} else {
f.scroll += 3
}
f.clampScroll()
return nil
}
if up {
// Zoom towards the block under the pointer.
if l := m.tm; l != nil && m.view == 0 {
for _, b := range l.blocks {
if b.Contains(ev.X, ev.Y) {
m.sel = b.ID
}
}
}
}
return m.views[m.view].wheel(m, up)
}
func (m *Model) mouseMotion(ev tea.Mouse) {
h := noSel
if l := m.tm; l != nil && m.view == 0 && len(m.modals) == 0 {
for _, b := range l.blocks {
if b.Contains(ev.X, ev.Y) {
h = b.ID
break
}
}
}
if h != m.hover {
m.hover = h
m.dirty = true
}
}