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