Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(viewport): auto-wrap #578

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
42 changes: 34 additions & 8 deletions viewport/viewport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand All @@ -103,24 +106,27 @@ 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()
}
}

// 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
}
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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())
}
Comment on lines +301 to +304
Copy link
Member

@meowgorithm meowgorithm Oct 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that the width of the viewport is tied to the window width, which will not always be the case: viewports can have a fixed size. Additionally, this requires the user to flow tea.WindowSizeMsgs through a viewport's update. It would be better to simply re-wrap when the width of the viewport changes.

In order to achieve this we'll probably need to deprecate Width and introduce SetWidth() and a less-than-ideal GetWidth(). In v2 we can correct the naming.

case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.PageDown):
Expand Down Expand Up @@ -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
}
50 changes: 50 additions & 0 deletions viewport/viewport_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
})
}
}
Loading