diff --git a/filter/filter.go b/filter/filter.go index 0c72023..d321bcb 100644 --- a/filter/filter.go +++ b/filter/filter.go @@ -19,7 +19,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" "github.com/sahilm/fuzzy" ) @@ -211,28 +211,16 @@ func (m model) View() string { continue } - var buf strings.Builder - lastIdx := 0 - - // Use ansi.Truncate and ansi.TruncateLeft and ansi.StringWidth to - // style match.MatchedIndexes without losing the original option style: + var ranges []lipgloss.Range for _, rng := range matchedRanges(match.MatchedIndexes) { - // fmt.Print("here ", lastIdx, rng, " - ", match.Str[rng[0]:rng[1]+1], "\r\n") - // Add the text before this match - if rng[0] > lastIdx { - buf.WriteString(ansi.Cut(styledOption, lastIdx, rng[0])) - } - - // Add the matched character with highlight - buf.WriteString(m.matchStyle.Render(ansi.Cut(match.Str, rng[0], rng[1]+1))) - lastIdx = rng[1] + 1 + // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. + // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. + // so we need to adjust it here: + start, stop := bytePosToVisibleCharPos(match.Str, rng) + ranges = append(ranges, lipgloss.NewRange(start, stop+1, m.matchStyle)) } - // Add any remaining text after the last match - buf.WriteString(ansi.TruncateLeft(styledOption, lastIdx, "")) - - // Flush text buffer. - s.WriteString(lineTextStyle.Render(buf.String())) + s.WriteString(lineTextStyle.Render(lipgloss.StyleRanges(styledOption, ranges...))) // We have finished displaying the match with all of it's matched // characters highlighted and the rest filled in. @@ -540,3 +528,26 @@ func matchedRanges(in []int) [][2]int { out = append(out, current) return out } + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} diff --git a/filter/filter_test.go b/filter/filter_test.go index 56e7590..5840002 100644 --- a/filter/filter_test.go +++ b/filter/filter_test.go @@ -3,6 +3,8 @@ package filter import ( "reflect" "testing" + + "github.com/charmbracelet/x/ansi" ) func TestMatchedRanges(t *testing.T) { @@ -39,3 +41,19 @@ func TestMatchedRanges(t *testing.T) { }) } } + +func TestByteToChar(t *testing.T) { + stStr := "\x1b[90m\ue615\x1b[39m \x1b[3m\x1b[32mDow\x1b[0m\x1b[90m\x1b[39m\x1b[3wnloads" + str := " Downloads" + rng := [2]int{4, 7} + expect := "Dow" + + if got := str[rng[0]:rng[1]]; got != expect { + t.Errorf("expected %q, got %q", expect, got) + } + + start, stop := bytePosToVisibleCharPos(str, rng) + if got := ansi.Strip(ansi.Cut(stStr, start, stop)); got != expect { + t.Errorf("expected %+q, got %+q", expect, got) + } +} diff --git a/go.mod b/go.mod index eb17f5f..5f13653 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.5-0.20241207142916-e0515bc22ad1 github.com/charmbracelet/glamour v0.8.0 - github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/lipgloss v1.0.1-0.20250110214317-ecc1bd014d51 github.com/charmbracelet/log v0.4.0 github.com/charmbracelet/x/ansi v0.7.0 github.com/charmbracelet/x/editor v0.1.0 @@ -17,6 +17,7 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 github.com/muesli/termenv v0.15.3-0.20241211131612-0d230cb6eb15 + github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.1 golang.org/x/text v0.21.0 ) @@ -39,7 +40,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-emoji v1.0.4 // indirect golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect diff --git a/go.sum b/go.sum index 7c0f230..776f9f4 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,8 @@ github.com/charmbracelet/bubbletea v1.2.5-0.20241207142916-e0515bc22ad1 h1:osd3d github.com/charmbracelet/bubbletea v1.2.5-0.20241207142916-e0515bc22ad1/go.mod h1:Hbk5+oE4a7cDyjfdPi4sHZ42aGTMYcmHnVDhsRswn7A= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= -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/lipgloss v1.0.1-0.20250110214317-ecc1bd014d51 h1:f+0mEkhorXNiBaHb4V9wyd364OH/aF7md7ZngkS+1gU= +github.com/charmbracelet/lipgloss v1.0.1-0.20250110214317-ecc1bd014d51/go.mod h1:QRGthpgH59/perglqXZC8xPHqDGZ9BB45ChJCFEWEMI= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404=