// Package list is the scrollable left-hand pane that lists captured
// requests. Selection state and filtering are owned here; the detail
// pane simply observes App.selected().
//
// mercemay.top/src/httptap/
package list
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"mercemay.top/httptap/internal/parser"
"mercemay.top/httptap/internal/tui/theme"
)
// Model is the list state.
type Model struct {
pal theme.Palette
items []parser.Message
filter Matcher
cursor int
top int
width int
height int
visible []int // indexes into items after filtering
}
// Matcher is a predicate used to filter rows; nil allows everything.
type Matcher func(parser.Message) bool
// New creates an empty list.
func New(pal theme.Palette) Model {
return Model{pal: pal}
}
// Append adds a message to the end of the list and rebuilds the filter
// projection if a filter is active.
func (m *Model) Append(msg parser.Message) {
m.items = append(m.items, msg)
m.rebuild()
}
// SetFilter installs a new matcher and rebuilds the visible projection.
// If expr is empty the filter is cleared.
func (m *Model) SetFilter(expr string) {
if strings.TrimSpace(expr) == "" {
m.filter = nil
} else {
m.filter = CompileMatcher(expr)
}
m.rebuild()
}
// rebuild refreshes the visible index slice using the current filter.
func (m *Model) rebuild() {
m.visible = m.visible[:0]
for i, it := range m.items {
if m.filter == nil || m.filter(it) {
m.visible = append(m.visible, i)
}
}
if m.cursor >= len(m.visible) {
m.cursor = len(m.visible) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
// SetSize is called by App.relayout.
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
// Height exposes the current height for app tests.
func (m Model) Height() int { return m.height }
// Len reports the current number of visible rows.
func (m Model) Len() int { return len(m.visible) }
// Selected returns the message under the cursor, if any.
func (m Model) Selected() (parser.Message, bool) {
if len(m.visible) == 0 {
return parser.Message{}, false
}
return m.items[m.visible[m.cursor]], true
}
// Update handles navigation keys.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor+1 < len(m.visible) {
m.cursor++
}
case "g", "home":
m.cursor = 0
case "G", "end":
m.cursor = len(m.visible) - 1
if m.cursor < 0 {
m.cursor = 0
}
}
// Keep cursor visible.
if m.cursor < m.top {
m.top = m.cursor
}
if m.cursor >= m.top+m.height-1 {
m.top = m.cursor - m.height + 2
}
}
return m, nil
}
// View renders a slice of visible rows via render.go (item.go).
func (m Model) View() string {
if len(m.visible) == 0 {
return m.pal.List.Width(m.width).Render("(no requests)")
}
return m.pal.List.Width(m.width).Render(m.renderRows())
}