Merge remote-tracking branch 'origin/v2-exp' into v2-area

This commit is contained in:
Carlos Alexandro Becker
2025-02-11 14:08:22 -03:00
7 changed files with 157 additions and 83 deletions
+36 -36
View File
@@ -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
}
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+7 -7
View File
@@ -975,13 +975,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)
}
}
@@ -1300,7 +1300,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))
+72 -28
View File
@@ -104,9 +104,12 @@ type Model struct {
// and [HihglightPrevious] to navigate.
SelectedHighlightStyle lipgloss.Style
// 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
memoizedMatchedLines []string
}
// 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 := range lines {
lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+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)
}
return m.prependColumn(lines)
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] = m.StyleLineFunc(i + offset).Render(lines[i])
}
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]
}
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 {
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
// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc].
func (m Model) setupGutter(lines []string) []string {
if m.LeftGutterFunc == nil {
return lines
}
result[i] = line
offset := max(0, m.lineToIndex(m.YOffset))
total := m.TotalLineCount()
result := make([]string, len(lines))
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] = 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
}
+30
View File
@@ -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)
}
})
}