diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-08 22:35:34 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-08 22:35:34 +0200 |
| commit | d8bf918e83515f48564e0d0b98d30907944a1e0d (patch) | |
| tree | ed155b0c365b0d322494a96ff799c3a59a1d9ec8 /internal | |
| parent | 9f21f1004beeac10be9223bc8c5514261e397b6e (diff) | |
tui: unify table navigation and rendering
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/common/keys.go | 26 | ||||
| -rw-r--r-- | internal/tui/common/styles.go | 21 | ||||
| -rw-r--r-- | internal/tui/common/table.go | 157 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 90 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 114 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 144 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 40 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes_test.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 55 | ||||
| -rw-r--r-- | internal/tui/dashboard/table.go | 46 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 56 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 88 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 2 | ||||
| -rw-r--r-- | internal/tui/tui.go | 6 |
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", }, }, { |
