From e2418047df36ffccfb96dc05c816757c96da6915 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 15:28:12 +0100 Subject: [PATCH 1/7] Let benchmarks use WithSimulationScreen The next commit adds a benchmark that draws a view. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index e4f2441b2..3727f4391 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -16,16 +16,16 @@ import ( ) // WithSimulationScreen swaps the package-level Screen for a tcell -// terminfo-backed mock terminal so tests can call view.draw() and -// inspect rendered cells via Screen.Get(). The previous Screen is -// restored on test cleanup. -func WithSimulationScreen(t *testing.T, width, height int) { - t.Helper() +// terminfo-backed mock terminal so tests and benchmarks can call +// view.draw() and inspect rendered cells via Screen.Get(). The previous +// Screen is restored on test cleanup. +func WithSimulationScreen(tb testing.TB, width, height int) { + tb.Helper() saved := Screen if err := (&Gui{}).tcellInitSimulation(width, height); err != nil { - t.Fatal(err) + tb.Fatal(err) } - t.Cleanup(func() { + tb.Cleanup(func() { Screen.Fini() Screen = saved }) From cccd088895db206013f6b4566b4e62034b37762e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 17:55:25 +0100 Subject: [PATCH 2/7] Add a benchmark for drawing a view with many search matches If a search has many matches, scrolling the view becomes laggy because every draw takes long. Add a benchmark for this, so that later commits can show how much faster they make drawing. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/search_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/gocui/search_test.go b/pkg/gocui/search_test.go index ba20da9de..f9a137877 100644 --- a/pkg/gocui/search_test.go +++ b/pkg/gocui/search_test.go @@ -56,3 +56,18 @@ func BenchmarkWriteToSearchedView(b *testing.B) { v.GetSearchStatus() } } + +func BenchmarkDrawViewWithManySearchMatches(b *testing.B) { + WithSimulationScreen(b, 102, 42) + v := NewView("name", 0, 0, 101, 41, OutputNormal) + // 44000 matches, as when searching a long log for a single letter. + for i := range 4000 { + fmt.Fprintf(v, "line %d of a branch log, where we searched for every letter e\n", i) + } + v.Search("e", nil) + v.SetOriginY(2000) + + for b.Loop() { + v.draw(true) + } +} From 5ee580b01085059b5e8d7e77fd7ac5237cec5b74 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 15:25:42 +0100 Subject: [PATCH 3/7] Add tests for search matches and hyperlinks scrolled under the gutter If a view shows the inclusion gutter and is scrolled sideways, the gutter shows the search highlight of a match that begins in the columns scrolled off to the left. It also shows the underline of a hovered hyperlink that begins there. setCharacter checks every cell it draws against the search matches and the hovered hyperlink, the cells of the gutter included. For a gutter cell, the content column it works out is one of the columns scrolled off to the left. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view_test.go | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 3727f4391..a3b59cca9 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1327,6 +1327,19 @@ func TestSearchMatchBesideTheInclusionGutter(t *testing.T) { assert.Equal(t, ".....##...", highlightedColumns(1)) + + // Scrolled sideways, a match that begins in the columns scrolled off to the left + // is highlighted where it shows, and not in the gutter. + v.UpdateSearchResults("12", nil) + v.SetOriginX(2) + v.draw(true) + // The row reads "✓ 23456789", so the "2" of the match is at view column 2. + assert.Equal(t, + /* EXPECTED: + "..#.......", + ACTUAL: */ + ".##.......", + highlightedColumns(1)) } // A hyperlink beside the inclusion gutter is underlined while the mouse is over it. @@ -1337,7 +1350,7 @@ func TestHoveredHyperlinkBesideTheInclusionGutter(t *testing.T) { v.InclusionGutterMarker = "✓" v.UnderlineHyperLinksOnlyOnHover = true - v.writeString("ab\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\cd\n") + v.writeString("ab\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\cdefg\n") v.SetInclusionGutter(true, []bool{true}) // The row reads "✓ ablinkcd". Point at the "l" of "link", at view column 4, @@ -1356,6 +1369,19 @@ func TestHoveredHyperlinkBesideTheInclusionGutter(t *testing.T) { assert.Equal(t, "....____..", underlinedColumns(1)) + + // Scrolled sideways, a hyperlink that begins in the columns scrolled off to the + // left is underlined where it shows, and not in the gutter. The row reads + // "✓ inkcdefg"; point at the "k" of "link", at view column 4 again. + v.SetOriginX(3) + v.onMouseMove(5, 1) + v.draw(true) + assert.Equal(t, + /* EXPECTED: + "..___.....", + ACTUAL: */ + ".____.....", + underlinedColumns(1)) } // Clicking a hyperlink beside the inclusion gutter opens it. From 0d77b04db3024b8970bc4f8b66e16d87a83527b2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 15:27:16 +0100 Subject: [PATCH 4/7] Don't show search matches and hovered hyperlinks in the inclusion gutter If a view shows the inclusion gutter and is scrolled sideways, the gutter shows the highlight of a search match that begins in the columns scrolled off to the left. The same goes for the underline of a hovered hyperlink. Let draw check only the content cells against the search matches and the hovered hyperlink, and pass the results to setCharacter. The gutter cells pass neither. Since draw now works out the content column of a cell once, and not again for every search match, this also makes drawing with many search matches faster. With 44000 matches, drawing a 100x40 view took 265ms and now takes 40ms. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view.go | 57 +++++++++++++++++++++++++++++------------- pkg/gocui/view_test.go | 6 ----- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ed9fee232..9b7690a46 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1034,8 +1034,12 @@ func (v *View) InclusionGutterWidthWhenShown() int { // setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies // the specified colors, taking into account if the cell must be highlighted. Also, it checks if the -// position is valid. width is the number of columns the character takes up. -func (v *View) setCharacter(x, y int, ch string, width int, fgColor, bgColor Attribute, isWindowFocused bool) { +// position is valid. width is the number of columns the character takes up. match says whether +// the character is part of a search match, and hoveredHyperlink whether it is part of the +// hyperlink under the mouse. +func (v *View) setCharacter(x, y int, ch string, width int, fgColor, bgColor Attribute, isWindowFocused bool, + match searchMatch, hoveredHyperlink bool, +) { maxX, maxY := v.Size() if x < 0 || x >= maxX || y < 0 || y >= maxY { return @@ -1066,16 +1070,16 @@ func (v *View) setCharacter(x, y int, ch string, width int, fgColor, bgColor Att } } - if matched, selected := v.isPatternMatchedRune(x, y); matched { + if match != noSearchMatch { fgColor = ColorBlack - if selected { + if match == currentSearchMatch { bgColor = ColorCyan } else { bgColor = ColorYellow } } - if v.isHoveredHyperlink(x, y) { + if hoveredHyperlink { fgColor |= AttrUnderline } @@ -1968,13 +1972,15 @@ func (v *View) draw(isWindowFocused bool) { // The inclusion gutter is blank but for the marker on a marked line, and the // content begins after it. The blanks go through setCharacter like everything - // else, so that a selection reaching the left edge covers the gutter too. + // else, so that a selection reaching the left edge covers the gutter too. Search + // matches and the hovered hyperlink belong to the content, so they don't show in + // the gutter, even where the content is scrolled off under it. for gx := range gutterWidth { - v.setCharacter(gx, y, " ", 1, v.FgColor, v.BgColor, isWindowFocused) + v.setCharacter(gx, y, " ", 1, v.FgColor, v.BgColor, isWindowFocused, noSearchMatch, false) } if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] { v.setCharacter(0, y, v.InclusionGutterMarker, uniseg.StringWidth(v.InclusionGutterMarker), - v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused) + v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused, noSearchMatch, false) } // x tracks the current x position in the view, and cellIdx tracks the @@ -2027,7 +2033,9 @@ func (v *View) draw(isWindowFocused bool) { fgColor |= AttrUnderline } - v.setCharacter(x, y, c.chr, c.width, fgColor, bgColor, isWindowFocused) + contentX, contentY := x-gutterWidth+v.ox, v.oy+y + v.setCharacter(x, y, c.chr, c.width, fgColor, bgColor, isWindowFocused, + v.searchMatchAt(contentX, contentY), v.isHoveredHyperlink(contentX, contentY)) x += c.width cellIdx++ @@ -2123,22 +2131,35 @@ func (v *View) contentX(x int) int { return x - v.inclusionGutterWidth() + v.ox } -func (v *View) isPatternMatchedRune(x, y int) (bool, bool) { +// searchMatch is whether a cell of the view is part of a search match, and if so, +// whether that match is the current one. +type searchMatch int + +const ( + noSearchMatch searchMatch = iota + otherSearchMatch + currentSearchMatch +) + +// searchMatchAt returns whether the cell at content column x of view line y is part of +// a search match. +func (v *View) searchMatchAt(x, y int) searchMatch { for i, pos := range v.searcher.searchPositions { - adjustedY := y + v.oy - adjustedX := v.contentX(x) - if adjustedY == pos.Y && adjustedX >= pos.XStart && adjustedX < pos.XEnd { - return true, i == v.searcher.currentSearchIndex + if y == pos.Y && x >= pos.XStart && x < pos.XEnd { + if i == v.searcher.currentSearchIndex { + return currentSearchMatch + } + return otherSearchMatch } } - return false, false + return noSearchMatch } +// isHoveredHyperlink returns whether the cell at content column x of view line y is +// part of the hyperlink under the mouse. func (v *View) isHoveredHyperlink(x, y int) bool { if v.UnderlineHyperLinksOnlyOnHover && v.hoveredHyperlink != nil { - adjustedY := y + v.oy - adjustedX := v.contentX(x) - return adjustedY == v.hoveredHyperlink.Y && adjustedX >= v.hoveredHyperlink.XStart && adjustedX < v.hoveredHyperlink.XEnd + return y == v.hoveredHyperlink.Y && x >= v.hoveredHyperlink.XStart && x < v.hoveredHyperlink.XEnd } return false } diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index a3b59cca9..364d7dedc 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1335,10 +1335,7 @@ func TestSearchMatchBesideTheInclusionGutter(t *testing.T) { v.draw(true) // The row reads "✓ 23456789", so the "2" of the match is at view column 2. assert.Equal(t, - /* EXPECTED: "..#.......", - ACTUAL: */ - ".##.......", highlightedColumns(1)) } @@ -1377,10 +1374,7 @@ func TestHoveredHyperlinkBesideTheInclusionGutter(t *testing.T) { v.onMouseMove(5, 1) v.draw(true) assert.Equal(t, - /* EXPECTED: "..___.....", - ACTUAL: */ - ".____.....", underlinedColumns(1)) } From 0bb456655438c07c93271ce7116d36981f51f45b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 15:31:07 +0100 Subject: [PATCH 5/7] Add tests for drawing search matches No test checks yet which cells draw shows as search matches. The next commit changes how draw works this out. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/search_test.go | 114 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/pkg/gocui/search_test.go b/pkg/gocui/search_test.go index f9a137877..efbc53afd 100644 --- a/pkg/gocui/search_test.go +++ b/pkg/gocui/search_test.go @@ -57,6 +57,120 @@ func BenchmarkWriteToSearchedView(b *testing.B) { } } +// drawSearchHighlights draws the view and returns how each of its rows is highlighted: +// "#" for a search match, "*" for the current one, and "." for neither. +func drawSearchHighlights(v *View) []string { + v.draw(true) + + width, height := v.InnerSize() + rows := make([]string, 0, height) + for y := range height { + row := "" + for x := range width { + _, style, _ := Screen.Get(v.x0+x+1, v.y0+y+1) + switch style.GetBackground() { + case getTcellColor(ColorYellow, OutputNormal): + row += "#" + case getTcellColor(ColorCyan, OutputNormal): + row += "*" + default: + row += "." + } + } + rows = append(rows, row) + } + return rows +} + +// The search matches on screen are highlighted, and the current one in a color of its +// own. +func TestDrawSearchMatches(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10, InnerHeight 4 + writeLines(v, + "ab", + "abab xab", + "no match", + "xxxxxxxxxab xxxxx ab", + "ab ab", + "ab", + ) + v.Search("ab", nil) + _ = v.gotoNextMatch() + _ = v.gotoNextMatch() + index, total := v.GetSearchStatus() + assert.Equal(t, 2, index, "the current match is the second one in the second line") + assert.Equal(t, 9, total) + + // Scrolled down past the first line, so that the last line is off screen too. + v.SetOriginY(1) + assert.Equal(t, + []string{ + "##**..##..", // abab xab + "..........", // no match + ".........#", // xxxxxxxxxa + "##.##.....", // ab ab + }, + drawSearchHighlights(v)) + + // Scrolled sideways, matches show where they are partly on screen. + v.SetOriginX(3) + assert.Equal(t, + []string{ + "*..##.....", // b xab + "..........", // match + "......##..", // xxxxxxab x + "##........", // ab + }, + drawSearchHighlights(v)) +} + +// Where matches overlap, a cell is highlighted as part of the first match it is in. +func TestDrawOverlappingSearchMatches(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 2, OutputNormal) // InnerWidth 10, InnerHeight 1 + writeLines(v, "aaaa") + v.Search("aa", nil) + _ = v.gotoNextMatch() + index, total := v.GetSearchStatus() + assert.Equal(t, 1, index, "the current match is the one in the middle") + assert.Equal(t, 3, total) + + assert.Equal(t, + []string{"##*#......"}, + drawSearchHighlights(v)) +} + +// The model can find a match that the view doesn't show, for example in a commit hash +// that the view abbreviates. Such a match is highlighted where the model says it is. +// The other matches are highlighted where the view shows them. +func TestDrawSearchMatchesFromTheModel(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) // InnerWidth 10, InnerHeight 3 + writeLines(v, "ab ab", "1234567", "ab") + v.Search("ab", []SearchPosition{ + {XStart: 2, XEnd: 6, Y: 0}, + {XStart: 2, XEnd: 6, Y: 1}, + {XStart: 2, XEnd: 6, Y: 2}, + }) + _ = v.gotoNextMatch() + _ = v.gotoNextMatch() + index, total := v.GetSearchStatus() + assert.Equal(t, 2, index, "the current match is the one the view doesn't show") + assert.Equal(t, 4, total) + + assert.Equal(t, + []string{ + "##.##.....", // ab ab + "..****....", // 1234567 + "##........", // ab + }, + drawSearchHighlights(v)) +} + func BenchmarkDrawViewWithManySearchMatches(b *testing.B) { WithSimulationScreen(b, 102, 42) v := NewView("name", 0, 0, 101, 41, OutputNormal) From e64b209aed24c323bd494db27c0fa23b30a85aa0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 15:32:52 +0100 Subject: [PATCH 6/7] Speed up drawing a view with many search matches If a search has many matches, scrolling the view becomes laggy. For example, a search for "e" in a long branch log can find 44000 matches. With that many, drawing a 100x40 view takes 40ms, where it takes 0.14ms without a search. For every cell it draws, draw looks through all the matches to see if the cell is part of one. The matches are sorted in the same order as draw visits the cells, so let draw step through them along with the cells. It finds the first match on screen with a binary search. Drawing that view now takes 0.14ms with or without a search. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view.go | 70 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 9b7690a46..29adf3fc8 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "slices" + "sort" "strings" "sync" "unicode" @@ -429,8 +430,13 @@ func (v *View) viewLineSpanOfBufferLine(bufferLine int) (int, int, bool) { } type searcher struct { - searchString string - searchPositions []SearchPosition + searchString string + + // The matches, sorted by line, and within a line by the column they start in. + // Since all matches in a line have the same width, they are in the order of the + // column they end in, too. + searchPositions []SearchPosition + modelSearchResults []SearchPosition currentSearchIndex int onSelectItem func(*View, int) @@ -780,6 +786,11 @@ type SearchPosition struct { Y int } +// contains returns whether the position covers content column x of view line y. +func (p SearchPosition) contains(x, y int) bool { + return p.Y == y && p.XStart <= x && x < p.XEnd +} + type viewLine struct { linesX, linesY int // coordinates relative to v.buf.lines line []cell @@ -1955,6 +1966,7 @@ func (v *View) draw(isWindowFocused bool) { emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault} gutterWidth := v.inclusionGutterWidth() + searchMatches := v.searcher.matchWalker(v.oy) for y, vline := range v.viewLines[start:] { if y >= maxY { @@ -2035,7 +2047,7 @@ func (v *View) draw(isWindowFocused bool) { contentX, contentY := x-gutterWidth+v.ox, v.oy+y v.setCharacter(x, y, c.chr, c.width, fgColor, bgColor, isWindowFocused, - v.searchMatchAt(contentX, contentY), v.isHoveredHyperlink(contentX, contentY)) + searchMatches.matchAt(contentX, contentY), v.isHoveredHyperlink(contentX, contentY)) x += c.width cellIdx++ @@ -2141,18 +2153,48 @@ const ( currentSearchMatch ) -// searchMatchAt returns whether the cell at content column x of view line y is part of -// a search match. -func (v *View) searchMatchAt(x, y int) searchMatch { - for i, pos := range v.searcher.searchPositions { - if y == pos.Y && x >= pos.XStart && x < pos.XEnd { - if i == v.searcher.currentSearchIndex { - return currentSearchMatch - } - return otherSearchMatch - } +// searchMatchWalker tells which cells of the view are part of a search match, for drawing +// them. It has to be asked about the cells in the order they are drawn: line by line, and +// from left to right within a line. The matches are in the same order, so the walker steps +// through them along with the cells. +type searchMatchWalker struct { + positions []SearchPosition + currentIndex int + + // The first match that doesn't end before the cell asked about last. + index int +} + +// matchWalker returns a searchMatchWalker for drawing the view from view line firstY +// on. +func (s *searcher) matchWalker(firstY int) searchMatchWalker { + return searchMatchWalker{ + positions: s.searchPositions, + currentIndex: s.currentSearchIndex, + index: sort.Search(len(s.searchPositions), func(i int) bool { + return s.searchPositions[i].Y >= firstY + }), } - return noSearchMatch +} + +// matchAt returns whether the cell at content column x of view line y is part of a +// search match. +func (w *searchMatchWalker) matchAt(x, y int) searchMatch { + for w.index < len(w.positions) { + pos := w.positions[w.index] + if pos.Y > y || (pos.Y == y && pos.XEnd > x) { + break + } + w.index++ + } + + if w.index == len(w.positions) || !w.positions[w.index].contains(x, y) { + return noSearchMatch + } + if w.index == w.currentIndex { + return currentSearchMatch + } + return otherSearchMatch } // isHoveredHyperlink returns whether the cell at content column x of view line y is From b9e0861dfc1f13a260e6ed40e0550841efb3df3c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 9 Nov 2026 16:23:19 +0100 Subject: [PATCH 7/7] Show the current search match in full where it overlaps other matches If the search string can overlap itself, as "aa" can in "aaaa", the matches overlap too. A cell that is part of two matches shows as part of the first one. If the second one is the current match, some of its cells then show in the color of the other matches, and it is hard to tell where the current match is. Show a cell as part of the current match whenever it is. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/search_test.go | 4 ++-- pkg/gocui/view.go | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/gocui/search_test.go b/pkg/gocui/search_test.go index efbc53afd..5e4c60e51 100644 --- a/pkg/gocui/search_test.go +++ b/pkg/gocui/search_test.go @@ -126,7 +126,7 @@ func TestDrawSearchMatches(t *testing.T) { drawSearchHighlights(v)) } -// Where matches overlap, a cell is highlighted as part of the first match it is in. +// Where matches overlap, the current one shows in full. func TestDrawOverlappingSearchMatches(t *testing.T) { WithSimulationScreen(t, 14, 6) @@ -139,7 +139,7 @@ func TestDrawOverlappingSearchMatches(t *testing.T) { assert.Equal(t, 3, total) assert.Equal(t, - []string{"##*#......"}, + []string{"#**#......"}, drawSearchHighlights(v)) } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 29adf3fc8..fd6b67ce9 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -2178,7 +2178,8 @@ func (s *searcher) matchWalker(firstY int) searchMatchWalker { } // matchAt returns whether the cell at content column x of view line y is part of a -// search match. +// search match. If it is part of several overlapping matches, and the current match is +// one of them, it counts as part of the current one. func (w *searchMatchWalker) matchAt(x, y int) searchMatch { for w.index < len(w.positions) { pos := w.positions[w.index] @@ -2191,7 +2192,7 @@ func (w *searchMatchWalker) matchAt(x, y int) searchMatch { if w.index == len(w.positions) || !w.positions[w.index].contains(x, y) { return noSearchMatch } - if w.index == w.currentIndex { + if w.positions[w.currentIndex].contains(x, y) { return currentSearchMatch } return otherSearchMatch