From 2f4a36a61536cdc4ec941cb71e045c99e366700c Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 7 Jan 2025 16:47:57 -0300 Subject: [PATCH 01/27] feat(viewport): column sign --- viewport/viewport.go | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 1835b8d0..87484740 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -66,6 +66,12 @@ type Model struct { // Deprecated: high performance rendering is now deprecated in Bubble Tea. HighPerformanceRendering bool + // ColumnSignFn allows to define a function that adds a column into the + // left of the viewpart, which is kept when horizontal scrolling. + // Thing line numbers, selection indicators, and etc. + // Argument [i] is the 0-indexed line, [total] is the total amount of lines. + ColumnSignFn func(i, total int) string + initialized bool lines []string longestLineWidth int @@ -77,6 +83,7 @@ func (m *Model) setInitialValues() { m.MouseWheelDelta = 3 m.initialized = true m.horizontalStep = defaultHorizontalStep + m.ColumnSignFn = func(int, int) string { return "" } } // Init exists to satisfy the tea.Model interface for composability purposes. @@ -147,7 +154,9 @@ func (m Model) maxYOffset() int { // viewport. func (m Model) visibleLines() (lines []string) { h := m.Height - m.Style.GetVerticalFrameSize() - w := m.Width - m.Style.GetHorizontalFrameSize() + w := m.Width - + m.Style.GetHorizontalFrameSize() - + lipgloss.Width(m.ColumnSignFn(0, 0)) if len(m.lines) > 0 { top := max(0, m.YOffset) @@ -155,15 +164,27 @@ func (m Model) visibleLines() (lines []string) { lines = m.lines[top:bottom] } + for len(lines) < h { + lines = append(lines, "") + } + if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 { - return lines + return m.prependColumn(lines) } cutLines := make([]string, len(lines)) for i := range lines { cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w) } - return cutLines + return m.prependColumn(cutLines) +} + +func (m Model) prependColumn(lines []string) []string { + result := make([]string, len(lines)) + for i, line := range lines { + result[i] = m.ColumnSignFn(i+m.YOffset, m.TotalLineCount()) + line + } + return result } // scrollArea returns the scrollable boundaries for high performance rendering. @@ -352,7 +373,10 @@ 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.Width - + m.Style.GetHorizontalFrameSize() - + lipgloss.Width(m.ColumnSignFn(0, 0)) + if m.xOffset > m.longestLineWidth-w { return } m.xOffset += cols From ea26eb7c58cadc8b3aa7add08ead1c86083aaafa Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 08:14:08 -0300 Subject: [PATCH 02/27] feat: gutter, soft wrap --- viewport/viewport.go | 79 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 87484740..ca233b55 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -1,6 +1,7 @@ package viewport import ( + "fmt" "math" "strings" @@ -29,6 +30,10 @@ 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 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 @@ -66,24 +71,50 @@ type Model struct { // Deprecated: high performance rendering is now deprecated in Bubble Tea. HighPerformanceRendering bool - // ColumnSignFn allows to define a function that adds a column into the + // LeftGutterFunc allows to define a function that adds a column into the // left of the viewpart, which is kept when horizontal scrolling. - // Thing line numbers, selection indicators, and etc. - // Argument [i] is the 0-indexed line, [total] is the total amount of lines. - ColumnSignFn func(i, total int) string + // This should help support things like line numbers, selection indicators, + // and etc. + LeftGutterFunc GutterFunc initialized bool lines []string longestLineWidth int } +// GutterFunc can be implemented and set into [Model.LeftGutterFunc]. +type GutterFunc func(GutterContext) string + +// LineNumbersGutter return a [GutterFunc] that shows line numbers. +func LineNumbersGutter(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() { m.KeyMap = DefaultKeyMap() m.MouseWheelEnabled = true m.MouseWheelDelta = 3 m.initialized = true m.horizontalStep = defaultHorizontalStep - m.ColumnSignFn = func(int, int) string { return "" } + m.LeftGutterFunc = NoGutter } // Init exists to satisfy the tea.Model interface for composability purposes. @@ -153,28 +184,45 @@ func (m Model) maxYOffset() int { // 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 - + maxHeight := m.Height - m.Style.GetVerticalFrameSize() + maxWidth := m.Width - m.Style.GetHorizontalFrameSize() - - lipgloss.Width(m.ColumnSignFn(0, 0)) + lipgloss.Width(m.LeftGutterFunc(GutterContext{})) if len(m.lines) > 0 { top := max(0, m.YOffset) - bottom := clamp(m.YOffset+h, top, len(m.lines)) + bottom := clamp(m.YOffset+maxHeight, top, len(m.lines)) lines = m.lines[top:bottom] } - for len(lines) < h { + for len(lines) < maxHeight { lines = append(lines, "") } - if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 { + if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 { return m.prependColumn(lines) } + if m.SoftWrap { + 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 + } + cutLines := make([]string, len(lines)) for i := range lines { - cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w) + cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth) } return m.prependColumn(cutLines) } @@ -182,7 +230,10 @@ func (m Model) visibleLines() (lines []string) { func (m Model) prependColumn(lines []string) []string { result := make([]string, len(lines)) for i, line := range lines { - result[i] = m.ColumnSignFn(i+m.YOffset, m.TotalLineCount()) + line + result[i] = m.LeftGutterFunc(GutterContext{ + Index: i + m.YOffset, + TotalLines: m.TotalLineCount(), + }) + line } return result } @@ -375,7 +426,7 @@ func (m *Model) MoveRight(cols int) { // prevents over scrolling to the right w := m.Width - m.Style.GetHorizontalFrameSize() - - lipgloss.Width(m.ColumnSignFn(0, 0)) + lipgloss.Width(m.LeftGutterFunc(GutterContext{})) if m.xOffset > m.longestLineWidth-w { return } From 6c10dc2bd01cdfec0cfd0b0e489b250a4d1df77d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 10:00:49 -0300 Subject: [PATCH 03/27] wip: search --- go.mod | 2 ++ go.sum | 2 -- viewport/viewport.go | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index c35957b3..45111328 100644 --- a/go.mod +++ b/go.mod @@ -32,3 +32,5 @@ require ( golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.3.8 // indirect ) + +replace github.com/charmbracelet/lipgloss => ../lipgloss diff --git a/go.sum b/go.sum index 7542d371..3369febb 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= -github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg= github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= diff --git a/viewport/viewport.go b/viewport/viewport.go index ca233b55..c8165668 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -3,6 +3,7 @@ package viewport import ( "fmt" "math" + "regexp" "strings" "github.com/charmbracelet/bubbles/key" @@ -80,6 +81,22 @@ type Model struct { initialized bool lines []string longestLineWidth int + + SearchMatchStyle lipgloss.Style + SearchHighlightMatchStyle lipgloss.Style + + searchRE *regexp.Regexp + currentMatch matched + memoizedMatchedLines []string + matches [][][]int +} + +type matched struct { + line, colStart, colEnd int +} + +func (m matched) eq(line int, match []int) bool { + return line == m.line && match[0] == m.colStart && match[1] == m.colEnd } // GutterFunc can be implemented and set into [Model.LeftGutterFunc]. @@ -193,6 +210,26 @@ func (m Model) visibleLines() (lines []string) { top := max(0, m.YOffset) bottom := clamp(m.YOffset+maxHeight, top, len(m.lines)) lines = m.lines[top:bottom] + if len(m.matches) > 0 { + for i, lmatches := range m.matches[top:bottom] { + if memoized := m.memoizedMatchedLines[i+top]; memoized != "" { + lines[i] = memoized + } else { + for _, match := range lmatches { + lines[i] = lipgloss.StyleRange(lines[i], match[0], match[1], m.SearchMatchStyle) + m.memoizedMatchedLines[i+top] = lines[i] + } + } + if m.currentMatch.line == i+top { + lines[i] = lipgloss.StyleRange( + lines[i], + m.currentMatch.colStart, + m.currentMatch.colEnd, + m.SearchHighlightMatchStyle, + ) + } + } + } } for len(lines) < maxHeight { @@ -438,6 +475,23 @@ func (m *Model) ResetIndent() { m.xOffset = 0 } +func (m *Model) Search(r *regexp.Regexp) { + m.searchRE = r + m.matches = make([][][]int, len(m.lines)) + m.memoizedMatchedLines = make([]string, len(m.lines)) + m.currentMatch = matched{} + for i, line := range m.lines { + found := r.FindAllStringIndex(ansi.Strip(line), -1) + m.matches[i] = found + } + for i, match := range m.matches { + if len(match) > 0 && len(match[0]) > 0 { + m.currentMatch = matched{line: i, colStart: match[0][0], colEnd: match[0][1]} + break + } + } +} + // Update handles standard message-based viewport updates. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd From 4eebb08e41c61fdf8985c9015d38afc30619d3d6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 10:33:11 -0300 Subject: [PATCH 04/27] wip: search --- viewport/viewport.go | 91 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index c8165668..107c51a5 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -86,17 +86,18 @@ type Model struct { SearchHighlightMatchStyle lipgloss.Style searchRE *regexp.Regexp + matches [][][]int + matchIndex int currentMatch matched memoizedMatchedLines []string - matches [][][]int } type matched struct { - line, colStart, colEnd int + line, start, end int } func (m matched) eq(line int, match []int) bool { - return line == m.line && match[0] == m.colStart && match[1] == m.colEnd + return line == m.line && match[0] == m.start && match[1] == m.end } // GutterFunc can be implemented and set into [Model.LeftGutterFunc]. @@ -198,6 +199,12 @@ func (m Model) maxYOffset() int { return max(0, len(m.lines)-m.Height) } +// 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) +} + // visibleLines returns the lines that should currently be visible in the // viewport. func (m Model) visibleLines() (lines []string) { @@ -223,8 +230,8 @@ func (m Model) visibleLines() (lines []string) { if m.currentMatch.line == i+top { lines[i] = lipgloss.StyleRange( lines[i], - m.currentMatch.colStart, - m.currentMatch.colEnd, + m.currentMatch.start, + m.currentMatch.end, m.SearchHighlightMatchStyle, ) } @@ -292,6 +299,28 @@ func (m *Model) SetYOffset(n int) { m.YOffset = clamp(n, 0, m.maxYOffset()) } +// SetXOffset sets the X offset. +func (m *Model) SetXOffset(n int) { + m.xOffset = clamp(n, 0, m.maxXOffset()) +} + +func (m *Model) EnsureVisible(line, col int) { + maxHeight := m.Height - m.Style.GetVerticalFrameSize() + maxWidth := m.Width - + m.Style.GetHorizontalFrameSize() - + lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + if line > maxHeight { + m.SetYOffset(line) + } else { + m.SetYOffset(0) + } + if col > maxWidth { + m.SetXOffset(col) + } else { + m.SetXOffset(0) + } +} + // ViewDown moves the view down by the number of lines in the viewport. // Basically, "page down". func (m *Model) ViewDown() []string { @@ -480,18 +509,68 @@ func (m *Model) Search(r *regexp.Regexp) { m.matches = make([][][]int, len(m.lines)) m.memoizedMatchedLines = make([]string, len(m.lines)) m.currentMatch = matched{} + m.matchIndex = -1 for i, line := range m.lines { found := r.FindAllStringIndex(ansi.Strip(line), -1) m.matches[i] = found } for i, match := range m.matches { if len(match) > 0 && len(match[0]) > 0 { - m.currentMatch = matched{line: i, colStart: match[0][0], colEnd: match[0][1]} + m.currentMatch = matched{line: i, start: match[0][0], end: match[0][1]} + m.matchIndex = 0 break } } } +func (m *Model) NextMatch() { + if m.matches == nil || m.matchIndex == -1 { + return + } + + got, ok := m.findMatch(m.matchIndex + 1) + if ok { + m.currentMatch = got + m.EnsureVisible(got.line, got.start) + m.matchIndex++ + return + } +} + +func (m *Model) PreviousMatch() { + if m.matches == nil || m.matchIndex <= 0 { + return + } + + got, ok := m.findMatch(m.matchIndex - 1) + if ok { + m.currentMatch = got + m.EnsureVisible(got.line, got.start) + m.matchIndex-- + return + } +} + +func (m *Model) findMatch(idx int) (matched, bool) { + totalMatches := 0 + for i, lineMatches := range m.matches { + if len(lineMatches) == 0 { + continue + } + if idx < totalMatches+len(lineMatches) { + matchInLine := idx - totalMatches + match := lineMatches[matchInLine] + return matched{ + line: i, + start: match[0], + end: match[1], + }, true + } + totalMatches += len(lineMatches) + } + return matched{}, false +} + // Update handles standard message-based viewport updates. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd From eb50edcdcff6ee569d0e220c6ff0530f86c217c1 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 11:57:38 -0300 Subject: [PATCH 05/27] wip: search --- viewport/viewport.go | 104 ++++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 107c51a5..eead66f7 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -205,18 +205,27 @@ 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) { - maxHeight := m.Height - m.Style.GetVerticalFrameSize() - maxWidth := m.Width - - m.Style.GetHorizontalFrameSize() - - lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + maxHeight := m.maxHeight() + maxWidth := m.maxWidth() if len(m.lines) > 0 { top := max(0, m.YOffset) bottom := clamp(m.YOffset+maxHeight, top, len(m.lines)) - lines = m.lines[top:bottom] + lines = make([]string, bottom-top) + copy(lines, m.lines[top:bottom]) if len(m.matches) > 0 { for i, lmatches := range m.matches[top:bottom] { if memoized := m.memoizedMatchedLines[i+top]; memoized != "" { @@ -264,11 +273,10 @@ func (m Model) visibleLines() (lines []string) { return wrappedLines } - cutLines := make([]string, len(lines)) for i := range lines { - cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth) + lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth) } - return m.prependColumn(cutLines) + return m.prependColumn(lines) } func (m Model) prependColumn(lines []string) []string { @@ -300,25 +308,32 @@ func (m *Model) SetYOffset(n int) { } // 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()) } func (m *Model) EnsureVisible(line, col int) { - maxHeight := m.Height - m.Style.GetVerticalFrameSize() - maxWidth := m.Width - - m.Style.GetHorizontalFrameSize() - - lipgloss.Width(m.LeftGutterFunc(GutterContext{})) - if line > maxHeight { + maxHeight := m.maxHeight() + maxWidth := m.maxWidth() + + if line >= m.YOffset && line < m.YOffset+maxHeight { + // Line is visible, no nothing + } else if line >= m.YOffset+maxHeight || line < m.YOffset { m.SetYOffset(line) - } else { - m.SetYOffset(0) } - if col > maxWidth { + + if col >= m.xOffset && col < m.xOffset+maxWidth { + // Column is visible, do nothing + } else if col >= m.xOffset+maxWidth || col < m.xOffset { + // Column is to the left of visible area m.SetXOffset(col) - } else { - m.SetXOffset(0) } + + m.visibleLines() } // ViewDown moves the view down by the number of lines in the viewport. @@ -368,6 +383,7 @@ func (m *Model) LineDown(n int) (lines []string) { // greater than the number of lines we actually have left before we reach // the bottom. m.SetYOffset(m.YOffset + n) + m.nearestMatchFromYOffset() // Gather lines to send off for performance scrolling. // @@ -387,6 +403,7 @@ func (m *Model) LineUp(n int) (lines []string) { // 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.nearestMatchFromYOffset() // Gather lines to send off for performance scrolling. // @@ -413,12 +430,14 @@ func (m *Model) GotoTop() (lines []string) { } m.SetYOffset(0) + m.nearestMatchFromYOffset() return m.visibleLines() } // GotoBottom sets the viewport to the bottom position. func (m *Model) GotoBottom() (lines []string) { m.SetYOffset(m.maxYOffset()) + m.nearestMatchFromYOffset() return m.visibleLines() } @@ -490,9 +509,7 @@ 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 - w := m.Width - - m.Style.GetHorizontalFrameSize() - - lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + w := m.maxWidth() if m.xOffset > m.longestLineWidth-w { return } @@ -504,27 +521,29 @@ func (m *Model) ResetIndent() { m.xOffset = 0 } +func (m *Model) ClearSearch() { + m.searchRE = nil + m.matches = nil + m.memoizedMatchedLines = nil + m.currentMatch = matched{} + m.matchIndex = -1 +} + func (m *Model) Search(r *regexp.Regexp) { + m.ClearSearch() m.searchRE = r m.matches = make([][][]int, len(m.lines)) m.memoizedMatchedLines = make([]string, len(m.lines)) - m.currentMatch = matched{} - m.matchIndex = -1 for i, line := range m.lines { found := r.FindAllStringIndex(ansi.Strip(line), -1) m.matches[i] = found } - for i, match := range m.matches { - if len(match) > 0 && len(match[0]) > 0 { - m.currentMatch = matched{line: i, start: match[0][0], end: match[0][1]} - m.matchIndex = 0 - break - } - } + m.nearestMatchFromYOffset() + m.EnsureVisible(m.currentMatch.line, m.currentMatch.start) } func (m *Model) NextMatch() { - if m.matches == nil || m.matchIndex == -1 { + if m.matches == nil { return } @@ -551,6 +570,29 @@ func (m *Model) PreviousMatch() { } } +func (m *Model) nearestMatchFromYOffset() { + if m.matches == nil { + return + } + + totalMatches := 0 + for i, match := range m.matches { + if len(match) == 0 { + continue + } + if i >= m.YOffset { + m.currentMatch = matched{ + line: i, + start: match[0][0], + end: match[0][1], + } + m.matchIndex = totalMatches + return + } + totalMatches += len(match) + } +} + func (m *Model) findMatch(idx int) (matched, bool) { totalMatches := 0 for i, lineMatches := range m.matches { From 7784024e2b4446230522ec2520cceac1c38e1653 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 12:58:27 -0300 Subject: [PATCH 06/27] fix: perf Signed-off-by: Carlos Alexandro Becker --- viewport/viewport.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index eead66f7..ec5f2022 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -215,6 +215,14 @@ func (m Model) maxHeight() int { return m.Height - m.Style.GetVerticalFrameSize() } +func (m Model) makeRanges(lmatches [][]int) []lipgloss.Range { + result := make([]lipgloss.Range, len(lmatches)) + for i, match := range lmatches { + result[i] = lipgloss.NewRange(match[0], match[1], m.SearchMatchStyle) + } + return result +} + // visibleLines returns the lines that should currently be visible in the // viewport. func (m Model) visibleLines() (lines []string) { @@ -231,10 +239,8 @@ func (m Model) visibleLines() (lines []string) { if memoized := m.memoizedMatchedLines[i+top]; memoized != "" { lines[i] = memoized } else { - for _, match := range lmatches { - lines[i] = lipgloss.StyleRange(lines[i], match[0], match[1], m.SearchMatchStyle) - m.memoizedMatchedLines[i+top] = lines[i] - } + lines[i] = lipgloss.StyleRanges(lines[i], m.makeRanges(lmatches)) + m.memoizedMatchedLines[i+top] = lines[i] } if m.currentMatch.line == i+top { lines[i] = lipgloss.StyleRange( From 303ded75de234d0ff1b38830b0eb8ef6ce1157c6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 14:47:47 -0300 Subject: [PATCH 07/27] fix: rename --- viewport/viewport.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index ec5f2022..c0b6798c 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -103,8 +103,8 @@ func (m matched) eq(line int, match []int) bool { // GutterFunc can be implemented and set into [Model.LeftGutterFunc]. type GutterFunc func(GutterContext) string -// LineNumbersGutter return a [GutterFunc] that shows line numbers. -func LineNumbersGutter(style lipgloss.Style) GutterFunc { +// 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(" │ ") From 619bac5239f5d1049f140ded4363f88959dda816 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 16:39:52 -0300 Subject: [PATCH 08/27] wip --- viewport/viewport.go | 209 +++++++++++++++++++++++-------------------- 1 file changed, 112 insertions(+), 97 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index c0b6798c..1fa7e1b3 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -3,7 +3,6 @@ package viewport import ( "fmt" "math" - "regexp" "strings" "github.com/charmbracelet/bubbles/key" @@ -80,23 +79,22 @@ type Model struct { initialized bool lines []string + lineWidths []int longestLineWidth int SearchMatchStyle lipgloss.Style SearchHighlightMatchStyle lipgloss.Style - searchRE *regexp.Regexp - matches [][][]int + highlights []highlightInfo matchIndex int - currentMatch matched memoizedMatchedLines []string } -type matched struct { +type highlightInfo struct { line, start, end int } -func (m matched) eq(line int, match []int) bool { +func (m highlightInfo) eq(line int, match []int) bool { return line == m.line && match[0] == m.start && match[1] == m.end } @@ -186,13 +184,17 @@ func (m Model) HorizontalScrollPercent() float64 { 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) + m.lineWidths, m.longestLineWidth = calcLineWidths(m.lines) if m.YOffset > len(m.lines)-1 { m.GotoBottom() } } +func (m Model) GetContent() string { + return strings.Join(m.lines, "\n") +} + // maxYOffset returns the maximum possible value of the y-offset based on the // viewport's content and set height. func (m Model) maxYOffset() int { @@ -215,10 +217,16 @@ func (m Model) maxHeight() int { return m.Height - m.Style.GetVerticalFrameSize() } -func (m Model) makeRanges(lmatches [][]int) []lipgloss.Range { - result := make([]lipgloss.Range, len(lmatches)) - for i, match := range lmatches { - result[i] = lipgloss.NewRange(match[0], match[1], m.SearchMatchStyle) +func (m Model) makeRanges(line int) []lipgloss.Range { + result := []lipgloss.Range{} + for _, match := range m.highlights { + if line > match.line { + continue + } + if match.line > line { + break + } + result = append(result, lipgloss.NewRange(match.start, match.end, m.SearchMatchStyle)) } return result } @@ -234,19 +242,23 @@ func (m Model) visibleLines() (lines []string) { bottom := clamp(m.YOffset+maxHeight, top, len(m.lines)) lines = make([]string, bottom-top) copy(lines, m.lines[top:bottom]) - if len(m.matches) > 0 { - for i, lmatches := range m.matches[top:bottom] { + if len(m.highlights) > 0 { + for i := range lines { if memoized := m.memoizedMatchedLines[i+top]; memoized != "" { lines[i] = memoized } else { - lines[i] = lipgloss.StyleRanges(lines[i], m.makeRanges(lmatches)) + lines[i] = lipgloss.StyleRanges(lines[i], m.makeRanges(i+top)) m.memoizedMatchedLines[i+top] = lines[i] } - if m.currentMatch.line == i+top { + if m.matchIndex < 0 { + continue + } + sel := m.highlights[m.matchIndex] + if sel.line == i+top { lines[i] = lipgloss.StyleRange( lines[i], - m.currentMatch.start, - m.currentMatch.end, + sel.start, + sel.end, m.SearchHighlightMatchStyle, ) } @@ -389,7 +401,7 @@ func (m *Model) LineDown(n int) (lines []string) { // greater than the number of lines we actually have left before we reach // the bottom. m.SetYOffset(m.YOffset + n) - m.nearestMatchFromYOffset() + m.matchIndex = m.nearestMatchFromYOffset() // Gather lines to send off for performance scrolling. // @@ -409,7 +421,7 @@ func (m *Model) LineUp(n int) (lines []string) { // 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.nearestMatchFromYOffset() + m.matchIndex = m.nearestMatchFromYOffset() // Gather lines to send off for performance scrolling. // @@ -436,14 +448,14 @@ func (m *Model) GotoTop() (lines []string) { } m.SetYOffset(0) - m.nearestMatchFromYOffset() + m.matchIndex = m.nearestMatchFromYOffset() return m.visibleLines() } // GotoBottom sets the viewport to the bottom position. func (m *Model) GotoBottom() (lines []string) { m.SetYOffset(m.maxYOffset()) - m.nearestMatchFromYOffset() + m.matchIndex = m.nearestMatchFromYOffset() return m.visibleLines() } @@ -527,96 +539,96 @@ func (m *Model) ResetIndent() { m.xOffset = 0 } -func (m *Model) ClearSearch() { - m.searchRE = nil - m.matches = nil - m.memoizedMatchedLines = nil - m.currentMatch = matched{} - m.matchIndex = -1 -} - -func (m *Model) Search(r *regexp.Regexp) { - m.ClearSearch() - m.searchRE = r - m.matches = make([][][]int, len(m.lines)) - m.memoizedMatchedLines = make([]string, len(m.lines)) - for i, line := range m.lines { - found := r.FindAllStringIndex(ansi.Strip(line), -1) - m.matches[i] = found +// SetHighligths 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. +func (m *Model) SetHighligths(matches [][]int) { + if len(matches) == 0 || len(m.lines) == 0 { + return } - m.nearestMatchFromYOffset() - m.EnsureVisible(m.currentMatch.line, m.currentMatch.start) -} + m.highlights = []highlightInfo{} + m.memoizedMatchedLines = make([]string, len(m.lines)) -func (m *Model) NextMatch() { - if m.matches == nil { - return + line := 0 + processed := 0 + for _, match := range matches { + start, end := match[0], match[1] + if line == len(m.lineWidths)-1 { + break + } + + for line < len(m.lineWidths) { + width := m.lineWidths[line] + + // fmt.Printf("Inside Loop: start=%d end=%d processed=%d line=%d width=%d\n", start, end, processed, line, width) + + if start > processed+width { + line++ + processed += width + continue + } + + hi := highlightInfo{ + line, + max(0, start-processed), + clamp(end-processed, start-processed, width-1), // discount \n + } + m.highlights = append(m.highlights, hi) + if hi.end == end { + // done with this highligh! + break + } + processed += width + line++ + } } - got, ok := m.findMatch(m.matchIndex + 1) - if ok { - m.currentMatch = got - m.EnsureVisible(got.line, got.start) - m.matchIndex++ + // fmt.Println("AQUI", matches, m.highlights) + + m.matchIndex = m.nearestMatchFromYOffset() + if m.matchIndex == 1 { return } + current := m.highlights[m.matchIndex] + m.EnsureVisible(current.line, current.start) } -func (m *Model) PreviousMatch() { - if m.matches == nil || m.matchIndex <= 0 { - return - } +// ClearHighlights clears previously set highlights. +func (m *Model) ClearHighlights() { + m.memoizedMatchedLines = nil + m.highlights = nil + m.matchIndex = -1 +} - got, ok := m.findMatch(m.matchIndex - 1) - if ok { - m.currentMatch = got - m.EnsureVisible(got.line, got.start) - m.matchIndex-- +func (m *Model) HightlightNext() { + if m.highlights == nil { return } + + m.matchIndex = clamp(m.matchIndex+1, 0, len(m.highlights)-1) + current := m.highlights[m.matchIndex] + m.EnsureVisible(current.line, current.start) } -func (m *Model) nearestMatchFromYOffset() { - if m.matches == nil { +func (m *Model) HighlightPrevious() { + if m.highlights == nil || m.matchIndex <= 0 { return } - totalMatches := 0 - for i, match := range m.matches { - if len(match) == 0 { - continue - } - if i >= m.YOffset { - m.currentMatch = matched{ - line: i, - start: match[0][0], - end: match[0][1], - } - m.matchIndex = totalMatches - return - } - totalMatches += len(match) - } + m.matchIndex = clamp(m.matchIndex-1, 0, len(m.highlights)-1) + current := m.highlights[m.matchIndex] + m.EnsureVisible(current.line, current.start) } -func (m *Model) findMatch(idx int) (matched, bool) { - totalMatches := 0 - for i, lineMatches := range m.matches { - if len(lineMatches) == 0 { - continue - } - if idx < totalMatches+len(lineMatches) { - matchInLine := idx - totalMatches - match := lineMatches[matchInLine] - return matched{ - line: i, - start: match[0], - end: match[1], - }, true +func (m Model) nearestMatchFromYOffset() int { // TODO: rename + for i, match := range m.highlights { + if match.line >= m.YOffset { + return i } - totalMatches += len(lineMatches) } - return matched{}, false + return -1 } // Update handles standard message-based viewport updates. @@ -754,12 +766,15 @@ 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 calcLineWidths(lines []string) ([]int, int) { + maxlen := 0 + all := make([]int, 0, len(lines)) + for _, line := range lines { + llen := ansi.StringWidth(line) + all = append(all, llen+1) // account for "\n" + if llen > maxlen { + maxlen = llen } } - return w + return all, maxlen } From d1ff1ab8d7a32a2ecfeef6fde1e191b3ad4435b3 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 22:48:12 -0300 Subject: [PATCH 09/27] wip --- viewport/viewport.go | 158 ++++++++++++++++++++++++++------------ viewport/viewport_test.go | 91 ++++++++++++++++++++++ 2 files changed, 199 insertions(+), 50 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 1fa7e1b3..c9b4c521 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -82,20 +82,41 @@ type Model struct { lineWidths []int longestLineWidth int - SearchMatchStyle lipgloss.Style - SearchHighlightMatchStyle lipgloss.Style + // 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 - matchIndex int + hiIdx int memoizedMatchedLines []string } type highlightInfo struct { - line, start, end int + lineStart, lineEnd int + lines [][][2]int +} + +func (hi highlightInfo) inLineRange(line int) bool { + return line >= hi.lineStart && line <= hi.lineEnd +} + +func (hi highlightInfo) forLine(line int) [][2]int { + if !hi.inLineRange(line) { + return nil + } + return hi.lines[line-hi.lineStart] } -func (m highlightInfo) eq(line int, match []int) bool { - return line == m.line && match[0] == m.start && match[1] == m.end +func (hi highlightInfo) coords() (line int, col int) { + if len(hi.lines) == 0 { + return hi.lineStart, 0 + } + return hi.lineStart, hi.lines[0][0][0] } // GutterFunc can be implemented and set into [Model.LeftGutterFunc]. @@ -219,14 +240,18 @@ func (m Model) maxHeight() int { func (m Model) makeRanges(line int) []lipgloss.Range { result := []lipgloss.Range{} - for _, match := range m.highlights { - if line > match.line { + for _, hi := range m.highlights { + if !hi.inLineRange(line) { + // out of range continue } - if match.line > line { - break + + for _, lihi := range hi.forLine(line) { + result = append(result, lipgloss.NewRange( + lihi[0], lihi[1], + m.HighlightStyle, + )) } - result = append(result, lipgloss.NewRange(match.start, match.end, m.SearchMatchStyle)) } return result } @@ -250,22 +275,26 @@ func (m Model) visibleLines() (lines []string) { lines[i] = lipgloss.StyleRanges(lines[i], m.makeRanges(i+top)) m.memoizedMatchedLines[i+top] = lines[i] } - if m.matchIndex < 0 { + if m.hiIdx < 0 { continue } - sel := m.highlights[m.matchIndex] - if sel.line == i+top { + sel := m.highlights[m.hiIdx] + if hi := sel.forLine(i + top); hi != nil { + if len(hi) == 0 { + continue + } lines[i] = lipgloss.StyleRange( lines[i], - sel.start, - sel.end, - m.SearchHighlightMatchStyle, + hi[0][0], + hi[0][1], + m.SelectedHighlightStyle, ) } } } } + // FIXME: make optional for len(lines) < maxHeight { lines = append(lines, "") } @@ -334,6 +363,7 @@ func (m *Model) SetXOffset(n int) { m.xOffset = clamp(n, 0, m.maxXOffset()) } +// EnsureVisible ensures that the given line and column are in the viewport. func (m *Model) EnsureVisible(line, col int) { maxHeight := m.maxHeight() maxWidth := m.maxWidth() @@ -401,7 +431,7 @@ func (m *Model) LineDown(n int) (lines []string) { // greater than the number of lines we actually have left before we reach // the bottom. m.SetYOffset(m.YOffset + n) - m.matchIndex = m.nearestMatchFromYOffset() + m.hiIdx = m.findNearedtMatch() // Gather lines to send off for performance scrolling. // @@ -421,7 +451,7 @@ func (m *Model) LineUp(n int) (lines []string) { // 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.matchIndex = m.nearestMatchFromYOffset() + m.hiIdx = m.findNearedtMatch() // Gather lines to send off for performance scrolling. // @@ -448,14 +478,14 @@ func (m *Model) GotoTop() (lines []string) { } m.SetYOffset(0) - m.matchIndex = m.nearestMatchFromYOffset() + 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.matchIndex = m.nearestMatchFromYOffset() + m.hiIdx = m.findNearedtMatch() return m.visibleLines() } @@ -544,6 +574,9 @@ func (m *Model) ResetIndent() { // 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 [SetHighligths] to set the highlight ranges, and [HightlightNext] +// and [HihglightPrevious] to navigate. +// Use [ClearHighlights] to remove all highlights. func (m *Model) SetHighligths(matches [][]int) { if len(matches) == 0 || len(m.lines) == 0 { return @@ -553,53 +586,78 @@ func (m *Model) SetHighligths(matches [][]int) { line := 0 processed := 0 + for _, match := range matches { start, end := match[0], match[1] - if line == len(m.lineWidths)-1 { - break + + // safety check + // XXX: return an error instead + if start > end { + panic(fmt.Sprintf("invalid match: %d, %d", start, end)) } + hi := highlightInfo{} + hiline := [][2]int{} for line < len(m.lineWidths) { width := m.lineWidths[line] - // fmt.Printf("Inside Loop: start=%d end=%d processed=%d line=%d width=%d\n", start, end, processed, line, width) - + // out of bounds if start > processed+width { line++ processed += width continue } - hi := highlightInfo{ - line, - max(0, start-processed), - clamp(end-processed, start-processed, width-1), // discount \n + colstart := max(0, start-processed) + colend := clamp(end-processed, colstart, width) + + if start >= processed && start <= processed+width { + hi.lineStart = line + } + if end <= processed+width { + hi.lineEnd = line } - m.highlights = append(m.highlights, hi) - if hi.end == end { - // done with this highligh! + + // fmt.Printf( + // "line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d hi=%+v\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, start, end, processed, width, hi, + // ) + + hiline = append(hiline, [2]int{colstart, colend}) + if end > processed+width { + if colend > 0 { + hi.lines = append(hi.lines, hiline) + } + hiline = [][2]int{} + line++ + processed += width + continue + } + if end <= processed+width { + if colend > 0 { + hi.lines = append(hi.lines, hiline) + } + hiline = [][2]int{} break } - processed += width - line++ } - } + m.highlights = append(m.highlights, hi) - // fmt.Println("AQUI", matches, m.highlights) + } - m.matchIndex = m.nearestMatchFromYOffset() - if m.matchIndex == 1 { + m.hiIdx = m.findNearedtMatch() + if m.hiIdx == -1 { return } - current := m.highlights[m.matchIndex] - m.EnsureVisible(current.line, current.start) + line, col := m.highlights[m.hiIdx].coords() + m.EnsureVisible(line, col) } // ClearHighlights clears previously set highlights. func (m *Model) ClearHighlights() { m.memoizedMatchedLines = nil m.highlights = nil - m.matchIndex = -1 + m.hiIdx = -1 } func (m *Model) HightlightNext() { @@ -607,24 +665,24 @@ func (m *Model) HightlightNext() { return } - m.matchIndex = clamp(m.matchIndex+1, 0, len(m.highlights)-1) - current := m.highlights[m.matchIndex] - m.EnsureVisible(current.line, current.start) + m.hiIdx = (m.hiIdx + 1) % len(m.highlights) + line, col := m.highlights[m.hiIdx].coords() + m.EnsureVisible(line, col) } func (m *Model) HighlightPrevious() { - if m.highlights == nil || m.matchIndex <= 0 { + if m.highlights == nil { return } - m.matchIndex = clamp(m.matchIndex-1, 0, len(m.highlights)-1) - current := m.highlights[m.matchIndex] - m.EnsureVisible(current.line, current.start) + m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights) + line, col := m.highlights[m.hiIdx].coords() + m.EnsureVisible(line, col) } -func (m Model) nearestMatchFromYOffset() int { // TODO: rename +func (m Model) findNearedtMatch() int { for i, match := range m.highlights { - if match.line >= m.YOffset { + if match.lineStart >= m.YOffset { return i } } diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index ef70a54f..001167ed 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,6 +1,8 @@ package viewport import ( + "reflect" + "regexp" "strings" "testing" ) @@ -381,3 +383,92 @@ func TestRightOverscroll(t *testing.T) { } }) } + +func TestMatchesToHighlights(t *testing.T) { + text := `hello +world` + vt := New(100, 100) + vt.SetContent(text) + + t.Run("first", func(t *testing.T) { + vt.SetHighligths(regexp.MustCompile("hello").FindAllStringIndex(vt.GetContent(), -1)) + expect := []highlightInfo{ + { + lineStart: 0, + lineEnd: 0, + lines: [][][2]int{ + {{0, 5}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("expect %+v, got %+v", expect, vt.highlights) + } + }) + + t.Run("multiple", func(t *testing.T) { + matches := regexp.MustCompile("l").FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighligths(matches) + expect := []highlightInfo{ + { + lineStart: 0, + lineEnd: 0, + lines: [][][2]int{ + {{2, 3}}, + }, + }, + { + lineStart: 0, + lineEnd: 0, + lines: [][][2]int{ + {{3, 4}}, + }, + }, + { + lineStart: 1, + lineEnd: 1, + lines: [][][2]int{ + {{3, 4}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("expect %+v, got %+v", expect, vt.highlights) + } + }) + + t.Run("span lines", func(t *testing.T) { + matches := regexp.MustCompile("lo\nwo").FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighligths(matches) + expect := []highlightInfo{ + { + lineStart: 0, + lineEnd: 1, + lines: [][][2]int{ + {{3, 6}}, + {{0, 2}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("expect %+v, got %+v", expect, vt.highlights) + } + }) + + t.Run("ends with newline", func(t *testing.T) { + matches := regexp.MustCompile("lo\n").FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighligths(matches) + expect := []highlightInfo{ + { + lineStart: 0, + lineEnd: 0, + lines: [][][2]int{ + {{3, 6}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("expect %+v, got %+v", expect, vt.highlights) + } + }) +} From fbf76e8c8132eaed0b01f1212409f82cef5cc525 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 22:54:30 -0300 Subject: [PATCH 10/27] refactor: viewport highlight ranges --- viewport/highlight.go | 117 ++++++++++++++++++++++++++++++++++++++++++ viewport/viewport.go | 113 +++------------------------------------- 2 files changed, 125 insertions(+), 105 deletions(-) create mode 100644 viewport/highlight.go diff --git a/viewport/highlight.go b/viewport/highlight.go new file mode 100644 index 00000000..f4fdd22b --- /dev/null +++ b/viewport/highlight.go @@ -0,0 +1,117 @@ +package viewport + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +func parseMatches( + matches [][]int, + lineWidths []int, +) (highlights []highlightInfo) { + line := 0 + processed := 0 + + for _, match := range matches { + start, end := match[0], match[1] + + // safety check + // XXX: return an error instead + if start > end { + panic(fmt.Sprintf("invalid match: %d, %d", start, end)) + } + + hi := highlightInfo{} + hiline := [][2]int{} + for line < len(lineWidths) { + width := lineWidths[line] + + // out of bounds + if start > processed+width { + line++ + processed += width + continue + } + + colstart := max(0, start-processed) + colend := clamp(end-processed, colstart, width) + + if start >= processed && start <= processed+width { + hi.lineStart = line + } + if end <= processed+width { + hi.lineEnd = line + } + + // fmt.Printf( + // "line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d hi=%+v\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, start, end, processed, width, hi, + // ) + + hiline = append(hiline, [2]int{colstart, colend}) + if end > processed+width { + if colend > 0 { + hi.lines = append(hi.lines, hiline) + } + hiline = [][2]int{} + line++ + processed += width + continue + } + if end <= processed+width { + if colend > 0 { + hi.lines = append(hi.lines, hiline) + } + break + } + } + highlights = append(highlights, hi) + } + return +} + +type highlightInfo struct { + lineStart, lineEnd int + lines [][][2]int +} + +func (hi highlightInfo) inLineRange(line int) bool { + return line >= hi.lineStart && line <= hi.lineEnd +} + +func (hi highlightInfo) forLine(line int) [][2]int { + if !hi.inLineRange(line) { + return nil + } + return hi.lines[line-hi.lineStart] +} + +func (hi highlightInfo) coords() (line int, col int) { + if len(hi.lines) == 0 { + return hi.lineStart, 0 + } + return hi.lineStart, hi.lines[0][0][0] +} + +func makeHilightRanges( + highlights []highlightInfo, + line int, + style lipgloss.Style, +) []lipgloss.Range { + result := []lipgloss.Range{} + for _, hi := range highlights { + if !hi.inLineRange(line) { + // out of range + continue + } + + for _, lihi := range hi.forLine(line) { + result = append(result, lipgloss.NewRange( + lihi[0], lihi[1], + style, + )) + } + } + return result +} diff --git a/viewport/viewport.go b/viewport/viewport.go index c9b4c521..6a54ab07 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -96,29 +96,6 @@ type Model struct { memoizedMatchedLines []string } -type highlightInfo struct { - lineStart, lineEnd int - lines [][][2]int -} - -func (hi highlightInfo) inLineRange(line int) bool { - return line >= hi.lineStart && line <= hi.lineEnd -} - -func (hi highlightInfo) forLine(line int) [][2]int { - if !hi.inLineRange(line) { - return nil - } - return hi.lines[line-hi.lineStart] -} - -func (hi highlightInfo) coords() (line int, col int) { - if len(hi.lines) == 0 { - return hi.lineStart, 0 - } - return hi.lineStart, hi.lines[0][0][0] -} - // GutterFunc can be implemented and set into [Model.LeftGutterFunc]. type GutterFunc func(GutterContext) string @@ -206,6 +183,7 @@ func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings m.lines = strings.Split(s, "\n") m.lineWidths, m.longestLineWidth = calcLineWidths(m.lines) + m.ClearHighlights() if m.YOffset > len(m.lines)-1 { m.GotoBottom() @@ -238,24 +216,6 @@ func (m Model) maxHeight() int { return m.Height - m.Style.GetVerticalFrameSize() } -func (m Model) makeRanges(line int) []lipgloss.Range { - result := []lipgloss.Range{} - for _, hi := range m.highlights { - if !hi.inLineRange(line) { - // out of range - continue - } - - for _, lihi := range hi.forLine(line) { - result = append(result, lipgloss.NewRange( - lihi[0], lihi[1], - m.HighlightStyle, - )) - } - } - return result -} - // visibleLines returns the lines that should currently be visible in the // viewport. func (m Model) visibleLines() (lines []string) { @@ -272,7 +232,12 @@ func (m Model) visibleLines() (lines []string) { if memoized := m.memoizedMatchedLines[i+top]; memoized != "" { lines[i] = memoized } else { - lines[i] = lipgloss.StyleRanges(lines[i], m.makeRanges(i+top)) + ranges := makeHilightRanges( + m.highlights, + i+top, + m.HighlightStyle, + ) + lines[i] = lipgloss.StyleRanges(lines[i], ranges) m.memoizedMatchedLines[i+top] = lines[i] } if m.hiIdx < 0 { @@ -581,70 +546,8 @@ func (m *Model) SetHighligths(matches [][]int) { if len(matches) == 0 || len(m.lines) == 0 { return } - m.highlights = []highlightInfo{} m.memoizedMatchedLines = make([]string, len(m.lines)) - - line := 0 - processed := 0 - - for _, match := range matches { - start, end := match[0], match[1] - - // safety check - // XXX: return an error instead - if start > end { - panic(fmt.Sprintf("invalid match: %d, %d", start, end)) - } - - hi := highlightInfo{} - hiline := [][2]int{} - for line < len(m.lineWidths) { - width := m.lineWidths[line] - - // out of bounds - if start > processed+width { - line++ - processed += width - continue - } - - colstart := max(0, start-processed) - colend := clamp(end-processed, colstart, width) - - if start >= processed && start <= processed+width { - hi.lineStart = line - } - if end <= processed+width { - hi.lineEnd = line - } - - // fmt.Printf( - // "line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d hi=%+v\n", - // line, hi.lineStart, hi.lineEnd, colstart, colend, start, end, processed, width, hi, - // ) - - hiline = append(hiline, [2]int{colstart, colend}) - if end > processed+width { - if colend > 0 { - hi.lines = append(hi.lines, hiline) - } - hiline = [][2]int{} - line++ - processed += width - continue - } - if end <= processed+width { - if colend > 0 { - hi.lines = append(hi.lines, hiline) - } - hiline = [][2]int{} - break - } - } - m.highlights = append(m.highlights, hi) - - } - + m.highlights = parseMatches(matches, m.lineWidths) m.hiIdx = m.findNearedtMatch() if m.hiIdx == -1 { return From 5880b3a8cbf3d8bed059d715a03d544c159b54e6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 23:02:43 -0300 Subject: [PATCH 11/27] fix: ligloss update --- viewport/viewport.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 6a54ab07..d916a09c 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -237,7 +237,7 @@ func (m Model) visibleLines() (lines []string) { i+top, m.HighlightStyle, ) - lines[i] = lipgloss.StyleRanges(lines[i], ranges) + lines[i] = lipgloss.StyleRanges(lines[i], ranges...) m.memoizedMatchedLines[i+top] = lines[i] } if m.hiIdx < 0 { @@ -248,12 +248,11 @@ func (m Model) visibleLines() (lines []string) { if len(hi) == 0 { continue } - lines[i] = lipgloss.StyleRange( - lines[i], + lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( hi[0][0], hi[0][1], m.SelectedHighlightStyle, - ) + )) } } } From 8e14bd2d94e2fca876949c30a59902231137505d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 23:07:40 -0300 Subject: [PATCH 12/27] doc: godoc --- viewport/viewport.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index d916a09c..aa72c450 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -71,10 +71,10 @@ type Model struct { // Deprecated: high performance rendering is now deprecated in Bubble Tea. HighPerformanceRendering bool - // LeftGutterFunc allows to define a function that adds a column into the - // left of the viewpart, which is kept when horizontal scrolling. - // This should help support things like line numbers, selection indicators, - // and etc. + // 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 From 7f6d0eb6a89f5a8adfb6ba52f5775eb0bb06cb70 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 23:42:04 -0300 Subject: [PATCH 13/27] feat: fill height optional --- viewport/viewport.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index aa72c450..2522f554 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -34,6 +34,9 @@ type Model struct { // 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 @@ -258,8 +261,7 @@ func (m Model) visibleLines() (lines []string) { } } - // FIXME: make optional - for len(lines) < maxHeight { + for m.FillHeight && len(lines) < maxHeight { lines = append(lines, "") } From 8ddb85693819dddcaa23cdcaa34a9629c0f9ee25 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 8 Jan 2025 23:58:43 -0300 Subject: [PATCH 14/27] fix: handle no content --- viewport/viewport.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/viewport/viewport.go b/viewport/viewport.go index 2522f554..5391b0f8 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -185,6 +185,11 @@ func (m Model) HorizontalScrollPercent() float64 { func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings m.lines = strings.Split(s, "\n") + // 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.lineWidths, m.longestLineWidth = calcLineWidths(m.lines) m.ClearHighlights() From 0c86665e3536a787e0c79520ff4069413baba82b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 9 Jan 2025 16:53:25 -0300 Subject: [PATCH 15/27] fix: empty lines --- viewport/highlight.go | 33 ++++++++++++++------------------ viewport/viewport.go | 5 +---- viewport/viewport_test.go | 40 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index f4fdd22b..3b7017b5 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -28,7 +28,7 @@ func parseMatches( width := lineWidths[line] // out of bounds - if start > processed+width { + if start >= processed+width { line++ processed += width continue @@ -58,8 +58,8 @@ func parseMatches( line++ processed += width continue - } - if end <= processed+width { + } else { + // if end <= processed+width { if colend > 0 { hi.lines = append(hi.lines, hiline) } @@ -76,15 +76,11 @@ type highlightInfo struct { lines [][][2]int } -func (hi highlightInfo) inLineRange(line int) bool { - return line >= hi.lineStart && line <= hi.lineEnd -} - -func (hi highlightInfo) forLine(line int) [][2]int { - if !hi.inLineRange(line) { - return nil +func (hi highlightInfo) forLine(line int) ([][2]int, bool) { + if line >= hi.lineStart && line <= hi.lineEnd { + return hi.lines[line-hi.lineStart], true } - return hi.lines[line-hi.lineStart] + return nil, false } func (hi highlightInfo) coords() (line int, col int) { @@ -101,16 +97,15 @@ func makeHilightRanges( ) []lipgloss.Range { result := []lipgloss.Range{} for _, hi := range highlights { - if !hi.inLineRange(line) { - // out of range + lihis, ok := hi.forLine(line) + if !ok { continue } - - for _, lihi := range hi.forLine(line) { - result = append(result, lipgloss.NewRange( - lihi[0], lihi[1], - style, - )) + for _, lihi := range lihis { + if lihi == [2]int{} { + continue + } + result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style)) } } return result diff --git a/viewport/viewport.go b/viewport/viewport.go index 5391b0f8..1e5aa387 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -252,10 +252,7 @@ func (m Model) visibleLines() (lines []string) { continue } sel := m.highlights[m.hiIdx] - if hi := sel.forLine(i + top); hi != nil { - if len(hi) == 0 { - continue - } + if hi, ok := sel.forLine(i + top); ok { lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( hi[0][0], hi[0][1], diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index 001167ed..eb41b692 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,6 +1,7 @@ package viewport import ( + "fmt" "reflect" "regexp" "strings" @@ -386,7 +387,9 @@ func TestRightOverscroll(t *testing.T) { func TestMatchesToHighlights(t *testing.T) { text := `hello -world` +world + +with empty rows` vt := New(100, 100) vt.SetContent(text) @@ -471,4 +474,39 @@ world` t.Errorf("expect %+v, got %+v", expect, vt.highlights) } }) + + t.Run("empty lines in the text", func(t *testing.T) { + matches := regexp.MustCompile("ith").FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighligths(matches) + expect := []highlightInfo{ + { + lineStart: 3, + lineEnd: 3, + lines: [][][2]int{ + {{1, 4}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("expect %+v, got %+v", expect, vt.highlights) + } + }) + + t.Run("empty lines in the text match start of new line", func(t *testing.T) { + matches := regexp.MustCompile("with").FindAllStringIndex(vt.GetContent(), -1) + t.Log("AQUIIII", matches, vt.GetContent()[matches[0][0]:matches[0][1]], fmt.Sprintf("%q", vt.GetContent())) + vt.SetHighligths(matches) + expect := []highlightInfo{ + { + lineStart: 3, + lineEnd: 3, + lines: [][][2]int{ + {{0, 4}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("expect %+v, got %+v", expect, vt.highlights) + } + }) } From 2d53a618a93c9cf2d3dd066d1b913fc6f46084cc Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 10 Jan 2025 12:38:48 +0100 Subject: [PATCH 16/27] feat(viewport): horizontal scroll (#240) * 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 * 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 * fix: visible lines take frame into account --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Carlos Alexandro Becker Co-authored-by: Christian Rocha --- go.mod | 2 +- go.sum | 4 +- viewport/keymap.go | 10 + viewport/viewport.go | 101 +++++++++- viewport/viewport_test.go | 383 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 viewport/viewport_test.go diff --git a/go.mod b/go.mod index 575d4f5b..c35957b3 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v1.0.0 - github.com/charmbracelet/x/ansi v0.4.2 + github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 diff --git a/go.sum b/go.sum index bd41a6f3..7542d371 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= -github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg= +github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= diff --git a/viewport/keymap.go b/viewport/keymap.go index 9289706a..060bb878 100644 --- a/viewport/keymap.go +++ b/viewport/keymap.go @@ -15,6 +15,8 @@ type KeyMap struct { HalfPageDown key.Binding Down key.Binding Up key.Binding + Left key.Binding + Right key.Binding } // DefaultKeyMap returns a set of pager-like default keybindings. @@ -44,5 +46,13 @@ func DefaultKeyMap() KeyMap { key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "move left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "move right"), + ), } } diff --git a/viewport/viewport.go b/viewport/viewport.go index f220d1eb..1835b8d0 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -7,6 +7,11 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +const ( + defaultHorizontalStep = 6 ) // New returns a new model with the given width and height as well as default @@ -34,6 +39,13 @@ type Model struct { // YOffset is the vertical scroll position. YOffset int + // xOffset is the horizontal scroll position. + xOffset int + + // horizontalStep is the number of columns we move left or right during a + // default horizontal scroll. + horizontalStep int + // YPosition is the position of the viewport in relation to the terminal // window. It's used in high performance rendering only. YPosition int @@ -54,8 +66,9 @@ type Model struct { // Deprecated: high performance rendering is now deprecated in Bubble Tea. HighPerformanceRendering bool - initialized bool - lines []string + initialized bool + lines []string + longestLineWidth int } func (m *Model) setInitialValues() { @@ -63,6 +76,7 @@ func (m *Model) setInitialValues() { m.MouseWheelEnabled = true m.MouseWheelDelta = 3 m.initialized = true + m.horizontalStep = defaultHorizontalStep } // Init exists to satisfy the tea.Model interface for composability purposes. @@ -99,10 +113,24 @@ func (m Model) ScrollPercent() float64 { return math.Max(0.0, math.Min(1.0, v)) } +// HorizontalScrollPercent returns the amount horizontally scrolled as a float +// between 0 and 1. +func (m Model) HorizontalScrollPercent() float64 { + if m.xOffset >= m.longestLineWidth-m.Width { + return 1.0 + } + y := float64(m.xOffset) + h := float64(m.Width) + t := float64(m.longestLineWidth) + v := y / (t - h) + return math.Max(0.0, math.Min(1.0, v)) +} + // SetContent set the pager's text content. 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 m.YOffset > len(m.lines)-1 { m.GotoBottom() @@ -118,12 +146,24 @@ func (m Model) maxYOffset() int { // 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() + if len(m.lines) > 0 { top := max(0, m.YOffset) - bottom := clamp(m.YOffset+m.Height, top, len(m.lines)) + bottom := clamp(m.YOffset+h, top, len(m.lines)) lines = m.lines[top:bottom] } - return lines + + if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 { + return lines + } + + cutLines := make([]string, len(lines)) + for i := range lines { + cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w) + } + return cutLines } // scrollArea returns the scrollable boundaries for high performance rendering. @@ -273,7 +313,7 @@ func ViewDown(m Model, lines []string) tea.Cmd { // XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we // won't need to return a command here. - return tea.ScrollDown(lines, top, bottom) + return tea.ScrollDown(lines, top, bottom) //nolint:staticcheck } // ViewUp is a high performance command the moves the viewport down by a given @@ -287,7 +327,40 @@ func ViewUp(m Model, lines []string) tea.Cmd { // XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we // won't need to return a command here. - return tea.ScrollUp(lines, top, bottom) + return tea.ScrollUp(lines, top, bottom) //nolint:staticcheck +} + +// SetHorizontalStep sets the amount of cells that the viewport moves in the +// default viewport keymapping. If set to 0 or less, horizontal scrolling is +// disabled. +func (m *Model) SetHorizontalStep(n int) { + if n < 0 { + n = 0 + } + + m.horizontalStep = n +} + +// MoveLeft moves the viewport to the left by the given number of columns. +func (m *Model) MoveLeft(cols int) { + m.xOffset -= cols + if m.xOffset < 0 { + m.xOffset = 0 + } +} + +// 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 { + return + } + m.xOffset += cols +} + +// Resets lines indent to zero. +func (m *Model) ResetIndent() { + m.xOffset = 0 } // Update handles standard message-based viewport updates. @@ -344,6 +417,12 @@ func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) { if m.HighPerformanceRendering { cmd = ViewUp(m, lines) } + + case key.Matches(msg, m.KeyMap.Left): + m.MoveLeft(m.horizontalStep) + + case key.Matches(msg, m.KeyMap.Right): + m.MoveRight(m.horizontalStep) } case tea.MouseMsg: @@ -418,3 +497,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 + } + } + return w +} diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go new file mode 100644 index 00000000..ef70a54f --- /dev/null +++ b/viewport/viewport_test.go @@ -0,0 +1,383 @@ +package viewport + +import ( + "strings" + "testing" +) + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("default values on create by New", func(t *testing.T) { + t.Parallel() + + m := New(10, 10) + + if !m.initialized { + t.Errorf("on create by New, Model should be initialized") + } + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + + if m.MouseWheelDelta != 3 { + t.Errorf("default MouseWheelDelta should be 3, got %d", m.MouseWheelDelta) + } + + if !m.MouseWheelEnabled { + t.Error("mouse wheel should be enabled by default") + } + }) +} + +func TestSetInitialValues(t *testing.T) { + t.Parallel() + + t.Run("default horizontalStep", func(t *testing.T) { + t.Parallel() + + m := Model{} + m.setInitialValues() + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + }) +} + +func TestSetHorizontalStep(t *testing.T) { + t.Parallel() + + t.Run("change default", func(t *testing.T) { + t.Parallel() + + m := New(10, 10) + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + + newStep := 8 + m.SetHorizontalStep(newStep) + if m.horizontalStep != newStep { + t.Errorf("horizontalStep should be %d, got %d", newStep, m.horizontalStep) + } + }) + + t.Run("no negative", func(t *testing.T) { + t.Parallel() + + m := New(10, 10) + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + + zero := 0 + m.SetHorizontalStep(-1) + if m.horizontalStep != zero { + t.Errorf("horizontalStep should be %d, got %d", zero, m.horizontalStep) + } + }) +} + +func TestMoveLeft(t *testing.T) { + t.Parallel() + + zeroPosition := 0 + + t.Run("zero position", func(t *testing.T) { + t.Parallel() + + m := New(10, 10) + if m.xOffset != zeroPosition { + t.Errorf("default indent should be %d, got %d", zeroPosition, m.xOffset) + } + + m.MoveLeft(m.horizontalStep) + if m.xOffset != zeroPosition { + t.Errorf("indent should be %d, got %d", zeroPosition, m.xOffset) + } + }) + + t.Run("move", func(t *testing.T) { + t.Parallel() + m := New(10, 10) + if m.xOffset != zeroPosition { + t.Errorf("default indent should be %d, got %d", zeroPosition, m.xOffset) + } + + m.xOffset = defaultHorizontalStep * 2 + m.MoveLeft(m.horizontalStep) + newIndent := defaultHorizontalStep + if m.xOffset != newIndent { + t.Errorf("indent should be %d, got %d", newIndent, m.xOffset) + } + }) +} + +func TestMoveRight(t *testing.T) { + t.Parallel() + + t.Run("move", func(t *testing.T) { + t.Parallel() + + zeroPosition := 0 + + m := New(10, 10) + m.SetContent("Some line that is longer than width") + if m.xOffset != zeroPosition { + t.Errorf("default indent should be %d, got %d", zeroPosition, m.xOffset) + } + + m.MoveRight(m.horizontalStep) + newIndent := defaultHorizontalStep + if m.xOffset != newIndent { + t.Errorf("indent should be %d, got %d", newIndent, m.xOffset) + } + }) +} + +func TestResetIndent(t *testing.T) { + t.Parallel() + + t.Run("reset", func(t *testing.T) { + t.Parallel() + + zeroPosition := 0 + + m := New(10, 10) + m.xOffset = 500 + + m.ResetIndent() + if m.xOffset != zeroPosition { + t.Errorf("indent should be %d, got %d", zeroPosition, m.xOffset) + } + }) +} + +func TestVisibleLines(t *testing.T) { + t.Parallel() + + defaultList := []string{ + `57 Precepts of narcissistic comedy character Zote from an awesome "Hollow knight" game (https://store.steampowered.com/app/367520/Hollow_Knight/).`, + `Precept One: 'Always Win Your Battles'. Losing a battle earns you nothing and teaches you nothing. Win your battles, or don't engage in them at all!`, + `Precept Two: 'Never Let Them Laugh at You'. Fools laugh at everything, even at their superiors. But beware, laughter isn't harmless! Laughter spreads like a disease, and soon everyone is laughing at you. You need to strike at the source of this perverse merriment quickly to stop it from spreading.`, + `Precept Three: 'Always Be Rested'. Fighting and adventuring take their toll on your body. When you rest, your body strengthens and repairs itself. The longer you rest, the stronger you become.`, + `Precept Four: 'Forget Your Past'. The past is painful, and thinking about your past can only bring you misery. Think about something else instead, such as the future, or some food.`, + `Precept Five: 'Strength Beats Strength'. Is your opponent strong? No matter! Simply overcome their strength with even more strength, and they'll soon be defeated.`, + `Precept Six: 'Choose Your Own Fate'. Our elders teach that our fate is chosen for us before we are even born. I disagree.`, + `Precept Seven: 'Mourn Not the Dead'. When we die, do things get better for us or worse? There's no way to tell, so we shouldn't bother mourning. Or celebrating for that matter.`, + `Precept Eight: 'Travel Alone'. You can rely on nobody, and nobody will always be loyal. Therefore, nobody should be your constant companion.`, + `Precept Nine: 'Keep Your Home Tidy'. Your home is where you keep your most prized possession - yourself. Therefore, you should make an effort to keep it nice and clean.`, + `Precept Ten: 'Keep Your Weapon Sharp'. I make sure that my weapon, 'Life Ender', is kept well-sharpened at all times. This makes it much easier to cut things.`, + `Precept Eleven: 'Mothers Will Always Betray You'. This Precept explains itself.`, + `Precept Twelve: 'Keep Your Cloak Dry'. If your cloak gets wet, dry it as soon as you can. Wearing wet cloaks is unpleasant, and can lead to illness.`, + `Precept Thirteen: 'Never Be Afraid'. Fear can only hold you back. Facing your fears can be a tremendous effort. Therefore, you should just not be afraid in the first place.`, + `Precept Fourteen: 'Respect Your Superiors'. If someone is your superior in strength or intellect or both, you need to show them your respect. Don't ignore them or laugh at them.`, + `Precept Fifteen: 'One Foe, One Blow'. You should only use a single blow to defeat an enemy. Any more is a waste. Also, by counting your blows as you fight, you'll know how many foes you've defeated.`, + `...`, + } + + t.Run("empty list", func(t *testing.T) { + t.Parallel() + + m := New(10, 10) + list := m.visibleLines() + + if len(list) != 0 { + t.Errorf("list should be empty, got %d", len(list)) + } + }) + + t.Run("empty list: with indent", func(t *testing.T) { + t.Parallel() + + m := New(10, 10) + list := m.visibleLines() + m.xOffset = 5 + + if len(list) != 0 { + t.Errorf("list should be empty, got %d", len(list)) + } + }) + + t.Run("list", func(t *testing.T) { + t.Parallel() + numberOfLines := 10 + + m := New(10, numberOfLines) + m.SetContent(strings.Join(defaultList, "\n")) + + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + lastItemIdx := numberOfLines - 1 + // we trim line if it doesn't fit to width of the viewport + shouldGet := defaultList[lastItemIdx][:m.Width] + if list[lastItemIdx] != shouldGet { + t.Errorf(`%dth list item should be '%s', got '%s'`, lastItemIdx, shouldGet, list[lastItemIdx]) + } + }) + + t.Run("list: with y offset", func(t *testing.T) { + t.Parallel() + numberOfLines := 10 + + m := New(10, numberOfLines) + m.SetContent(strings.Join(defaultList, "\n")) + m.YOffset = 5 + + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + if list[0] == defaultList[0] { + t.Error("first item of list should not be the first item of initial list because of Y offset") + } + + lastItemIdx := numberOfLines - 1 + // we trim line if it doesn't fit to width of the viewport + shouldGet := defaultList[m.YOffset+lastItemIdx][:m.Width] + if list[lastItemIdx] != shouldGet { + t.Errorf(`%dth list item should be '%s', got '%s'`, lastItemIdx, shouldGet, list[lastItemIdx]) + } + }) + + t.Run("list: with y offset: horizontal scroll", func(t *testing.T) { + t.Parallel() + numberOfLines := 10 + + m := New(10, numberOfLines) + m.lines = defaultList + m.YOffset = 7 + + // default list + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + lastItem := numberOfLines - 1 + defaultLastItem := len(defaultList) - 1 + if list[lastItem] != defaultList[defaultLastItem] { + t.Errorf("%dth list item should the the same as %dth default list item", lastItem, defaultLastItem) + } + + perceptPrefix := "Precept" + if !strings.HasPrefix(list[0], perceptPrefix) { + t.Errorf("first list item has to have prefix %s", perceptPrefix) + } + + // move right + m.MoveRight(m.horizontalStep) + list = m.visibleLines() + + newPrefix := perceptPrefix[m.xOffset:] + if !strings.HasPrefix(list[0], newPrefix) { + t.Errorf("first list item has to have prefix %s, get %s", newPrefix, list[0]) + } + + if list[lastItem] != "..." { + t.Errorf("last item should be empty, got %s", list[lastItem]) + } + + // move left + m.MoveLeft(m.horizontalStep) + list = m.visibleLines() + if !strings.HasPrefix(list[0], perceptPrefix) { + t.Errorf("first list item has to have prefix %s", perceptPrefix) + } + + if list[lastItem] != defaultList[defaultLastItem] { + t.Errorf("%dth list item should the the same as %dth default list item", lastItem, defaultLastItem) + } + }) + + t.Run("list: with 2 cells symbols: horizontal scroll", func(t *testing.T) { + t.Parallel() + + const horizontalStep = 5 + + initList := []string{ + "あいうえお", + "Aあいうえお", + "あいうえお", + "Aあいうえお", + } + numberOfLines := len(initList) + + m := New(20, numberOfLines) + m.lines = initList + m.longestLineWidth = 30 // dirty hack: not checking right overscroll for this test case + + // default list + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + lastItemIdx := numberOfLines - 1 + initLastItem := len(initList) - 1 + shouldGet := initList[initLastItem] + if list[lastItemIdx] != shouldGet { + t.Errorf("%dth list item should the the same as %dth default list item", lastItemIdx, initLastItem) + } + + // move right + m.MoveRight(horizontalStep) + list = m.visibleLines() + + for i := range list { + cutLine := "うえお" + if list[i] != cutLine { + t.Errorf("line must be `%s`, get `%s`", cutLine, list[i]) + } + } + + // move left + m.MoveLeft(horizontalStep) + list = m.visibleLines() + for i := range list { + if list[i] != initList[i] { + t.Errorf("line must be `%s`, get `%s`", list[i], initList[i]) + } + } + + // move left second times do not change lites if indent == 0 + m.xOffset = 0 + m.MoveLeft(horizontalStep) + list = m.visibleLines() + for i := range list { + if list[i] != initList[i] { + t.Errorf("line must be `%s`, get `%s`", list[i], initList[i]) + } + } + }) +} + +func TestRightOverscroll(t *testing.T) { + t.Parallel() + + t.Run("prevent right overscroll", func(t *testing.T) { + t.Parallel() + content := "Content is short" + m := New(len(content)+1, 5) + m.SetContent(content) + + for i := 0; i < 10; i++ { + m.MoveRight(m.horizontalStep) + } + + visibleLines := m.visibleLines() + visibleLine := visibleLines[0] + + if visibleLine != content { + t.Error("visible line should stay the same as content") + } + }) +} From b5f12511a2b0f9a775d85be8212a7088928c4b06 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 15:03:08 -0300 Subject: [PATCH 17/27] wip --- viewport/highlight.go | 29 +++++++++++++++++++++++++++++ viewport/viewport.go | 2 +- viewport/viewport_test.go | 2 +- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index 3b7017b5..d9b53332 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -4,15 +4,21 @@ import ( "fmt" "github.com/charmbracelet/lipgloss" + "github.com/rivo/uniseg" ) func parseMatches( matches [][]int, + content string, lineWidths []int, ) (highlights []highlightInfo) { line := 0 processed := 0 + gr := uniseg.NewGraphemes(content) + graphemeStart := 0 + bytePos := 0 + for _, match := range matches { start, end := match[0], match[1] @@ -22,6 +28,29 @@ func parseMatches( panic(fmt.Sprintf("invalid match: %d, %d", start, end)) } + for gr.Next() { + if bytePos >= start { + break + } + bytePos += len(gr.Bytes()) + graphemeStart++ + } + + graphemeEnd := graphemeStart + for gr.Next() { + if bytePos >= end { + break + } + bytePos += len(gr.Bytes()) + graphemeEnd++ + } + + if start != graphemeStart || end != graphemeEnd { + fmt.Printf("content=%q start=%d end=%d graphemeStart=%d graphemeEnd=%d\n", content[start:end], start, end, graphemeStart, graphemeEnd) + } + + start, end = graphemeStart, graphemeEnd + hi := highlightInfo{} hiline := [][2]int{} for line < len(lineWidths) { diff --git a/viewport/viewport.go b/viewport/viewport.go index 1e5aa387..dbcc735c 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -550,7 +550,7 @@ func (m *Model) SetHighligths(matches [][]int) { return } m.memoizedMatchedLines = make([]string, len(m.lines)) - m.highlights = parseMatches(matches, m.lineWidths) + m.highlights = parseMatches(matches, m.GetContent(), m.lineWidths) m.hiIdx = m.findNearedtMatch() if m.hiIdx == -1 { return diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index eb41b692..34c2242b 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -436,7 +436,7 @@ with empty rows` }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) From 933f181e88405c21d65a08b816a88030806ca03e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 15:03:22 -0300 Subject: [PATCH 18/27] wip --- viewport/highlight.go | 172 +++++++++++++++++++++----------------- viewport/viewport.go | 4 +- viewport/viewport_test.go | 44 +++++----- 3 files changed, 116 insertions(+), 104 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index d9b53332..1bd27a4a 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -1,122 +1,136 @@ package viewport import ( - "fmt" - "github.com/charmbracelet/lipgloss" + "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 +// +// We'll then convert the ranges into [highlightInfo]s, which hold the starting +// line and the grapheme positions. func parseMatches( matches [][]int, content string, - lineWidths []int, -) (highlights []highlightInfo) { - line := 0 - processed := 0 +) []highlightInfo { + if len(matches) == 0 { + return nil + } - gr := uniseg.NewGraphemes(content) - graphemeStart := 0 + line := 0 + graphemePos := 0 + previousLinesOffset := 0 bytePos := 0 - for _, match := range matches { - start, end := match[0], match[1] + highlights := make([]highlightInfo, 0, len(matches)) + gr := uniseg.NewGraphemes(ansi.Strip(content)) - // safety check - // XXX: return an error instead - if start > end { - panic(fmt.Sprintf("invalid match: %d, %d", start, end)) +matchLoop: + for _, match := range matches { + hi := highlightInfo{ + lines: map[int][][2]int{}, } + byteStart, byteEnd := match[0], match[1] - for gr.Next() { - if bytePos >= start { + for byteStart > bytePos { + if !gr.Next() { break } - bytePos += len(gr.Bytes()) - graphemeStart++ + graphemePos += len(gr.Str()) + if content[bytePos] == '\n' { + previousLinesOffset = graphemePos + line++ + } + bytePos++ } - graphemeEnd := graphemeStart - for gr.Next() { - if bytePos >= end { - break + hi.lineStart = line + hi.lineEnd = line + + graphemeStart := graphemePos + graphemeEnd := graphemePos + + for byteEnd >= bytePos { + if bytePos == byteEnd { + graphemeEnd = graphemePos + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemeEnd-previousLinesOffset, colstart) + + // fmt.Printf( + // "no line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, + // ) + // + if colend > colstart { + hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lineEnd = line + } + highlights = append(highlights, hi) + continue matchLoop } - bytePos += len(gr.Bytes()) - graphemeEnd++ - } - if start != graphemeStart || end != graphemeEnd { - fmt.Printf("content=%q start=%d end=%d graphemeStart=%d graphemeEnd=%d\n", content[start:end], start, end, graphemeStart, graphemeEnd) - } + if content[bytePos] == '\n' { + graphemeEnd = graphemePos + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemeEnd-previousLinesOffset+1, colstart) - start, end = graphemeStart, graphemeEnd + // fmt.Printf( + // "nl line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, + // ) - hi := highlightInfo{} - hiline := [][2]int{} - for line < len(lineWidths) { - width := lineWidths[line] + if colend > colstart { + hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lineEnd = line + } - // out of bounds - if start >= processed+width { + previousLinesOffset = graphemePos + len(gr.Str()) line++ - processed += width - continue - } - - colstart := max(0, start-processed) - colend := clamp(end-processed, colstart, width) - - if start >= processed && start <= processed+width { - hi.lineStart = line } - if end <= processed+width { - hi.lineEnd = line - } - - // fmt.Printf( - // "line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d hi=%+v\n", - // line, hi.lineStart, hi.lineEnd, colstart, colend, start, end, processed, width, hi, - // ) - hiline = append(hiline, [2]int{colstart, colend}) - if end > processed+width { - if colend > 0 { - hi.lines = append(hi.lines, hiline) - } - hiline = [][2]int{} - line++ - processed += width - continue - } else { - // if end <= processed+width { - if colend > 0 { - hi.lines = append(hi.lines, hiline) - } + if !gr.Next() { break } + + bytePos++ + graphemePos += len(gr.Str()) } + highlights = append(highlights, hi) } - return + + return highlights } type highlightInfo struct { + // in which line this highlight starts and ends lineStart, lineEnd int - lines [][][2]int -} -func (hi highlightInfo) forLine(line int) ([][2]int, bool) { - if line >= hi.lineStart && line <= hi.lineEnd { - return hi.lines[line-hi.lineStart], true - } - return nil, false + // the grapheme highlight ranges for each of these lines + lines map[int][][2]int } +// func (hi *highlightInfo) addToLine(line int, rng [2]int) { +// hi.lines[line] = append(hi.lines[line], rng) +// } +// +// func (hi highlightInfo) forLine(line int) ([][2]int, bool) { +// got, ok := hi.lines[line] +// return got, ok +// } + func (hi highlightInfo) coords() (line int, col int) { - if len(hi.lines) == 0 { - return hi.lineStart, 0 - } - return hi.lineStart, hi.lines[0][0][0] + // if len(hi.lines) == 0 { + return hi.lineStart, 0 + // } + // return hi.lineStart, hi.lines[line][0][0] } func makeHilightRanges( @@ -126,7 +140,7 @@ func makeHilightRanges( ) []lipgloss.Range { result := []lipgloss.Range{} for _, hi := range highlights { - lihis, ok := hi.forLine(line) + lihis, ok := hi.lines[line] if !ok { continue } diff --git a/viewport/viewport.go b/viewport/viewport.go index dbcc735c..efb650d6 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -252,7 +252,7 @@ func (m Model) visibleLines() (lines []string) { continue } sel := m.highlights[m.hiIdx] - if hi, ok := sel.forLine(i + top); ok { + if hi, ok := sel.lines[i+top]; ok { lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( hi[0][0], hi[0][1], @@ -550,7 +550,7 @@ func (m *Model) SetHighligths(matches [][]int) { return } m.memoizedMatchedLines = make([]string, len(m.lines)) - m.highlights = parseMatches(matches, m.GetContent(), m.lineWidths) + m.highlights = parseMatches(matches, m.GetContent()) m.hiIdx = m.findNearedtMatch() if m.hiIdx == -1 { return diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index 34c2242b..c86f7069 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,7 +1,6 @@ package viewport import ( - "fmt" "reflect" "regexp" "strings" @@ -399,8 +398,8 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{0, 5}}, + lines: map[int][][2]int{ + 0: {{0, 5}}, }, }, } @@ -416,22 +415,22 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{2, 3}}, + lines: map[int][][2]int{ + 0: {{2, 3}}, }, }, { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{3, 4}}, + lines: map[int][][2]int{ + 0: {{3, 4}}, }, }, { lineStart: 1, lineEnd: 1, - lines: [][][2]int{ - {{3, 4}}, + lines: map[int][][2]int{ + 1: {{3, 4}}, }, }, } @@ -447,14 +446,14 @@ with empty rows` { lineStart: 0, lineEnd: 1, - lines: [][][2]int{ - {{3, 6}}, - {{0, 2}}, + lines: map[int][][2]int{ + 0: {{3, 6}}, + 1: {{0, 2}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) @@ -465,13 +464,13 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{3, 6}}, + lines: map[int][][2]int{ + 0: {{3, 6}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) @@ -482,31 +481,30 @@ with empty rows` { lineStart: 3, lineEnd: 3, - lines: [][][2]int{ - {{1, 4}}, + lines: map[int][][2]int{ + 3: {{1, 4}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) t.Run("empty lines in the text match start of new line", func(t *testing.T) { matches := regexp.MustCompile("with").FindAllStringIndex(vt.GetContent(), -1) - t.Log("AQUIIII", matches, vt.GetContent()[matches[0][0]:matches[0][1]], fmt.Sprintf("%q", vt.GetContent())) vt.SetHighligths(matches) expect := []highlightInfo{ { lineStart: 3, lineEnd: 3, - lines: [][][2]int{ - {{0, 4}}, + lines: map[int][][2]int{ + 3: {{0, 4}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) } From 0e3e31b70f53d93250a1474547cc5157d50e9237 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 15:09:24 -0300 Subject: [PATCH 19/27] Revert "wip" This reverts commit 933f181e88405c21d65a08b816a88030806ca03e. --- viewport/highlight.go | 172 +++++++++++++++++--------------------- viewport/viewport.go | 4 +- viewport/viewport_test.go | 44 +++++----- 3 files changed, 104 insertions(+), 116 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index 1bd27a4a..d9b53332 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -1,136 +1,122 @@ package viewport import ( + "fmt" + "github.com/charmbracelet/lipgloss" - "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 -// -// We'll then convert the ranges into [highlightInfo]s, which hold the starting -// line and the grapheme positions. func parseMatches( matches [][]int, content string, -) []highlightInfo { - if len(matches) == 0 { - return nil - } - + lineWidths []int, +) (highlights []highlightInfo) { line := 0 - graphemePos := 0 - previousLinesOffset := 0 - bytePos := 0 + processed := 0 - highlights := make([]highlightInfo, 0, len(matches)) - gr := uniseg.NewGraphemes(ansi.Strip(content)) + gr := uniseg.NewGraphemes(content) + graphemeStart := 0 + bytePos := 0 -matchLoop: for _, match := range matches { - hi := highlightInfo{ - lines: map[int][][2]int{}, + start, end := match[0], match[1] + + // safety check + // XXX: return an error instead + if start > end { + panic(fmt.Sprintf("invalid match: %d, %d", start, end)) } - byteStart, byteEnd := match[0], match[1] - for byteStart > bytePos { - if !gr.Next() { + for gr.Next() { + if bytePos >= start { break } - graphemePos += len(gr.Str()) - if content[bytePos] == '\n' { - previousLinesOffset = graphemePos - line++ - } - bytePos++ + bytePos += len(gr.Bytes()) + graphemeStart++ } - hi.lineStart = line - hi.lineEnd = line - - graphemeStart := graphemePos - graphemeEnd := graphemePos - - for byteEnd >= bytePos { - if bytePos == byteEnd { - graphemeEnd = graphemePos - colstart := max(0, graphemeStart-previousLinesOffset) - colend := max(graphemeEnd-previousLinesOffset, colstart) - - // fmt.Printf( - // "no line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", - // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, - // ) - // - if colend > colstart { - hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) - hi.lineEnd = line - } - highlights = append(highlights, hi) - continue matchLoop + graphemeEnd := graphemeStart + for gr.Next() { + if bytePos >= end { + break } + bytePos += len(gr.Bytes()) + graphemeEnd++ + } - if content[bytePos] == '\n' { - graphemeEnd = graphemePos - colstart := max(0, graphemeStart-previousLinesOffset) - colend := max(graphemeEnd-previousLinesOffset+1, colstart) + if start != graphemeStart || end != graphemeEnd { + fmt.Printf("content=%q start=%d end=%d graphemeStart=%d graphemeEnd=%d\n", content[start:end], start, end, graphemeStart, graphemeEnd) + } - // fmt.Printf( - // "nl line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", - // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, - // ) + start, end = graphemeStart, graphemeEnd - if colend > colstart { - hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) - hi.lineEnd = line - } + hi := highlightInfo{} + hiline := [][2]int{} + for line < len(lineWidths) { + width := lineWidths[line] - previousLinesOffset = graphemePos + len(gr.Str()) + // out of bounds + if start >= processed+width { line++ + processed += width + continue } - if !gr.Next() { - break + colstart := max(0, start-processed) + colend := clamp(end-processed, colstart, width) + + if start >= processed && start <= processed+width { + hi.lineStart = line + } + if end <= processed+width { + hi.lineEnd = line } - bytePos++ - graphemePos += len(gr.Str()) - } + // fmt.Printf( + // "line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d hi=%+v\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, start, end, processed, width, hi, + // ) + hiline = append(hiline, [2]int{colstart, colend}) + if end > processed+width { + if colend > 0 { + hi.lines = append(hi.lines, hiline) + } + hiline = [][2]int{} + line++ + processed += width + continue + } else { + // if end <= processed+width { + if colend > 0 { + hi.lines = append(hi.lines, hiline) + } + break + } + } highlights = append(highlights, hi) } - - return highlights + return } 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 + lines [][][2]int } -// func (hi *highlightInfo) addToLine(line int, rng [2]int) { -// hi.lines[line] = append(hi.lines[line], rng) -// } -// -// func (hi highlightInfo) forLine(line int) ([][2]int, bool) { -// got, ok := hi.lines[line] -// return got, ok -// } +func (hi highlightInfo) forLine(line int) ([][2]int, bool) { + if line >= hi.lineStart && line <= hi.lineEnd { + return hi.lines[line-hi.lineStart], true + } + return nil, false +} func (hi highlightInfo) coords() (line int, col int) { - // if len(hi.lines) == 0 { - return hi.lineStart, 0 - // } - // return hi.lineStart, hi.lines[line][0][0] + if len(hi.lines) == 0 { + return hi.lineStart, 0 + } + return hi.lineStart, hi.lines[0][0][0] } func makeHilightRanges( @@ -140,7 +126,7 @@ func makeHilightRanges( ) []lipgloss.Range { result := []lipgloss.Range{} for _, hi := range highlights { - lihis, ok := hi.lines[line] + lihis, ok := hi.forLine(line) if !ok { continue } diff --git a/viewport/viewport.go b/viewport/viewport.go index efb650d6..dbcc735c 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -252,7 +252,7 @@ func (m Model) visibleLines() (lines []string) { continue } sel := m.highlights[m.hiIdx] - if hi, ok := sel.lines[i+top]; ok { + if hi, ok := sel.forLine(i + top); ok { lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( hi[0][0], hi[0][1], @@ -550,7 +550,7 @@ func (m *Model) SetHighligths(matches [][]int) { return } m.memoizedMatchedLines = make([]string, len(m.lines)) - m.highlights = parseMatches(matches, m.GetContent()) + m.highlights = parseMatches(matches, m.GetContent(), m.lineWidths) m.hiIdx = m.findNearedtMatch() if m.hiIdx == -1 { return diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index c86f7069..34c2242b 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,6 +1,7 @@ package viewport import ( + "fmt" "reflect" "regexp" "strings" @@ -398,8 +399,8 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{0, 5}}, + lines: [][][2]int{ + {{0, 5}}, }, }, } @@ -415,22 +416,22 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{2, 3}}, + lines: [][][2]int{ + {{2, 3}}, }, }, { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{3, 4}}, + lines: [][][2]int{ + {{3, 4}}, }, }, { lineStart: 1, lineEnd: 1, - lines: map[int][][2]int{ - 1: {{3, 4}}, + lines: [][][2]int{ + {{3, 4}}, }, }, } @@ -446,14 +447,14 @@ with empty rows` { lineStart: 0, lineEnd: 1, - lines: map[int][][2]int{ - 0: {{3, 6}}, - 1: {{0, 2}}, + lines: [][][2]int{ + {{3, 6}}, + {{0, 2}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + t.Errorf("expect %+v, got %+v", expect, vt.highlights) } }) @@ -464,13 +465,13 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{3, 6}}, + lines: [][][2]int{ + {{3, 6}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + t.Errorf("expect %+v, got %+v", expect, vt.highlights) } }) @@ -481,30 +482,31 @@ with empty rows` { lineStart: 3, lineEnd: 3, - lines: map[int][][2]int{ - 3: {{1, 4}}, + lines: [][][2]int{ + {{1, 4}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + t.Errorf("expect %+v, got %+v", expect, vt.highlights) } }) t.Run("empty lines in the text match start of new line", func(t *testing.T) { matches := regexp.MustCompile("with").FindAllStringIndex(vt.GetContent(), -1) + t.Log("AQUIIII", matches, vt.GetContent()[matches[0][0]:matches[0][1]], fmt.Sprintf("%q", vt.GetContent())) vt.SetHighligths(matches) expect := []highlightInfo{ { lineStart: 3, lineEnd: 3, - lines: map[int][][2]int{ - 3: {{0, 4}}, + lines: [][][2]int{ + {{0, 4}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + t.Errorf("expect %+v, got %+v", expect, vt.highlights) } }) } From a7dc5f88a9d264c2f62da00773a0aa5bb9ac2fac Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 15:09:59 -0300 Subject: [PATCH 20/27] Reapply "wip" This reverts commit 0e3e31b70f53d93250a1474547cc5157d50e9237. --- viewport/highlight.go | 172 +++++++++++++++++++++----------------- viewport/viewport.go | 4 +- viewport/viewport_test.go | 44 +++++----- 3 files changed, 116 insertions(+), 104 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index d9b53332..1bd27a4a 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -1,122 +1,136 @@ package viewport import ( - "fmt" - "github.com/charmbracelet/lipgloss" + "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 +// +// We'll then convert the ranges into [highlightInfo]s, which hold the starting +// line and the grapheme positions. func parseMatches( matches [][]int, content string, - lineWidths []int, -) (highlights []highlightInfo) { - line := 0 - processed := 0 +) []highlightInfo { + if len(matches) == 0 { + return nil + } - gr := uniseg.NewGraphemes(content) - graphemeStart := 0 + line := 0 + graphemePos := 0 + previousLinesOffset := 0 bytePos := 0 - for _, match := range matches { - start, end := match[0], match[1] + highlights := make([]highlightInfo, 0, len(matches)) + gr := uniseg.NewGraphemes(ansi.Strip(content)) - // safety check - // XXX: return an error instead - if start > end { - panic(fmt.Sprintf("invalid match: %d, %d", start, end)) +matchLoop: + for _, match := range matches { + hi := highlightInfo{ + lines: map[int][][2]int{}, } + byteStart, byteEnd := match[0], match[1] - for gr.Next() { - if bytePos >= start { + for byteStart > bytePos { + if !gr.Next() { break } - bytePos += len(gr.Bytes()) - graphemeStart++ + graphemePos += len(gr.Str()) + if content[bytePos] == '\n' { + previousLinesOffset = graphemePos + line++ + } + bytePos++ } - graphemeEnd := graphemeStart - for gr.Next() { - if bytePos >= end { - break + hi.lineStart = line + hi.lineEnd = line + + graphemeStart := graphemePos + graphemeEnd := graphemePos + + for byteEnd >= bytePos { + if bytePos == byteEnd { + graphemeEnd = graphemePos + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemeEnd-previousLinesOffset, colstart) + + // fmt.Printf( + // "no line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, + // ) + // + if colend > colstart { + hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lineEnd = line + } + highlights = append(highlights, hi) + continue matchLoop } - bytePos += len(gr.Bytes()) - graphemeEnd++ - } - if start != graphemeStart || end != graphemeEnd { - fmt.Printf("content=%q start=%d end=%d graphemeStart=%d graphemeEnd=%d\n", content[start:end], start, end, graphemeStart, graphemeEnd) - } + if content[bytePos] == '\n' { + graphemeEnd = graphemePos + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemeEnd-previousLinesOffset+1, colstart) - start, end = graphemeStart, graphemeEnd + // fmt.Printf( + // "nl line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, + // ) - hi := highlightInfo{} - hiline := [][2]int{} - for line < len(lineWidths) { - width := lineWidths[line] + if colend > colstart { + hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lineEnd = line + } - // out of bounds - if start >= processed+width { + previousLinesOffset = graphemePos + len(gr.Str()) line++ - processed += width - continue - } - - colstart := max(0, start-processed) - colend := clamp(end-processed, colstart, width) - - if start >= processed && start <= processed+width { - hi.lineStart = line } - if end <= processed+width { - hi.lineEnd = line - } - - // fmt.Printf( - // "line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d hi=%+v\n", - // line, hi.lineStart, hi.lineEnd, colstart, colend, start, end, processed, width, hi, - // ) - hiline = append(hiline, [2]int{colstart, colend}) - if end > processed+width { - if colend > 0 { - hi.lines = append(hi.lines, hiline) - } - hiline = [][2]int{} - line++ - processed += width - continue - } else { - // if end <= processed+width { - if colend > 0 { - hi.lines = append(hi.lines, hiline) - } + if !gr.Next() { break } + + bytePos++ + graphemePos += len(gr.Str()) } + highlights = append(highlights, hi) } - return + + return highlights } type highlightInfo struct { + // in which line this highlight starts and ends lineStart, lineEnd int - lines [][][2]int -} -func (hi highlightInfo) forLine(line int) ([][2]int, bool) { - if line >= hi.lineStart && line <= hi.lineEnd { - return hi.lines[line-hi.lineStart], true - } - return nil, false + // the grapheme highlight ranges for each of these lines + lines map[int][][2]int } +// func (hi *highlightInfo) addToLine(line int, rng [2]int) { +// hi.lines[line] = append(hi.lines[line], rng) +// } +// +// func (hi highlightInfo) forLine(line int) ([][2]int, bool) { +// got, ok := hi.lines[line] +// return got, ok +// } + func (hi highlightInfo) coords() (line int, col int) { - if len(hi.lines) == 0 { - return hi.lineStart, 0 - } - return hi.lineStart, hi.lines[0][0][0] + // if len(hi.lines) == 0 { + return hi.lineStart, 0 + // } + // return hi.lineStart, hi.lines[line][0][0] } func makeHilightRanges( @@ -126,7 +140,7 @@ func makeHilightRanges( ) []lipgloss.Range { result := []lipgloss.Range{} for _, hi := range highlights { - lihis, ok := hi.forLine(line) + lihis, ok := hi.lines[line] if !ok { continue } diff --git a/viewport/viewport.go b/viewport/viewport.go index dbcc735c..efb650d6 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -252,7 +252,7 @@ func (m Model) visibleLines() (lines []string) { continue } sel := m.highlights[m.hiIdx] - if hi, ok := sel.forLine(i + top); ok { + if hi, ok := sel.lines[i+top]; ok { lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( hi[0][0], hi[0][1], @@ -550,7 +550,7 @@ func (m *Model) SetHighligths(matches [][]int) { return } m.memoizedMatchedLines = make([]string, len(m.lines)) - m.highlights = parseMatches(matches, m.GetContent(), m.lineWidths) + m.highlights = parseMatches(matches, m.GetContent()) m.hiIdx = m.findNearedtMatch() if m.hiIdx == -1 { return diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index 34c2242b..c86f7069 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,7 +1,6 @@ package viewport import ( - "fmt" "reflect" "regexp" "strings" @@ -399,8 +398,8 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{0, 5}}, + lines: map[int][][2]int{ + 0: {{0, 5}}, }, }, } @@ -416,22 +415,22 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{2, 3}}, + lines: map[int][][2]int{ + 0: {{2, 3}}, }, }, { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{3, 4}}, + lines: map[int][][2]int{ + 0: {{3, 4}}, }, }, { lineStart: 1, lineEnd: 1, - lines: [][][2]int{ - {{3, 4}}, + lines: map[int][][2]int{ + 1: {{3, 4}}, }, }, } @@ -447,14 +446,14 @@ with empty rows` { lineStart: 0, lineEnd: 1, - lines: [][][2]int{ - {{3, 6}}, - {{0, 2}}, + lines: map[int][][2]int{ + 0: {{3, 6}}, + 1: {{0, 2}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) @@ -465,13 +464,13 @@ with empty rows` { lineStart: 0, lineEnd: 0, - lines: [][][2]int{ - {{3, 6}}, + lines: map[int][][2]int{ + 0: {{3, 6}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) @@ -482,31 +481,30 @@ with empty rows` { lineStart: 3, lineEnd: 3, - lines: [][][2]int{ - {{1, 4}}, + lines: map[int][][2]int{ + 3: {{1, 4}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) t.Run("empty lines in the text match start of new line", func(t *testing.T) { matches := regexp.MustCompile("with").FindAllStringIndex(vt.GetContent(), -1) - t.Log("AQUIIII", matches, vt.GetContent()[matches[0][0]:matches[0][1]], fmt.Sprintf("%q", vt.GetContent())) vt.SetHighligths(matches) expect := []highlightInfo{ { lineStart: 3, lineEnd: 3, - lines: [][][2]int{ - {{0, 4}}, + lines: map[int][][2]int{ + 3: {{0, 4}}, }, }, } if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) } From d1928bebde154a0bd170ead49d48ce293916d580 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 15:38:04 -0300 Subject: [PATCH 21/27] fix: wide --- viewport/highlight.go | 77 +++++++++++++++++++++---------------------- viewport/viewport.go | 2 +- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index 1bd27a4a..83d8966d 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -9,16 +9,17 @@ import ( // 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 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( - matches [][]int, content string, + matches [][]int, ) []highlightInfo { if len(matches) == 0 { return nil @@ -32,23 +33,26 @@ func parseMatches( highlights := make([]highlightInfo, 0, len(matches)) gr := uniseg.NewGraphemes(ansi.Strip(content)) -matchLoop: for _, match := range matches { + byteStart, byteEnd := match[0], match[1] + + // hilight for this match: hi := highlightInfo{ lines: map[int][][2]int{}, } - byteStart, byteEnd := match[0], match[1] + // find the beginning of this byte range, setup current line and + // grapheme position. for byteStart > bytePos { if !gr.Next() { break } - graphemePos += len(gr.Str()) if content[bytePos] == '\n' { - previousLinesOffset = graphemePos + previousLinesOffset = graphemePos + 1 line++ } - bytePos++ + graphemePos++ + bytePos += len(gr.Str()) } hi.lineStart = line @@ -57,25 +61,13 @@ matchLoop: graphemeStart := graphemePos graphemeEnd := graphemePos - for byteEnd >= bytePos { - if bytePos == byteEnd { - graphemeEnd = graphemePos - colstart := max(0, graphemeStart-previousLinesOffset) - colend := max(graphemeEnd-previousLinesOffset, colstart) - - // fmt.Printf( - // "no line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", - // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, - // ) - // - if colend > colstart { - hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) - hi.lineEnd = line - } - highlights = append(highlights, hi) - continue matchLoop + // 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' { graphemeEnd = graphemePos colstart := max(0, graphemeStart-previousLinesOffset) @@ -91,19 +83,33 @@ matchLoop: hi.lineEnd = line } - previousLinesOffset = graphemePos + len(gr.Str()) + previousLinesOffset = graphemePos + 1 line++ } - if !gr.Next() { - break + graphemePos++ + bytePos += len(gr.Str()) + } + + // we found it!, add highlight and continue + if bytePos == byteEnd { + graphemeEnd = graphemePos + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemeEnd-previousLinesOffset, colstart) + + // fmt.Printf( + // "no line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", + // line, hi.lineStart, hi.lineEnd, colstart, colend, graphemeStart, graphemeEnd, previousLinesOffset, graphemePos-previousLinesOffset, + // ) + + if colend > colstart { + hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lineEnd = line } - bytePos++ - graphemePos += len(gr.Str()) + highlights = append(highlights, hi) } - highlights = append(highlights, hi) } return highlights @@ -117,15 +123,6 @@ type highlightInfo struct { lines map[int][][2]int } -// func (hi *highlightInfo) addToLine(line int, rng [2]int) { -// hi.lines[line] = append(hi.lines[line], rng) -// } -// -// func (hi highlightInfo) forLine(line int) ([][2]int, bool) { -// got, ok := hi.lines[line] -// return got, ok -// } - func (hi highlightInfo) coords() (line int, col int) { // if len(hi.lines) == 0 { return hi.lineStart, 0 diff --git a/viewport/viewport.go b/viewport/viewport.go index efb650d6..7af64633 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -550,7 +550,7 @@ func (m *Model) SetHighligths(matches [][]int) { return } m.memoizedMatchedLines = make([]string, len(m.lines)) - m.highlights = parseMatches(matches, m.GetContent()) + m.highlights = parseMatches(m.GetContent(), matches) m.hiIdx = m.findNearedtMatch() if m.hiIdx == -1 { return From 28cd0adbb6c49ae9cfcc648cc2479e98fc1dcbc0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 15:43:35 -0300 Subject: [PATCH 22/27] fix: wide, find --- viewport/highlight.go | 9 ++++++--- viewport/viewport_test.go | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index 83d8966d..b18bf639 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -123,11 +123,14 @@ type highlightInfo struct { lines map[int][][2]int } +// coords returns the line x column of this highlight. func (hi highlightInfo) coords() (line int, col int) { - // if len(hi.lines) == 0 { + for line, hl := range hi.lines { + for _, colRange := range hl { + return line, colRange[0] + } + } return hi.lineStart, 0 - // } - // return hi.lineStart, hi.lines[line][0][0] } func makeHilightRanges( diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index c86f7069..c32784ac 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -388,7 +388,11 @@ func TestMatchesToHighlights(t *testing.T) { text := `hello world -with empty rows` +with empty rows + +wide chars: あいうえおafter +` + vt := New(100, 100) vt.SetContent(text) @@ -507,4 +511,21 @@ with empty rows` t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) } }) + + t.Run("wide characteres", func(t *testing.T) { + matches := regexp.MustCompile("after").FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighligths(matches) + expect := []highlightInfo{ + { + lineStart: 5, + lineEnd: 5, + lines: map[int][][2]int{ + 5: {{17, 22}}, + }, + }, + } + if !reflect.DeepEqual(expect, vt.highlights) { + t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + } + }) } From 067e70a5fe6eb55fcdf420a1147d036fbe373854 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 16:24:44 -0300 Subject: [PATCH 23/27] still not quite there --- go.mod | 4 +- go.sum | 2 + viewport/highlight.go | 30 +++---- viewport/viewport.go | 4 +- viewport/viewport_test.go | 166 ++++++++++++++++++++++---------------- 5 files changed, 117 insertions(+), 89 deletions(-) diff --git a/go.mod b/go.mod index 45111328..a3a585a7 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/lipgloss v1.0.1-0.20250109182251-99421664af19 github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 github.com/dustin/go-humanize v1.0.1 @@ -32,5 +32,3 @@ require ( golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.3.8 // indirect ) - -replace github.com/charmbracelet/lipgloss => ../lipgloss diff --git a/go.sum b/go.sum index 3369febb..20c1aceb 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.0.1-0.20250109182251-99421664af19 h1:um15AqNvVUVrfU+2ENdIc2YtIm83jF+6D1dW8Tm3S+8= +github.com/charmbracelet/lipgloss v1.0.1-0.20250109182251-99421664af19/go.mod h1:QRGthpgH59/perglqXZC8xPHqDGZ9BB45ChJCFEWEMI= github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg= github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= diff --git a/viewport/highlight.go b/viewport/highlight.go index b18bf639..fa90464a 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -38,7 +38,7 @@ func parseMatches( // hilight for this match: hi := highlightInfo{ - lines: map[int][][2]int{}, + lines: map[int][2]int{}, } // find the beginning of this byte range, setup current line and @@ -71,7 +71,7 @@ func parseMatches( if content[bytePos] == '\n' { graphemeEnd = graphemePos colstart := max(0, graphemeStart-previousLinesOffset) - colend := max(graphemeEnd-previousLinesOffset+1, colstart) + colend := max(graphemeEnd-previousLinesOffset+1, colstart) // +1 its \n itself // fmt.Printf( // "nl line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", @@ -79,7 +79,7 @@ func parseMatches( // ) if colend > colstart { - hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lines[line] = [2]int{colstart, colend} hi.lineEnd = line } @@ -103,13 +103,13 @@ func parseMatches( // ) if colend > colstart { - hi.lines[line] = append(hi.lines[line], [2]int{colstart, colend}) + hi.lines[line] = [2]int{colstart, colend} hi.lineEnd = line } - highlights = append(highlights, hi) } + highlights = append(highlights, hi) } return highlights @@ -120,15 +120,17 @@ type highlightInfo struct { lineStart, lineEnd int // the grapheme highlight ranges for each of these lines - lines map[int][][2]int + lines map[int][2]int } // coords returns the line x column of this highlight. func (hi highlightInfo) coords() (line int, col int) { - for line, hl := range hi.lines { - for _, colRange := range hl { - return line, colRange[0] + for i := hi.lineStart; i <= hi.lineEnd; i++ { + hl, ok := hi.lines[i] + if !ok { + continue } + return line, hl[0] } return hi.lineStart, 0 } @@ -140,16 +142,14 @@ func makeHilightRanges( ) []lipgloss.Range { result := []lipgloss.Range{} for _, hi := range highlights { - lihis, ok := hi.lines[line] + lihi, ok := hi.lines[line] if !ok { continue } - for _, lihi := range lihis { - if lihi == [2]int{} { - continue - } - result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style)) + if lihi == [2]int{} { + continue } + result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style)) } return result } diff --git a/viewport/viewport.go b/viewport/viewport.go index 7af64633..de0e7f68 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -254,8 +254,8 @@ func (m Model) visibleLines() (lines []string) { sel := m.highlights[m.hiIdx] if hi, ok := sel.lines[i+top]; ok { lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( - hi[0][0], - hi[0][1], + hi[0], + hi[1], m.SelectedHighlightStyle, )) } diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index c32784ac..d16013b1 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -5,6 +5,8 @@ import ( "regexp" "strings" "testing" + + "github.com/charmbracelet/x/ansi" ) func TestNew(t *testing.T) { @@ -385,147 +387,173 @@ func TestRightOverscroll(t *testing.T) { } func TestMatchesToHighlights(t *testing.T) { - text := `hello + content := `hello world with empty rows wide chars: あいうえおafter + +爱开源 • Charm does open source + +Charm热爱开源 • Charm loves open source ` vt := New(100, 100) - vt.SetContent(text) + vt.SetContent(content) t.Run("first", func(t *testing.T) { - vt.SetHighligths(regexp.MustCompile("hello").FindAllStringIndex(vt.GetContent(), -1)) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("hello"), []highlightInfo{ { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{0, 5}}, + lines: map[int][2]int{ + 0: {0, 5}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("expect %+v, got %+v", expect, vt.highlights) - } + }) }) t.Run("multiple", func(t *testing.T) { - matches := regexp.MustCompile("l").FindAllStringIndex(vt.GetContent(), -1) - vt.SetHighligths(matches) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("l"), []highlightInfo{ { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{2, 3}}, + lines: map[int][2]int{ + 0: {2, 3}, }, }, { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{3, 4}}, + lines: map[int][2]int{ + 0: {3, 4}, }, }, { lineStart: 1, lineEnd: 1, - lines: map[int][][2]int{ - 1: {{3, 4}}, + lines: map[int][2]int{ + 1: {3, 4}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } + { + lineStart: 9, + lineEnd: 9, + lines: map[int][2]int{ + 9: {22, 23}, + }, + }, + }) }) t.Run("span lines", func(t *testing.T) { - matches := regexp.MustCompile("lo\nwo").FindAllStringIndex(vt.GetContent(), -1) - vt.SetHighligths(matches) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("lo\nwo"), []highlightInfo{ { lineStart: 0, lineEnd: 1, - lines: map[int][][2]int{ - 0: {{3, 6}}, - 1: {{0, 2}}, + lines: map[int][2]int{ + 0: {3, 6}, + 1: {0, 2}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } + }) }) t.Run("ends with newline", func(t *testing.T) { - matches := regexp.MustCompile("lo\n").FindAllStringIndex(vt.GetContent(), -1) - vt.SetHighligths(matches) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("lo\n"), []highlightInfo{ { lineStart: 0, lineEnd: 0, - lines: map[int][][2]int{ - 0: {{3, 6}}, + lines: map[int][2]int{ + 0: {3, 6}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } + }) }) t.Run("empty lines in the text", func(t *testing.T) { - matches := regexp.MustCompile("ith").FindAllStringIndex(vt.GetContent(), -1) - vt.SetHighligths(matches) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("ith"), []highlightInfo{ { lineStart: 3, lineEnd: 3, - lines: map[int][][2]int{ - 3: {{1, 4}}, + lines: map[int][2]int{ + 3: {1, 4}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } + }) }) t.Run("empty lines in the text match start of new line", func(t *testing.T) { - matches := regexp.MustCompile("with").FindAllStringIndex(vt.GetContent(), -1) - vt.SetHighligths(matches) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("with"), []highlightInfo{ { lineStart: 3, lineEnd: 3, - lines: map[int][][2]int{ - 3: {{0, 4}}, + lines: map[int][2]int{ + 3: {0, 4}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } + }) }) t.Run("wide characteres", func(t *testing.T) { - matches := regexp.MustCompile("after").FindAllStringIndex(vt.GetContent(), -1) - vt.SetHighligths(matches) - expect := []highlightInfo{ + testHighlights(t, content, regexp.MustCompile("after"), []highlightInfo{ { lineStart: 5, lineEnd: 5, - lines: map[int][][2]int{ - 5: {{17, 22}}, + lines: map[int][2]int{ + 5: {22, 27}, }, }, - } - if !reflect.DeepEqual(expect, vt.highlights) { - t.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } + }) + }) + + 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(100, 100) + vt.SetContent(content) + + matches := re.FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighligths(matches) + + 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) + } + } + } + + if !reflect.DeepEqual(expect, vt.highlights) { + tb.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + } +} From 2a3bb65f71b6f4aade672bb0a3ca1824a4265b53 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 16:36:44 -0300 Subject: [PATCH 24/27] fix: grapheme width --- viewport/highlight.go | 4 ++-- viewport/viewport_test.go | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index fa90464a..442b0092 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -51,7 +51,7 @@ func parseMatches( previousLinesOffset = graphemePos + 1 line++ } - graphemePos++ + graphemePos += max(1, gr.Width()) bytePos += len(gr.Str()) } @@ -87,7 +87,7 @@ func parseMatches( line++ } - graphemePos++ + graphemePos += max(1, gr.Width()) bytePos += len(gr.Str()) } diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index d16013b1..7e3f7582 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -544,6 +544,15 @@ func testHighlights(tb testing.TB, content string, re *regexp.Regexp, expect []h matches := re.FindAllStringIndex(vt.GetContent(), -1) vt.SetHighligths(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]) @@ -552,8 +561,4 @@ func testHighlights(tb testing.TB, content string, re *regexp.Regexp, expect []h } } } - - if !reflect.DeepEqual(expect, vt.highlights) { - tb.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) - } } From 7b96ddd2dacf1bef86b72286daf262e4a28356b6 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 10 Jan 2025 16:47:35 -0300 Subject: [PATCH 25/27] fix: cleanups --- viewport/highlight.go | 10 +++------- viewport/viewport.go | 9 +++------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index 442b0092..2f9b94a6 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -59,7 +59,6 @@ func parseMatches( hi.lineEnd = line graphemeStart := graphemePos - graphemeEnd := graphemePos // loop until we find the end for byteEnd > bytePos { @@ -69,9 +68,8 @@ func parseMatches( // if it ends with a new line, add the range, increase line, and continue if content[bytePos] == '\n' { - graphemeEnd = graphemePos colstart := max(0, graphemeStart-previousLinesOffset) - colend := max(graphemeEnd-previousLinesOffset+1, colstart) // +1 its \n itself + colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself // fmt.Printf( // "nl line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", @@ -93,9 +91,8 @@ func parseMatches( // we found it!, add highlight and continue if bytePos == byteEnd { - graphemeEnd = graphemePos colstart := max(0, graphemeStart-previousLinesOffset) - colend := max(graphemeEnd-previousLinesOffset, colstart) + colend := max(graphemePos-previousLinesOffset, colstart) // fmt.Printf( // "no line=%d linestart=%d lineend=%d colstart=%d colend=%d start=%d end=%d processed=%d width=%d\n", @@ -106,7 +103,6 @@ func parseMatches( hi.lines[line] = [2]int{colstart, colend} hi.lineEnd = line } - } highlights = append(highlights, hi) @@ -125,7 +121,7 @@ type highlightInfo struct { // coords returns the line x column of this highlight. func (hi highlightInfo) coords() (line int, col int) { - for i := hi.lineStart; i <= hi.lineEnd; i++ { + for i := hi.lineStart; i < hi.lineEnd; i++ { hl, ok := hi.lines[i] if !ok { continue diff --git a/viewport/viewport.go b/viewport/viewport.go index de0e7f68..77c32904 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -82,7 +82,6 @@ type Model struct { initialized bool lines []string - lineWidths []int longestLineWidth int // HighlightStyle highlights the ranges set with [SetHighligths]. @@ -190,7 +189,7 @@ func (m *Model) SetContent(s string) { if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 { m.lines = nil } - m.lineWidths, m.longestLineWidth = calcLineWidths(m.lines) + m.longestLineWidth = maxLineWidth(m.lines) m.ClearHighlights() if m.YOffset > len(m.lines)-1 { @@ -730,15 +729,13 @@ func max(a, b int) int { return b } -func calcLineWidths(lines []string) ([]int, int) { +func maxLineWidth(lines []string) int { maxlen := 0 - all := make([]int, 0, len(lines)) for _, line := range lines { llen := ansi.StringWidth(line) - all = append(all, llen+1) // account for "\n" if llen > maxlen { maxlen = llen } } - return all, maxlen + return maxlen } From 912d216af597b797a7241fa826c8ad02aec0e4ae Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Sat, 11 Jan 2025 11:38:57 -0300 Subject: [PATCH 26/27] fix: refactors, improves highlight visibility --- viewport/highlight.go | 8 +-- viewport/viewport.go | 132 +++++++++++++++++++++++------------------- 2 files changed, 75 insertions(+), 65 deletions(-) diff --git a/viewport/highlight.go b/viewport/highlight.go index 2f9b94a6..8b9c2843 100644 --- a/viewport/highlight.go +++ b/viewport/highlight.go @@ -120,15 +120,15 @@ type highlightInfo struct { } // coords returns the line x column of this highlight. -func (hi highlightInfo) coords() (line int, col int) { - for i := hi.lineStart; i < hi.lineEnd; i++ { +func (hi highlightInfo) coords() (int, int, int) { + for i := hi.lineStart; i <= hi.lineEnd; i++ { hl, ok := hi.lines[i] if !ok { continue } - return line, hl[0] + return i, hl[0], hl[1] } - return hi.lineStart, 0 + return hi.lineStart, 0, 0 } func makeHilightRanges( diff --git a/viewport/viewport.go b/viewport/viewport.go index 77c32904..1e4549dc 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -234,57 +234,20 @@ func (m Model) visibleLines() (lines []string) { bottom := clamp(m.YOffset+maxHeight, top, len(m.lines)) lines = make([]string, bottom-top) copy(lines, m.lines[top:bottom]) - if len(m.highlights) > 0 { - for i := range lines { - if memoized := m.memoizedMatchedLines[i+top]; memoized != "" { - lines[i] = memoized - } else { - ranges := makeHilightRanges( - m.highlights, - i+top, - m.HighlightStyle, - ) - lines[i] = lipgloss.StyleRanges(lines[i], ranges...) - m.memoizedMatchedLines[i+top] = lines[i] - } - if m.hiIdx < 0 { - continue - } - sel := m.highlights[m.hiIdx] - if hi, ok := sel.lines[i+top]; ok { - lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( - hi[0], - hi[1], - m.SelectedHighlightStyle, - )) - } - } - } + lines = m.highlighLines(lines, top) } 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 { - 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 + return m.softWrap(lines, maxWidth) } for i := range lines { @@ -293,6 +256,54 @@ func (m Model) visibleLines() (lines []string) { return m.prependColumn(lines) } +func (m Model) highlighLines(lines []string, offset int) []string { + if len(m.highlights) == 0 { + return lines + } + for i := range lines { + if memoized := m.memoizedMatchedLines[i+offset]; memoized != "" { + lines[i] = memoized + } else { + ranges := makeHilightRanges( + 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 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 { @@ -331,21 +342,16 @@ func (m *Model) SetXOffset(n int) { } // EnsureVisible ensures that the given line and column are in the viewport. -func (m *Model) EnsureVisible(line, col int) { - maxHeight := m.maxHeight() +func (m *Model) EnsureVisible(line, colstart, colend int) { maxWidth := m.maxWidth() - - if line >= m.YOffset && line < m.YOffset+maxHeight { - // Line is visible, no nothing - } else if line >= m.YOffset+maxHeight || line < m.YOffset { - m.SetYOffset(line) + if colend <= maxWidth { + m.SetXOffset(0) + } else { + m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural } - if col >= m.xOffset && col < m.xOffset+maxWidth { - // Column is visible, do nothing - } else if col >= m.xOffset+maxWidth || col < m.xOffset { - // Column is to the left of visible area - m.SetXOffset(col) + if line < m.YOffset || line >= m.YOffset+m.maxHeight() { + m.SetYOffset(line) } m.visibleLines() @@ -551,11 +557,7 @@ func (m *Model) SetHighligths(matches [][]int) { m.memoizedMatchedLines = make([]string, len(m.lines)) m.highlights = parseMatches(m.GetContent(), matches) m.hiIdx = m.findNearedtMatch() - if m.hiIdx == -1 { - return - } - line, col := m.highlights[m.hiIdx].coords() - m.EnsureVisible(line, col) + m.showHighlight() } // ClearHighlights clears previously set highlights. @@ -565,24 +567,32 @@ func (m *Model) ClearHighlights() { 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) +} + +// HightlightNext highlights the next match. func (m *Model) HightlightNext() { if m.highlights == nil { return } m.hiIdx = (m.hiIdx + 1) % len(m.highlights) - line, col := m.highlights[m.hiIdx].coords() - m.EnsureVisible(line, col) + 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) - line, col := m.highlights[m.hiIdx].coords() - m.EnsureVisible(line, col) + m.showHighlight() } func (m Model) findNearedtMatch() int { From 7d13ae0e4e0272f915722da39c4e9147c695afec Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Sat, 11 Jan 2025 13:51:07 -0300 Subject: [PATCH 27/27] docs: godoc --- viewport/viewport.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/viewport/viewport.go b/viewport/viewport.go index 1e4549dc..8a42fa35 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -181,6 +181,7 @@ 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") @@ -197,6 +198,8 @@ func (m *Model) SetContent(s string) { } } +// 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") }