diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 22:58:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 22:58:40 +0200 |
| commit | 4c34b9efcd539c819648c927d7e3f53220df8ad2 (patch) | |
| tree | f9de9fd650a2d16316ba2c159990d891c9de5189 /internal/tui/eventstream | |
| parent | 67e10f34c92e93343adbd690b3b21e455e863bd3 (diff) | |
Fix stream paused scrolling and apply pending TUI/probe updates
Diffstat (limited to 'internal/tui/eventstream')
| -rw-r--r-- | internal/tui/eventstream/model.go | 88 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 137 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 2 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 24 |
4 files changed, 239 insertions, 12 deletions
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index f51b7b5..0c50d0c 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -36,6 +36,17 @@ func NewModel(source *RingBuffer) Model { } } +// SetViewport updates the render/scroll viewport dimensions used for +// max-scroll and page-step calculations during key handling. +func (m *Model) SetViewport(width, height int) { + if width > 0 { + m.width = width + } + if height > 0 { + m.height = height + } +} + // SetSource updates the backing ring buffer and refreshes visible rows. func (m *Model) SetSource(source *RingBuffer) { m.source = source @@ -47,6 +58,11 @@ func (m Model) FilterModalVisible() bool { return m.filterModal.Visible() } +// Paused reports whether stream refresh is currently paused. +func (m Model) Paused() bool { + return m.paused +} + func (m *Model) HandleKey(keyStr string) bool { if m.filterModal.Visible() { wasVisible := m.filterModal.Visible() @@ -66,10 +82,12 @@ func (m *Model) HandleKey(keyStr string) bool { case " ", "space": m.paused = !m.paused if !m.paused { + // Resuming should return to live-tail behavior immediately. + m.autoScroll = true m.Refresh() } return true - case "f": + case "f", "F": m.pauseBeforeFilter = m.paused m.paused = true m.filterModal = m.filterModal.Open(m.filter) @@ -87,24 +105,44 @@ func (m *Model) HandleKey(keyStr string) bool { m.applyFilter() return true case "j", "down": - if m.scrollOffset < m.maxScrollOffset() { - m.scrollOffset++ - } - if m.scrollOffset < m.maxScrollOffset() { - m.autoScroll = false - } + m.scrollByLines(1) return true case "k", "up": - if m.scrollOffset > 0 { - m.scrollOffset-- - } - m.autoScroll = false + m.scrollByLines(-1) + return true + case "pgdown", "pgdn", "pagedown": + m.scrollByLines(m.pageStep()) + return true + case "pgup", "pageup": + m.scrollByLines(-m.pageStep()) return true default: return false } } +// HandleTeaKey handles stream keys based on Bubble Tea key message types first, +// then falls back to string matching for rune-driven shortcuts. +func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool { + switch msg.Type { + case tea.KeyUp: + return m.HandleKey("up") + case tea.KeyDown: + return m.HandleKey("down") + case tea.KeyPgUp: + return m.HandleKey("pgup") + case tea.KeyPgDown: + return m.HandleKey("pgdown") + case tea.KeySpace: + return m.HandleKey("space") + case tea.KeyRunes: + if len(msg.Runes) == 1 { + return m.HandleKey(string(msg.Runes[0])) + } + } + return m.HandleKey(msg.String()) +} + func (m *Model) View(width, height int) string { if width <= 0 { width = 100 @@ -198,6 +236,34 @@ func (m *Model) visibleRows() int { return rows } +func (m *Model) pageStep() int { + rows := m.visibleRows() + if rows <= 1 { + return 1 + } + return rows - 1 +} + +func (m *Model) scrollByLines(delta int) { + if delta == 0 { + return + } + max := m.maxScrollOffset() + next := m.scrollOffset + delta + if next < 0 { + next = 0 + } + if next > max { + next = max + } + if next != m.scrollOffset { + m.scrollOffset = next + } + if m.scrollOffset < max { + m.autoScroll = false + } +} + 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 937bb33..bfcbca7 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -62,6 +62,76 @@ func TestModelScrollClamp(t *testing.T) { } } +func TestModelPageScrollWithPgUpPgDown(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 12 // visibleRows=4, pageStep=3 + pushEvents(rb, 30) + m.Refresh() + m.HandleKey("g") + + if !m.HandleKey("pgdown") { + t.Fatalf("pgdown should be handled") + } + if m.scrollOffset != 3 { + t.Fatalf("expected page down to move by 3, got %d", m.scrollOffset) + } + + if !m.HandleKey("pagedown") { + t.Fatalf("pagedown should be handled") + } + if m.scrollOffset != 6 { + t.Fatalf("expected pagedown alias to move by 3, got %d", m.scrollOffset) + } + + if !m.HandleKey("pgup") { + t.Fatalf("pgup should be handled") + } + if m.scrollOffset != 3 { + t.Fatalf("expected page up to move up by 3, got %d", m.scrollOffset) + } + if !m.HandleKey("pageup") { + t.Fatalf("pageup should be handled") + } + if m.scrollOffset != 0 { + t.Fatalf("expected pageup alias to return to top, got %d", m.scrollOffset) + } +} + +func TestModelArrowAndJKScroll(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 12 + pushEvents(rb, 30) + m.Refresh() + m.HandleKey("g") + + if !m.HandleKey("down") { + t.Fatalf("down should be handled") + } + if m.scrollOffset != 1 { + t.Fatalf("expected down to increment offset, got %d", m.scrollOffset) + } + if !m.HandleKey("j") { + t.Fatalf("j should be handled") + } + if m.scrollOffset != 2 { + t.Fatalf("expected j to increment offset, got %d", m.scrollOffset) + } + if !m.HandleKey("up") { + t.Fatalf("up should be handled") + } + if m.scrollOffset != 1 { + t.Fatalf("expected up to decrement offset, got %d", m.scrollOffset) + } + if !m.HandleKey("k") { + t.Fatalf("k should be handled") + } + if m.scrollOffset != 0 { + t.Fatalf("expected k to decrement offset, got %d", m.scrollOffset) + } +} + func TestModelFilterReducesVisibleRows(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) @@ -170,3 +240,70 @@ func TestFilterModalTemporarilyPausesAndRestoresState(t *testing.T) { t.Fatalf("expected paused state preserved after modal close") } } + +func TestUnpauseRestoresLiveTailAndRefresh(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 10 + pushEvents(rb, 20) + m.Refresh() + + // Move off tail, then pause. + m.HandleKey("g") + if m.autoScroll { + t.Fatalf("expected autoScroll disabled at top") + } + m.HandleKey("space") + if !m.paused { + t.Fatalf("expected paused") + } + + // New events arrive while paused. + pushEvents(rb, 5) + m.Refresh() + + // Resume: should auto-tail and refresh immediately. + m.HandleKey("space") + if m.paused { + t.Fatalf("expected unpaused") + } + if !m.autoScroll { + t.Fatalf("expected autoScroll restored on resume") + } + if m.scrollOffset != m.maxScrollOffset() { + t.Fatalf("expected tail offset after resume, got offset=%d max=%d", m.scrollOffset, m.maxScrollOffset()) + } +} + +func TestPausedScrollWithJKAndPageKeys(t *testing.T) { + rb := NewRingBuffer() + m := NewModel(rb) + m.height = 20 + pushEvents(rb, 100) + m.Refresh() + if !m.HandleKey("space") { + t.Fatalf("space should toggle pause") + } + before := rowNumber(m.scrollOffset, len(m.filtered)) + if !m.HandleKey("k") { + t.Fatalf("k should be handled while paused") + } + afterK := rowNumber(m.scrollOffset, len(m.filtered)) + if afterK >= before { + t.Fatalf("expected k to scroll up while paused: before=%d after=%d", before, afterK) + } + if !m.HandleKey("pgup") { + t.Fatalf("pgup should be handled while paused") + } + afterPgUp := rowNumber(m.scrollOffset, len(m.filtered)) + if afterPgUp >= afterK { + t.Fatalf("expected pgup to scroll up while paused: afterK=%d afterPgUp=%d", afterK, afterPgUp) + } + if !m.HandleKey("pgdown") { + t.Fatalf("pgdown should be handled while paused") + } + afterPgDown := rowNumber(m.scrollOffset, len(m.filtered)) + if afterPgDown <= afterPgUp { + t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) + } +} diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index e1781f8..24864b9 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -32,7 +32,7 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer lines = append(lines, renderEventRow(ev, contentWidth)) } - return common.PanelStyle.Width(width).Render(strings.Join(lines, "\n")) + return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n")) } func renderStatusLine(paused bool, totalCount, filteredCount, bufferLen, bufferCap int) string { diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index c7f32cd..65d7a61 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -3,6 +3,8 @@ package eventstream import ( "strings" "testing" + + "github.com/charmbracelet/lipgloss" ) func TestRenderStatusAndFilterLines(t *testing.T) { @@ -100,3 +102,25 @@ func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) { t.Fatalf("expected file column to get most width, got %d", cols.file) } } + +func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, []StreamEvent{ + { + Syscall: "read", + Comm: "worker", + PID: 1, + TID: 2, + DurationNs: 2000, + GapNs: 100, + Bytes: 64, + FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", + RetVal: 1, + }, + }) + + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > 80 { + t.Fatalf("line exceeds width 80: %d %q", lipgloss.Width(line), line) + } + } +} |
