diff --git a/viewport/viewport.go b/viewport/viewport.go index e0a4cc33..05e93ae1 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) // New returns a new model with the given width and height as well as default @@ -87,12 +88,14 @@ func (m Model) PastBottom() bool { // ScrollPercent returns the amount scrolled as a float between 0 and 1. func (m Model) ScrollPercent() float64 { - if m.Height >= len(m.lines) { + wrappedLines := len(wrap(m.lines, m.Width)) + + if m.Height >= wrappedLines { return 1.0 } y := float64(m.YOffset) h := float64(m.Height) - t := float64(len(m.lines)) + t := float64(wrappedLines) v := y / (t - h) return math.Max(0.0, math.Min(1.0, v)) } @@ -103,7 +106,7 @@ func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings m.lines = strings.Split(s, "\n") - if m.YOffset > len(m.lines)-1 { + if m.YOffset > len(wrap(m.lines, m.Width))-1 { m.GotoBottom() } } @@ -111,16 +114,19 @@ func (m *Model) SetContent(s string) { // maxYOffset returns the maximum possible value of the y-offset based on the // viewport's content and set height. func (m Model) maxYOffset() int { - return max(0, len(m.lines)-m.Height) + linesHeight := len(wrap(m.lines, m.Width)) + return max(0, linesHeight-m.Height) } // visibleLines returns the lines that should currently be visible in the // viewport. func (m Model) visibleLines() (lines []string) { if len(m.lines) > 0 { + wrappedLines := wrap(m.lines, m.Width) top := max(0, m.YOffset) - bottom := clamp(m.YOffset+m.Height, top, len(m.lines)) - lines = m.lines[top:bottom] + bottom := clamp(m.YOffset+m.Height, top, len(wrappedLines)) + + lines = wrappedLines[top:bottom] } return lines } @@ -191,7 +197,7 @@ func (m *Model) LineDown(n int) (lines []string) { // Gather lines to send off for performance scrolling. bottom := clamp(m.YOffset+m.Height, 0, len(m.lines)) top := clamp(m.YOffset+m.Height-n, 0, bottom) - return m.lines[top:bottom] + return wrap(m.lines, m.Width)[top:bottom] } // LineUp moves the view down by the given number of lines. Returns the new @@ -208,7 +214,7 @@ func (m *Model) LineUp(n int) (lines []string) { // Gather lines to send off for performance scrolling. top := max(0, m.YOffset) bottom := clamp(m.YOffset+n, 0, m.maxYOffset()) - return m.lines[top:bottom] + return wrap(m.lines, m.Width)[top:bottom] } // TotalLineCount returns the total number of lines (both hidden and visible) within the viewport. @@ -292,6 +298,10 @@ func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.PastBottom() { + m.SetYOffset(m.maxYOffset()) + } case tea.KeyMsg: switch { case key.Matches(msg, m.KeyMap.PageDown): @@ -403,3 +413,19 @@ func max(a, b int) int { } return b } + +// wrap returns lines wrapped to the given width. +func wrap(lines []string, width int) []string { + var out []string + for _, line := range lines { + // word wrap lines + wrapWords := ansi.Wordwrap(line, width, "") + // wrap lines (handles lines that could not be word wrapped) + wrap := ansi.Hardwrap(wrapWords, width, true) + // split string by new lines + wrapLines := strings.Split(strings.TrimSpace(wrap), "\n") + + out = append(out, wrapLines...) + } + return out +} diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go new file mode 100644 index 00000000..0dc79746 --- /dev/null +++ b/viewport/viewport_test.go @@ -0,0 +1,50 @@ +package viewport + +import ( + "testing" +) + +func TestWrap(t *testing.T) { + t.Parallel() + tests := map[string]struct { + lines []string + width int + want []string + }{ + "empty slice": { + lines: []string{}, + width: 3, + want: []string{}, + }, + "all lines are within width": { + lines: []string{"aaa", "bbb", "ccc"}, + width: 3, + want: []string{"aaa", "bbb", "ccc"}, + }, + "some lines exceeds width": { + lines: []string{"aaaaaa", "bbbbbbbb", "ccc"}, + width: 3, + want: []string{"aaa", "aaa", "bbb", "bbb", "bb", "ccc"}, + }, + "full sentence exceeding width": { + lines: []string{"hello bob, I like yogurt in the mornings."}, + width: 12, + want: []string{"hello bob, I", "like yogurt", "in the", "mornings."}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := wrap(tt.lines, tt.width) + + if len(got) != len(tt.want) { + t.Fatalf("expected len is %d but got %d", len(tt.want), len(got)) + } + for i := range tt.want { + if tt.want[i] != got[i] { + t.Fatalf("expected %s but got %s", tt.want[i], got[i]) + } + } + }) + } +}