Skip to content

Commit

Permalink
[fix] improve performance of reapplying ansi
Browse files Browse the repository at this point in the history
  • Loading branch information
robinovitch61 committed Dec 24, 2024
1 parent 69b9b55 commit 2db53d3
Show file tree
Hide file tree
Showing 8 changed files with 365 additions and 93 deletions.
26 changes: 13 additions & 13 deletions internal/filterable_viewport/filterable_viewport.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,26 @@ type FilterableViewport[T viewport.RenderableComparable] struct {
}

type FilterableViewportConfig[T viewport.RenderableComparable] struct {
TopHeader string
StartShowContext bool
CanToggleShowContext bool
StartSelectionEnabled bool
StartWrapOn bool
KeyMap keymap.KeyMap
Width int
Height int
AllRows []T
MatchesFilter func(T, filter.Model) bool
ViewWhenEmpty string
Styles style.Styles
TopHeader string
StartShowContext bool
CanToggleShowContext bool
SelectionEnabled bool
StartWrapOn bool
KeyMap keymap.KeyMap
Width int
Height int
AllRows []T
MatchesFilter func(T, filter.Model) bool
ViewWhenEmpty string
Styles style.Styles
}

func NewFilterableViewport[T viewport.RenderableComparable](config FilterableViewportConfig[T]) FilterableViewport[T] {
f := filter.New(config.KeyMap)
f.SetShowContext(config.StartShowContext, config.CanToggleShowContext)

var vp = viewport.New[T](config.Width, config.Height)
vp.SetSelectionEnabled(config.StartSelectionEnabled)
vp.SetSelectionEnabled(config.SelectionEnabled)
vp.SetWrapText(config.StartWrapOn)

fv := FilterableViewport[T]{
Expand Down
49 changes: 32 additions & 17 deletions internal/filterable_viewport/filterable_viewport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,18 @@ func newFilterableViewport() FilterableViewport[TestItem] {

return NewFilterableViewport[TestItem](
FilterableViewportConfig[TestItem]{
TopHeader: "Test Header",
StartShowContext: false,
CanToggleShowContext: true,
StartSelectionEnabled: true,
StartWrapOn: false,
KeyMap: keymap.DefaultKeyMap(),
Width: 80,
Height: 20,
AllRows: []TestItem{},
MatchesFilter: matchesFilter,
ViewWhenEmpty: "No items",
Styles: styles,
TopHeader: "Test Header",
StartShowContext: false,
CanToggleShowContext: true,
SelectionEnabled: true,
StartWrapOn: false,
KeyMap: keymap.DefaultKeyMap(),
Width: 80,
Height: 20,
AllRows: []TestItem{},
MatchesFilter: matchesFilter,
ViewWhenEmpty: "No items",
Styles: styles,
},
)
}
Expand Down Expand Up @@ -151,7 +151,7 @@ func TestFilterableViewport_FilterNoContext(t *testing.T) {
lines := getTestLines(fv)
util.CmpStr(
t,
"Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(matches only) \x1b[m\x1b[m",
"Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(matches only) \x1b[m",
lines[0],
)
if len(lines) != 2 { // 1 for header
Expand Down Expand Up @@ -211,7 +211,7 @@ func TestFilterableViewport_FilterShowContext(t *testing.T) {
}

lines := getTestLines(fv)
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(1/1, n/N to cycle) \x1b[m\x1b[m" {
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(1/1, n/N to cycle) \x1b[m" {
t.Errorf("unexpected header with show context filter\n%q", fv.View())
}

Expand All @@ -227,7 +227,7 @@ func TestFilterableViewport_FilterShowContext(t *testing.T) {
{content: "another item"},
})
lines = getTestLines(fv)
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(1/2, n/N to cycle) \x1b[m\x1b[m" {
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(1/2, n/N to cycle) \x1b[m" {
t.Errorf("unexpected header with show context filter\n%q", fv.View())
}
if len(lines) != 5 { // 1 for header
Expand All @@ -240,7 +240,7 @@ func TestFilterableViewport_FilterShowContext(t *testing.T) {
t.Error("contextual filtering should be disabled")
}
lines = getTestLines(fv)
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(matches only) \x1b[m\x1b[m" {
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mfilter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mone\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(matches only) \x1b[m" {
t.Errorf("unexpected header with filter\n%q", fv.View())
}

Expand Down Expand Up @@ -333,10 +333,25 @@ func TestFilterableViewport_FilterRegex(t *testing.T) {

fv = applyTestFilter(fv, focusRegexFilterKeyMsg, "i.*m")
lines := getTestLines(fv)
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mregex filter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mi.*m\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(matches only) \x1b[m\x1b[m" {
if lines[0] != "Test Header \x1b[48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m\x1b[38;2;0;0;0;48;2;225;225;225mregex filter: \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225mi.*m\x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m \x1b[m\x1b[38;2;0;0;0;48;2;225;225;225m(matches only) \x1b[m" {
t.Errorf("unexpected header with regex filter\n%q", fv.View())
}
if len(lines) != 3 { // 1 for header
t.Errorf("expected 2 visible item, got %d", len(lines)-1)
}
}

// TODO LEO: improve this
//func TestFilterableViewport_LongLineManyMatches(t *testing.T) {
// fv := newFilterableViewport()
// fv, _ = fv.Update(wrapKeyMsg)
// if !fv.viewport.GetWrapText() {
// t.Error("wrap text should be enabled")
// }
// fv.SetAllRows([]TestItem{
// {content: strings.Repeat("r", 10000)},
// })
// fv = applyTestFilter(fv, focusRegexFilterKeyMsg, "r")
// lines := getTestLines(fv)
// println(len(lines))
//}
61 changes: 44 additions & 17 deletions internal/linebuffer/linebuffer.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package linebuffer

import (
"fmt"
"github.com/robinovitch61/kl/internal/constants"
"github.com/robinovitch61/kl/internal/dev"
"strings"
"unicode/utf8"
)
Expand Down Expand Up @@ -57,9 +59,6 @@ func (l LineBuffer) Truncate(xOffset, width int) string {
lenPlainText := len(l.plainTextRunes)

if lenPlainText == 0 || xOffset >= lenPlainText {
if len(l.ansiCodeIndexes) > 0 {
return l.reapplyANSI("", 0)
}
return ""
}

Expand All @@ -80,7 +79,7 @@ func (l LineBuffer) Truncate(xOffset, width int) string {
}
if end <= start {
if len(l.ansiCodeIndexes) > 0 {
return l.reapplyANSI("", 0)
return ""
}
return ""
}
Expand Down Expand Up @@ -114,52 +113,80 @@ func (l LineBuffer) Truncate(xOffset, width int) string {
}

if len(l.ansiCodeIndexes) > 0 {
return l.reapplyANSI(visible, l.byteOffsets[start])
return reapplyANSI(l.line, visible, l.byteOffsets[start], l.ansiCodeIndexes)
}
return visible
}

func (l LineBuffer) reapplyANSI(truncated string, startBytes int) string {
func reapplyANSI(original, truncated string, truncByteOffset int, ansiCodeIndexes [][]int) string {
var result []byte
var lenAnsiAdded int
isReset := true
truncatedBytes := []byte(truncated)

ansiCodeIndexes := l.ansiCodeIndexes
for i := 0; i < len(truncatedBytes); {
// collect all ansi codes that should be applied immediately before the current runes
var ansisToAdd []string
for len(ansiCodeIndexes) > 0 {
candidateAnsi := ansiCodeIndexes[0]
codeStart, codeEnd := candidateAnsi[0], candidateAnsi[1]
originalIdx := startBytes + i + lenAnsiAdded
if codeStart <= originalIdx || codeEnd <= originalIdx {
result = append(result, l.line[codeStart:codeEnd]...)
originalIdx := truncByteOffset + i + lenAnsiAdded
if codeStart <= originalIdx {
code := original[codeStart:codeEnd]
isReset = code == "\x1b[m"
ansisToAdd = append(ansisToAdd, code)
lenAnsiAdded += codeEnd - codeStart
ansiCodeIndexes = ansiCodeIndexes[1:]
} else {
break
}
}

// if there's just a bunch of reset sequences, compress it to one
allReset := len(ansisToAdd) > 0
for _, ansi := range ansisToAdd {
if ansi != "\x1b[m" {
allReset = false
break
}
}
if allReset {
ansisToAdd = []string{"\x1b[m"}
}

// if the last sequence in a set of more than one is a reset, no point adding any of them
redundant := len(ansisToAdd) > 1 && ansisToAdd[len(ansisToAdd)-1] == "\x1b[m"
if !redundant {
for _, ansi := range ansisToAdd {
result = append(result, ansi...)
}
}

// add the bytes of the current rune
_, size := utf8.DecodeRune(truncatedBytes[i:])
result = append(result, truncatedBytes[i:i+size]...)
i += size
}

// add remaining ansi codes in order to end
for _, codeIndexes := range ansiCodeIndexes {
codeStart, codeEnd := codeIndexes[0], codeIndexes[1]
result = append(result, l.line[codeStart:codeEnd]...)
if !isReset {
result = append(result, "\x1b[m"...)
}

// removing empty sequences may hurt performance, but helps legibility
return constants.EmptySequenceRegex.ReplaceAllString(string(result), "")
return string(result)
}

func initByteOffsets(runes []rune) []int {
offsets := make([]int, len(runes)+1)
currentOffset := 0
for i, r := range runes {
offsets[i] = currentOffset
currentOffset += utf8.RuneLen(r)
runeLen := utf8.RuneLen(r)
if runeLen == -1 {
// invalid utf-8 value, assume 1 byte
dev.Debug(fmt.Sprintf("invalid utf-8 value: %v", r))
runeLen = 1
}
currentOffset += runeLen
}
offsets[len(runes)] = currentOffset
return offsets
Expand Down
Loading

0 comments on commit 2db53d3

Please sign in to comment.