diff --git a/docs/Config.md b/docs/Config.md index 650c3c969..e8e31e3d9 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -35,6 +35,7 @@ gui: windowSize: 'normal' # one of 'normal' | 'half' | 'full' default is 'normal' scrollHeight: 2 # how many lines you scroll by scrollPastBottom: true # enable scrolling past the bottom + scrollOffMargin: 2 # how many lines to keep before/after the cursor when it reaches the top/bottom of the view sidePanelWidth: 0.3333 # number from 0 to 1 expandFocusedSidePanel: false mainPanelSplitMode: 'flexible' # one of 'horizontal' | 'flexible' | 'vertical' diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 9eb8e028a..a217d8099 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -31,6 +31,7 @@ type GuiConfig struct { BranchColors map[string]string `yaml:"branchColors"` ScrollHeight int `yaml:"scrollHeight"` ScrollPastBottom bool `yaml:"scrollPastBottom"` + ScrollOffMargin int `yaml:"scrollOffMargin"` MouseEvents bool `yaml:"mouseEvents"` SkipDiscardChangeWarning bool `yaml:"skipDiscardChangeWarning"` SkipStashWarning bool `yaml:"skipStashWarning"` @@ -418,6 +419,7 @@ func GetDefaultConfig() *UserConfig { Gui: GuiConfig{ ScrollHeight: 2, ScrollPastBottom: true, + ScrollOffMargin: 2, MouseEvents: true, SkipDiscardChangeWarning: false, SkipStashWarning: false, diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index fb6d8736a..cdaea413a 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -82,6 +82,12 @@ func (self *ListController) handleLineChange(change int) error { // doing this check so that if we're holding the up key at the start of the list // we're not constantly re-rendering the main view. if before != after { + if change == -1 { + checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig.Gui.ScrollOffMargin, before, after) + } else if change == 1 { + checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig.Gui.ScrollOffMargin, before, after) + } + return self.context.HandleFocus(types.OnFocusOpts{}) } diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index dd19d08db..83c8633a7 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -159,13 +159,25 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO } func (self *PatchExplorerController) HandlePrevLine() error { + before := self.context.GetState().GetSelectedLineIdx() self.context.GetState().CycleSelection(false) + after := self.context.GetState().GetSelectedLineIdx() + + if self.context.GetState().SelectingLine() { + checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig.Gui.ScrollOffMargin, before, after) + } return nil } func (self *PatchExplorerController) HandleNextLine() error { + before := self.context.GetState().GetSelectedLineIdx() self.context.GetState().CycleSelection(true) + after := self.context.GetState().GetSelectedLineIdx() + + if self.context.GetState().SelectingLine() { + checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig.Gui.ScrollOffMargin, before, after) + } return nil } diff --git a/pkg/gui/controllers/scroll_off_margin.go b/pkg/gui/controllers/scroll_off_margin.go new file mode 100644 index 000000000..119f30090 --- /dev/null +++ b/pkg/gui/controllers/scroll_off_margin.go @@ -0,0 +1,70 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +// To be called after pressing up-arrow; checks whether the cursor entered the +// top scroll-off margin, and so the view needs to be scrolled up one line +func checkScrollUp(view types.IViewTrait, scrollOffMargin int, lineIdxBefore int, lineIdxAfter int) { + viewPortStart, viewPortHeight := view.ViewPortYBounds() + + linesToScroll := calculateLinesToScrollUp( + viewPortStart, viewPortHeight, scrollOffMargin, lineIdxBefore, lineIdxAfter) + if linesToScroll != 0 { + view.ScrollUp(linesToScroll) + } +} + +// To be called after pressing down-arrow; checks whether the cursor entered the +// bottom scroll-off margin, and so the view needs to be scrolled down one line +func checkScrollDown(view types.IViewTrait, scrollOffMargin int, lineIdxBefore int, lineIdxAfter int) { + viewPortStart, viewPortHeight := view.ViewPortYBounds() + + linesToScroll := calculateLinesToScrollDown( + viewPortStart, viewPortHeight, scrollOffMargin, lineIdxBefore, lineIdxAfter) + if linesToScroll != 0 { + view.ScrollDown(linesToScroll) + } +} + +func calculateLinesToScrollUp(viewPortStart int, viewPortHeight int, scrollOffMargin int, lineIdxBefore int, lineIdxAfter int) int { + // Cap the margin to half the view height. This allows setting the config to + // a very large value to keep the cursor always in the middle of the screen. + // Use +.5 so that if the height is even, the top margin is one line higher + // than the bottom margin. + scrollOffMargin = utils.Min(scrollOffMargin, int((float64(viewPortHeight)+.5)/2)) + + // Scroll only if the "before" position was visible (this could be false if + // the scroll wheel was used to scroll the selected line out of view) ... + if lineIdxBefore >= viewPortStart && lineIdxBefore < viewPortStart+viewPortHeight { + marginEnd := viewPortStart + scrollOffMargin + // ... and the "after" position is within the top margin (or before it) + if lineIdxAfter < marginEnd { + return marginEnd - lineIdxAfter + } + } + + return 0 +} + +func calculateLinesToScrollDown(viewPortStart int, viewPortHeight int, scrollOffMargin int, lineIdxBefore int, lineIdxAfter int) int { + // Cap the margin to half the view height. This allows setting the config to + // a very large value to keep the cursor always in the middle of the screen. + // Use -.5 so that if the height is even, the bottom margin is one line lower + // than the top margin. + scrollOffMargin = utils.Min(scrollOffMargin, int((float64(viewPortHeight)-.5)/2)) + + // Scroll only if the "before" position was visible (this could be false if + // the scroll wheel was used to scroll the selected line out of view) ... + if lineIdxBefore >= viewPortStart && lineIdxBefore < viewPortStart+viewPortHeight { + marginStart := viewPortStart + viewPortHeight - scrollOffMargin - 1 + // ... and the "after" position is within the bottom margin (or after it) + if lineIdxAfter > marginStart { + return lineIdxAfter - marginStart + } + } + + return 0 +} diff --git a/pkg/gui/controllers/scroll_off_margin_test.go b/pkg/gui/controllers/scroll_off_margin_test.go new file mode 100644 index 000000000..099da299f --- /dev/null +++ b/pkg/gui/controllers/scroll_off_margin_test.go @@ -0,0 +1,171 @@ +package controllers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_calculateLinesToScrollUp(t *testing.T) { + scenarios := []struct { + name string + viewPortStart int + viewPortHeight int + scrollOffMargin int + lineIdxBefore int + lineIdxAfter int + expectedLinesToScroll int + }{ + { + name: "before position is above viewport - don't scroll", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 9, + lineIdxAfter: 8, + expectedLinesToScroll: 0, + }, + { + name: "before position is below viewport - don't scroll", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 20, + lineIdxAfter: 19, + expectedLinesToScroll: 0, + }, + { + name: "before and after positions are outside scroll-off margin - don't scroll", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 14, + lineIdxAfter: 13, + expectedLinesToScroll: 0, + }, + { + name: "before outside, after inside scroll-off margin - scroll by 1", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 13, + lineIdxAfter: 12, + expectedLinesToScroll: 1, + }, + { + name: "before inside scroll-off margin - scroll by more than 1", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 11, + lineIdxAfter: 10, + expectedLinesToScroll: 3, + }, + { + name: "very large scroll-off margin - keep view centered (even viewport height)", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 999, + lineIdxBefore: 15, + lineIdxAfter: 14, + expectedLinesToScroll: 1, + }, + { + name: "very large scroll-off margin - keep view centered (odd viewport height)", + viewPortStart: 10, + viewPortHeight: 9, + scrollOffMargin: 999, + lineIdxBefore: 14, + lineIdxAfter: 13, + expectedLinesToScroll: 1, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + linesToScroll := calculateLinesToScrollUp(scenario.viewPortStart, scenario.viewPortHeight, scenario.scrollOffMargin, scenario.lineIdxBefore, scenario.lineIdxAfter) + assert.Equal(t, scenario.expectedLinesToScroll, linesToScroll) + }) + } +} + +func Test_calculateLinesToScrollDown(t *testing.T) { + scenarios := []struct { + name string + viewPortStart int + viewPortHeight int + scrollOffMargin int + lineIdxBefore int + lineIdxAfter int + expectedLinesToScroll int + }{ + { + name: "before position is above viewport - don't scroll", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 9, + lineIdxAfter: 10, + expectedLinesToScroll: 0, + }, + { + name: "before position is below viewport - don't scroll", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 20, + lineIdxAfter: 21, + expectedLinesToScroll: 0, + }, + { + name: "before and after positions are outside scroll-off margin - don't scroll", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 15, + lineIdxAfter: 16, + expectedLinesToScroll: 0, + }, + { + name: "before outside, after inside scroll-off margin - scroll by 1", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 16, + lineIdxAfter: 17, + expectedLinesToScroll: 1, + }, + { + name: "before inside scroll-off margin - scroll by more than 1", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 3, + lineIdxBefore: 18, + lineIdxAfter: 19, + expectedLinesToScroll: 3, + }, + { + name: "very large scroll-off margin - keep view centered (even viewport height)", + viewPortStart: 10, + viewPortHeight: 10, + scrollOffMargin: 999, + lineIdxBefore: 15, + lineIdxAfter: 16, + expectedLinesToScroll: 1, + }, + { + name: "very large scroll-off margin - keep view centered (odd viewport height)", + viewPortStart: 10, + viewPortHeight: 9, + scrollOffMargin: 999, + lineIdxBefore: 14, + lineIdxAfter: 15, + expectedLinesToScroll: 1, + }, + } + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + linesToScroll := calculateLinesToScrollDown(scenario.viewPortStart, scenario.viewPortHeight, scenario.scrollOffMargin, scenario.lineIdxBefore, scenario.lineIdxAfter) + assert.Equal(t, scenario.expectedLinesToScroll, linesToScroll) + }) + } +}