From 07995e630b3790703a9d0c3dd442ed45aea97a05 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:59:10 +0200 Subject: [PATCH 1/3] Add tests for search matches and hyperlinks beside the inclusion gutter If the inclusion gutter is shown, as it is in the focused main view while building a custom patch, search matches are highlighted two columns to the left of where they are. Hovering over a hyperlink underlines the wrong columns, and a click acts on the cell two columns to the right of the one that was clicked. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/view_test.go | 90 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index d5eed55ac..201249646 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1245,6 +1245,96 @@ 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, + /* EXPECTED: + ".....##...", + ACTUAL: */ + "...##.....", + 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, + /* EXPECTED: + "....____..", + ACTUAL: */ + "..____....", + 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, + /* EXPECTED: + []string{"https://example.com"}, + ACTUAL: */ + []string{}, + 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. From fda5abdfb346d5b0ccc702d6dab0bbf2406a3d70 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 09:01:11 +0200 Subject: [PATCH 2/3] Account for the inclusion gutter when mapping view columns to content Search matches beside the inclusion gutter are highlighted in the wrong place, and hovering over or clicking a hyperlink there acts 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 adds the horizontal origin and ignores the gutter. Add a helper that does both, and use it for search highlights, hover and clicks. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/gui.go | 2 +- pkg/gocui/view.go | 13 ++++++++++--- pkg/gocui/view_test.go | 9 --------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 465a7b48e..adaa1f650 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -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 { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ec20ab429..40e067f00 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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,7 +2029,7 @@ 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 @@ -2846,7 +2853,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 { diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 201249646..edc800990 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1267,10 +1267,7 @@ func TestSearchMatchBesideTheInclusionGutter(t *testing.T) { } // The row reads "✓ 01234567", so the match is at view columns 5 and 6. assert.Equal(t, - /* EXPECTED: ".....##...", - ACTUAL: */ - "...##.....", highlightedColumns(1)) } @@ -1299,10 +1296,7 @@ func TestHoveredHyperlinkBesideTheInclusionGutter(t *testing.T) { return s } assert.Equal(t, - /* EXPECTED: "....____..", - ACTUAL: */ - "..____....", underlinedColumns(1)) } @@ -1328,10 +1322,7 @@ func TestClickHyperlinkBesideTheInclusionGutter(t *testing.T) { Key: NewKeyName(MouseLeft), })) assert.Equal(t, - /* EXPECTED: []string{"https://example.com"}, - ACTUAL: */ - []string{}, opened) } From 42f7bf66a626bfaa91d485a61ee2974c85e66157 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 10:03:09 +0200 Subject: [PATCH 3/3] Remove the unused View.Line and View.Word Nothing in lazygit calls View.Line or View.Word; they came with the original gocui code and have never been used. They are also the only callers of realPosition. That function ignores the inclusion gutter when it maps a view position to the buffer. On top of that, Word uses the returned column as an index into the line's cells, and this goes wrong for wide characters. Remove them, together with realPosition and the indexFunc helper. This leaves contentX as the only code that maps a column of the view to a column of the content. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/view.go | 77 ----------------------------------------------- 1 file changed, 77 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 40e067f00..f48a4704e 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -2035,33 +2035,6 @@ func (v *View) isHoveredHyperlink(x, y int) bool { 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() @@ -2290,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 {