diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 18:26:39 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 18:26:39 +0200 |
| commit | bd076884619c8f4d9e76ef8bc67b3bfd8b83235a (patch) | |
| tree | 74ae380bf616e1b3b298e0f5a8e790e6469f06c3 | |
| parent | b566bc141e971ae2a7634c9d836f2ad8b0a62402 (diff) | |
refactor(tui): add dashboard viz mode registry (task 382)
| -rw-r--r-- | internal/tui/dashboard/bubbles.go | 79 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 201 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 12 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 2 |
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) { |
