diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index b10edd387..fb22ca13c 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -534,9 +534,14 @@ func (v *View) SelectSearchResult(index int) { index = itemCount - 1 } - y := v.searcher.searchPositions[index].Y + pos := v.searcher.searchPositions[index] + y := pos.Y v.FocusPoint(v.ox, y, true) + // A match without a position in the line has nothing to scroll to. + if pos.XStart >= 0 { + v.scrollColumnsIntoView(pos.XStart, pos.XEnd) + } v.renderSearchStatus(index, itemCount) if v.searcher.onSelectItem != nil { v.searcher.onSelectItem(v, y) @@ -2693,6 +2698,29 @@ func (v *View) ScrollRight(amount int) { v.clearHover() } +// scrollColumnsIntoView scrolls the view sideways if the columns from xStart up to +// xEnd aren't all on screen. If they fit in the view without any scrolling, the view +// goes back to the left edge; otherwise xStart ends up a third of the way across, so +// that some of what comes before it is on screen too. Only a view that doesn't wrap +// has columns off screen. +func (v *View) scrollColumnsIntoView(xStart, xEnd int) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + width := v.InnerWidth() - v.inclusionGutterWidth() + if xStart >= v.ox && xEnd <= v.ox+width { + return + } + + if xEnd <= width { + v.SetOriginX(0) + } else { + v.SetOriginX(xStart - width/3) + } + + v.clearHover() +} + // ClampOriginXToContent scrolls the view back to the left if it is scrolled further // than its content needs, so that the end of the widest line is at the right edge. // A view that doesn't wrap keeps its horizontal scroll position when its content diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index b123ea3ad..bd0aabd40 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1351,6 +1351,38 @@ func TestInclusionGutterWithHorizontallyScrolledContent(t *testing.T) { assert.Equal(t, " 3456789a", row(2)) } +// Selecting a search match that is off screen to the side scrolls the view so that +// the match starts a third of the way across it, or back to the left edge if that +// is enough to show the match. A match on screen leaves the view where it is. +func TestSelectSearchResultScrollsTheMatchIntoView(t *testing.T) { + v := NewView("name", 0, 0, 13, 5, OutputNormal) // InnerWidth 12 + + v.writeString("0123456789abcdefghijklmnopqrstuvwxyz\n") + + v.UpdateSearchResults("uv", nil) + v.SelectSearchResult(0) + assert.Equal(t, 26, v.OriginX(), "u is at 30, and a third of the width is 4") + + v.UpdateSearchResults("yz", nil) + v.SelectSearchResult(0) + assert.Equal(t, 26, v.OriginX(), "the match is on screen") + + v.UpdateSearchResults("mn", nil) + v.SelectSearchResult(0) + assert.Equal(t, 18, v.OriginX()) + + v.UpdateSearchResults("ab", nil) + v.SelectSearchResult(0) + assert.Equal(t, 0, v.OriginX(), "the match fits without scrolling") + + // The gutter leaves the content ten columns, of which a third is 3. + v.InclusionGutterMarker = "✓" + v.SetInclusionGutter(true, []bool{false}) + v.UpdateSearchResults("ab", nil) + v.SelectSearchResult(0) + assert.Equal(t, 7, v.OriginX(), "a is at 10, and doesn't fit in the first ten columns") +} + // A view scrolled further right than its content needs goes back as far as it takes // to put the end of the widest line at the right edge, and no further. func TestClampOriginXToContent(t *testing.T) { diff --git a/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go b/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go new file mode 100644 index 000000000..c9a2e52f0 --- /dev/null +++ b/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go @@ -0,0 +1,45 @@ +package main_view + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchScrollsToAMatchOffTheEdge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With wrapping turned off for diffs, searching the focused diff scrolls it sideways to show a match past its edge", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.WrapLinesInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one "+strings.Repeat("word ", 60)+"needle\ntwo needle\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + FilterOrSearch("needle"). + SelectedLines( + Contains("+one word"), + ). + // The match is at column 305 of the diff; it ends up a third of the way + // across the pane, which is 78 columns wide. + OriginX(279). + Press(keys.Universal.NextMatch). + SelectedLines( + Contains("+two needle"), + ). + OriginX(0) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2381fa1a2..496789e0c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -475,6 +475,7 @@ var tests = []*components.IntegrationTest{ main_view.ScrollDiffHorizontally, main_view.SearchCollapsesTheSelection, main_view.SearchFollowsTheSelection, + main_view.SearchScrollsToAMatchOffTheEdge, main_view.SelectBelowALongCommitMessage, main_view.SelectBelowALongDiffstat, main_view.SelectDiffLines,