Files
Stefan HallerandClaude Opus 5 aebf495dce Scroll the selection into view by default
Ever since scrolling the selection into view became opt-in, we have been
fixing the same class of regression by hand, five times so far: a
controller moves the selection somewhere new, doesn't say that it wants
the view to follow, and the selection ends up off screen. The decision
needs facts from two places — whether the selection went somewhere new is
known to the list, whether the scroll position is the caller's to manage
is known to the caller — and asking every caller for both is what keeps
going wrong. The callers that get it wrong are usually not even the ones
that moved the selection: they are pass-throughs like postRefreshUpdate,
which can't know what a refresh did to the selection.

So default to scrolling, and let the two callers that maintain the scroll
position themselves say so.

The one case where scrolling is always wrong is a refresh that no user
action is behind: a background poll, or a reload of state on window
focus, after a subprocess, or after a repo switch. Those must leave the
viewport wherever the user last scrolled it to — that is what made the
scrolling opt-in in the first place. Both are already marked in
RefreshOptions, so the refresh can decide it once, centrally, instead of
each caller judging it.

A user action that ends in a foreground refresh does now yank the view
back to the selection if the user had scrolled away from it. That's a
behaviour change, and there may be actions where it turns out to be
unwelcome; those we can fix individually, and it beats the ones that
don't scroll today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00

390 lines
13 KiB
Go

package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type ListControllerFactory struct {
c *ControllerCommon
}
func NewListControllerFactory(c *ControllerCommon) *ListControllerFactory {
return &ListControllerFactory{
c: c,
}
}
func (self *ListControllerFactory) Create(context types.IListContext) *ListController {
controller := &ListController{
baseController: baseController{},
c: self.c,
context: context,
}
controller.dragAutoscroller = helpers.NewDragAutoscroller(
self.c.HelperCommon,
context,
func(int) bool { return context.GetList().IsSelectingRange() },
controller.handleDragAutoscroll,
)
return controller
}
type ListController struct {
baseController
c *ControllerCommon
context types.IListContext
dragAutoscroller *helpers.DragAutoscroller
draggingWithMouse bool
}
func (self *ListController) Context() types.Context {
return self.context
}
func (self *ListController) HandlePrevLine() error {
return self.handleLineChange(-1)
}
func (self *ListController) HandleNextLine() error {
return self.handleLineChange(1)
}
func (self *ListController) HandleScrollLeft() error {
return self.scrollHorizontal(self.context.GetViewTrait().ScrollLeft)
}
func (self *ListController) HandleScrollRight() error {
return self.scrollHorizontal(self.context.GetViewTrait().ScrollRight)
}
func (self *ListController) HandleScrollUp() error {
scrollHeight := self.c.UserConfig().Gui.ScrollHeight
self.context.GetViewTrait().ScrollUp(scrollHeight)
if self.context.RenderOnlyVisibleLines() {
self.context.HandleRender()
}
return nil
}
func (self *ListController) HandleScrollDown() error {
scrollHeight := self.c.UserConfig().Gui.ScrollHeight
self.context.GetViewTrait().ScrollDown(scrollHeight)
if self.context.RenderOnlyVisibleLines() {
self.context.HandleRender()
}
return nil
}
func (self *ListController) scrollHorizontal(scrollFunc func()) error {
scrollFunc()
self.context.HandleFocus(types.OnFocusOpts{})
if self.context.NeedsRerenderOnWidthChange() == types.NEEDS_RERENDER_ON_WIDTH_CHANGE_WHEN_WIDTH_CHANGES {
self.context.HandleRender()
}
return nil
}
func (self *ListController) handleLineChange(change int) error {
return self.handleLineChangeAux(
self.context.GetList().MoveSelectedLine, change,
)
}
func (self *ListController) HandleRangeSelectChange(change int) error {
return self.handleLineChangeAux(
self.context.GetList().ExpandNonStickyRange, change,
)
}
func (self *ListController) handleLineChangeAux(f func(int), change int) error {
list := self.context.GetList()
rangeBefore := list.IsSelectingRange()
before := list.GetSelectedLineIdx()
f(change)
rangeAfter := list.IsSelectingRange()
after := list.GetSelectedLineIdx()
// 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.
cursorMoved := before != after
originYBefore := self.context.GetView().OriginY()
if cursorMoved {
switch change {
case -1:
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig(),
self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after))
case 1:
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig(),
self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after))
}
}
if cursorMoved || rangeBefore != rangeAfter {
if originYBefore != self.context.GetView().OriginY() {
// Since we already scrolled the view above, the normal mechanism that
// ListContextTrait.FocusLine uses for deciding whether rerendering is needed won't
// work. It is based on checking whether the origin was changed by the call to
// FocusPoint in that function, but since we scrolled the view directly above, the
// origin has already been updated. So we must tell it explicitly to rerender.
self.context.SetNeedRerenderVisibleLines()
}
self.context.HandleFocus(types.OnFocusOpts{})
} else {
// If the selection did not change (because, for example, we are at the top of the list and
// press up), we still want to ensure that the selection is visible. This is useful after
// scrolling the selection out of view with the mouse.
self.context.FocusLine(true)
}
return nil
}
func (self *ListController) HandlePrevPage() error {
return self.handlePageChange(-self.context.GetViewTrait().PageDelta())
}
func (self *ListController) HandleNextPage() error {
return self.handlePageChange(self.context.GetViewTrait().PageDelta())
}
func (self *ListController) handlePageChange(delta int) error {
list := self.context.GetList()
view := self.context.GetViewTrait()
before := list.GetSelectedLineIdx()
viewPortStart, viewPortHeight := view.ViewPortYBounds()
beforeViewIdx := self.context.ModelIndexToViewIndex(before)
afterViewIdx := beforeViewIdx + delta
newModelIndex := self.context.ViewIndexToModelIndex(afterViewIdx)
if delta < 0 {
// Previous page: keep selection at top of viewport
indexAtTopOfPage := self.context.ViewIndexToModelIndex(viewPortStart)
if before != indexAtTopOfPage {
// If the selection isn't already at the top of the page, move it there without scrolling
list.MoveSelectedLine(indexAtTopOfPage - before)
} else {
// Otherwise, move the selection by one page and scroll
list.MoveSelectedLine(newModelIndex - before)
linesToScroll := afterViewIdx - viewPortStart
if linesToScroll < 0 {
view.ScrollUp(-linesToScroll)
}
}
} else {
// Next page: keep selection at bottom of viewport
indexAtBottomOfPage := self.context.ViewIndexToModelIndex(viewPortStart + viewPortHeight - 1)
if before != indexAtBottomOfPage {
// If the selection isn't already at the bottom of the page, move it there without scrolling
list.MoveSelectedLine(indexAtBottomOfPage - before)
} else {
// Otherwise, move the selection by one page and scroll
list.MoveSelectedLine(newModelIndex - before)
linesToScroll := afterViewIdx - (viewPortStart + viewPortHeight - 1)
if linesToScroll > 0 {
view.ScrollDown(linesToScroll)
}
}
}
// Since we already scrolled the view above, the normal mechanism that
// ListContextTrait.FocusLine uses for deciding whether rerendering is needed won't work. It is
// based on checking whether the origin was changed by the call to FocusPoint in that function,
// but since we scrolled the view directly above, the origin has already been updated. So we
// must tell it explicitly to rerender.
self.context.SetNeedRerenderVisibleLines()
// This function scrolls the view itself, keeping the selection at the edge of
// the viewport rather than in its middle, so the scroll position is ours to
// maintain, not the focus mechanism's.
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
return nil
}
func (self *ListController) HandleGotoTop() error {
return self.handleLineChange(-self.context.GetList().Len())
}
func (self *ListController) HandleGotoBottom() error {
bottomIdx := self.context.IndexForGotoBottom()
change := bottomIdx - self.context.GetList().GetSelectedLineIdx()
return self.handleLineChange(change)
}
func (self *ListController) HandleToggleRangeSelect() error {
list := self.context.GetList()
list.ToggleStickyRange()
self.context.HandleFocus(types.OnFocusOpts{})
return nil
}
func (self *ListController) HandleRangeSelectDown() error {
return self.HandleRangeSelectChange(1)
}
func (self *ListController) HandleRangeSelectUp() error {
return self.HandleRangeSelectChange(-1)
}
func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error {
newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y)
alreadyFocused := self.isFocused()
if err := self.pushContextIfNotFocused(); err != nil {
return err
}
if newSelectedLineIdx > self.context.GetList().Len()-1 {
return nil
}
self.context.GetList().SetSelection(newSelectedLineIdx)
if opts.IsDoubleClick && alreadyFocused && self.context.GetOnDoubleClick() != nil {
return self.context.GetOnDoubleClick()()
}
self.context.HandleFocus(types.OnFocusOpts{})
// Let view-specific controllers do additional click handling
if self.context.GetOnClick() != nil {
return self.context.GetOnClick()(opts)
}
return nil
}
func (self *ListController) HandleDrag(opts gocui.ViewMouseBindingOpts) error {
self.draggingWithMouse = true
self.selectRangeThroughViewIndex(opts.Y)
originY, _ := self.context.GetViewTrait().ViewPortYBounds()
self.dragAutoscroller.Update(opts.Y - originY)
return nil
}
func (self *ListController) selectRangeThroughViewIndex(viewIndex int) {
list := self.context.GetList()
newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex)
list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx())
// The pointer can be outside the viewport, in which case so is the end of
// the range; the drag autoscroller takes care of following it, one line at a
// time, for as long as the pointer stays there.
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
}
func (self *ListController) handleDragAutoscroll(viewIndex int) bool {
if !self.context.GetList().IsSelectingRange() {
return false
}
self.context.SetNeedRerenderVisibleLines()
self.selectRangeThroughViewIndex(viewIndex)
return true
}
func (self *ListController) handleDragRelease() error {
self.draggingWithMouse = false
self.dragAutoscroller.Cancel()
return nil
}
func (self *ListController) GetOnFocusLost() func(types.OnFocusLostOpts) {
return func(types.OnFocusLostOpts) {
self.dragAutoscroller.Cancel()
if self.draggingWithMouse {
self.draggingWithMouse = false
self.c.GocuiGui().CancelMouseCapture()
}
}
}
func (self *ListController) pushContextIfNotFocused() error {
if !self.isFocused() {
self.c.Context().Push(self.context, types.OnFocusOpts{})
}
return nil
}
func (self *ListController) isFocused() bool {
return self.c.Context().Current().GetKey() == self.context.GetKey()
}
func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.HandleNextLine},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.HandlePrevPage, Description: self.c.Tr.PrevPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.HandleNextPage, Description: self.c.Tr.NextPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight},
}
if self.context.RangeSelectEnabled() {
bindings = append(bindings,
[]*types.Binding{
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp},
}...,
)
}
return bindings
}
func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
bindings := []*gocui.ViewMouseBinding{
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseWheelUp,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollUp() },
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Handler: func(opts gocui.ViewMouseBindingOpts) error { return self.HandleClick(opts) },
},
{
ViewName: self.context.GetViewName(),
Key: gocui.MouseWheelDown,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() },
},
}
if self.context.RangeSelectEnabled() {
bindings = append(bindings,
&gocui.ViewMouseBinding{
ViewName: self.context.GetViewName(),
Key: gocui.MouseLeft,
Modifier: gocui.ModMotion,
Handler: self.HandleDrag,
},
&gocui.ViewMouseBinding{
ViewName: self.context.GetViewName(),
Key: gocui.MouseRelease,
Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() },
},
)
}
return bindings
}