summaryrefslogtreecommitdiff
path: root/internal/tui/eventstream
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 22:58:40 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 22:58:40 +0200
commit4c34b9efcd539c819648c927d7e3f53220df8ad2 (patch)
treef9de9fd650a2d16316ba2c159990d891c9de5189 /internal/tui/eventstream
parent67e10f34c92e93343adbd690b3b21e455e863bd3 (diff)
Fix stream paused scrolling and apply pending TUI/probe updates
Diffstat (limited to 'internal/tui/eventstream')
-rw-r--r--internal/tui/eventstream/model.go88
-rw-r--r--internal/tui/eventstream/model_test.go137
-rw-r--r--internal/tui/eventstream/render.go2
-rw-r--r--internal/tui/eventstream/render_test.go24
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)
+ }
+ }
+}