summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-26 10:24:01 +0200
committerPaul Buetow <paul@buetow.org>2026-02-26 10:24:01 +0200
commitc661b23f2940e07a1e1cbe16334598d999096f27 (patch)
tree1cb86ad9b47b49ed4bd126b86835fdccf4e51abe
parent76db79bbd74ebf58ea4403a7e623316c1e4b41de (diff)
tui: add paused stream row selection and highlight
-rw-r--r--internal/tui/eventstream/model.go112
-rw-r--r--internal/tui/eventstream/model_test.go61
-rw-r--r--internal/tui/eventstream/render.go18
-rw-r--r--internal/tui/eventstream/render_test.go10
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 {