internal/tui/viewport/viewport.go

// Package viewport is a small scrollable pane. We roll our own rather
// than importing bubbles/viewport because we need hard-wrap behaviour
// tuned for header/body dumps: wrap on rune boundary, never mid-byte.
//
// mercemay.top/src/httptap/
package viewport

import (
	"strings"

	tea "github.com/charmbracelet/bubbletea"
)

// Model is the viewport state.
type Model struct {
	width    int
	height   int
	offset   int
	lines    []string
	rendered string
}

// New returns a Model with the given size.
func New(w, h int) Model {
	return Model{width: w, height: h}
}

// SetSize is called by the parent relayout routine.
func (m *Model) SetSize(w, h int) {
	m.width, m.height = w, h
	m.rewrap()
}

// SetContent replaces the text in the viewport and resets the scroll
// offset to the top.
func (m *Model) SetContent(s string) {
	m.rendered = s
	m.offset = 0
	m.rewrap()
}

// rewrap computes the wrapped lines cache.
func (m *Model) rewrap() {
	if m.width <= 0 {
		m.lines = strings.Split(m.rendered, "\n")
		return
	}
	out := make([]string, 0, 32)
	for _, raw := range strings.Split(m.rendered, "\n") {
		runes := []rune(raw)
		for len(runes) > m.width {
			out = append(out, string(runes[:m.width]))
			runes = runes[m.width:]
		}
		out = append(out, string(runes))
	}
	m.lines = out
}

// Update handles scroll 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.offset > 0 {
				m.offset--
			}
		case "down", "j":
			if m.offset+m.height < len(m.lines) {
				m.offset++
			}
		case "pgup", "b":
			m.offset -= m.height
			if m.offset < 0 {
				m.offset = 0
			}
		case "pgdown", "f", " ":
			m.offset += m.height
			if m.offset+m.height > len(m.lines) {
				m.offset = len(m.lines) - m.height
			}
			if m.offset < 0 {
				m.offset = 0
			}
		}
	}
	return m, nil
}

// View paints the visible slice of lines.
func (m Model) View() string {
	end := m.offset + m.height
	if end > len(m.lines) {
		end = len(m.lines)
	}
	if m.offset < 0 {
		m.offset = 0
	}
	return strings.Join(m.lines[m.offset:end], "\n")
}