mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-05 04:36:07 +00:00
Merge remote-tracking branch 'origin/v2-exp' into v2-area
This commit is contained in:
+36
-36
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user