From c5b76847ca66aa2cef477952816194b38d56853b Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 7 Feb 2025 13:13:47 -0300 Subject: [PATCH 1/3] chore(go.mod): update `github.com/charmbracelet/x/exp/golden` --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 33ffc312..76f45db8 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d github.com/charmbracelet/x/ansi v0.8.0 - github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f + github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.16 diff --git a/go.sum b/go.sum index d3275821..7746f25a 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 h1:RFHEvURPMgGGd8epjjhi2UpXSKyFs39iRF4JTYCEdLg= github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY= -github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= -github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= +github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/input v0.3.1 h1:TE4s3fTRj+OUpJ86dKphrN99+NgBnto//EkWncMJQIg= github.com/charmbracelet/x/input v0.3.1/go.mod h1:4w9jS/NW62WrHSdmjbpzydvnbqkd+mtyK8WOWbHCdvs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= From c20ae83e2f58925560ff99ffbd434c6f1c73e22d Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 10 Feb 2025 16:00:55 -0300 Subject: [PATCH 2/3] fix: lint issues (#730) --- filepicker/filepicker.go | 72 ++++++++++++++++++------------------ filepicker/hidden_windows.go | 4 +- textarea/textarea.go | 14 +++---- 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 89b31b75..d370740d 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -36,8 +36,8 @@ func New() Model { FileAllowed: true, AutoHeight: true, height: 0, - max: 0, - min: 0, + maxIdx: 0, + minIdx: 0, selectedStack: newStack(), minStack: newStack(), maxStack: newStack(), @@ -147,8 +147,8 @@ type Model struct { selected int selectedStack stack - min int - max int + minIdx int + maxIdx int maxStack stack minStack stack @@ -182,10 +182,10 @@ func newStack() stack { } } -func (m *Model) pushView(selected, min, max int) { +func (m *Model) pushView(selected, minIdx, maxIdx int) { m.selectedStack.Push(selected) - m.minStack.Push(min) - m.maxStack.Push(max) + m.minStack.Push(minIdx) + m.maxStack.Push(maxIdx) } func (m *Model) popView() (int, int, int) { @@ -245,72 +245,72 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { break } m.files = msg.entries - m.max = max(m.max, m.Height()-1) + m.maxIdx = max(m.maxIdx, m.Height()-1) case tea.WindowSizeMsg: if m.AutoHeight { m.SetHeight(msg.Height - marginBottom) } - m.max = m.Height() - 1 + m.maxIdx = m.Height() - 1 case tea.KeyPressMsg: switch { case key.Matches(msg, m.KeyMap.GoToTop): m.selected = 0 - m.min = 0 - m.max = m.Height() - 1 + m.minIdx = 0 + m.maxIdx = m.Height() - 1 case key.Matches(msg, m.KeyMap.GoToLast): m.selected = len(m.files) - 1 - m.min = len(m.files) - m.Height() - m.max = len(m.files) - 1 + m.minIdx = len(m.files) - m.Height() + m.maxIdx = len(m.files) - 1 case key.Matches(msg, m.KeyMap.Down): m.selected++ if m.selected >= len(m.files) { m.selected = len(m.files) - 1 } - if m.selected > m.max { - m.min++ - m.max++ + if m.selected > m.maxIdx { + m.minIdx++ + m.maxIdx++ } case key.Matches(msg, m.KeyMap.Up): m.selected-- if m.selected < 0 { m.selected = 0 } - if m.selected < m.min { - m.min-- - m.max-- + if m.selected < m.minIdx { + m.minIdx-- + m.maxIdx-- } case key.Matches(msg, m.KeyMap.PageDown): m.selected += m.Height() if m.selected >= len(m.files) { m.selected = len(m.files) - 1 } - m.min += m.Height() - m.max += m.Height() + m.minIdx += m.Height() + m.maxIdx += m.Height() - if m.max >= len(m.files) { - m.max = len(m.files) - 1 - m.min = m.max - m.Height() + if m.maxIdx >= len(m.files) { + m.maxIdx = len(m.files) - 1 + m.minIdx = m.maxIdx - m.Height() } case key.Matches(msg, m.KeyMap.PageUp): m.selected -= m.Height() if m.selected < 0 { m.selected = 0 } - m.min -= m.Height() - m.max -= m.Height() + m.minIdx -= m.Height() + m.maxIdx -= m.Height() - if m.min < 0 { - m.min = 0 - m.max = m.min + m.Height() + if m.minIdx < 0 { + m.minIdx = 0 + m.maxIdx = m.minIdx + m.Height() } case key.Matches(msg, m.KeyMap.Back): m.CurrentDirectory = filepath.Dir(m.CurrentDirectory) if m.selectedStack.Length() > 0 { - m.selected, m.min, m.max = m.popView() + m.selected, m.minIdx, m.maxIdx = m.popView() } else { m.selected = 0 - m.min = 0 - m.max = m.Height() - 1 + m.minIdx = 0 + m.maxIdx = m.Height() - 1 } return m, m.readDir(m.CurrentDirectory, m.ShowHidden) case key.Matches(msg, m.KeyMap.Open): @@ -349,10 +349,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } m.CurrentDirectory = filepath.Join(m.CurrentDirectory, f.Name()) - m.pushView(m.selected, m.min, m.max) + m.pushView(m.selected, m.minIdx, m.maxIdx) m.selected = 0 - m.min = 0 - m.max = m.Height() - 1 + m.minIdx = 0 + m.maxIdx = m.Height() - 1 return m, m.readDir(m.CurrentDirectory, m.ShowHidden) } } @@ -367,7 +367,7 @@ func (m Model) View() string { var s strings.Builder for i, f := range m.files { - if i < m.min || i > m.max { + if i < m.minIdx || i > m.maxIdx { continue } diff --git a/filepicker/hidden_windows.go b/filepicker/hidden_windows.go index d9ec5add..b5f81265 100644 --- a/filepicker/hidden_windows.go +++ b/filepicker/hidden_windows.go @@ -11,11 +11,11 @@ import ( func IsHidden(file string) (bool, error) { pointer, err := syscall.UTF16PtrFromString(file) if err != nil { - return false, err + return false, err //nolint:wrapcheck } attributes, err := syscall.GetFileAttributes(pointer) if err != nil { - return false, err + return false, err //nolint:wrapcheck } return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil } diff --git a/textarea/textarea.go b/textarea/textarea.go index 7b4f298a..437e3829 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -943,13 +943,13 @@ func (m Model) LineInfo() LineInfo { // repositionView repositions the view of the viewport based on the defined // scrolling behavior. func (m *Model) repositionView() { - min := m.viewport.YOffset - max := min + m.viewport.Height() - 1 + minOffset := m.viewport.YOffset + maxOffset := minOffset + m.viewport.Height() - 1 - if row := m.cursorLineNumber(); row < min { - m.viewport.LineUp(min - row) - } else if row > max { - m.viewport.LineDown(row - max) + if row := m.cursorLineNumber(); row < minOffset { + m.viewport.LineUp(minOffset - row) + } else if row > maxOffset { + m.viewport.LineDown(row - maxOffset) } } @@ -1219,7 +1219,7 @@ func (m Model) View() string { displayLine++ var ln string - if m.ShowLineNumbers { //nolint:nestif + if m.ShowLineNumbers { if wl == 0 { // normal line isCursorLine := m.row == l s.WriteString(m.lineNumberView(l+1, isCursorLine)) From 4491afa808c7ee29ec79b7a5b270b6c78ef0ae7f Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 11 Feb 2025 14:07:51 -0300 Subject: [PATCH 3/3] feat(viewport): SetLines (#728) --- viewport/viewport.go | 112 ++++++++++++++++++++++++++------------ viewport/viewport_test.go | 30 ++++++++++ 2 files changed, 108 insertions(+), 34 deletions(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 57638f15..00a32504 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -104,9 +104,12 @@ type Model struct { // and [HihglightPrevious] to navigate. SelectedHighlightStyle lipgloss.Style - highlights []highlightInfo - hiIdx int - memoizedMatchedLines []string + // StyleLineFunc allows to return a [lipgloss.Style] for each line. + // The argument is the line index. + StyleLineFunc func(int) lipgloss.Style + + highlights []highlightInfo + hiIdx int } // GutterFunc can be implemented and set into [Model.LeftGutterFunc]. @@ -216,9 +219,16 @@ func (m Model) HorizontalScrollPercent() float64 { // Line endings will be normalized to '\n'. func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings - m.lines = strings.Split(s, "\n") + m.SetContentLines(strings.Split(s, "\n")) +} + +// SetContentLines allows to set the lines to be shown instead of the content. +// If a given line has a \n in it, it'll be considered a [Model.SoftWrap]. +// See also [Model.SetContent]. +func (m *Model) SetContentLines(lines []string) { // if there's no content, set content to actual nil instead of one empty // line. + m.lines = lines if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 { m.lines = nil } @@ -236,25 +246,38 @@ func (m Model) GetContent() string { return strings.Join(m.lines, "\n") } -// calculateLine taking soft wrapiing into account, returns the total viewable +// calculateLine taking soft wraping into account, returns the total viewable // lines and the real-line index for the given yoffset. func (m Model) calculateLine(yoffset int) (total, idx int) { if !m.SoftWrap { - return len(m.lines), yoffset + for i, line := range m.lines { + adjust := max(1, lipgloss.Height(line)) + if yoffset >= total && yoffset < total+adjust { + idx = i + } + total += adjust + } + if yoffset >= total { + idx = len(m.lines) + } + return total, idx } - maxWidth := m.maxWidth() + maxWidth := m.maxWidth() var gutterSize int if m.LeftGutterFunc != nil { gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{})) } for i, line := range m.lines { - adjust := max(1, ansi.StringWidth(line)/(maxWidth-gutterSize)) + adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize)) if yoffset >= total && yoffset < total+adjust { idx = i } total += adjust } + if yoffset >= total { + idx = len(m.lines) + } return total, idx } @@ -310,6 +333,7 @@ func (m Model) visibleLines() (lines []string) { bottom := clamp(pos+maxHeight, top, len(m.lines)) lines = make([]string, bottom-top) copy(lines, m.lines[top:bottom]) + lines = m.styleLines(lines, top) lines = m.highlightLines(lines, top) } @@ -319,35 +343,47 @@ func (m Model) visibleLines() (lines []string) { // 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) + return m.setupGutter(lines) } if m.SoftWrap { return m.softWrap(lines, maxWidth) } + for i, line := range lines { + sublines := strings.Split(line, "\n") // will only have more than 1 if caller used [Model.SetContentLines]. + for j := range sublines { + sublines[j] = ansi.Cut(sublines[j], m.xOffset, m.xOffset+maxWidth) + } + lines[i] = strings.Join(sublines, "\n") + } + return m.setupGutter(lines) +} + +// styleLines styles the lines using [Model.StyleLineFunc]. +func (m Model) styleLines(lines []string, offset int) []string { + if m.StyleLineFunc == nil { + return lines + } for i := range lines { - lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth) + lines[i] = m.StyleLineFunc(i + offset).Render(lines[i]) } - return m.prependColumn(lines) + return lines } +// highlightLines highlights the lines with [Model.HighlightStyle] and +// [Model.SelectedHighlightStyle]. func (m Model) highlightLines(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 := makeHighlightRanges( - m.highlights, - i+offset, - m.HighlightStyle, - ) - lines[i] = lipgloss.StyleRanges(lines[i], ranges...) - m.memoizedMatchedLines[i+offset] = lines[i] - } + ranges := makeHighlightRanges( + m.highlights, + i+offset, + m.HighlightStyle, + ) + lines[i] = lipgloss.StyleRanges(lines[i], ranges...) if m.hiIdx < 0 { continue } @@ -365,6 +401,7 @@ func (m Model) highlightLines(lines []string, offset int) []string { func (m Model) softWrap(lines []string, maxWidth int) []string { var wrappedLines []string + total := m.TotalLineCount() for i, line := range lines { idx := 0 for ansi.StringWidth(line) >= idx { @@ -372,7 +409,7 @@ func (m Model) softWrap(lines []string, maxWidth int) []string { if m.LeftGutterFunc != nil { truncatedLine = m.LeftGutterFunc(GutterContext{ Index: i + m.YOffset, - TotalLines: m.TotalLineCount(), + TotalLines: total, Soft: idx > 0, }) + truncatedLine } @@ -383,16 +420,25 @@ func (m Model) softWrap(lines []string, maxWidth int) []string { return wrappedLines } -func (m Model) prependColumn(lines []string) []string { +// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc]. +func (m Model) setupGutter(lines []string) []string { + if m.LeftGutterFunc == nil { + return lines + } + + offset := max(0, m.lineToIndex(m.YOffset)) + total := m.TotalLineCount() result := make([]string, len(lines)) - for i, line := range lines { - if m.LeftGutterFunc != nil { - line = m.LeftGutterFunc(GutterContext{ - Index: i + m.YOffset, - TotalLines: m.TotalLineCount(), - }) + line + for i := range lines { + var line []string + for j, realLine := range strings.Split(lines[i], "\n") { + line = append(line, m.LeftGutterFunc(GutterContext{ + Index: i + offset, + TotalLines: total, + Soft: j > 0, + })+realLine) } - result[i] = line + result[i] = strings.Join(line, "\n") } return result } @@ -564,7 +610,6 @@ func (m *Model) SetHighlights(matches [][]int) { if len(matches) == 0 || len(m.lines) == 0 { return } - m.memoizedMatchedLines = make([]string, len(m.lines)) m.highlights = parseMatches(m.GetContent(), matches) m.hiIdx = m.findNearedtMatch() m.showHighlight() @@ -572,7 +617,6 @@ func (m *Model) SetHighlights(matches [][]int) { // ClearHighlights clears previously set highlights. func (m *Model) ClearHighlights() { - m.memoizedMatchedLines = nil m.highlights = nil m.hiIdx = -1 } @@ -704,7 +748,7 @@ func clamp(v, low, high int) int { func maxLineWidth(lines []string) int { result := 0 for _, line := range lines { - result = max(result, ansi.StringWidth(line)) + result = max(result, lipgloss.Width(line)) } return result } diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index 860d0d0b..1e108386 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -562,3 +562,33 @@ func testHighlights(tb testing.TB, content string, re *regexp.Regexp, expect []h } } } + +func TestCalculateLine(t *testing.T) { + t.Run("simple", func(t *testing.T) { + vp := New(WithWidth(40), WithHeight(20)) + vp.SetContent("foo\nbar") + total, idx := vp.calculateLine(0) + if total != 2 || idx != 0 { + t.Errorf("total: %d, idx: %d", total, idx) + } + }) + + t.Run("line breaks", func(t *testing.T) { + vp := New(WithWidth(40), WithHeight(20)) + vp.SetContentLines([]string{"new\nbar", "foo", "another line", "multiple\nlines"}) + total, idx := vp.calculateLine(6) + if total != 6 || idx != 4 { + t.Errorf("total: %d, idx: %d", total, idx) + } + }) + + t.Run("soft breaks", func(t *testing.T) { + vp := New(WithWidth(40), WithHeight(20)) + vp.SoftWrap = true + vp.SetContent("super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super\nlong line super long line super long line super long line") + total, idx := vp.calculateLine(10) + if total != 6 || idx != 2 { + t.Errorf("total: %d, idx: %d", total, idx) + } + }) +}