summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 22:35:34 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 22:35:34 +0200
commitd8bf918e83515f48564e0d0b98d30907944a1e0d (patch)
treeed155b0c365b0d322494a96ff799c3a59a1d9ec8 /internal
parent9f21f1004beeac10be9223bc8c5514261e397b6e (diff)
tui: unify table navigation and rendering
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/common/keys.go26
-rw-r--r--internal/tui/common/styles.go21
-rw-r--r--internal/tui/common/table.go157
-rw-r--r--internal/tui/dashboard/files.go90
-rw-r--r--internal/tui/dashboard/model.go114
-rw-r--r--internal/tui/dashboard/model_test.go144
-rw-r--r--internal/tui/dashboard/processes.go40
-rw-r--r--internal/tui/dashboard/processes_test.go2
-rw-r--r--internal/tui/dashboard/syscalls.go55
-rw-r--r--internal/tui/dashboard/table.go46
-rw-r--r--internal/tui/eventstream/model.go56
-rw-r--r--internal/tui/eventstream/render.go88
-rw-r--r--internal/tui/eventstream/render_test.go2
-rw-r--r--internal/tui/tui.go6
14 files changed, 624 insertions, 223 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index f2e1271..6289997 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -110,13 +110,14 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.Visualize,
k.Metric,
helpTextBinding("space", "stream pause"),
- helpTextBinding("enter", "stream push filter"),
+ helpTextBinding("enter", "selected filter"),
helpTextBinding("esc", "stream undo filter"),
- helpTextBinding("g/G", "stream top/tail"),
- helpTextBinding("left/right", "stream col"),
- helpTextBinding("h/l", "stream col"),
- helpTextBinding("j/k", "scroll"),
- helpTextBinding("up/down", "scroll"),
+ helpTextBinding("g/G", "table top/bottom"),
+ helpTextBinding("left/right", "table col"),
+ helpTextBinding("h/l", "table col"),
+ helpTextBinding("j/k", "table row"),
+ helpTextBinding("up/down", "table row"),
+ helpTextBinding("pgup/down", "table page"),
helpTextBinding("/,?", "stream regex search"),
helpTextBinding("n/N", "stream search next/prev"),
helpTextBinding("x", "stream export"),
@@ -144,13 +145,14 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
controls,
{
helpTextBinding("space", "stream pause"),
- helpTextBinding("enter", "stream push filter"),
+ helpTextBinding("enter", "selected filter"),
helpTextBinding("esc", "stream undo filter"),
- helpTextBinding("g/G", "stream top/tail"),
- helpTextBinding("left/right", "stream col"),
- helpTextBinding("h/l", "stream col"),
- helpTextBinding("j/k", "scroll"),
- helpTextBinding("up/down", "scroll"),
+ helpTextBinding("g/G", "table top/bottom"),
+ helpTextBinding("left/right", "table col"),
+ helpTextBinding("h/l", "table col"),
+ helpTextBinding("j/k", "table row"),
+ helpTextBinding("up/down", "table row"),
+ helpTextBinding("pgup/down", "table page"),
helpTextBinding("x", "stream export"),
helpTextBinding("X", "stream export as"),
helpTextBinding("E", "stream open last"),
diff --git a/internal/tui/common/styles.go b/internal/tui/common/styles.go
index a71ef81..111927e 100644
--- a/internal/tui/common/styles.go
+++ b/internal/tui/common/styles.go
@@ -77,6 +77,15 @@ var (
// ErrorStyle is used for fatal or warning messages.
ErrorStyle lipgloss.Style
+
+ // TableHeaderStyle is used by shared table headers.
+ TableHeaderStyle lipgloss.Style
+
+ // TableSelectedRowStyle highlights the selected row in shared tables.
+ TableSelectedRowStyle lipgloss.Style
+
+ // TableSelectedCellStyle highlights the selected cell in shared tables.
+ TableSelectedCellStyle lipgloss.Style
)
// ApplyPalette updates shared colors and styles to match the provided theme.
@@ -110,6 +119,18 @@ func ApplyPalette(isDark bool) {
BorderForeground(ColorPanel)
HighlightStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent)
ErrorStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorDanger)
+ TableHeaderStyle = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ BorderTop(true).
+ BorderForeground(ColorPanel)
+ TableSelectedRowStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorBackground).
+ Background(ColorPrimary)
+ TableSelectedCellStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorBackground).
+ Background(ColorAccent)
}
func init() {
diff --git a/internal/tui/common/table.go b/internal/tui/common/table.go
new file mode 100644
index 0000000..f4c94e8
--- /dev/null
+++ b/internal/tui/common/table.go
@@ -0,0 +1,157 @@
+package common
+
+import (
+ "strings"
+ "unicode/utf8"
+
+ "charm.land/lipgloss/v2"
+)
+
+// TableColumn defines one shared TUI table column.
+type TableColumn struct {
+ Title string
+ Width int
+}
+
+// RenderTableHeader renders a shared table header row.
+func RenderTableHeader(columns []TableColumn) string {
+ cells := make([]string, 0, len(columns))
+ for _, col := range columns {
+ cells = append(cells, renderTableCell(col.Title, col.Width))
+ }
+ return TableHeaderStyle.Render(strings.Join(cells, " "))
+}
+
+// RenderTableRow renders one shared TUI table row.
+func RenderTableRow(columns []TableColumn, cells []string, selected bool, selectedCol int, baseStyle lipgloss.Style) string {
+ rendered := make([]string, 0, len(columns))
+ for idx, col := range columns {
+ value := ""
+ if idx < len(cells) {
+ value = cells[idx]
+ }
+ cell := renderTableCell(value, col.Width)
+ switch {
+ case selected && idx == selectedCol:
+ cell = TableSelectedCellStyle.Render(cell)
+ case selected:
+ cell = TableSelectedRowStyle.Render(cell)
+ }
+ rendered = append(rendered, cell)
+ }
+ row := strings.Join(rendered, " ")
+ if selected {
+ return row
+ }
+ return baseStyle.Render(row)
+}
+
+// HandleTableNavigationKey applies shared row/column movement for table views.
+func HandleTableNavigationKey(keyStr string, row, col *int, rowCount, colCount, pageStep int) bool {
+ switch keyStr {
+ case "down", "j":
+ *row = clampIndex(*row+1, rowCount)
+ return true
+ case "up", "k":
+ *row = clampIndex(*row-1, rowCount)
+ return true
+ case "left", "h":
+ *col = clampIndex(*col-1, colCount)
+ return true
+ case "right", "l":
+ *col = clampIndex(*col+1, colCount)
+ return true
+ case "g":
+ *row = clampIndex(0, rowCount)
+ return true
+ case "G":
+ *row = clampIndex(rowCount-1, rowCount)
+ return true
+ case "pgup", "pageup":
+ if pageStep < 1 {
+ pageStep = 1
+ }
+ *row = clampIndex(*row-pageStep, rowCount)
+ return true
+ case "pgdown", "pgdn", "pagedown":
+ if pageStep < 1 {
+ pageStep = 1
+ }
+ *row = clampIndex(*row+pageStep, rowCount)
+ return true
+ default:
+ return false
+ }
+}
+
+// VisibleTableWindow returns a centered visible row range for the selection.
+func VisibleTableWindow(selectedRow, rowCount, visibleRows int) (int, int) {
+ if rowCount <= 0 {
+ return 0, 0
+ }
+ if visibleRows <= 0 || visibleRows >= rowCount {
+ return 0, rowCount
+ }
+ selectedRow = clampIndex(selectedRow, rowCount)
+ start := selectedRow - visibleRows/2
+ if start < 0 {
+ start = 0
+ }
+ maxStart := rowCount - visibleRows
+ if start > maxStart {
+ start = maxStart
+ }
+ return start, start + visibleRows
+}
+
+// ClampTableCol clamps a selected column index for the current table shape.
+func ClampTableCol(col, colCount int) int {
+ return clampIndex(col, colCount)
+}
+
+func renderTableCell(value string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ value = sanitizeTableCell(value)
+ value = truncateTableCell(value, width)
+ cellWidth := utf8.RuneCountInString(value)
+ if cellWidth >= width {
+ return value
+ }
+ return value + strings.Repeat(" ", width-cellWidth)
+}
+
+func sanitizeTableCell(value string) string {
+ value = strings.ReplaceAll(value, "\n", " ")
+ value = strings.ReplaceAll(value, "\r", " ")
+ value = strings.ReplaceAll(value, "\t", " ")
+ return value
+}
+
+func truncateTableCell(value string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ if utf8.RuneCountInString(value) <= width {
+ return value
+ }
+ if width <= 3 {
+ return string([]rune(value)[:width])
+ }
+ runes := []rune(value)
+ return string(runes[:width-3]) + "..."
+}
+
+func clampIndex(value, count int) int {
+ if count <= 0 {
+ return 0
+ }
+ if value < 0 {
+ return 0
+ }
+ if value >= count {
+ return count - 1
+ }
+ return value
+}
diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go
index e9bb218..e868e3a 100644
--- a/internal/tui/dashboard/files.go
+++ b/internal/tui/dashboard/files.go
@@ -1,14 +1,12 @@
package dashboard
import (
- "fmt"
"path/filepath"
"sort"
"strconv"
"ior/internal/statsengine"
-
- "charm.land/bubbles/v2/table"
+ common "ior/internal/tui/common"
)
type DirSnapshot struct {
@@ -24,10 +22,10 @@ type DirSnapshot struct {
}
func renderFiles(snap *statsengine.Snapshot, width, height int) string {
- return renderFilesWithOffset(snap, width, height, 0)
+ return renderFilesWithOffset(snap, width, height, 0, 0)
}
-func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int) string {
+func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
if snap == nil {
return "Files: waiting for stats..."
}
@@ -38,28 +36,11 @@ func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int
return "Files: no data"
}
- columns := []table.Column{
- {Title: "Accesses", Width: 8},
- {Title: "Read", Width: 9},
- {Title: "Write", Width: 9},
- {Title: "Avg Latency", Width: 11},
- {Title: "Max Latency", Width: 11},
- {Title: "Path", Width: pathWidth},
- }
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs] [v:mode in dirs]", cursor+1, len(rows))
+ columns := fileColumns(width)
+ return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:dirs", "v:mode in dirs")
}
-func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int) string {
+func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
if snap == nil {
return "Files (dirs): waiting for stats..."
}
@@ -70,32 +51,14 @@ func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int
return "Files (dirs): no data"
}
- columns := []table.Column{
- {Title: "Accesses", Width: 8},
- {Title: "Read", Width: 9},
- {Title: "Write", Width: 9},
- {Title: "Avg Latency", Width: 11},
- {Title: "Max Latency", Width: 11},
- {Title: "Files", Width: 5},
- {Title: "Directory", Width: pathWidth},
- }
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files] [v:mode] [b:metric]", cursor+1, len(rows))
+ columns := fileDirColumns(width)
+ return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:files", "v:mode", "b:metric")
}
-func fileRows(files []statsengine.FileSnapshot, pathWidth int) []table.Row {
- rows := make([]table.Row, 0, len(files))
+func fileRows(files []statsengine.FileSnapshot, pathWidth int) [][]string {
+ rows := make([][]string, 0, len(files))
for _, f := range files {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
strconv.FormatUint(f.Accesses, 10),
formatBytes(float64(f.BytesRead)),
formatBytes(float64(f.BytesWritten)),
@@ -132,6 +95,31 @@ func dirPathWidth(width int) int {
return w
}
+func fileColumns(width int) []common.TableColumn {
+ pathWidth := filePathWidth(width)
+ return []common.TableColumn{
+ {Title: "Accesses", Width: 8},
+ {Title: "Read", Width: 9},
+ {Title: "Write", Width: 9},
+ {Title: "Avg Latency", Width: 11},
+ {Title: "Max Latency", Width: 11},
+ {Title: "Path", Width: pathWidth},
+ }
+}
+
+func fileDirColumns(width int) []common.TableColumn {
+ pathWidth := dirPathWidth(width)
+ return []common.TableColumn{
+ {Title: "Accesses", Width: 8},
+ {Title: "Read", Width: 9},
+ {Title: "Write", Width: 9},
+ {Title: "Avg Latency", Width: 11},
+ {Title: "Max Latency", Width: 11},
+ {Title: "Files", Width: 5},
+ {Title: "Directory", Width: pathWidth},
+ }
+}
+
func truncatePathMiddle(path string, limit int) string {
if len(path) <= limit {
return path
@@ -187,10 +175,10 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot {
return out
}
-func dirRows(dirs []DirSnapshot, pathWidth int) []table.Row {
- rows := make([]table.Row, 0, len(dirs))
+func dirRows(dirs []DirSnapshot, pathWidth int) [][]string {
+ rows := make([][]string, 0, len(dirs))
for _, d := range dirs {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
strconv.FormatUint(d.Accesses, 10),
formatBytes(float64(d.BytesRead)),
formatBytes(float64(d.BytesWritten)),
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 1ffb6e8..3a19b74 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -70,11 +70,15 @@ type Model struct {
filterStack []string
pidFilter int
syscallsOffset int
+ syscallsCol int
syscallsTreemapSelection int
filesOffset int
+ filesCol int
filesDirGrouped bool
filesDirOffset int
+ filesDirCol int
processesOffset int
+ processesCol int
syscallsVizMode tabVizMode
filesVizMode tabVizMode
processesVizMode tabVizMode
@@ -164,6 +168,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
+ m.clampTableColumns()
streamWidth, streamHeight := streamViewport(msg.Width, msg.Height)
m.streamModel.SetViewport(streamWidth, streamHeight)
flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp)
@@ -226,6 +231,7 @@ func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) {
m.filesOffset = clampOffset(m.filesOffset, m.maxFilesRows())
m.filesDirOffset = clampOffset(m.filesDirOffset, m.maxFilesDirRowsForMode())
m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows())
+ m.clampTableColumns()
m.streamModel.Refresh()
if m.refreshBubbleData() {
return m, bubbleTickCmdFn()
@@ -279,6 +285,24 @@ func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
return false, nil
}
switch m.activeTab {
+ case TabSyscalls:
+ if m.syscallsVizMode != tabVizModeTable {
+ return false, nil
+ }
+ filter, action, ok := m.selectedSyscallFilter()
+ if !ok {
+ return false, nil
+ }
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
+ case TabFiles:
+ if m.filesVizMode != tabVizModeTable {
+ return false, nil
+ }
+ filter, action, ok := m.selectedFileFilter()
+ if !ok {
+ return false, nil
+ }
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
case TabProcesses:
filter, action, ok := m.selectedProcessFilter()
if !ok {
@@ -290,6 +314,52 @@ func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
}
}
+func (m Model) selectedSyscallFilter() (globalfilter.Filter, string, bool) {
+ if m.latest == nil || m.syscallsOffset < 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ rows := m.latest.Syscalls()
+ if len(rows) == 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ selected := rows[clampOffset(m.syscallsOffset, len(rows))]
+ if strings.TrimSpace(selected.Name) == "" {
+ return globalfilter.Filter{}, "", false
+ }
+ filter := m.globalFilter.Clone()
+ filter.Syscall = &globalfilter.StringFilter{Pattern: selected.Name}
+ return filter, "syscall~" + selected.Name, true
+}
+
+func (m Model) selectedFileFilter() (globalfilter.Filter, string, bool) {
+ if m.latest == nil {
+ return globalfilter.Filter{}, "", false
+ }
+ filter := m.globalFilter.Clone()
+ if m.filesDirGrouped {
+ dirs := aggregateFilesByDir(m.latest.Files())
+ if len(dirs) == 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ selected := dirs[clampOffset(m.filesDirOffset, len(dirs))]
+ if strings.TrimSpace(selected.Dir) == "" {
+ return globalfilter.Filter{}, "", false
+ }
+ filter.File = &globalfilter.StringFilter{Pattern: selected.Dir}
+ return filter, "file~" + selected.Dir, true
+ }
+ files := m.latest.Files()
+ if len(files) == 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ selected := files[clampOffset(m.filesOffset, len(files))]
+ if strings.TrimSpace(selected.Path) == "" {
+ return globalfilter.Filter{}, "", false
+ }
+ filter.File = &globalfilter.StringFilter{Pattern: selected.Path}
+ return filter, "file~" + selected.Path, true
+}
+
func (m Model) handleHelpToggleKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
if msg.String() != "H" {
return false, m, nil
@@ -380,9 +450,15 @@ func (m Model) selectedProcessFilter() (globalfilter.Filter, string, bool) {
return globalfilter.Filter{}, "", false
}
filter := m.globalFilter.Clone()
+ if m.processesCol == 1 {
+ comm := strings.TrimSpace(proc.Comm)
+ if comm != "" {
+ filter.Comm = &globalfilter.StringFilter{Pattern: comm}
+ return filter, "comm~" + comm, true
+ }
+ }
filter.PID = &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: int64(proc.PID)}
- action := fmt.Sprintf("pid=%d", proc.PID)
- return filter, action, true
+ return filter, fmt.Sprintf("pid=%d", proc.PID), true
}
func (m Model) selectedProcessSnapshot() (statsengine.ProcessSnapshot, bool) {
@@ -515,14 +591,14 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
if m.syscallsVizMode == tabVizModeTreemap {
return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil
}
- return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.syscallsOffset, &m.syscallsCol, m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
case TabFiles:
if m.filesDirGrouped {
- return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRowsForMode()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.filesDirOffset, &m.filesDirCol, m.maxFilesDirRowsForMode(), len(fileDirColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
- return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.filesOffset, &m.filesCol, m.maxFilesRows(), len(fileColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
case TabProcesses:
- return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.processesOffset, &m.processesCol, m.maxProcessesRows(), len(processColumns()), tablePageStep(m.activeTableHeight())), nil
case TabStream:
streamWidth, streamHeight := streamViewport(m.width, m.height)
m.streamModel.SetViewport(streamWidth, streamHeight)
@@ -566,6 +642,13 @@ func scrollOffset(keyStr string, offset *int, maxRows int) bool {
}
}
+func (m *Model) clampTableColumns() {
+ m.syscallsCol = common.ClampTableCol(m.syscallsCol, len(syscallColumns(m.width)))
+ m.filesCol = common.ClampTableCol(m.filesCol, len(fileColumns(m.width)))
+ m.filesDirCol = common.ClampTableCol(m.filesDirCol, len(fileDirColumns(m.width)))
+ m.processesCol = common.ClampTableCol(m.processesCol, len(processColumns()))
+}
+
func (m Model) maxSyscallsRows() int {
if m.latest == nil {
return 0
@@ -775,13 +858,22 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
activeHeight,
m.pidFilter,
m.syscallsOffset,
+ m.syscallsCol,
m.filesOffset,
+ m.filesCol,
m.filesDirGrouped,
m.filesDirOffset,
+ m.filesDirCol,
m.processesOffset,
+ m.processesCol,
)
}
+func (m Model) activeTableHeight() int {
+ _, activeHeight := flameViewport(m.width, m.height, m.showHelp)
+ return activeHeight
+}
+
func (m *Model) setBubbleViewports(width, height int) {
m.syscallsChart.SetViewport(width, height)
m.filesChart.SetViewport(width, height)
@@ -988,7 +1080,7 @@ func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} })
}
-func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string {
+func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, syscallsCol, filesOffset, filesCol int, filesDirGrouped bool, filesDirOffset, filesDirCol, processesOffset, processesCol int) string {
if tab == TabStream {
if streamModel == nil {
return common.PanelStyle.Render("Stream: waiting for source...")
@@ -1011,14 +1103,14 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre
case TabOverview:
return renderOverview(snap, width, height)
case TabSyscalls:
- return renderSyscallsWithOffset(snap, width, height, syscallsOffset)
+ return renderSyscallsWithOffset(snap, width, height, syscallsOffset, syscallsCol)
case TabFiles:
if filesDirGrouped {
- return renderFilesDirGrouped(snap, width, height, filesDirOffset)
+ return renderFilesDirGrouped(snap, width, height, filesDirOffset, filesDirCol)
}
- return renderFilesWithOffset(snap, width, height, filesOffset)
+ return renderFilesWithOffset(snap, width, height, filesOffset, filesCol)
case TabProcesses:
- return renderProcessesWithOffset(snap, width, height, processesOffset, pidFilter)
+ return renderProcessesWithOffset(snap, width, height, processesOffset, processesCol, pidFilter)
case TabLatency:
return renderLatencyGapsTab(snap, width, height)
default:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index cfeb5c8..dbcde07 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -142,6 +142,63 @@ func TestProcessesTabScrollsWithJK(t *testing.T) {
}
}
+func TestSyscallsTabSupportsHorizontalColumnNavigation(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ model := next.(Model)
+ if model.syscallsCol != 1 {
+ t.Fatalf("expected syscalls selected column 1 after right, got %d", model.syscallsCol)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ model = next.(Model)
+ if model.syscallsCol != 0 {
+ t.Fatalf("expected syscalls selected column 0 after left, got %d", model.syscallsCol)
+ }
+}
+
+func TestFilesTabSupportsHorizontalColumnNavigation(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ model := next.(Model)
+ if model.filesCol != 1 {
+ t.Fatalf("expected files selected column 1 after right, got %d", model.filesCol)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ model = next.(Model)
+ if model.filesCol != 0 {
+ t.Fatalf("expected files selected column 0 after left, got %d", model.filesCol)
+ }
+}
+
+func TestProcessesTabSupportsHorizontalColumnNavigation(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabProcesses
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1, Comm: "alpha"}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ model := next.(Model)
+ if model.processesCol != 1 {
+ t.Fatalf("expected processes selected column 1 after right, got %d", model.processesCol)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ model = next.(Model)
+ if model.processesCol != 0 {
+ t.Fatalf("expected processes selected column 0 after left, got %d", model.processesCol)
+ }
+}
+
func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabProcesses
@@ -170,6 +227,35 @@ func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
}
}
+func TestProcessesTabEnterCommColumnEmitsCommFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabProcesses
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{
+ {PID: 111, Comm: "alpha", Syscalls: 9},
+ {PID: 222, Comm: "beta", Syscalls: 4},
+ }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.processesOffset = 1
+ m.processesCol = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on processes comm column to emit a filter request")
+ }
+ msg := cmd()
+ req, ok := msg.(messages.GlobalFilterRequestedMsg)
+ if !ok {
+ t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg)
+ }
+ if req.Filter.Comm == nil || req.Filter.Comm.Pattern != "beta" {
+ t.Fatalf("expected comm beta filter, got %+v", req.Filter.Comm)
+ }
+ if req.Action != "comm~beta" {
+ t.Fatalf("expected action comm~beta, got %q", req.Action)
+ }
+}
+
func TestFilesTabScrollsWithJK(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
@@ -189,6 +275,34 @@ func TestFilesTabScrollsWithJK(t *testing.T) {
}
}
+func TestSyscallsTabEnterEmitsGlobalFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "read", Count: 9},
+ {Name: "write", Count: 4},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.syscallsOffset = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on syscalls tab to emit a filter request")
+ }
+ msg := cmd()
+ req, ok := msg.(messages.GlobalFilterRequestedMsg)
+ if !ok {
+ t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg)
+ }
+ if req.Filter.Syscall == nil || req.Filter.Syscall.Pattern != "write" {
+ t.Fatalf("expected syscall write filter, got %+v", req.Filter.Syscall)
+ }
+ if req.Action != "syscall~write" {
+ t.Fatalf("expected action syscall~write, got %q", req.Action)
+ }
+}
+
func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
@@ -210,6 +324,34 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
}
}
+func TestFilesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
+ {Path: "/tmp/a"},
+ {Path: "/tmp/b"},
+ }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.filesOffset = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on files tab to emit a filter request")
+ }
+ msg := cmd()
+ req, ok := msg.(messages.GlobalFilterRequestedMsg)
+ if !ok {
+ t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg)
+ }
+ if req.Filter.File == nil || req.Filter.File.Pattern != "/tmp/b" {
+ t.Fatalf("expected file /tmp/b filter, got %+v", req.Filter.File)
+ }
+ if req.Action != "file~/tmp/b" {
+ t.Fatalf("expected action file~/tmp/b, got %q", req.Action)
+ }
+}
+
func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) {
rb := eventstream.NewRingBuffer()
m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap())
@@ -853,7 +995,7 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) {
statsengine.HistogramSnapshot{},
statsengine.HistogramSnapshot{},
)
- out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, true, 0, 0)
+ out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, 0, 0, true, 0, 0, 0, 0)
if !strings.Contains(out, "Directory") {
t.Fatalf("expected grouped directory files view header, got %q", out)
}
diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go
index b71b22c..dbd8fbe 100644
--- a/internal/tui/dashboard/processes.go
+++ b/internal/tui/dashboard/processes.go
@@ -6,15 +6,14 @@ import (
"strings"
"ior/internal/statsengine"
-
- "charm.land/bubbles/v2/table"
+ common "ior/internal/tui/common"
)
func renderProcesses(snap *statsengine.Snapshot, width, height int) string {
- return renderProcessesWithOffset(snap, width, height, 0, -1)
+ return renderProcessesWithOffset(snap, width, height, 0, 0, -1)
}
-func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, pidFilter int) string {
+func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol, pidFilter int) string {
if snap == nil {
return "Processes: waiting for stats..."
}
@@ -24,7 +23,16 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset
return "Processes: no data"
}
- columns := []table.Column{
+ columns := processColumns()
+ out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric")
+ if pidFilter > 0 {
+ out += "\n" + "Note: this tab is most useful with All PIDs."
+ }
+ return out
+}
+
+func processColumns() []common.TableColumn {
+ return []common.TableColumn{
{Title: "PID", Width: 8},
{Title: "Comm", Width: 18},
{Title: "Syscalls", Width: 10},
@@ -32,28 +40,12 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset
{Title: "Total Bytes", Width: 12},
{Title: "Avg Latency", Width: 12},
}
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
-
- out := tbl.View() + fmt.Sprintf("\nRow %d/%d [enter:filter] [v:mode] [b:metric]", cursor+1, len(rows))
- if pidFilter > 0 {
- out += "\n" + "Note: this tab is most useful with All PIDs."
- }
- return out
}
-func processRows(processes []statsengine.ProcessSnapshot) []table.Row {
- rows := make([]table.Row, 0, len(processes))
+func processRows(processes []statsengine.ProcessSnapshot) [][]string {
+ rows := make([][]string, 0, len(processes))
for _, p := range processes {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
strconv.FormatUint(uint64(p.PID), 10),
truncateText(p.Comm, 18),
strconv.FormatUint(p.Syscalls, 10),
diff --git a/internal/tui/dashboard/processes_test.go b/internal/tui/dashboard/processes_test.go
index 24c1c1b..bfc26fc 100644
--- a/internal/tui/dashboard/processes_test.go
+++ b/internal/tui/dashboard/processes_test.go
@@ -41,7 +41,7 @@ func TestRenderProcessesShowsSinglePIDNote(t *testing.T) {
statsengine.HistogramSnapshot{},
)
- out := renderProcessesWithOffset(&snap, 100, 20, 0, 77)
+ out := renderProcessesWithOffset(&snap, 100, 20, 0, 0, 77)
if !strings.Contains(out, "most useful with All PIDs") {
t.Fatalf("expected single-pid guidance note")
}
diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go
index 5235b3e..9518f09 100644
--- a/internal/tui/dashboard/syscalls.go
+++ b/internal/tui/dashboard/syscalls.go
@@ -6,15 +6,14 @@ import (
"time"
"ior/internal/statsengine"
-
- "charm.land/bubbles/v2/table"
+ common "ior/internal/tui/common"
)
func renderSyscalls(snap *statsengine.Snapshot, width, height int) string {
- return renderSyscallsWithOffset(snap, width, height, 0)
+ return renderSyscallsWithOffset(snap, width, height, 0, 0)
}
-func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset int) string {
+func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
if snap == nil {
return