From bab716a6a5931c211fad2f17ee2f67e8d182a7fc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:20:18 +0200 Subject: feat(tui): migrate Bubble Tea stack to charm.land v2 --- internal/tui/eventstream/exportmodal.go | 8 ++--- internal/tui/eventstream/filtermodal.go | 10 +++--- internal/tui/eventstream/filtermodal_test.go | 50 ++++++++++++++-------------- internal/tui/eventstream/model.go | 29 ++++++++-------- internal/tui/eventstream/render.go | 2 +- internal/tui/eventstream/render_test.go | 2 +- internal/tui/eventstream/searchmodal.go | 8 ++--- 7 files changed, 56 insertions(+), 53 deletions(-) (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/exportmodal.go b/internal/tui/eventstream/exportmodal.go index cf020f7..70c57ee 100644 --- a/internal/tui/eventstream/exportmodal.go +++ b/internal/tui/eventstream/exportmodal.go @@ -3,9 +3,9 @@ package eventstream import ( "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type ExportModal struct { @@ -18,7 +18,7 @@ func NewExportModal() ExportModal { input := textinput.New() input.Prompt = "" input.CharLimit = 0 - input.Width = 44 + input.SetWidth(44) return ExportModal{textInput: input} } diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go index f98db7f..4090925 100644 --- a/internal/tui/eventstream/filtermodal.go +++ b/internal/tui/eventstream/filtermodal.go @@ -5,9 +5,9 @@ import ( "strconv" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type fieldKey int @@ -48,7 +48,7 @@ func NewFilterModal() FilterModal { input := textinput.New() input.Prompt = "" input.CharLimit = 0 - input.Width = 24 + input.SetWidth(24) m := FilterModal{textInput: input} m.fields = defaultFilterFields() @@ -112,7 +112,7 @@ func (m FilterModal) Update(msg tea.Msg) FilterModal { m.fields[m.activeField].opIndex = (m.fields[m.activeField].opIndex + 1) % len(compareOps) } return m - case " ": + case " ", "space": if !m.editing && m.fields[m.activeField].fieldKey == fieldErrorsOnly { if strings.TrimSpace(m.fields[m.activeField].value) == "true" { m.fields[m.activeField].value = "false" diff --git a/internal/tui/eventstream/filtermodal_test.go b/internal/tui/eventstream/filtermodal_test.go index ee53c82..a33cbb1 100644 --- a/internal/tui/eventstream/filtermodal_test.go +++ b/internal/tui/eventstream/filtermodal_test.go @@ -3,7 +3,7 @@ package eventstream import ( "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) func TestFilterModalOpenClose(t *testing.T) { @@ -17,7 +17,7 @@ func TestFilterModalOpenClose(t *testing.T) { t.Fatalf("modal should be visible after open") } - m = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if m.Visible() { t.Fatalf("modal should close on esc") } @@ -29,11 +29,11 @@ func TestFilterModalNavigateFields(t *testing.T) { t.Fatalf("activeField=%d, want 0", m.activeField) } - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) if m.activeField != 1 { t.Fatalf("activeField=%d, want 1", m.activeField) } - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) + m = m.Update(tea.KeyPressMsg{Code: []rune("k")[0], Text: string([]rune("k"))}) if m.activeField != 0 { t.Fatalf("activeField=%d, want 0", m.activeField) } @@ -43,34 +43,34 @@ func TestFilterModalEditAndBuildFilter(t *testing.T) { m := NewFilterModal().Open(Filter{}) // Syscall = read - m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("read")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // PID >= 123 - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyTab}) // '=' -> '>' - m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("123")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // '=' -> '>' + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = m.Update(tea.KeyPressMsg{Code: []rune("123")[0], Text: string([]rune("123"))}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // Latency >= 1ms - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyTab}) // '=' -> '>=' - m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1ms")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // '=' -> '>=' + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = m.Update(tea.KeyPressMsg{Code: []rune("1ms")[0], Text: string([]rune("1ms"))}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // ErrorsOnly = true for m.activeField < len(m.fields)-1 { - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) } - m = m.Update(tea.KeyMsg{Type: tea.KeySpace}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) - m = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if m.Visible() { t.Fatalf("modal should close on esc") } @@ -98,8 +98,8 @@ func TestFilterModalClearAll(t *testing.T) { } m := NewFilterModal().Open(initial) - m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) - m = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = m.Update(tea.KeyPressMsg{Code: []rune("c")[0], Text: string([]rune("c"))}) + m = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) f := m.Filter() if f.IsActive() { diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index d9c4ee3..cb05e47 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) const ( @@ -354,7 +354,7 @@ func (m *Model) HandleKey(keyStr string) bool { // 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 { + switch msg.Key().Code { case tea.KeyLeft: return m.HandleKey("left") case tea.KeyRight: @@ -373,9 +373,12 @@ func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool { return m.HandleKey("esc") case tea.KeyEnter: return m.HandleKey("enter") - case tea.KeyRunes: - if len(msg.Runes) == 1 { - return m.HandleKey(string(msg.Runes[0])) + default: + if msg.Key().Text != "" { + runes := []rune(msg.Key().Text) + if len(runes) == 1 { + return m.HandleKey(msg.Key().Text) + } } } return m.HandleKey(msg.String()) @@ -810,23 +813,23 @@ func (m *Model) clampSelection() { func keyMsgFromString(keyStr string) tea.KeyMsg { switch keyStr { case "esc": - return tea.KeyMsg{Type: tea.KeyEsc} + return tea.KeyPressMsg{Code: tea.KeyEsc} case "enter": - return tea.KeyMsg{Type: tea.KeyEnter} + return tea.KeyPressMsg{Code: tea.KeyEnter} case "tab": - return tea.KeyMsg{Type: tea.KeyTab} + return tea.KeyPressMsg{Code: tea.KeyTab} case "up": - return tea.KeyMsg{Type: tea.KeyUp} + return tea.KeyPressMsg{Code: tea.KeyUp} case "down": - return tea.KeyMsg{Type: tea.KeyDown} + return tea.KeyPressMsg{Code: tea.KeyDown} case " ", "space": - return tea.KeyMsg{Type: tea.KeySpace} + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} } if keyStr == "" { - return tea.KeyMsg{} + return tea.KeyPressMsg{} } runes := []rune(keyStr) - return tea.KeyMsg{Type: tea.KeyRunes, Runes: runes} + return tea.KeyPressMsg{Code: runes[0], Text: keyStr} } func rowNumber(start, total int) int { diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index 1f539c6..f93a63d 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) type columnLayout struct { diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index b020edf..6240c69 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) func TestRenderStatusAndFilterLines(t *testing.T) { diff --git a/internal/tui/eventstream/searchmodal.go b/internal/tui/eventstream/searchmodal.go index f744d00..94e9cd7 100644 --- a/internal/tui/eventstream/searchmodal.go +++ b/internal/tui/eventstream/searchmodal.go @@ -3,9 +3,9 @@ package eventstream import ( "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type SearchDirection int @@ -26,7 +26,7 @@ func NewSearchModal() SearchModal { input := textinput.New() input.Prompt = "" input.CharLimit = 0 - input.Width = 44 + input.SetWidth(44) return SearchModal{textInput: input, direction: SearchForward} } -- cgit v1.2.3 From c3adb86fda302eeb90c2b07e56aae5abaea469ce Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:25:09 +0200 Subject: refactor(tui): migrate source key handling to KeyPressMsg --- internal/tui/eventstream/exportmodal.go | 2 +- internal/tui/eventstream/filtermodal.go | 2 +- internal/tui/eventstream/model.go | 12 ++++++------ internal/tui/eventstream/searchmodal.go | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/exportmodal.go b/internal/tui/eventstream/exportmodal.go index 70c57ee..ec35343 100644 --- a/internal/tui/eventstream/exportmodal.go +++ b/internal/tui/eventstream/exportmodal.go @@ -47,7 +47,7 @@ func (m ExportModal) Update(msg tea.Msg) (ExportModal, string, bool) { if !m.visible { return m, "", false } - if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { switch keyMsg.String() { case "esc": return m.Close(), "", false diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go index 4090925..6c7653e 100644 --- a/internal/tui/eventstream/filtermodal.go +++ b/internal/tui/eventstream/filtermodal.go @@ -86,7 +86,7 @@ func (m FilterModal) Update(msg tea.Msg) FilterModal { return m } - if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { switch keyMsg.String() { case "esc": if m.editing { diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index cb05e47..af7f67d 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -353,8 +353,8 @@ func (m *Model) HandleKey(keyStr string) bool { // 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.Key().Code { +func (m *Model) HandleTeaKey(msg tea.KeyPressMsg) bool { + switch msg.Code { case tea.KeyLeft: return m.HandleKey("left") case tea.KeyRight: @@ -374,10 +374,10 @@ func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool { case tea.KeyEnter: return m.HandleKey("enter") default: - if msg.Key().Text != "" { - runes := []rune(msg.Key().Text) + if msg.Text != "" { + runes := []rune(msg.Text) if len(runes) == 1 { - return m.HandleKey(msg.Key().Text) + return m.HandleKey(msg.Text) } } } @@ -810,7 +810,7 @@ func (m *Model) clampSelection() { m.selectedIdx = clamp(m.selectedIdx, 0, len(m.filtered)-1) } -func keyMsgFromString(keyStr string) tea.KeyMsg { +func keyMsgFromString(keyStr string) tea.KeyPressMsg { switch keyStr { case "esc": return tea.KeyPressMsg{Code: tea.KeyEsc} diff --git a/internal/tui/eventstream/searchmodal.go b/internal/tui/eventstream/searchmodal.go index 94e9cd7..892b6d9 100644 --- a/internal/tui/eventstream/searchmodal.go +++ b/internal/tui/eventstream/searchmodal.go @@ -60,7 +60,7 @@ func (m SearchModal) Update(msg tea.Msg) (SearchModal, string, bool) { if !m.visible { return m, "", false } - if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { switch keyMsg.String() { case "esc": return m.Close(), "", false -- cgit v1.2.3 From 5fe164e91e40e8a3f749f4143f7562f940bf9f67 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:34:01 +0200 Subject: feat(tui): detect terminal theme and apply palettes --- internal/tui/eventstream/exportmodal.go | 7 +++++++ internal/tui/eventstream/filtermodal.go | 7 +++++++ internal/tui/eventstream/model.go | 14 +++++++++++++- internal/tui/eventstream/searchmodal.go | 7 +++++++ 4 files changed, 34 insertions(+), 1 deletion(-) (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/exportmodal.go b/internal/tui/eventstream/exportmodal.go index ec35343..3c0e2cd 100644 --- a/internal/tui/eventstream/exportmodal.go +++ b/internal/tui/eventstream/exportmodal.go @@ -19,6 +19,7 @@ func NewExportModal() ExportModal { input.Prompt = "" input.CharLimit = 0 input.SetWidth(44) + input.SetStyles(textinput.DefaultStyles(true)) return ExportModal{textInput: input} } @@ -26,6 +27,12 @@ func (m ExportModal) Visible() bool { return m.visible } +// SetDarkMode updates export modal text input styles. +func (m ExportModal) SetDarkMode(isDark bool) ExportModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m ExportModal) Open(defaultName string) ExportModal { m.visible = true m.err = "" diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go index 6c7653e..bd20a03 100644 --- a/internal/tui/eventstream/filtermodal.go +++ b/internal/tui/eventstream/filtermodal.go @@ -49,6 +49,7 @@ func NewFilterModal() FilterModal { input.Prompt = "" input.CharLimit = 0 input.SetWidth(24) + input.SetStyles(textinput.DefaultStyles(true)) m := FilterModal{textInput: input} m.fields = defaultFilterFields() @@ -63,6 +64,12 @@ func (m FilterModal) Filter() Filter { return m.filter } +// SetDarkMode updates filter modal text input styles. +func (m FilterModal) SetDarkMode(isDark bool) FilterModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m FilterModal) Open(initial Filter) FilterModal { m.visible = true m.activeField = 0 diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index af7f67d..68b0cd5 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -53,6 +53,7 @@ type Model struct { pendingOpenPath string statusMessage string exportDir string + isDark bool width int height int @@ -69,7 +70,7 @@ type fdTraceViewState struct { } func NewModel(source *RingBuffer) Model { - return Model{ + m := Model{ source: source, filterModal: NewFilterModal(), exportModal: NewExportModal(), @@ -79,7 +80,10 @@ func NewModel(source *RingBuffer) Model { selectedCol: 0, exportDir: ".", showFooter: true, + isDark: true, } + m.SetDarkMode(true) + return m } // SetViewport updates the render/scroll viewport dimensions used for @@ -104,6 +108,14 @@ func (m *Model) SetSource(source *RingBuffer) { m.Refresh() } +// SetDarkMode updates stream modal text input styles for the active theme. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.filterModal = m.filterModal.SetDarkMode(isDark) + m.exportModal = m.exportModal.SetDarkMode(isDark) + m.searchModal = m.searchModal.SetDarkMode(isDark) +} + // FilterModalVisible reports whether the filter modal is currently open. func (m Model) FilterModalVisible() bool { return m.filterModal.Visible() diff --git a/internal/tui/eventstream/searchmodal.go b/internal/tui/eventstream/searchmodal.go index 892b6d9..c09542b 100644 --- a/internal/tui/eventstream/searchmodal.go +++ b/internal/tui/eventstream/searchmodal.go @@ -27,6 +27,7 @@ func NewSearchModal() SearchModal { input.Prompt = "" input.CharLimit = 0 input.SetWidth(44) + input.SetStyles(textinput.DefaultStyles(true)) return SearchModal{textInput: input, direction: SearchForward} } @@ -38,6 +39,12 @@ func (m SearchModal) Direction() SearchDirection { return m.direction } +// SetDarkMode updates search modal text input styles. +func (m SearchModal) SetDarkMode(isDark bool) SearchModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + func (m SearchModal) Open(direction SearchDirection, defaultTerm string) SearchModal { m.visible = true m.err = "" -- cgit v1.2.3 From 47c53c0d9f06451972fa32d6d74ebe572757c639 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 19:39:51 +0200 Subject: refactor(stream): back scrolling with viewport state --- internal/tui/eventstream/model.go | 105 +++++++++++++++++++++++++++----------- 1 file changed, 76 insertions(+), 29 deletions(-) (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 68b0cd5..3e31203 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" ) @@ -59,6 +60,7 @@ type Model struct { height int showFooter bool + viewport viewport.Model } type fdTraceViewState struct { @@ -81,19 +83,36 @@ func NewModel(source *RingBuffer) Model { exportDir: ".", showFooter: true, isDark: true, + viewport: newStreamViewport(), } m.SetDarkMode(true) return m } +func newStreamViewport() viewport.Model { + vp := viewport.New() + keyMap := viewport.DefaultKeyMap() + keyMap.Down.SetKeys("down", "j") + keyMap.Up.SetKeys("up", "k") + keyMap.Left.SetKeys("left", "h") + keyMap.Right.SetKeys("right", "l") + keyMap.PageDown.SetKeys("pgdown", "pgdn", "pagedown") + keyMap.PageUp.SetKeys("pgup", "pageup") + vp.KeyMap = keyMap + vp.SoftWrap = true + return vp +} + // 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 + m.viewport.SetWidth(width) } if height > 0 { m.height = height + m.viewport.SetHeight(m.visibleRows()) } } @@ -296,7 +315,8 @@ func (m *Model) HandleKey(keyStr string) bool { m.moveSelectionTo(len(m.filtered) - 1) } else { m.autoScroll = true - m.scrollOffset = m.maxScrollOffset() + m.viewport.GotoBottom() + m.scrollOffset = clamp(m.viewport.YOffset(), 0, m.maxScrollOffset()) } return true case "g": @@ -304,6 +324,7 @@ func (m *Model) HandleKey(keyStr string) bool { m.moveSelectionTo(0) } else { m.autoScroll = false + m.viewport.GotoTop() m.scrollOffset = 0 } return true @@ -317,14 +338,14 @@ func (m *Model) HandleKey(keyStr string) bool { if m.paused { m.moveSelectionBy(1) } else { - m.scrollByLines(1) + m.handleViewportUpdate(keyMsgFromString("down")) } return true case "k", "up": if m.paused { m.moveSelectionBy(-1) } else { - m.scrollByLines(-1) + m.handleViewportUpdate(keyMsgFromString("up")) } return true case "left", "h": @@ -332,25 +353,25 @@ func (m *Model) HandleKey(keyStr string) bool { m.moveSelectedColBy(-1) return true } - return false + return m.handleViewportUpdate(keyMsgFromString("left")) case "right", "l": if m.paused { m.moveSelectedColBy(1) return true } - return false + return m.handleViewportUpdate(keyMsgFromString("right")) case "pgdown", "pgdn", "pagedown": if m.paused { m.moveSelectionBy(m.pageStep()) } else { - m.scrollByLines(m.pageStep()) + m.handleViewportUpdate(keyMsgFromString("pgdown")) } return true case "pgup", "pageup": if m.paused { m.moveSelectionBy(-m.pageStep()) } else { - m.scrollByLines(-m.pageStep()) + m.handleViewportUpdate(keyMsgFromString("pgup")) } return true case "esc": @@ -366,6 +387,10 @@ func (m *Model) HandleKey(keyStr string) bool { // 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.KeyPressMsg) bool { + if m.handleViewportUpdate(msg) { + return true + } + switch msg.Code { case tea.KeyLeft: return m.HandleKey("left") @@ -396,6 +421,34 @@ func (m *Model) HandleTeaKey(msg tea.KeyPressMsg) bool { return m.HandleKey(msg.String()) } +func (m *Model) handleViewportUpdate(msg tea.KeyPressMsg) bool { + if m.paused || m.fdTraceView.visible || m.filterModal.Visible() || m.exportModal.Visible() || m.searchModal.Visible() { + return false + } + + switch msg.String() { + case "down", "j", "up", "k", "left", "h", "right", "l", "pgup", "pageup", "pgdown", "pgdn", "pagedown": + default: + return false + } + + switch msg.String() { + case "pgup", "pageup": + m.viewport.ScrollUp(m.pageStep()) + case "pgdown", "pgdn", "pagedown": + m.viewport.ScrollDown(m.pageStep()) + default: + vp, cmd := m.viewport.Update(msg) + _ = cmd + m.viewport = vp + } + m.scrollOffset = clamp(m.viewport.YOffset(), 0, m.maxScrollOffset()) + if m.scrollOffset < m.maxScrollOffset() { + m.autoScroll = false + } + return true +} + func (m *Model) View(width, height int) string { if width <= 0 { width = 100 @@ -405,13 +458,16 @@ func (m *Model) View(width, height int) string { } m.width = width m.height = height + m.viewport.SetWidth(width) + m.viewport.SetHeight(m.visibleRows()) if m.fdTraceView.visible { return m.viewFDTrace(width) } rows := m.visibleRows() - start := clamp(m.scrollOffset, 0, m.maxScrollOffset()) + start := clamp(m.viewport.YOffset(), 0, m.maxScrollOffset()) + m.scrollOffset = start end := start + rows if end > len(m.filtered) { end = len(m.filtered) @@ -479,6 +535,8 @@ func (m *Model) Refresh() { m.allEvents = []StreamEvent{} m.filtered = []StreamEvent{} m.scrollOffset = 0 + m.viewport.SetContentLines(nil) + m.viewport.SetYOffset(0) return } @@ -491,6 +549,8 @@ func (m *Model) applyFilter() { m.filtered = []StreamEvent{} m.scrollOffset = 0 m.selectedIdx = -1 + m.viewport.SetContentLines(nil) + m.viewport.SetYOffset(0) return } @@ -502,12 +562,18 @@ func (m *Model) applyFilter() { } } m.filtered = filtered + m.viewport.SetWidth(m.width) + m.viewport.SetHeight(m.visibleRows()) + lines := make([]string, len(m.filtered)) + m.viewport.SetContentLines(lines) max := m.maxScrollOffset() if m.autoScroll { - m.scrollOffset = max + m.viewport.GotoBottom() + m.scrollOffset = clamp(m.viewport.YOffset(), 0, max) } else { m.scrollOffset = clamp(m.scrollOffset, 0, max) + m.viewport.SetYOffset(m.scrollOffset) } m.clampSelection() if m.paused { @@ -544,26 +610,6 @@ func (m *Model) pageStep() int { 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 (m *Model) openFDTraceView() bool { if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { return false @@ -661,6 +707,7 @@ func (m *Model) centerSelection() { mid := m.visibleRows() / 2 target := m.selectedIdx - mid m.scrollOffset = clamp(target, 0, m.maxScrollOffset()) + m.viewport.SetYOffset(m.scrollOffset) } func (m *Model) ensureSelection() { -- cgit v1.2.3 From f6135befedb7da8603ecff2c59b155effd0183db Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 23:51:59 +0200 Subject: Add package-level docs across internal packages --- internal/tui/eventstream/doc.go | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 internal/tui/eventstream/doc.go (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/doc.go b/internal/tui/eventstream/doc.go new file mode 100644 index 0000000..38bc854 --- /dev/null +++ b/internal/tui/eventstream/doc.go @@ -0,0 +1,2 @@ +// Package eventstream renders live event rows and interactive filtering controls. +package eventstream -- cgit v1.2.3 From acd91c7c11658360e0540f9d550f1fe0da167109 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 5 Mar 2026 23:53:01 +0200 Subject: Normalize Go import grouping with local ior section --- internal/tui/eventstream/render.go | 3 ++- internal/tui/eventstream/streamevent.go | 3 ++- internal/tui/eventstream/streamevent_test.go | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index f93a63d..3ec4d65 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -2,10 +2,11 @@ package eventstream import ( "fmt" - "ior/internal/tui/common" "strconv" "strings" + "ior/internal/tui/common" + "charm.land/lipgloss/v2" ) diff --git a/internal/tui/eventstream/streamevent.go b/internal/tui/eventstream/streamevent.go index dbe04dd..5f1e27f 100644 --- a/internal/tui/eventstream/streamevent.go +++ b/internal/tui/eventstream/streamevent.go @@ -1,9 +1,10 @@ package eventstream import ( + "time" + "ior/internal/event" "ior/internal/types" - "time" ) type StreamEvent struct { diff --git a/internal/tui/eventstream/streamevent_test.go b/internal/tui/eventstream/streamevent_test.go index 6131fed..dd65dd1 100644 --- a/internal/tui/eventstream/streamevent_test.go +++ b/internal/tui/eventstream/streamevent_test.go @@ -1,10 +1,11 @@ package eventstream import ( + "testing" + "ior/internal/event" "ior/internal/file" "ior/internal/types" - "testing" ) func TestNewStreamEventPopulatesFields(t *testing.T) { -- cgit v1.2.3 From 1d72e80ed340564c6c2d37366e20896aa00341e2 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 6 Mar 2026 16:38:41 +0200 Subject: refactor: use interfaces for TUI runtime binding sources (task 382) --- internal/tui/eventstream/model.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'internal/tui/eventstream') diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 3e31203..12aff4d 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -24,8 +24,14 @@ const ( streamColumnCount ) +// Source is the minimal stream buffer contract needed by the stream model. +type Source interface { + Len() int + Snapshot() []StreamEvent +} + type Model struct { - source *RingBuffer + source Source allEvents []StreamEvent filtered []StreamEvent @@ -71,7 +77,7 @@ type fdTraceViewState struct { offset int } -func NewModel(source *RingBuffer) Model { +func NewModel(source Source) Model { m := Model{ source: source, filterModal: NewFilterModal(), @@ -122,7 +128,7 @@ func (m *Model) SetFooterVisible(visible bool) { } // SetSource updates the backing ring buffer and refreshes visible rows. -func (m *Model) SetSource(source *RingBuffer) { +func (m *Model) SetSource(source Source) { m.source = source m.Refresh() } -- cgit v1.2.3