Work the search positions out when they are read, not on each line written

A view's search positions were worked out again from every write, and
each of those walks the whole view. Content arrives a line at a time, so
rendering into a searched view costs a walk per line. Streaming 2000
lines takes 565ms, where the same render into an unsearched view takes
about 10ms.

Mark the positions stale on a write instead, and work them out where they
are read: when the view is drawn, when a key steps through the matches,
when the status is asked for. That is at most once a frame, and the same
2000 lines now take 8ms.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-09-05 10:02:55 +02:00
parent 4c90bc334c
commit 0505e778b3
2 changed files with 64 additions and 1 deletions

View file

@ -34,3 +34,25 @@ func TestSearchStatusAfterTheMatchesChange(t *testing.T) {
assert.Equal(t, 0, index)
assert.Equal(t, 1, total)
}
func TestSearchPositionsFollowStreamedContent(t *testing.T) {
v := NewView("name", 0, 0, 40, 10, OutputNormal)
v.Search("match", nil)
// A render arrives a line at a time, and the status describes all of it.
writeLines(v, "other", "match", "other", "match")
_, total := v.GetSearchStatus()
assert.Equal(t, 2, total)
}
func BenchmarkWriteToSearchedView(b *testing.B) {
for b.Loop() {
v := NewView("name", 0, 0, 100, 40, OutputNormal)
v.Search("match", nil)
for i := range 2000 {
fmt.Fprintf(v, "line %d of a diff, most of which does not match\n", i)
}
v.GetSearchStatus()
}
}

View file

@ -271,6 +271,12 @@ type searcher struct {
currentSearchIndex int
onSelectItem func(*View, int)
renderSearchStatus func(*View, int, int)
// Whether the content has changed since the positions were worked out, so that
// they have to be worked out again before they are read. Working them out walks
// the whole view, and content arrives a line at a time, so it happens once per
// read rather than once per line written.
positionsStale bool
}
func (v *View) setRenderSearchStatus(renderSearchStatus func(*View, int, int)) {
@ -287,7 +293,27 @@ func (v *View) renderSearchStatus(index int, itemCount int) {
}
}
// refreshSearchPositions works the search positions out again if the content has
// changed since they were last worked out. Every read of the positions goes through
// this, so that no caller has to know whether the view has been drawn since the
// content it is asking about arrived.
func (v *View) refreshSearchPositions() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshSearchPositionsIfNeeded()
}
// refreshSearchPositions for a caller that already holds writeMutex.
func (v *View) refreshSearchPositionsIfNeeded() {
if v.searcher.positionsStale {
v.updateSearchPositions()
}
}
func (v *View) gotoNextMatch() error {
v.refreshSearchPositions()
if len(v.searcher.searchPositions) == 0 {
return nil
}
@ -307,6 +333,8 @@ func (v *View) gotoNextMatch() error {
}
func (v *View) gotoPreviousMatch() error {
v.refreshSearchPositions()
if len(v.searcher.searchPositions) == 0 {
return nil
}
@ -328,6 +356,8 @@ func (v *View) gotoPreviousMatch() error {
}
func (v *View) SelectSearchResult(index int) {
v.refreshSearchPositions()
itemCount := len(v.searcher.searchPositions)
if itemCount == 0 {
return
@ -347,6 +377,8 @@ func (v *View) SelectSearchResult(index int) {
// Returns <current match index>, <total matches>
func (v *View) GetSearchStatus() (int, int) {
v.refreshSearchPositions()
return v.searcher.currentSearchIndex, len(v.searcher.searchPositions)
}
@ -420,6 +452,8 @@ func (v *View) nearestSearchPosition() int {
}
func (v *View) SetNearestSearchPosition() {
v.refreshSearchPositions()
if len(v.searcher.searchPositions) > 0 {
newPos := v.nearestSearchPosition()
if newPos != v.searcher.currentSearchIndex {
@ -902,7 +936,7 @@ func (v *View) write(p []byte) {
v.buf.write(v, p)
v.updateSearchPositions()
v.searcher.positionsStale = true
}
// write parses p into cells and appends them to the buffer at its write cursor.
@ -1353,6 +1387,8 @@ func stringToGraphemes(s string) []string {
}
func (v *View) updateSearchPositions() {
v.searcher.positionsStale = false
if v.searcher.searchString != "" {
var normalizeRune func(s string) string
var normalizedSearchStr string
@ -1466,6 +1502,7 @@ func (v *View) draw(isWindowFocused bool) {
}
v.refreshViewLinesIfNeeded()
v.refreshSearchPositionsIfNeeded()
visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
if v.Autoscroll && visibleViewLinesHeight > maxY {
@ -2075,6 +2112,8 @@ func (v *View) setContentLineCount(lineCount int) {
// result that is visible in the view, if any, or the first one that is below the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollUp() {
v.refreshSearchPositions()
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
windowBottom := v.oy + v.InnerHeight()
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y >= windowBottom {
@ -2098,6 +2137,8 @@ func (v *View) selectVisibleSearchResultAfterScrollUp() {
// result that is visible in the view, if any, or the last one that is above the view if none is
// visible.
func (v *View) selectVisibleSearchResultAfterScrollDown() {
v.refreshSearchPositions()
if !v.Highlight && len(v.searcher.searchPositions) != 0 {
if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y < v.oy {
newSearchIndex := v.searcher.currentSearchIndex