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 "Syscalls: waiting for stats..."
}
@@ -23,22 +22,20 @@ func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset
if len(rows) == 0 {
return "Syscalls: no data"
}
+ return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric")
+}
- 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 [v:mode] [b:metric]", cursor+1, len(rows))
+func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]common.TableColumn, [][]string) {
+ columns := syscallColumns(width)
+ if width < 140 {
+ return columns, syscallRowsCompact(syscalls)
+ }
+ return columns, syscallRowsFull(syscalls)
}
-func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]table.Column, []table.Row) {
+func syscallColumns(width int) []common.TableColumn {
if width < 140 {
- columns := []table.Column{
+ return []common.TableColumn{
{Title: "Syscall", Width: 14},
{Title: "Count", Width: 6},
{Title: "Rate/s", Width: 7},
@@ -48,10 +45,9 @@ func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]tabl
{Title: "Bytes", Width: 8},
{Title: "Errors", Width: 6},
}
- return columns, syscallRowsCompact(syscalls)
}
- columns := []table.Column{
+ return []common.TableColumn{
{Title: "Syscall", Width: 16},
{Title: "Count", Width: 8},
{Title: "Rate/s", Width: 8},
@@ -64,13 +60,12 @@ func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]tabl
{Title: "Bytes", Width: 10},
{Title: "Errors", Width: 8},
}
- return columns, syscallRowsFull(syscalls)
}
-func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) []table.Row {
- rows := make([]table.Row, 0, len(syscalls))
+func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) [][]string {
+ rows := make([][]string, 0, len(syscalls))
for _, s := range syscalls {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
s.Name,
strconv.FormatUint(s.Count, 10),
fmt.Sprintf("%.1f", s.RatePerSec),
@@ -87,10 +82,10 @@ func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) []table.Row {
return rows
}
-func syscallRowsCompact(syscalls []statsengine.SyscallSnapshot) []table.Row {
- rows := make([]table.Row, 0, len(syscalls))
+func syscallRowsCompact(syscalls []statsengine.SyscallSnapshot) [][]string {
+ rows := make([][]string, 0, len(syscalls))
for _, s := range syscalls {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
s.Name,
strconv.FormatUint(s.Count, 10),
fmt.Sprintf("%.1f", s.RatePerSec),
@@ -135,16 +130,6 @@ func syscallTableHeight(height int) int {
return h
}
-func tableWidth(width int) int {
- if width <= 0 {
- return 80
- }
- if width < 60 {
- return 60
- }
- return width
-}
-
func clampOffset(offset, size int) int {
if size == 0 {
return 0
diff --git a/internal/tui/dashboard/table.go b/internal/tui/dashboard/table.go
new file mode 100644
index 0000000..c47b305
--- /dev/null
+++ b/internal/tui/dashboard/table.go
@@ -0,0 +1,46 @@
+package dashboard
+
+import (
+ "fmt"
+ "strings"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/lipgloss/v2"
+)
+
+func renderSelectableTable(columns []common.TableColumn, rows [][]string, height, selectedRow, selectedCol int, rowHint string, extraHints ...string) string {
+ if len(rows) == 0 {
+ return ""
+ }
+
+ selectedRow = clampOffset(selectedRow, len(rows))
+ selectedCol = common.ClampTableCol(selectedCol, len(columns))
+ start, end := common.VisibleTableWindow(selectedRow, len(rows), syscallTableHeight(height))
+
+ lines := make([]string, 0, end-start+2)
+ lines = append(lines, common.RenderTableHeader(columns))
+ for idx := start; idx < end; idx++ {
+ col := -1
+ if idx == selectedRow {
+ col = selectedCol
+ }
+ lines = append(lines, common.RenderTableRow(columns, rows[idx], idx == selectedRow, col, lipgloss.Style{}))
+ }
+
+ hints := []string{fmt.Sprintf("Row %d/%d Col %d/%d", selectedRow+1, len(rows), selectedCol+1, len(columns))}
+ if rowHint != "" {
+ hints = append(hints, rowHint)
+ }
+ hints = append(hints, extraHints...)
+ lines = append(lines, "["+strings.Join(hints, "] [")+"]")
+ return strings.Join(lines, "\n")
+}
+
+func tablePageStep(height int) int {
+ rows := syscallTableHeight(height)
+ if rows <= 1 {
+ return 1
+ }
+ return rows - 1
+}
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index 600d40d..2780524 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
+ "ior/internal/tui/common"
+
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
)
@@ -321,7 +323,7 @@ func (m *Model) HandleKey(keyStr string) bool {
return true
case "G":
if m.paused {
- m.moveSelectionTo(len(m.filtered) - 1)
+ return m.handlePausedTableNavigation("G")
} else {
m.autoScroll = true
m.viewport.GotoBottom()
@@ -330,7 +332,7 @@ func (m *Model) HandleKey(keyStr string) bool {
return true
case "g":
if m.paused {
- m.moveSelectionTo(0)
+ return m.handlePausedTableNavigation("g")
} else {
m.autoScroll = false
m.viewport.GotoTop()
@@ -339,40 +341,38 @@ func (m *Model) HandleKey(keyStr string) bool {
return true
case "j", "down":
if m.paused {
- m.moveSelectionBy(1)
+ return m.handlePausedTableNavigation(keyStr)
} else {
m.handleViewportUpdate(keyMsgFromString("down"))
}
return true
case "k", "up":
if m.paused {
- m.moveSelectionBy(-1)
+ return m.handlePausedTableNavigation(keyStr)
} else {
m.handleViewportUpdate(keyMsgFromString("up"))
}
return true
case "left", "h":
if m.paused {
- m.moveSelectedColBy(-1)
- return true
+ return m.handlePausedTableNavigation(keyStr)
}
return m.handleViewportUpdate(keyMsgFromString("left"))
case "right", "l":
if m.paused {
- m.moveSelectedColBy(1)
- return true
+ return m.handlePausedTableNavigation(keyStr)
}
return m.handleViewportUpdate(keyMsgFromString("right"))
case "pgdown", "pgdn", "pagedown":
if m.paused {
- m.moveSelectionBy(m.pageStep())
+ return m.handlePausedTableNavigation(keyStr)
} else {
m.handleViewportUpdate(keyMsgFromString("pgdown"))
}
return true
case "pgup", "pageup":
if m.paused {
- m.moveSelectionBy(-m.pageStep())
+ return m.handlePausedTableNavigation(keyStr)
} else {
m.handleViewportUpdate(keyMsgFromString("pgup"))
}
@@ -597,6 +597,24 @@ func (m *Model) pageStep() int {
return rows - 1
}
+func (m *Model) handlePausedTableNavigation(keyStr string) bool {
+ if len(m.filtered) == 0 {
+ m.selectedIdx = -1
+ return true
+ }
+ m.ensureSelection()
+ m.ensureSelectedCol()
+ row := m.selectedIdx
+ col := m.selectedCol
+ if !common.HandleTableNavigationKey(keyStr, &row, &col, len(m.filtered), streamColumnCount, m.pageStep()) {
+ return false
+ }
+ m.selectedIdx = row
+ m.selectedCol = col
+ m.centerSelection()
+ return true
+}
+
func (m *Model) openFDTraceView() bool {
if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) {
return false
@@ -666,16 +684,6 @@ func (m *Model) scrollFDTraceByLines(delta int) {
m.fdTraceView.offset = next
}
-func (m *Model) moveSelectionBy(delta int) {
- if len(m.filtered) == 0 {
- m.selectedIdx = -1
- return
- }
- m.ensureSelection()
- m.ensureSelectedCol()
- m.moveSelectionTo(m.selectedIdx + delta)
-}
-
func (m *Model) moveSelectionTo(idx int) {
if len(m.filtered) == 0 {
m.selectedIdx = -1
@@ -718,14 +726,6 @@ func (m *Model) ensureSelectedCol() {
}
}
-func (m *Model) moveSelectedColBy(delta int) {
- if delta == 0 {
- return
- }
- m.ensureSelectedCol()
- m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1)
-}
-
func (m *Model) requestGlobalFilterFromSelectedCell() bool {
if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) {
return false
diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go
index 819b6f4..4d7970b 100644
--- a/internal/tui/eventstream/render.go
+++ b/internal/tui/eventstream/render.go
@@ -23,21 +23,12 @@ type columnLayout struct {
file int
}
-var selectedRowStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(common.ColorBackground).
- Background(common.ColorPrimary)
-
-var selectedCellStyle = lipgloss.NewStyle().
- Bold(true).
- Foreground(common.ColorBackground).
- Background(common.ColorAccent)
-
func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, filterStack []string, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string {
if width <= 0 {
width = 100
}
contentWidth := panelContentWidth(width)
+ columns := streamColumns(contentWidth)
lines := make([]string, 0, len(events)+3)
lines = append(lines, renderStatusLine(paused, totalCount, filteredCount, bufferLen, bufferCap))
@@ -45,13 +36,9 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer
if len(filterStack) > 0 {
lines = append(lines, renderFilterStackLine(filterStack))
}
- lines = append(lines, renderColumnHeader(contentWidth))
+ lines = append(lines, common.RenderTableHeader(columns))
for i, ev := range events {
- col := -1
- if i == selectedVisibleIdx {
- col = selectedCol
- }
- lines = append(lines, renderEventRow(ev, contentWidth, i == selectedVisibleIdx, col))
+ lines = append(lines, renderEventRow(ev, columns, i == selectedVisibleIdx, selectedCol))
}
return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n"))
@@ -66,9 +53,10 @@ func RenderFDTraceTable(width int, pid uint32, fd int32, totalCount int, events
lines := make([]string, 0, len(events)+3)
lines = append(lines, common.HeaderStyle.Render("FD Trace (ring snapshot)"))
lines = append(lines, fmt.Sprintf("PID:%d FD:%d matched:%d", pid, fd, totalCount))
- lines = append(lines, renderColumnHeader(contentWidth))
+ columns := streamColumns(contentWidth)
+ lines = append(lines, common.RenderTableHeader(columns))
for _, ev := range events {
- lines = append(lines, renderEventRow(ev, contentWidth, false, -1))
+ lines = append(lines, renderEventRow(ev, columns, false, -1))
}
return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n"))
@@ -98,55 +86,43 @@ func renderFilterStackLine(filterStack []string) string {
return common.HeaderStyle.Render("Stack:") + " " + strings.Join(filterStack, " | ")
}
-func renderColumnHeader(width int) string {
+func streamColumns(width int) []common.TableColumn {
cols := computeColumnLayout(width)
- header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s",
- cols.gap, "Gap",
- cols.latency, "Latency",
- cols.comm, "Comm",
- cols.pid, "PID",
- cols.tid, "TID",
- cols.syscall, "Syscall",
- cols.fd, "FD",
- cols.ret, "Ret",
- cols.bytes, "Bytes",
- "File",
- )
- return common.HelpBarStyle.Render(header)
+ return []common.TableColumn{
+ {Title: "Gap", Width: cols.gap},
+ {Title: "Latency", Width: cols.latency},
+ {Title: "Comm", Width: cols.comm},
+ {Title: "PID", Width: cols.pid},
+ {Title: "TID", Width: cols.tid},
+ {Title: "Syscall", Width: cols.syscall},
+ {Title: "FD", Width: cols.fd},
+ {Title: "Ret", Width: cols.ret},
+ {Title: "Bytes", Width: cols.bytes},
+ {Title: "File", Width: cols.file},
+ }
}
-func renderEventRow(ev StreamEvent, width int, selected bool, selectedCol int) string {
- cols := computeColumnLayout(width)
+func renderEventRow(ev StreamEvent, columns []common.TableColumn, selected bool, selectedCol int) string {
fd := "-"
if ev.FD >= 0 {
fd = strconv.FormatInt(int64(ev.FD), 10)
}
cells := []string{
- fmt.Sprintf("%-*s", cols.gap, fitCell(formatDurationNs(ev.GapNs), cols.gap)),
- fmt.Sprintf("%-*s", cols.latency, fitCell(formatDurationNs(ev.DurationNs), cols.latency)),
- fmt.Sprintf("%-*s", cols.comm, fitCell(ev.Comm, cols.comm)),
- fmt.Sprintf("%-*s", cols.pid, fitCell(strconv.FormatUint(uint64(ev.PID), 10), cols.pid)),
- fmt.Sprintf("%-*s", cols.tid, fitCell(strconv.FormatUint(uint64(ev.TID), 10), cols.tid)),
- fmt.Sprintf("%-*s", cols.syscall, fitCell(ev.Syscall, cols.syscall)),
- fmt.Sprintf("%-*s", cols.fd, fitCell(fd, cols.fd)),
- fmt.Sprintf("%-*s", cols.ret, fitCell(strconv.FormatInt(ev.RetVal, 10), cols.ret)),
- fmt.Sprintf("%-*s", cols.bytes, fitCell(strconv.FormatUint(ev.Bytes, 10), cols.bytes)),
- fitCell(ev.FileName, cols.file),
- }
- if selected {
- for i := range cells {
- if i == selectedCol {
- cells[i] = selectedCellStyle.Render(cells[i])
- } else {
- cells[i] = selectedRowStyle.Render(cells[i])
- }
- }
- return strings.Join(cells, " ")
+ fitCell(formatDurationNs(ev.GapNs), columns[0].Width),
+ fitCell(formatDurationNs(ev.DurationNs), columns[1].Width),
+ fitCell(ev.Comm, columns[2].Width),
+ fitCell(strconv.FormatUint(uint64(ev.PID), 10), columns[3].Width),
+ fitCell(strconv.FormatUint(uint64(ev.TID), 10), columns[4].Width),
+ fitCell(ev.Syscall, columns[5].Width),
+ fitCell(fd, columns[6].Width),
+ fitCell(strconv.FormatInt(ev.RetVal, 10), columns[7].Width),
+ fitCell(strconv.FormatUint(ev.Bytes, 10), columns[8].Width),
+ fitCell(ev.FileName, columns[9].Width),
}
if ev.IsError {
- return common.ErrorStyle.Render(strings.Join(cells, " "))
+ return common.RenderTableRow(columns, cells, selected, selectedCol, common.ErrorStyle)
}
- return strings.Join(cells, " ")
+ return common.RenderTableRow(columns, cells, selected, selectedCol, lipgloss.Style{})
}
func computeColumnLayout(width int) columnLayout {
diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go
index d5b2af5..a5d7d95 100644
--- a/internal/tui/eventstream/render_test.go
+++ b/internal/tui/eventstream/render_test.go
@@ -95,7 +95,7 @@ func TestRenderEventRowIsSingleLineWithControlCharsAndLongValues(t *testing.T) {
RetVal: -9223372036854775808,
}
- row := renderEventRow(ev, 80, false, -1)
+ row := renderEventRow(ev, streamColumns(80), false, -1)
if strings.Contains(row, "\n") || strings.Contains(row, "\r") || strings.Contains(row, "\t") {
t.Fatalf("expected a sanitized single-line row, got %q", row)
}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index fe23a49..3e14060 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -1223,13 +1223,13 @@ func (m Model) helpSections() []helpSection {
title: "Dashboard Tabs",
lines: []string{
"tab/shift+tab tabs 1..7 jump tab r reset baseline",
- "sys/files/proc tables: j/k or up/down scroll",
+ "sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom",
"sys/proc: v bubbles b metric events/bytes",
"files: d dirs toggle v bubbles (dirs only) b metric",
"flame: arrows/hjkl nav enter/click zoom click ancestor undo u/bs/esc undo o order",
"flame: / filter n/N match next/prev space/p pause b metric",
- "stream: space pause /? n/N search",
- "stream: j/k/pg scroll g/G top/tail h/l cols x/X export E open",
+ "stream: space pause enter push filter esc/F undo /? n/N search",
+ "stream: x/X export E open",
},
},
{