feat(viewport)!: gutter column, soft wrap, search highlight (#697)

* horizontal scroll

* rebase branch

* add tests

* add tests with 2 cells symbols

* trimLeft, move to charmbracelete/x/ansi lib

* up ansi package

* Update viewport/viewport.go

Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: do not navigate out to the right

* fix: cache line width on setcontent

* fix tests

* fix viewport tests

* add test for preventing right overscroll

* chore(viewport): increase horizontal step to 6

* chore(viewport): make horizontal scroll API better match vertical scroll API

* fix: nolint

* fix: use ansi.Cut

* perf: do not cut anything if not needed

* feat: expose HorizontalScrollPercent

* fix: do not scroll if width is 0

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: visible lines take frame into account

* feat(viewport): column sign

* feat: gutter, soft wrap

* wip: search

* wip: search

* wip: search

* fix: perf

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: rename

* wip

* wip

* refactor: viewport highlight ranges

* fix: ligloss update

* doc: godoc

* feat: fill height optional

* fix: handle no content

* fix: empty lines

* wip

* wip

* Revert "wip"

This reverts commit 933f181e88405c21d65a08b816a88030806ca03e.

* Reapply "wip"

This reverts commit 0e3e31b70f53d93250a1474547cc5157d50e9237.

* fix: wide

* fix: wide, find

* still not quite there

* fix: grapheme width

* fix: cleanups

* fix: refactors, improves highlight visibility

* docs: godoc

* chore: lipgloss update

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* chore: x/ansi update

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: typos, godocs

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: rename

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: typo

* fix: scroll when soft-wrapping

* fix: soft wrap adjustments

* fix: update

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

* fix: deps

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

---------

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Roman Suvorov <suvorov83@gmail.com>
Co-authored-by: Roman Suvorov <tty2.rs@gmail.com>
Co-authored-by: Christian Rocha <christian@rocha.is>
This commit is contained in:
Carlos Alexandro Becker
2025-02-04 13:15:40 -03:00
committed by GitHub
co-authored by Roman Suvorov Roman Suvorov Christian Rocha
parent b2e3cc5371
commit edbb81c136
5 changed files with 646 additions and 56 deletions
+141
View File
@@ -0,0 +1,141 @@
package viewport
import (
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/rivo/uniseg"
)
// parseMatches converts the given matches into highlight ranges.
//
// Assumptions:
// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
// - matches were made against the given content
// - matches are in order
// - matches do not overlap
// - content is line terminated with \n only
//
// We'll then convert the ranges into [highlightInfo]s, which hold the starting
// line and the grapheme positions.
func parseMatches(
content string,
matches [][]int,
) []highlightInfo {
if len(matches) == 0 {
return nil
}
line := 0
graphemePos := 0
previousLinesOffset := 0
bytePos := 0
highlights := make([]highlightInfo, 0, len(matches))
gr := uniseg.NewGraphemes(ansi.Strip(content))
for _, match := range matches {
byteStart, byteEnd := match[0], match[1]
// hilight for this match:
hi := highlightInfo{
lines: map[int][2]int{},
}
// find the beginning of this byte range, setup current line and
// grapheme position.
for byteStart > bytePos {
if !gr.Next() {
break
}
if content[bytePos] == '\n' {
previousLinesOffset = graphemePos + 1
line++
}
graphemePos += max(1, gr.Width())
bytePos += len(gr.Str())
}
hi.lineStart = line
hi.lineEnd = line
graphemeStart := graphemePos
// loop until we find the end
for byteEnd > bytePos {
if !gr.Next() {
break
}
// if it ends with a new line, add the range, increase line, and continue
if content[bytePos] == '\n' {
colstart := max(0, graphemeStart-previousLinesOffset)
colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
if colend > colstart {
hi.lines[line] = [2]int{colstart, colend}
hi.lineEnd = line
}
previousLinesOffset = graphemePos + 1
line++
}
graphemePos += max(1, gr.Width())
bytePos += len(gr.Str())
}
// we found it!, add highlight and continue
if bytePos == byteEnd {
colstart := max(0, graphemeStart-previousLinesOffset)
colend := max(graphemePos-previousLinesOffset, colstart)
if colend > colstart {
hi.lines[line] = [2]int{colstart, colend}
hi.lineEnd = line
}
}
highlights = append(highlights, hi)
}
return highlights
}
type highlightInfo struct {
// in which line this highlight starts and ends
lineStart, lineEnd int
// the grapheme highlight ranges for each of these lines
lines map[int][2]int
}
// coords returns the line x column of this highlight.
func (hi highlightInfo) coords() (int, int, int) {
for i := hi.lineStart; i <= hi.lineEnd; i++ {
hl, ok := hi.lines[i]
if !ok {
continue
}
return i, hl[0], hl[1]
}
return hi.lineStart, 0, 0
}
func makeHighlightRanges(
highlights []highlightInfo,
line int,
style lipgloss.Style,
) []lipgloss.Range {
result := []lipgloss.Range{}
for _, hi := range highlights {
lihi, ok := hi.lines[line]
if !ok {
continue
}
if lihi == [2]int{} {
continue
}
result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
}
return result
}
+303 -24
View File
@@ -1,6 +1,7 @@
package viewport
import (
"fmt"
"math"
"strings"
@@ -52,6 +53,13 @@ type Model struct {
height int
KeyMap KeyMap
// Whether or not to wrap text. If false, it'll allow horizontal scrolling
// instead.
SoftWrap bool
// Whether or not to fill to the height of the viewport with empty lines.
FillHeight bool
// Whether or not to respond to the mouse. The mouse must be enabled in
// Bubble Tea for this to work. For details, see the Bubble Tea docs.
MouseWheelEnabled bool
@@ -77,9 +85,54 @@ type Model struct {
// useful for setting borders, margins and padding.
Style lipgloss.Style
// LeftGutterFunc allows to define a [GutterFunc] that adds a column into
// the left of the viewport, which is kept when horizontal scrolling.
// This can be used for things like line numbers, selection indicators,
// show statuses, etc.
LeftGutterFunc GutterFunc
initialized bool
lines []string
longestLineWidth int
// HighlightStyle highlights the ranges set with [SetHighligths].
HighlightStyle lipgloss.Style
// SelectedHighlightStyle highlights the highlight range focused during
// navigation.
// Use [SetHighligths] to set the highlight ranges, and [HightlightNext]
// and [HihglightPrevious] to navigate.
SelectedHighlightStyle lipgloss.Style
highlights []highlightInfo
hiIdx int
memoizedMatchedLines []string
}
// GutterFunc can be implemented and set into [Model.LeftGutterFunc].
type GutterFunc func(GutterContext) string
// LineNumberGutter return a [GutterFunc] that shows line numbers.
func LineNumberGutter(style lipgloss.Style) GutterFunc {
return func(info GutterContext) string {
if info.Soft {
return style.Render(" │ ")
}
if info.Index >= info.TotalLines {
return style.Render(" ~ │ ")
}
return style.Render(fmt.Sprintf("%4d │ ", info.Index+1))
}
}
// NoGutter is the default gutter used.
var NoGutter = func(GutterContext) string { return "" }
// GutterContext provides context to a [GutterFunc].
type GutterContext struct {
Index int
TotalLines int
Soft bool
}
func (m *Model) setInitialValues() {
@@ -88,6 +141,7 @@ func (m *Model) setInitialValues() {
m.MouseWheelDelta = 3
m.initialized = true
m.horizontalStep = defaultHorizontalStep
m.LeftGutterFunc = NoGutter
}
// Init exists to satisfy the tea.Model interface for composability purposes.
@@ -134,12 +188,13 @@ 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) {
count := m.lineCount()
if m.Height() >= count {
return 1.0
}
y := float64(m.YOffset)
h := float64(m.Height())
t := float64(len(m.lines))
t := float64(count)
v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
}
@@ -158,43 +213,174 @@ func (m Model) HorizontalScrollPercent() float64 {
}
// SetContent set the pager's text content.
// Line endings will be normalized to '\n'.
func (m *Model) SetContent(s string) {
s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
m.lines = strings.Split(s, "\n")
m.longestLineWidth = findLongestLineWidth(m.lines)
// if there's no content, set content to actual nil instead of one empty
// line.
if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 {
m.lines = nil
}
m.longestLineWidth = maxLineWidth(m.lines)
m.ClearHighlights()
if m.YOffset > len(m.lines)-1 {
if m.YOffset > m.maxYOffset() {
m.GotoBottom()
}
}
// GetContent returns the entire content as a single string.
// Line endings are normalized to '\n'.
func (m Model) GetContent() string {
return strings.Join(m.lines, "\n")
}
// calculateLine taking soft wrapiing into account, returns the total viewable
// lines and the real-line index for the given yoffset.
func (m Model) calculateLine(yoffset int) (total, idx int) {
if !m.SoftWrap {
return len(m.lines), yoffset
}
maxWidth := m.maxWidth()
gutterSize := lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
for i, line := range m.lines {
adjust := max(1, ansi.StringWidth(line)/(maxWidth-gutterSize))
if yoffset >= total && yoffset < total+adjust {
idx = i
}
total += adjust
}
return total, idx
}
// lineToIndex taking soft wrappign into account, return the real line index
// for the given line.
func (m Model) lineToIndex(y int) int {
_, idx := m.calculateLine(y)
return idx
}
// lineCount taking soft wrapping into account, return the total viewable line
// count (real lines + soft wrapped line).
func (m Model) lineCount() int {
total, _ := m.calculateLine(0)
return total
}
// 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()+m.Style.GetVerticalFrameSize())
return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize())
}
// maxXOffset returns the maximum possible value of the x-offset based on the
// viewport's content and set width.
func (m Model) maxXOffset() int {
return max(0, m.longestLineWidth-m.Width())
}
func (m Model) maxWidth() int {
return m.Width() -
m.Style.GetHorizontalFrameSize() -
lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
}
func (m Model) maxHeight() int {
return m.Height() - m.Style.GetVerticalFrameSize()
}
// visibleLines returns the lines that should currently be visible in the
// viewport.
func (m Model) visibleLines() (lines []string) {
h := m.Height() - m.Style.GetVerticalFrameSize()
w := m.Width() - m.Style.GetHorizontalFrameSize()
maxHeight := m.maxHeight()
maxWidth := m.maxWidth()
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := clamp(m.YOffset+h, top, len(m.lines))
lines = m.lines[top:bottom]
if m.lineCount() > 0 {
pos := m.lineToIndex(m.YOffset)
top := max(0, pos)
bottom := clamp(pos+maxHeight, top, len(m.lines))
lines = make([]string, bottom-top)
copy(lines, m.lines[top:bottom])
lines = m.highlightLines(lines, top)
}
if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 {
for m.FillHeight && len(lines) < maxHeight {
lines = append(lines, "")
}
// if longest line fit within width, no need to do anything else.
if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 {
return m.prependColumn(lines)
}
if m.SoftWrap {
return m.softWrap(lines, maxWidth)
}
for i := range lines {
lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth)
}
return m.prependColumn(lines)
}
func (m Model) highlightLines(lines []string, offset int) []string {
if len(m.highlights) == 0 {
return lines
}
cutLines := make([]string, len(lines))
for i := range lines {
cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w)
if memoized := m.memoizedMatchedLines[i+offset]; memoized != "" {
lines[i] = memoized
} else {
ranges := makeHighlightRanges(
m.highlights,
i+offset,
m.HighlightStyle,
)
lines[i] = lipgloss.StyleRanges(lines[i], ranges...)
m.memoizedMatchedLines[i+offset] = lines[i]
}
if m.hiIdx < 0 {
continue
}
sel := m.highlights[m.hiIdx]
if hi, ok := sel.lines[i+offset]; ok {
lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange(
hi[0],
hi[1],
m.SelectedHighlightStyle,
))
}
}
return cutLines
return lines
}
func (m Model) softWrap(lines []string, maxWidth int) []string {
var wrappedLines []string
for i, line := range lines {
idx := 0
for ansi.StringWidth(line) >= idx {
truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
wrappedLines = append(wrappedLines, m.LeftGutterFunc(GutterContext{
Index: i + m.YOffset,
TotalLines: m.TotalLineCount(),
Soft: idx > 0,
})+truncatedLine)
idx += maxWidth
}
}
return wrappedLines
}
func (m Model) prependColumn(lines []string) []string {
result := make([]string, len(lines))
for i, line := range lines {
result[i] = m.LeftGutterFunc(GutterContext{
Index: i + m.YOffset,
TotalLines: m.TotalLineCount(),
}) + line
}
return result
}
// SetYOffset sets the Y offset.
@@ -202,6 +388,31 @@ func (m *Model) SetYOffset(n int) {
m.YOffset = clamp(n, 0, m.maxYOffset())
}
// SetXOffset sets the X offset.
// No-op when soft wrap is enabled.
func (m *Model) SetXOffset(n int) {
if m.SoftWrap {
return
}
m.xOffset = clamp(n, 0, m.maxXOffset())
}
// EnsureVisible ensures that the given line and column are in the viewport.
func (m *Model) EnsureVisible(line, colstart, colend int) {
maxWidth := m.maxWidth()
if colend <= maxWidth {
m.SetXOffset(0)
} else {
m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural
}
if line < m.YOffset || line >= m.YOffset+m.maxHeight() {
m.SetYOffset(line)
}
m.visibleLines()
}
// ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down".
func (m *Model) ViewDown() {
@@ -249,6 +460,7 @@ func (m *Model) LineDown(n int) {
// greater than the number of lines we actually have left before we reach
// the bottom.
m.SetYOffset(m.YOffset + n)
m.hiIdx = m.findNearedtMatch()
}
// LineUp moves the view down by the given number of lines. Returns the new
@@ -261,11 +473,12 @@ func (m *Model) LineUp(n int) {
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top.
m.SetYOffset(m.YOffset - n)
m.hiIdx = m.findNearedtMatch()
}
// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
func (m Model) TotalLineCount() int {
return len(m.lines)
return m.lineCount()
}
// VisibleLineCount returns the number of the visible lines within the viewport.
@@ -280,12 +493,14 @@ func (m *Model) GotoTop() (lines []string) {
}
m.SetYOffset(0)
m.hiIdx = m.findNearedtMatch()
return m.visibleLines()
}
// GotoBottom sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.SetYOffset(m.maxYOffset())
m.hiIdx = m.findNearedtMatch()
return m.visibleLines()
}
@@ -311,7 +526,8 @@ func (m *Model) MoveLeft(cols int) {
// MoveRight moves viewport to the right by the given number of columns.
func (m *Model) MoveRight(cols int) {
// prevents over scrolling to the right
if m.xOffset >= m.longestLineWidth-m.Width() {
w := m.maxWidth()
if m.xOffset > m.longestLineWidth-w {
return
}
m.xOffset += cols
@@ -322,6 +538,68 @@ func (m *Model) ResetIndent() {
m.xOffset = 0
}
// SetHighlights sets ranges of characters to highlight.
// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters
// 2 to 10 and 20 to 30.
// Note that highlights are not expected to transpose each other, and are also
// expected to be in order.
// Use [Model.SetHighlights] to set the highlight ranges, and
// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate.
// Use [Model.ClearHighlights] to remove all highlights.
func (m *Model) SetHighlights(matches [][]int) {
if len(matches) == 0 || len(m.lines) == 0 {
return
}
m.memoizedMatchedLines = make([]string, len(m.lines))
m.highlights = parseMatches(m.GetContent(), matches)
m.hiIdx = m.findNearedtMatch()
m.showHighlight()
}
// ClearHighlights clears previously set highlights.
func (m *Model) ClearHighlights() {
m.memoizedMatchedLines = nil
m.highlights = nil
m.hiIdx = -1
}
func (m *Model) showHighlight() {
if m.hiIdx == -1 {
return
}
line, colstart, colend := m.highlights[m.hiIdx].coords()
m.EnsureVisible(line, colstart, colend)
}
// HighlightNext highlights the next match.
func (m *Model) HighlightNext() {
if m.highlights == nil {
return
}
m.hiIdx = (m.hiIdx + 1) % len(m.highlights)
m.showHighlight()
}
// HighlightPrevious highlights the previous match.
func (m *Model) HighlightPrevious() {
if m.highlights == nil {
return
}
m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights)
m.showHighlight()
}
func (m Model) findNearedtMatch() int {
for i, match := range m.highlights {
if match.lineStart >= m.YOffset {
return i
}
}
return -1
}
// Update handles standard message-based viewport updates.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m = m.updateAsModel(msg)
@@ -423,12 +701,13 @@ func max(a, b int) int {
return b
}
func findLongestLineWidth(lines []string) int {
w := 0
for _, l := range lines {
if ww := ansi.StringWidth(l); ww > w {
w = ww
func maxLineWidth(lines []string) int {
maxlen := 0
for _, line := range lines {
llen := ansi.StringWidth(line)
if llen > maxlen {
maxlen = llen
}
}
return w
return maxlen
}
+181
View File
@@ -1,8 +1,12 @@
package viewport
import (
"reflect"
"regexp"
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestNew(t *testing.T) {
@@ -381,3 +385,180 @@ func TestRightOverscroll(t *testing.T) {
}
})
}
func TestMatchesToHighlights(t *testing.T) {
content := `hello
world
with empty rows
wide chars: あいうえおafter
爱开源 • Charm does open source
Charm热爱开源 • Charm loves open source
`
vt := New(WithWidth(100), WithHeight(100))
vt.SetContent(content)
t.Run("first", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("hello"), []highlightInfo{
{
lineStart: 0,
lineEnd: 0,
lines: map[int][2]int{
0: {0, 5},
},
},
})
})
t.Run("multiple", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("l"), []highlightInfo{
{
lineStart: 0,
lineEnd: 0,
lines: map[int][2]int{
0: {2, 3},
},
},
{
lineStart: 0,
lineEnd: 0,
lines: map[int][2]int{
0: {3, 4},
},
},
{
lineStart: 1,
lineEnd: 1,
lines: map[int][2]int{
1: {3, 4},
},
},
{
lineStart: 9,
lineEnd: 9,
lines: map[int][2]int{
9: {22, 23},
},
},
})
})
t.Run("span lines", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("lo\nwo"), []highlightInfo{
{
lineStart: 0,
lineEnd: 1,
lines: map[int][2]int{
0: {3, 6},
1: {0, 2},
},
},
})
})
t.Run("ends with newline", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("lo\n"), []highlightInfo{
{
lineStart: 0,
lineEnd: 0,
lines: map[int][2]int{
0: {3, 6},
},
},
})
})
t.Run("empty lines in the text", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("ith"), []highlightInfo{
{
lineStart: 3,
lineEnd: 3,
lines: map[int][2]int{
3: {1, 4},
},
},
})
})
t.Run("empty lines in the text match start of new line", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("with"), []highlightInfo{
{
lineStart: 3,
lineEnd: 3,
lines: map[int][2]int{
3: {0, 4},
},
},
})
})
t.Run("wide characteres", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("after"), []highlightInfo{
{
lineStart: 5,
lineEnd: 5,
lines: map[int][2]int{
5: {22, 27},
},
},
})
})
t.Run("wide 2", func(t *testing.T) {
testHighlights(t, content, regexp.MustCompile("Charm"), []highlightInfo{
{
lineStart: 7,
lineEnd: 7,
lines: map[int][2]int{
7: {9, 14},
},
},
{
lineStart: 9,
lineEnd: 9,
lines: map[int][2]int{
9: {0, 5},
},
},
{
lineStart: 9,
lineEnd: 9,
lines: map[int][2]int{
9: {16, 21},
},
},
})
})
}
func testHighlights(tb testing.TB, content string, re *regexp.Regexp, expect []highlightInfo) {
tb.Helper()
vt := New(WithHeight(100), WithWidth(100))
vt.SetContent(content)
matches := re.FindAllStringIndex(vt.GetContent(), -1)
vt.SetHighlights(matches)
if !reflect.DeepEqual(expect, vt.highlights) {
tb.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights)
}
if strings.Contains(re.String(), "\n") {
tb.Log("cannot check text when regex has span lines")
return
}
for _, hi := range expect {
for line, hl := range hi.lines {
cut := ansi.Cut(vt.lines[line], hl[0], hl[1])
if !re.MatchString(cut) {
tb.Errorf("exptect to match '%s', got '%s': line: %d, cut: %+v", re.String(), cut, line, hl)
}
}
}
}