summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 08:38:56 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 08:38:56 +0200
commit1a6e71ac31353167ec4c614d45e8e06de411a8f9 (patch)
treeb42c23fd58600fef0c7780140429abb6d997faaa
parent87462508e417816c3f1ae832e02bda19c1642ec9 (diff)
Add event stream model with filtering and scroll state
-rw-r--r--internal/tui/eventstream/model.go231
-rw-r--r--internal/tui/eventstream/model_test.go124
2 files changed, 355 insertions, 0 deletions
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
new file mode 100644
index 0000000..fb4b88f
--- /dev/null
+++ b/internal/tui/eventstream/model.go
@@ -0,0 +1,231 @@
+package eventstream
+
+import (
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Model struct {
+ source *RingBuffer
+
+ allEvents []StreamEvent
+ filtered []StreamEvent
+
+ filter Filter
+ filterModal FilterModal
+
+ paused bool
+
+ scrollOffset int
+ autoScroll bool
+
+ width int
+ height int
+}
+
+func NewModel(source *RingBuffer) Model {
+ return Model{
+ source: source,
+ filterModal: NewFilterModal(),
+ autoScroll: true,
+ }
+}
+
+func (m *Model) HandleKey(keyStr string) bool {
+ if m.filterModal.Visible() {
+ wasVisible := m.filterModal.Visible()
+ m.filterModal = m.filterModal.Update(keyMsgFromString(keyStr))
+ if wasVisible && !m.filterModal.Visible() {
+ m.filter = m.filterModal.Filter()
+ m.applyFilter()
+ }
+ return true
+ }
+
+ switch keyStr {
+ case " ", "space":
+ m.paused = !m.paused
+ if !m.paused {
+ m.Refresh()
+ }
+ return true
+ case "f":
+ m.filterModal = m.filterModal.Open(m.filter)
+ return true
+ case "G":
+ m.autoScroll = true
+ m.scrollOffset = m.maxScrollOffset()
+ return true
+ case "g":
+ m.autoScroll = false
+ m.scrollOffset = 0
+ return true
+ case "c":
+ m.filter = Filter{}
+ m.applyFilter()
+ return true
+ case "j", "down":
+ if m.scrollOffset < m.maxScrollOffset() {
+ m.scrollOffset++
+ }
+ if m.scrollOffset < m.maxScrollOffset() {
+ m.autoScroll = false
+ }
+ return true
+ case "k", "up":
+ if m.scrollOffset > 0 {
+ m.scrollOffset--
+ }
+ m.autoScroll = false
+ return true
+ default:
+ return false
+ }
+}
+
+func (m *Model) View(width, height int) string {
+ if width <= 0 {
+ width = 100
+ }
+ if height <= 0 {
+ height = 24
+ }
+ m.width = width
+ m.height = height
+
+ rows := m.visibleRows()
+ start := clamp(m.scrollOffset, 0, m.maxScrollOffset())
+ end := start + rows
+ if end > len(m.filtered) {
+ end = len(m.filtered)
+ }
+ visible := m.filtered[start:end]
+
+ 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)
+ 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))
+ out := base + "\n" + status
+
+ if m.filterModal.Visible() {
+ return m.filterModal.View(width, height) + "\n" + out
+ }
+ return out
+}
+
+func (m *Model) Refresh() {
+ if m.paused {
+ return
+ }
+ if m.source == nil {
+ m.allEvents = []StreamEvent{}
+ m.filtered = []StreamEvent{}
+ m.scrollOffset = 0
+ return
+ }
+
+ m.allEvents = m.source.Snapshot()
+ m.applyFilter()
+}
+
+func (m *Model) applyFilter() {
+ if len(m.allEvents) == 0 {
+ m.filtered = []StreamEvent{}
+ m.scrollOffset = 0
+ return
+ }
+
+ filtered := make([]StreamEvent, 0, len(m.allEvents))
+ for i := range m.allEvents {
+ ev := m.allEvents[i]
+ if m.filter.Matches(&ev) {
+ filtered = append(filtered, ev)
+ }
+ }
+ m.filtered = filtered
+
+ max := m.maxScrollOffset()
+ if m.autoScroll {
+ m.scrollOffset = max
+ } else {
+ m.scrollOffset = clamp(m.scrollOffset, 0, max)
+ }
+}
+
+func (m *Model) maxScrollOffset() int {
+ rows := m.visibleRows()
+ if len(m.filtered) <= rows {
+ return 0
+ }
+ return len(m.filtered) - rows
+}
+
+func (m *Model) visibleRows() int {
+ if m.height <= 0 {
+ return 8
+ }
+ rows := m.height - 8
+ if rows < 1 {
+ return 1
+ }
+ return rows
+}
+
+func keyMsgFromString(keyStr string) tea.KeyMsg {
+ switch keyStr {
+ case "esc":
+ return tea.KeyMsg{Type: tea.KeyEsc}
+ case "enter":
+ return tea.KeyMsg{Type: tea.KeyEnter}
+ case "tab":
+ return tea.KeyMsg{Type: tea.KeyTab}
+ case "up":
+ return tea.KeyMsg{Type: tea.KeyUp}
+ case "down":
+ return tea.KeyMsg{Type: tea.KeyDown}
+ case " ", "space":
+ return tea.KeyMsg{Type: tea.KeySpace}
+ }
+ if keyStr == "" {
+ return tea.KeyMsg{}
+ }
+ runes := []rune(keyStr)
+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: runes}
+}
+
+func rowNumber(start, total int) int {
+ if total == 0 {
+ return 0
+ }
+ return start + 1
+}
+
+func clamp(v, min, max int) int {
+ if max < min {
+ return min
+ }
+ if v < min {
+ return min
+ }
+ if v > max {
+ return max
+ }
+ return v
+}
+
+func (m *Model) setFilterForTest(f Filter) {
+ m.filter = f
+}
+
+func (m *Model) dumpVisibleForTest() string {
+ rows := make([]string, 0, len(m.filtered))
+ for _, ev := range m.filtered {
+ rows = append(rows, ev.Syscall)
+ }
+ return strings.Join(rows, ",")
+}
diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go
new file mode 100644
index 0000000..69369d8
--- /dev/null
+++ b/internal/tui/eventstream/model_test.go
@@ -0,0 +1,124 @@
+package eventstream
+
+import "testing"
+
+func pushEvents(rb *RingBuffer, count int) {
+ for i := 0; i < count; i++ {
+ rb.Push(StreamEvent{
+ Seq: uint64(i),
+ Syscall: map[bool]string{true: "read", false: "write"}[i%2 == 0],
+ Comm: "proc",
+ PID: 100,
+ TID: uint32(100 + i),
+ DurationNs: uint64(1000 + i),
+ GapNs: uint64(10 + i),
+ Bytes: uint64(64 + i),
+ FileName: "/tmp/file",
+ RetVal: int64(i),
+ IsError: i%3 == 0,
+ })
+ }
+}
+
+func TestModelPauseFreezesDisplay(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 20
+ pushEvents(rb, 3)
+ m.Refresh()
+ if len(m.filtered) != 3 {
+ t.Fatalf("filtered=%d, want 3", len(m.filtered))
+ }
+
+ if !m.HandleKey("space") {
+ t.Fatalf("space should be handled")
+ }
+ pushEvents(rb, 2)
+ m.Refresh()
+ if len(m.filtered) != 3 {
+ t.Fatalf("paused refresh should not change filtered len, got %d", len(m.filtered))
+ }
+}
+
+func TestModelScrollClamp(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 10
+ pushEvents(rb, 30)
+ m.Refresh()
+
+ for i := 0; i < 100; i++ {
+ m.HandleKey("j")
+ }
+ if m.scrollOffset > m.maxScrollOffset() {
+ t.Fatalf("scrollOffset=%d exceeds max=%d", m.scrollOffset, m.maxScrollOffset())
+ }
+
+ for i := 0; i < 100; i++ {
+ m.HandleKey("k")
+ }
+ if m.scrollOffset != 0 {
+ t.Fatalf("scrollOffset=%d, want 0", m.scrollOffset)
+ }
+}
+
+func TestModelFilterReducesVisibleRows(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 20
+ pushEvents(rb, 10)
+ m.Refresh()
+
+ m.setFilterForTest(Filter{Syscall: &StringFilter{Pattern: "read"}})
+ m.applyFilter()
+
+ if len(m.filtered) >= len(m.allEvents) {
+ t.Fatalf("expected filtered rows to be less than all rows: filtered=%d all=%d", len(m.filtered), len(m.allEvents))
+ }
+}
+
+func TestModelAutoScrollBehavior(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 10
+ pushEvents(rb, 12)
+ m.Refresh()
+
+ if m.scrollOffset != m.maxScrollOffset() {
+ t.Fatalf("expected auto-scroll at bottom, got offset=%d max=%d", m.scrollOffset, m.maxScrollOffset())
+ }
+
+ m.HandleKey("k")
+ prev := m.scrollOffset
+ pushEvents(rb, 3)
+ m.Refresh()
+ if m.scrollOffset != prev {
+ t.Fatalf("when autoScroll=false, offset should stay %d, got %d", prev, m.scrollOffset)
+ }
+
+ m.HandleKey("G")
+ if m.scrollOffset != m.maxScrollOffset() {
+ t.Fatalf("G should jump to tail")
+ }
+}
+
+func TestModelHandleKeyRouting(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+
+ if m.HandleKey("x") {
+ t.Fatalf("unknown key should not be handled")
+ }
+ if !m.HandleKey("f") {
+ t.Fatalf("f should be handled")
+ }
+ if !m.filterModal.Visible() {
+ t.Fatalf("modal should be visible after f")
+ }
+ if !m.HandleKey("esc") {
+ t.Fatalf("esc should route to modal")
+ }
+ if m.filterModal.Visible() {
+ t.Fatalf("modal should close on esc")
+ }
+}