Fix search match highlighting when a custom patch is active (#6107)

Search matches beside the inclusion gutter were highlighted in the wrong
place, and hovering over or clicking a hyperlink there acted on the
wrong cell. The draw loop starts the content after the gutter, but the
code that maps a view column back to a column of the content only added
the horizontal origin and ignores the gutter.

Add a helper that does both, and use it for search highlights, hover and
clicks.
This commit is contained in:
Stefan Haller
2026-10-10 13:44:39 +02:00
committed by GitHub
3 changed files with 92 additions and 81 deletions
+1 -1
View File
@@ -1793,7 +1793,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
newCx := mx - v.x0 - 1
newCy := my - v.y0 - 1
// newX and newY are relative to the view's content, independent of its scroll position
newX := newCx + v.ox
newX := v.contentX(newCx)
newY := newCy + v.oy
// if view is editable don't go further than the furthest character for that line
if v.Editable {
+10 -80
View File
@@ -2008,10 +2008,17 @@ func (v *View) viewLineLengthIgnoringTrailingBlankLines() int {
return 0
}
// contentX returns the column of the view's content that is drawn at column x of the
// view. The content starts after the inclusion gutter, and is scrolled by the view's
// horizontal origin.
func (v *View) contentX(x int) int {
return x - v.inclusionGutterWidth() + v.ox
}
func (v *View) isPatternMatchedRune(x, y int) (bool, bool) {
for i, pos := range v.searcher.searchPositions {
adjustedY := y + v.oy
adjustedX := x + v.ox
adjustedX := v.contentX(x)
if adjustedY == pos.Y && adjustedX >= pos.XStart && adjustedX < pos.XEnd {
return true, i == v.searcher.currentSearchIndex
}
@@ -2022,39 +2029,12 @@ func (v *View) isPatternMatchedRune(x, y int) (bool, bool) {
func (v *View) isHoveredHyperlink(x, y int) bool {
if v.UnderlineHyperLinksOnlyOnHover && v.hoveredHyperlink != nil {
adjustedY := y + v.oy
adjustedX := x + v.ox
adjustedX := v.contentX(x)
return adjustedY == v.hoveredHyperlink.Y && adjustedX >= v.hoveredHyperlink.XStart && adjustedX < v.hoveredHyperlink.XEnd
}
return false
}
// realPosition returns the position in the internal buffer corresponding to the
// point (x, y) of the view.
func (v *View) realPosition(vx, vy int) (x, y int, ok bool) {
vx = v.ox + vx
vy = v.oy + vy
if vx < 0 || vy < 0 {
return 0, 0, false
}
if len(v.viewLines) == 0 {
return vx, vy, true
}
if vy < len(v.viewLines) {
vline := v.viewLines[vy]
x = vline.linesX + vx
y = vline.linesY
} else {
vline := v.viewLines[len(v.viewLines)-1]
x = vx
y = vline.linesY + vy - len(v.viewLines) + 1
}
return x, y, true
}
// clearRunes erases all the cells in the view.
func (v *View) clearRunes() {
maxX, maxY := v.InnerSize()
@@ -2283,56 +2263,6 @@ func (v *View) ViewBuffer() string {
return strings.Join(strs, "\n")
}
// Line returns a string with the line of the view's internal buffer
// at the position corresponding to the point (x, y).
func (v *View) Line(y int) (string, bool) {
_, y, ok := v.realPosition(0, y)
if !ok {
return "", false
}
if y < 0 || y >= len(v.buf.lines) {
return "", false
}
return v.buf.lines[y].cells.String(), true
}
// Word returns a string with the word of the view's internal buffer
// at the position corresponding to the point (x, y).
func (v *View) Word(x, y int) (string, bool) {
x, y, ok := v.realPosition(x, y)
if !ok {
return "", false
}
if x < 0 || y < 0 || y >= len(v.buf.lines) || x >= len(v.buf.lines[y].cells) {
return "", false
}
str := v.buf.lines[y].cells.String()
nl := strings.LastIndexFunc(str[:x], indexFunc)
if nl == -1 {
nl = 0
} else {
nl = nl + 1
}
nr := strings.IndexFunc(str[x:], indexFunc)
if nr == -1 {
nr = len(str)
} else {
nr = nr + x
}
return str[nl:nr], true
}
// indexFunc allows to split lines by words taking into account spaces
// and 0.
func indexFunc(r rune) bool {
return r == ' ' || r == 0
}
// applySelTextColor adds the attributes of selTextColor to fgColor, and
// replaces the color of fgColor with that of selTextColor if it has one.
func applySelTextColor(fgColor, selTextColor Attribute) Attribute {
@@ -2846,7 +2776,7 @@ func (v *View) onMouseMove(x int, y int) {
newCx := x - v.x0 - 1
newCy := y - v.y0 - 1
// newX and newY are relative to the view's content, independent of its scroll position
newX := newCx + v.ox
newX := v.contentX(newCx)
newY := newCy + v.oy
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
+81
View File
@@ -1245,6 +1245,87 @@ func TestInclusionGutter(t *testing.T) {
assert.Equal(t, "a", chr)
}
// A search match beside the inclusion gutter is highlighted where it is drawn.
func TestSearchMatchBesideTheInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.InclusionGutterMarker = "✓"
v.writeString("0123456789\n")
v.SetInclusionGutter(true, []bool{true})
v.UpdateSearchResults("34", nil)
v.draw(true)
highlightedColumns := func(y int) string {
s := ""
for x := 1; x <= 10; x++ {
_, style, _ := Screen.Get(x, y)
s += lo.Ternary(style.GetBackground() != tcell.ColorDefault, "#", ".")
}
return s
}
// The row reads "✓ 01234567", so the match is at view columns 5 and 6.
assert.Equal(t,
".....##...",
highlightedColumns(1))
}
// A hyperlink beside the inclusion gutter is underlined while the mouse is over it.
func TestHoveredHyperlinkBesideTheInclusionGutter(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.InclusionGutterMarker = "✓"
v.UnderlineHyperLinksOnlyOnHover = true
v.writeString("ab\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\cd\n")
v.SetInclusionGutter(true, []bool{true})
// The row reads "✓ ablinkcd". Point at the "l" of "link", at view column 4,
// which the frame puts at screen column 5.
v.onMouseMove(5, 1)
v.draw(true)
underlinedColumns := func(y int) string {
s := ""
for x := 1; x <= 10; x++ {
_, style, _ := Screen.Get(x, y)
s += lo.Ternary(style.HasUnderline(), "_", ".")
}
return s
}
assert.Equal(t,
"....____..",
underlinedColumns(1))
}
// Clicking a hyperlink beside the inclusion gutter opens it.
func TestClickHyperlinkBesideTheInclusionGutter(t *testing.T) {
g := newTestGui(t)
v, _ := g.SetView("main", 0, 0, 20, 10, 0)
v.InclusionGutterMarker = "✓"
v.writeString("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ text\n")
v.SetInclusionGutter(true, []bool{true})
opened := []string{}
g.SetOpenHyperlinkFunc(func(link string, _ string) error {
opened = append(opened, link)
return nil
})
// The row reads "✓ link text". Click the "k" of "link", at view column 5, which
// the frame puts at screen column 6.
assert.NoError(t, g.onKey(&GocuiEvent{
Type: eventMouse, MouseX: 6, MouseY: 1,
Key: NewKeyName(MouseLeft),
}))
assert.Equal(t,
[]string{"https://example.com"},
opened)
}
// A marked line the view wraps is marked on every segment it is drawn as, so that
// the mark doesn't look like it belongs to the first part of the line alone. The
// gutter takes its columns out of the width the content wraps in.