feat(cli): add Viewport for scrollable content (logs, diffs, docs)
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
a0660e5802
commit
58ca902320
2 changed files with 237 additions and 0 deletions
176
pkg/cli/viewport.go
Normal file
176
pkg/cli/viewport.go
Normal file
|
|
@ -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
|
||||
}
|
||||
61
pkg/cli/viewport_test.go
Normal file
61
pkg/cli/viewport_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue