// 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")
}