From 58ca902320b662ba3bbe92cd6491e2a79266d7b9 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 18:13:37 +0000 Subject: [PATCH] feat(cli): add Viewport for scrollable content (logs, diffs, docs) Co-Authored-By: Virgil --- pkg/cli/viewport.go | 176 +++++++++++++++++++++++++++++++++++++++ pkg/cli/viewport_test.go | 61 ++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 pkg/cli/viewport.go create mode 100644 pkg/cli/viewport_test.go diff --git a/pkg/cli/viewport.go b/pkg/cli/viewport.go new file mode 100644 index 0000000..152aab7 --- /dev/null +++ b/pkg/cli/viewport.go @@ -0,0 +1,176 @@ +package cli + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" +) + +// viewportModel is the internal bubbletea model for scrollable content. +type viewportModel struct { + title string + lines []string + offset int + height int + quitted bool +} + +func newViewportModel(content, title string, height int) *viewportModel { + lines := strings.Split(content, "\n") + return &viewportModel{ + title: title, + lines: lines, + height: height, + } +} + +func (m *viewportModel) scrollDown() { + maxOffset := len(m.lines) - m.height + if maxOffset < 0 { + maxOffset = 0 + } + if m.offset < maxOffset { + m.offset++ + } +} + +func (m *viewportModel) scrollUp() { + if m.offset > 0 { + m.offset-- + } +} + +func (m *viewportModel) Init() tea.Cmd { + return nil +} + +func (m *viewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.scrollUp() + case tea.KeyDown: + m.scrollDown() + case tea.KeyPgUp: + for i := 0; i < m.height; i++ { + m.scrollUp() + } + case tea.KeyPgDown: + for i := 0; i < m.height; i++ { + m.scrollDown() + } + case tea.KeyHome: + m.offset = 0 + case tea.KeyEnd: + maxOffset := len(m.lines) - m.height + if maxOffset < 0 { + maxOffset = 0 + } + m.offset = maxOffset + case tea.KeyEscape, tea.KeyCtrlC: + m.quitted = true + return m, tea.Quit + case tea.KeyRunes: + switch string(msg.Runes) { + case "q": + m.quitted = true + return m, tea.Quit + case "j": + m.scrollDown() + case "k": + m.scrollUp() + case "g": + m.offset = 0 + case "G": + maxOffset := len(m.lines) - m.height + if maxOffset < 0 { + maxOffset = 0 + } + m.offset = maxOffset + } + } + } + return m, nil +} + +func (m *viewportModel) View() string { + var sb strings.Builder + + if m.title != "" { + sb.WriteString(BoldStyle.Render(m.title) + "\n") + sb.WriteString(DimStyle.Render(strings.Repeat("\u2500", len(m.title))) + "\n") + } + + // Visible window + end := m.offset + m.height + if end > len(m.lines) { + end = len(m.lines) + } + for _, line := range m.lines[m.offset:end] { + sb.WriteString(line + "\n") + } + + // Scroll indicator + total := len(m.lines) + if total > m.height { + pct := (m.offset * 100) / (total - m.height) + sb.WriteString(DimStyle.Render(fmt.Sprintf("\n%d%% (%d/%d lines)", pct, m.offset+m.height, total))) + } + + sb.WriteString("\n" + DimStyle.Render("\u2191/\u2193 scroll \u2022 PgUp/PgDn page \u2022 q quit")) + + return sb.String() +} + +// ViewportOption configures Viewport behaviour. +type ViewportOption func(*viewportConfig) + +type viewportConfig struct { + title string + height int +} + +// WithViewportTitle sets the title shown above the viewport. +func WithViewportTitle(title string) ViewportOption { + return func(c *viewportConfig) { + c.title = title + } +} + +// WithViewportHeight sets the visible height in lines. +func WithViewportHeight(n int) ViewportOption { + return func(c *viewportConfig) { + c.height = n + } +} + +// Viewport displays scrollable content in the terminal. +// Falls back to printing the full content when stdin is not a terminal. +// +// cli.Viewport(longContent, WithViewportTitle("Build Log"), WithViewportHeight(20)) +func Viewport(content string, opts ...ViewportOption) error { + cfg := &viewportConfig{ + height: 20, + } + for _, opt := range opts { + opt(cfg) + } + + // Fall back to plain output if not a terminal + if !term.IsTerminal(0) { + if cfg.title != "" { + fmt.Println(BoldStyle.Render(cfg.title)) + fmt.Println(DimStyle.Render(strings.Repeat("\u2500", len(cfg.title)))) + } + fmt.Println(content) + return nil + } + + m := newViewportModel(content, cfg.title, cfg.height) + p := tea.NewProgram(m) + _, err := p.Run() + return err +} diff --git a/pkg/cli/viewport_test.go b/pkg/cli/viewport_test.go new file mode 100644 index 0000000..e33d7ad --- /dev/null +++ b/pkg/cli/viewport_test.go @@ -0,0 +1,61 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestViewportModel_Good_Create(t *testing.T) { + content := "line 1\nline 2\nline 3" + m := newViewportModel(content, "Title", 5) + assert.Equal(t, "Title", m.title) + assert.Equal(t, 3, len(m.lines)) + assert.Equal(t, 0, m.offset) +} + +func TestViewportModel_Good_ScrollDown(t *testing.T) { + lines := make([]string, 20) + for i := range lines { + lines[i] = strings.Repeat("x", 10) + } + m := newViewportModel(strings.Join(lines, "\n"), "", 5) + m.scrollDown() + assert.Equal(t, 1, m.offset) +} + +func TestViewportModel_Good_ScrollUp(t *testing.T) { + lines := make([]string, 20) + for i := range lines { + lines[i] = strings.Repeat("x", 10) + } + m := newViewportModel(strings.Join(lines, "\n"), "", 5) + m.scrollDown() + m.scrollDown() + m.scrollUp() + assert.Equal(t, 1, m.offset) +} + +func TestViewportModel_Good_NoScrollPastTop(t *testing.T) { + m := newViewportModel("a\nb\nc", "", 5) + m.scrollUp() // Already at top + assert.Equal(t, 0, m.offset) +} + +func TestViewportModel_Good_NoScrollPastBottom(t *testing.T) { + m := newViewportModel("a\nb\nc", "", 5) + for i := 0; i < 10; i++ { + m.scrollDown() + } + // Should clamp -- can't scroll past content + assert.GreaterOrEqual(t, m.offset, 0) +} + +func TestViewportModel_Good_View(t *testing.T) { + m := newViewportModel("line 1\nline 2", "My Title", 10) + view := m.View() + assert.Contains(t, view, "My Title") + assert.Contains(t, view, "line 1") + assert.Contains(t, view, "line 2") +}