summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 18:26:39 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 18:26:39 +0200
commitbd076884619c8f4d9e76ef8bc67b3bfd8b83235a (patch)
tree74ae380bf616e1b3b298e0f5a8e790e6469f06c3
parentb566bc141e971ae2a7634c9d836f2ad8b0a62402 (diff)
refactor(tui): add dashboard viz mode registry (task 382)
-rw-r--r--internal/tui/dashboard/bubbles.go79
-rw-r--r--internal/tui/dashboard/files.go4
-rw-r--r--internal/tui/dashboard/model.go201
-rw-r--r--internal/tui/dashboard/model_test.go12
-rw-r--r--internal/tui/dashboard/processes.go2
-rw-r--r--internal/tui/dashboard/syscalls.go2
6 files changed, 224 insertions, 76 deletions
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go
index f94015d..0ec91a9 100644
--- a/internal/tui/dashboard/bubbles.go
+++ b/internal/tui/dashboard/bubbles.go
@@ -2,6 +2,7 @@ package dashboard
import (
"fmt"
+ "hash/fnv"
"image/color"
"math"
"path/filepath"
@@ -51,6 +52,8 @@ type bubbleNode struct {
ySpring harmonica.Spring
targetRadius float64
+ anchorX float64
+ anchorY float64
targetX float64
targetY float64
@@ -61,6 +64,11 @@ type bubbleNode struct {
velocityRadius float64
velocityX float64
velocityY float64
+
+ driftPhase float64
+ driftSpeed float64
+ driftAmpX float64
+ driftAmpY float64
}
type bubbleCell struct {
@@ -78,6 +86,7 @@ type bubbleChart struct {
animating bool
statusHint string
isDark bool
+ driftTime float64
}
func newBubbleChart() bubbleChart {
@@ -160,6 +169,8 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
Bytes: target.Bytes,
Value: target.Value,
targetRadius: target.targetRadius,
+ anchorX: target.targetX,
+ anchorY: target.targetY,
targetX: target.targetX,
targetY: target.targetY,
radiusSpring: harmonica.NewSpring(harmonica.FPS(bubbleFPS), bubbleAngularVelocity, bubbleDamping),
@@ -173,15 +184,26 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
node.velocityRadius = prev.velocityRadius
node.velocityX = prev.velocityX
node.velocityY = prev.velocityY
+ node.driftPhase = prev.driftPhase
+ node.driftSpeed = prev.driftSpeed
+ node.driftAmpX = prev.driftAmpX
+ node.driftAmpY = prev.driftAmpY
// New metrics or topology can otherwise produce stale springs.
if node.radius == 0 {
node.radius = target.targetRadius
}
+ if node.driftSpeed == 0 {
+ c.initNodeDrift(&node)
+ } else {
+ c.updateNodeDriftAmplitude(&node)
+ }
} else {
node.radius = target.targetRadius
node.x = target.targetX
node.y = target.targetY
+ c.initNodeDrift(&node)
}
+ node.applyDrift(c.driftTime, c.width, c.height)
next = append(next, node)
}
c.nodes = next
@@ -239,9 +261,12 @@ func (c *bubbleChart) Tick(delta float64) bool {
if delta <= 0 {
delta = baseDelta
}
+ c.driftTime += delta
+
active := false
for idx := range c.nodes {
node := &c.nodes[idx]
+ node.applyDrift(c.driftTime, c.width, c.height)
if delta != baseDelta {
node.radiusSpring = harmonica.NewSpring(delta, bubbleAngularVelocity, bubbleDamping)
node.xSpring = harmonica.NewSpring(delta, bubbleAngularVelocity, bubbleDamping)
@@ -276,6 +301,54 @@ func (c *bubbleChart) nodeAnimating(node bubbleNode) bool {
return false
}
+func (c *bubbleChart) initNodeDrift(node *bubbleNode) {
+ if node == nil {
+ return
+ }
+ h := stableHash(node.ID)
+ node.driftPhase = float64(h%628) / 100.0
+ node.driftSpeed = 0.12 + float64((h>>8)%35)/1000.0
+ c.updateNodeDriftAmplitude(node)
+}
+
+func (c *bubbleChart) updateNodeDriftAmplitude(node *bubbleNode) {
+ if node == nil {
+ return
+ }
+ h := stableHash(node.ID)
+ baseAmp := clampFloat(node.targetRadius*0.32, 0.45, 1.8)
+ node.driftAmpX = baseAmp * (0.85 + float64((h>>16)%31)/100.0)
+ node.driftAmpY = baseAmp * 0.75 * (0.85 + float64((h>>24)%31)/100.0)
+}
+
+func (n *bubbleNode) applyDrift(t float64, width, height int) {
+ if n == nil {
+ return
+ }
+ phase := n.driftPhase + t*n.driftSpeed
+ n.targetX = n.anchorX + math.Sin(phase)*n.driftAmpX
+ n.targetY = n.anchorY + math.Cos(phase*0.91+0.37)*n.driftAmpY
+
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 18
+ }
+ minX := n.targetRadius + 1.0
+ maxX := float64(width-1) - n.targetRadius - 1.0
+ minY := n.targetRadius
+ maxY := float64(height-1) - n.targetRadius
+ n.targetX = clampFloat(n.targetX, minX, maxX)
+ n.targetY = clampFloat(n.targetY, minY, maxY)
+}
+
+func stableHash(value string) uint32 {
+ hasher := fnv.New32a()
+ _, _ = hasher.Write([]byte(value))
+ return hasher.Sum32()
+}
+
func (c *bubbleChart) MoveSelection(delta int) bool {
if len(c.nodes) == 0 {
return false
@@ -294,6 +367,10 @@ func (c *bubbleChart) MoveSelection(delta int) bool {
return true
}
+func (c bubbleChart) HasNodes() bool {
+ return len(c.nodes) > 0
+}
+
func (c *bubbleChart) Render(tabLabel string, width, height int) string {
if width <= 0 {
width = c.width
@@ -307,7 +384,7 @@ func (c *bubbleChart) Render(tabLabel string, width, height int) string {
if height <= 0 {
height = 18
}
- header := fmt.Sprintf("%s bubbles | metric:%s | v table | b metric | j/k select", tabLabel, c.metricLabel())
+ header := fmt.Sprintf("%s bubbles | metric:%s | v mode | b metric | j/k select", tabLabel, c.metricLabel())
if len(c.nodes) == 0 {
body := "No data yet."
if c.statusHint != "" {
diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go
index 98158d8..e9bb218 100644
--- a/internal/tui/dashboard/files.go
+++ b/internal/tui/dashboard/files.go
@@ -56,7 +56,7 @@ func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int
tbl.SetWidth(tableWidth(width))
cursor := clampOffset(offset, len(rows))
tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs] [v:bubbles in dirs]", cursor+1, len(rows))
+ return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs] [v:mode in dirs]", cursor+1, len(rows))
}
func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int) string {
@@ -89,7 +89,7 @@ func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int
tbl.SetWidth(tableWidth(width))
cursor := clampOffset(offset, len(rows))
tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files] [v:bubbles] [b:metric]", cursor+1, len(rows))
+ return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files] [v:mode] [b:metric]", cursor+1, len(rows))
}
func fileRows(files []statsengine.FileSnapshot, pathWidth int) []table.Row {
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 8a7d85c..0eef629 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -41,6 +41,13 @@ type streamEditorDoneMsg struct {
err error
}
+type tabVizMode uint8
+
+const (
+ tabVizModeTable tabVizMode = iota
+ tabVizModeBubbles
+)
+
// Model is the dashboard tab framework model.
type Model struct {
activeTab Tab
@@ -52,25 +59,25 @@ type Model struct {
width int
height int
- refreshEvery time.Duration
- keys common.KeyMap
- pidFilter int
- syscallsOffset int
- filesOffset int
- filesDirGrouped bool
- filesDirOffset int
- processesOffset int
- syscallsBubble bool
- filesBubble bool
- processesBubble bool
- streamModel eventstream.Model
- flamegraphModel flamegraphtui.Model
- syscallsChart bubbleChart
- filesChart bubbleChart
- processesChart bubbleChart
- showHelp bool
- isDark bool
- focused bool
+ refreshEvery time.Duration
+ keys common.KeyMap
+ pidFilter int
+ syscallsOffset int
+ filesOffset int
+ filesDirGrouped bool
+ filesDirOffset int
+ processesOffset int
+ syscallsVizMode tabVizMode
+ filesVizMode tabVizMode
+ processesVizMode tabVizMode
+ streamModel eventstream.Model
+ flamegraphModel flamegraphtui.Model
+ syscallsChart bubbleChart
+ filesChart bubbleChart
+ processesChart bubbleChart
+ showHelp bool
+ isDark bool
+ focused bool
}
// NewModel creates a dashboard model with default refresh cadence.
@@ -84,18 +91,21 @@ func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source,
refreshMs = defaultRefreshMs
}
m := Model{
- activeTab: TabFlame,
- engine: engine,
- refreshEvery: time.Duration(refreshMs) * time.Millisecond,
- keys: keys,
- pidFilter: -1,
- streamModel: eventstream.NewModel(streamSource),
- flamegraphModel: flamegraphtui.NewModel(nil),
- syscallsChart: newBubbleChart(),
- filesChart: newBubbleChart(),
- processesChart: newBubbleChart(),
- isDark: true,
- focused: true,
+ activeTab: TabFlame,
+ engine: engine,
+ refreshEvery: time.Duration(refreshMs) * time.Millisecond,
+ keys: keys,
+ pidFilter: -1,
+ syscallsVizMode: tabVizModeTable,
+ filesVizMode: tabVizModeTable,
+ processesVizMode: tabVizModeTable,
+ streamModel: eventstream.NewModel(streamSource),
+ flamegraphModel: flamegraphtui.NewModel(nil),
+ syscallsChart: newBubbleChart(),
+ filesChart: newBubbleChart(),
+ processesChart: newBubbleChart(),
+ isDark: true,
+ focused: true,
}
m.SetDarkMode(true)
return m
@@ -175,7 +185,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !m.bubbleEnabledForTab(m.activeTab) {
return m, nil
}
- if m.tickActiveBubbleChart() {
+ _ = m.tickActiveBubbleChart()
+ if m.activeBubbleChartHasNodes() {
return m, bubbleTickCmdFn()
}
return m, nil
@@ -260,7 +271,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
handled = true
case key.Matches(msg, m.keys.Visualize):
handled = true
- cmd = m.toggleBubbleVisualization()
+ cmd = m.cycleVisualizationMode()
case key.Matches(msg, m.keys.Metric):
handled = true
cmd = m.toggleBubbleMetric()
@@ -270,10 +281,10 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.DirGroup):
if m.activeTab == TabFiles {
m.filesDirGrouped = !m.filesDirGrouped
- if !m.filesDirGrouped && m.filesBubble {
- m.filesBubble = false
+ if !m.filesDirGrouped && m.filesVizMode == tabVizModeBubbles {
+ m.filesVizMode = tabVizModeTable
}
- if m.filesDirGrouped && m.filesBubble && m.refreshBubbleData() {
+ if m.bubbleEnabledForTab(m.activeTab) && m.refreshBubbleData() {
cmd = bubbleTickCmdFn()
}
handled = true
@@ -558,11 +569,11 @@ func (m *Model) refreshBubbleData() bool {
switch m.activeTab {
case TabSyscalls:
- return m.syscallsBubble && syscallsAnimating
+ return m.syscallsVizMode == tabVizModeBubbles && syscallsAnimating
case TabFiles:
- return m.filesBubble && filesAnimating
+ return m.filesVizMode == tabVizModeBubbles && filesAnimating
case TabProcesses:
- return m.processesBubble && processesAnimating
+ return m.processesVizMode == tabVizModeBubbles && processesAnimating
default:
return false
}
@@ -571,17 +582,17 @@ func (m *Model) refreshBubbleData() bool {
func (m *Model) tickActiveBubbleChart() bool {
switch m.activeTab {
case TabSyscalls:
- if !m.syscallsBubble {
+ if m.syscallsVizMode != tabVizModeBubbles {
return false
}
return m.syscallsChart.Tick(0)
case TabFiles:
- if !m.filesBubble {
+ if m.filesVizMode != tabVizModeBubbles {
return false
}
return m.filesChart.Tick(0)
case TabProcesses:
- if !m.processesBubble {
+ if m.processesVizMode != tabVizModeBubbles {
return false
}
return m.processesChart.Tick(0)
@@ -603,37 +614,44 @@ func (m *Model) moveBubbleSelection(delta int) bool {
}
}
-func (m Model) bubbleEnabledForTab(tab Tab) bool {
- switch tab {
+func (m Model) activeBubbleChartHasNodes() bool {
+ switch m.activeTab {
case TabSyscalls:
- return m.syscallsBubble
+ return m.syscallsChart.HasNodes()
case TabFiles:
- return m.filesBubble && m.filesDirGrouped
+ return m.filesChart.HasNodes()
case TabProcesses:
- return m.processesBubble
+ return m.processesChart.HasNodes()
default:
return false
}
}
-func (m *Model) toggleBubbleVisualization() tea.Cmd {
- switch m.activeTab {
+func (m Model) bubbleEnabledForTab(tab Tab) bool {
+ switch tab {
case TabSyscalls:
- m.syscallsBubble = !m.syscallsBubble
- if m.syscallsBubble && m.refreshBubbleData() {
- return bubbleTickCmdFn()
- }
+ return m.syscallsVizMode == tabVizModeBubbles
case TabFiles:
- if !m.filesDirGrouped {
- return nil
- }
- m.filesBubble = !m.filesBubble
- if m.filesBubble && m.refreshBubbleData() {
- return bubbleTickCmdFn()
- }
+ return m.filesDirGrouped && m.filesVizMode == tabVizModeBubbles
case TabProcesses:
- m.processesBubble = !m.processesBubble
- if m.processesBubble && m.refreshBubbleData() {
+ return m.processesVizMode == tabVizModeBubbles
+ default:
+ return false
+ }
+}
+
+func (m *Model) cycleVisualizationMode() tea.Cmd {
+ allowed := m.allowedVizModes(m.activeTab)
+ if len(allowed) < 2 {
+ return nil
+ }
+ current := m.tabVizModeFor(m.activeTab)
+ next := nextVizMode(current, allowed)
+ m.setTabVizMode(m.activeTab, next)
+
+ if next == tabVizModeBubbles {
+ m.refreshBubbleData()
+ if m.activeBubbleChartHasNodes() {
return bubbleTickCmdFn()
}
}
@@ -644,7 +662,8 @@ func (m *Model) toggleBubbleMetric() tea.Cmd {
switch m.activeTab {
case TabSyscalls:
m.syscallsChart.SetMetric(nextBubbleMetric(m.syscallsChart.Metric()))
- if m.refreshBubbleData() {
+ m.refreshBubbleData()
+ if m.syscallsVizMode == tabVizModeBubbles && m.activeBubbleChartHasNodes() {
return bubbleTickCmdFn()
}
case TabFiles:
@@ -652,18 +671,70 @@ func (m *Model) toggleBubbleMetric() tea.Cmd {
return nil
}
m.filesChart.SetMetric(nextBubbleMetric(m.filesChart.Metric()))
- if m.refreshBubbleData() {
+ m.refreshBubbleData()
+ if m.filesVizMode == tabVizModeBubbles && m.activeBubbleChartHasNodes() {
return bubbleTickCmdFn()
}
case TabProcesses:
m.processesChart.SetMetric(nextBubbleMetric(m.processesChart.Metric()))
- if m.refreshBubbleData() {
+ m.refreshBubbleData()
+ if m.processesVizMode == tabVizModeBubbles && m.activeBubbleChartHasNodes() {
return bubbleTickCmdFn()
}
}
return nil
}
+func (m Model) tabVizModeFor(tab Tab) tabVizMode {
+ switch tab {
+ case TabSyscalls:
+ return m.syscallsVizMode
+ case TabFiles:
+ return m.filesVizMode
+ case TabProcesses:
+ return m.processesVizMode
+ default:
+ return tabVizModeTable
+ }
+}
+
+func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) {
+ switch tab {
+ case TabSyscalls:
+ m.syscallsVizMode = mode
+ case TabFiles:
+ m.filesVizMode = mode
+ case TabProcesses:
+ m.processesVizMode = mode
+ }
+}
+
+func (m Model) allowedVizModes(tab Tab) []tabVizMode {
+ switch tab {
+ case TabSyscalls, TabProcesses:
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles}
+ case TabFiles:
+ if m.filesDirGrouped {
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles}
+ }
+ return []tabVizMode{tabVizModeTable}
+ default:
+ return []tabVizMode{tabVizModeTable}
+ }
+}
+
+func nextVizMode(current tabVizMode, allowed []tabVizMode) tabVizMode {
+ if len(allowed) == 0 {
+ return tabVizModeTable
+ }
+ for idx, mode := range allowed {
+ if mode == current {
+ return allowed[(idx+1)%len(allowed)]
+ }
+ }
+ return allowed[0]
+}
+
func nextBubbleMetric(metric bubbleMetric) bubbleMetric {
if metric == bubbleMetricBytes {
return bubbleMetricCount
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 934577d..e33271b 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -353,13 +353,13 @@ func TestBubbleVisualizationToggleForSyscallsTab(t *testing.T) {
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model := next.(Model)
- if !model.syscallsBubble {
+ if got := model.syscallsVizMode; got != tabVizModeBubbles {
t.Fatalf("expected syscalls bubble mode enabled")
}
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model = next.(Model)
- if model.syscallsBubble {
+ if got := model.syscallsVizMode; got != tabVizModeTable {
t.Fatalf("expected syscalls bubble mode toggled off")
}
}
@@ -390,7 +390,7 @@ func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model := next.(Model)
- if model.filesBubble {
+ if got := model.filesVizMode; got != tabVizModeTable {
t.Fatalf("expected files bubble mode to stay disabled without directory mode")
}
@@ -402,13 +402,13 @@ func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model = next.(Model)
- if !model.filesBubble {
+ if got := model.filesVizMode; got != tabVizModeBubbles {
t.Fatalf("expected files bubble mode enabled in directory mode")
}
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
model = next.(Model)
- if model.filesBubble {
+ if got := model.filesVizMode; got != tabVizModeTable {
t.Fatalf("expected files bubble mode disabled when leaving directory mode")
}
}
@@ -421,7 +421,7 @@ func TestBubbleModeUsesJKForSelection(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabSyscalls
m.latest = &snap
- m.syscallsBubble = true
+ m.syscallsVizMode = tabVizModeBubbles
m.refreshBubbleData()
if len(m.syscallsChart.nodes) < 2 {
t.Fatalf("expected at least two syscall bubbles")
diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go
index 74a185f..9988ea4 100644
--- a/internal/tui/dashboard/processes.go
+++ b/internal/tui/dashboard/processes.go
@@ -43,7 +43,7 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset
cursor := clampOffset(offset, len(rows))
tbl.SetCursor(cursor)
- out := tbl.View() + fmt.Sprintf("\nRow %d/%d [v:bubbles] [b:metric]", cursor+1, len(rows))
+ out := tbl.View() + fmt.Sprintf("\nRow %d/%d [v:mode] [b:metric]", cursor+1, len(rows))
if pidFilter > 0 {
out += "\n" + "Note: this tab is most useful with All PIDs."
}
diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go
index bf7909e..5235b3e 100644
--- a/internal/tui/dashboard/syscalls.go
+++ b/internal/tui/dashboard/syscalls.go
@@ -33,7 +33,7 @@ func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset
tbl.SetWidth(tableWidth(width))
cursor := clampOffset(offset, len(rows))
tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [v:bubbles] [b:metric]", cursor+1, len(rows))
+ return tbl.View() + fmt.Sprintf("\nRow %d/%d [v:mode] [b:metric]", cursor+1, len(rows))
}
func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]table.Column, []table.Row) {