diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 10:24:01 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 10:24:01 +0200 |
| commit | c661b23f2940e07a1e1cbe16334598d999096f27 (patch) | |
| tree | 1cb86ad9b47b49ed4bd126b86835fdccf4e51abe /internal | |
| parent | 76db79bbd74ebf58ea4403a7e623316c1e4b41de (diff) | |
tui: add paused stream row selection and highlight
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/eventstream/model.go | 112 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 61 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 18 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 10 |
4 files changed, 183 insertions, 18 deletions
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 0c50d0c..fc423cb 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -23,6 +23,7 @@ type Model struct { scrollOffset int autoScroll bool + selectedIdx int width int height int @@ -33,6 +34,7 @@ func NewModel(source *RingBuffer) Model { source: source, filterModal: NewFilterModal(), autoScroll: true, + selectedIdx: -1, } } @@ -84,7 +86,11 @@ func (m *Model) HandleKey(keyStr string) bool { if !m.paused { // Resuming should return to live-tail behavior immediately. m.autoScroll = true + m.selectedIdx = -1 m.Refresh() + } else { + m.ensureSelection() + m.centerSelection() } return true case "f", "F": @@ -93,28 +99,52 @@ func (m *Model) HandleKey(keyStr string) bool { m.filterModal = m.filterModal.Open(m.filter) return true case "G": - m.autoScroll = true - m.scrollOffset = m.maxScrollOffset() + if m.paused { + m.moveSelectionTo(len(m.filtered) - 1) + } else { + m.autoScroll = true + m.scrollOffset = m.maxScrollOffset() + } return true case "g": - m.autoScroll = false - m.scrollOffset = 0 + if m.paused { + m.moveSelectionTo(0) + } else { + m.autoScroll = false + m.scrollOffset = 0 + } return true case "c": m.filter = Filter{} m.applyFilter() return true case "j", "down": - m.scrollByLines(1) + if m.paused { + m.moveSelectionBy(1) + } else { + m.scrollByLines(1) + } return true case "k", "up": - m.scrollByLines(-1) + if m.paused { + m.moveSelectionBy(-1) + } else { + m.scrollByLines(-1) + } return true case "pgdown", "pgdn", "pagedown": - m.scrollByLines(m.pageStep()) + if m.paused { + m.moveSelectionBy(m.pageStep()) + } else { + m.scrollByLines(m.pageStep()) + } return true case "pgup", "pageup": - m.scrollByLines(-m.pageStep()) + if m.paused { + m.moveSelectionBy(-m.pageStep()) + } else { + m.scrollByLines(-m.pageStep()) + } return true default: return false @@ -160,14 +190,21 @@ func (m *Model) View(width, height int) string { end = len(m.filtered) } visible := m.filtered[start:end] + selectedVisibleIdx := -1 + if m.paused && m.selectedIdx >= start && m.selectedIdx < end { + selectedVisibleIdx = m.selectedIdx - start + } bufferLen := 0 if m.source != nil { bufferLen = m.source.Len() } - base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible) + base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx) status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", rowNumber(start, len(m.filtered)), len(m.filtered)) + if m.paused && m.selectedIdx >= 0 { + status = fmt.Sprintf("Row %d/%d | Sel %d/%d | space:pause f:filter G:tail g:top c:clear j/k:select", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered)) + } out := base + "\n" + status if m.filterModal.Visible() { @@ -197,6 +234,7 @@ func (m *Model) applyFilter() { if len(m.allEvents) == 0 { m.filtered = []StreamEvent{} m.scrollOffset = 0 + m.selectedIdx = -1 return } @@ -215,6 +253,11 @@ func (m *Model) applyFilter() { } else { m.scrollOffset = clamp(m.scrollOffset, 0, max) } + m.clampSelection() + if m.paused { + m.ensureSelection() + m.centerSelection() + } } func (m *Model) maxScrollOffset() int { @@ -264,6 +307,57 @@ func (m *Model) scrollByLines(delta int) { } } +func (m *Model) moveSelectionBy(delta int) { + if len(m.filtered) == 0 { + m.selectedIdx = -1 + return + } + m.ensureSelection() + m.moveSelectionTo(m.selectedIdx + delta) +} + +func (m *Model) moveSelectionTo(idx int) { + if len(m.filtered) == 0 { + m.selectedIdx = -1 + return + } + m.selectedIdx = clamp(idx, 0, len(m.filtered)-1) + m.centerSelection() +} + +func (m *Model) centerSelection() { + if len(m.filtered) == 0 || m.selectedIdx < 0 { + return + } + m.autoScroll = false + mid := m.visibleRows() / 2 + target := m.selectedIdx - mid + m.scrollOffset = clamp(target, 0, m.maxScrollOffset()) +} + +func (m *Model) ensureSelection() { + if len(m.filtered) == 0 { + m.selectedIdx = -1 + return + } + if m.selectedIdx >= 0 && m.selectedIdx < len(m.filtered) { + return + } + mid := m.visibleRows() / 2 + m.selectedIdx = clamp(m.scrollOffset+mid, 0, len(m.filtered)-1) +} + +func (m *Model) clampSelection() { + if len(m.filtered) == 0 { + m.selectedIdx = -1 + return + } + if m.selectedIdx < 0 { + return + } + m.selectedIdx = clamp(m.selectedIdx, 0, len(m.filtered)-1) +} + func keyMsgFromString(keyStr string) tea.KeyMsg { switch keyStr { case "esc": diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index bfcbca7..ade1010 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -307,3 +307,64 @@ func TestPausedScrollWithJKAndPageKeys(t *testing.T) { t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) } } + +func TestPausedSelectionInitializesNearMiddleAndCenters(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 20 // visibleRows = 12 + pushEvents(rb, 100) + m.Refresh() + + if !m.HandleKey("space") { + t.Fatalf("space should toggle pause") + } + if !m.paused { + t.Fatalf("expected paused state") + } + if m.selectedIdx < 0 { + t.Fatalf("expected selected index while paused") + } + + mid := m.visibleRows() / 2 + wantOffset := clamp(m.selectedIdx-mid, 0, m.maxScrollOffset()) + if m.scrollOffset != wantOffset { + t.Fatalf("expected centered offset %d, got %d", wantOffset, m.scrollOffset) + } +} + +func TestPausedSelectionMovesAndRecentersWithJKAndArrows(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 20 // visibleRows = 12 + pushEvents(rb, 100) + m.Refresh() + + if !m.HandleKey("g") { + t.Fatalf("g should be handled") + } + if !m.HandleKey("space") { + t.Fatalf("space should toggle pause") + } + startSel := m.selectedIdx + + if !m.HandleKey("j") { + t.Fatalf("j should be handled while paused") + } + if m.selectedIdx != startSel+1 { + t.Fatalf("expected selected index +1 after j, got %d->%d", startSel, m.selectedIdx) + } + mid := m.visibleRows() / 2 + if m.scrollOffset != clamp(m.selectedIdx-mid, 0, m.maxScrollOffset()) { + t.Fatalf("expected centered viewport after j") + } + + if !m.HandleKey("up") { + t.Fatalf("up should be handled while paused") + } + if m.selectedIdx != startSel { + t.Fatalf("expected selected index back to start after up, got %d", m.selectedIdx) + } + if m.scrollOffset != clamp(m.selectedIdx-mid, 0, m.maxScrollOffset()) { + t.Fatalf("expected centered viewport after up") + } +} diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index 24864b9..92a70be 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -5,6 +5,8 @@ import ( "ior/internal/tui/common" "strconv" "strings" + + "github.com/charmbracelet/lipgloss" ) type columnLayout struct { @@ -18,7 +20,12 @@ type columnLayout struct { file int } -func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent) string { +var selectedRowStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(common.ColorBackground). + Background(common.ColorPrimary) + +func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent, selectedVisibleIdx int) string { if width <= 0 { width = 100 } @@ -28,8 +35,8 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer lines = append(lines, renderStatusLine(paused, totalCount, filteredCount, bufferLen, bufferCap)) lines = append(lines, renderFilterLine(filter)) lines = append(lines, renderColumnHeader(contentWidth)) - for _, ev := range events { - lines = append(lines, renderEventRow(ev, contentWidth)) + for i, ev := range events { + lines = append(lines, renderEventRow(ev, contentWidth, i == selectedVisibleIdx)) } return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) @@ -70,7 +77,7 @@ func renderColumnHeader(width int) string { return common.HelpBarStyle.Render(header) } -func renderEventRow(ev StreamEvent, width int) string { +func renderEventRow(ev StreamEvent, width int, selected bool) string { cols := computeColumnLayout(width) pidTid := fmt.Sprintf("%d.%d", ev.PID, ev.TID) row := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", @@ -83,6 +90,9 @@ func renderEventRow(ev StreamEvent, width int) string { cols.bytes, fitCell(strconv.FormatUint(ev.Bytes, 10), cols.bytes), fitCell(ev.FileName, cols.file), ) + if selected { + return selectedRowStyle.Render(row) + } if ev.IsError { return common.ErrorStyle.Render(row) } diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index 65d7a61..8c2d39a 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -10,7 +10,7 @@ import ( func TestRenderStatusAndFilterLines(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "nginx", PID: 1, TID: 2, DurationNs: 1200, GapNs: 300, Bytes: 64, FileName: "/tmp/a", RetVal: 64}} f := Filter{Syscall: &StringFilter{Pattern: "read"}, PID: &NumericFilter{Op: OpEq, Value: 1}} - out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events) + out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events, -1) for _, want := range []string{"LIVE", "total:100", "filtered:1", "buffer:100/10000", "Filter:", "syscall~read", "pid=1"} { if !strings.Contains(out, want) { @@ -21,7 +21,7 @@ func TestRenderStatusAndFilterLines(t *testing.T) { func TestRenderPausedAndErrorRow(t *testing.T) { events := []StreamEvent{{Syscall: "write", Comm: "worker", PID: 1, TID: 2, DurationNs: 1000000, GapNs: 5000, Bytes: 32, FileName: "/tmp/b", RetVal: -1, IsError: true}} - out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events) + out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events, -1) if !strings.Contains(out, "PAUSED") { t.Fatalf("expected PAUSED indicator\n%s", out) @@ -46,7 +46,7 @@ func TestRenderHeaderAndTruncate(t *testing.T) { FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", RetVal: 1, }} - out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events) + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1) for _, col := range []string{"Gap", "Latency", "Comm", "PID.TID", "Syscall", "Ret", "Bytes", "File"} { if !strings.Contains(out, col) { @@ -87,7 +87,7 @@ func TestRenderEventRowIsSingleLineWithControlCharsAndLongValues(t *testing.T) { RetVal: -9223372036854775808, } - row := renderEventRow(ev, 80) + row := renderEventRow(ev, 80, false) if strings.Contains(row, "\n") || strings.Contains(row, "\r") || strings.Contains(row, "\t") { t.Fatalf("expected a sanitized single-line row, got %q", row) } @@ -116,7 +116,7 @@ func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", RetVal: 1, }, - }) + }, -1) for _, line := range strings.Split(out, "\n") { if lipgloss.Width(line) > 80 { |
